` and `string`-keyed mapped types in addition to literal index signatures — the spelling the previous detector went blind on. It judges the type argument only, where an index signature is an accidental eraser, and never an export annotation, where one is a stated contract.
+
+- f5e1143: A collapsed sidebar now survives a reload — `SidebarProvider` reads the `sidebar_state` cookie it has always written
+
+ The cookie half of this feature only ever ran in one direction. `setOpen` wrote `sidebar_state` on every toggle with a 7-day max-age, and nothing ever read it back: `SidebarProvider` seeded its state from `defaultOpen` (default `true`), so a sidebar you collapsed came back expanded on the next load with the correct cookie sitting right there, unread. QA measured it at 255px and `data-state=expanded` at +2s, +4s and +8s after load, reproduced three times.
+
+ Upstream Shadcn closes this loop in a **server component** — it reads the cookie there and passes the value down as `defaultOpen`. A pure SPA like the console has no such step, which is why nothing downstream could paper over it: passing a cookie-derived `defaultOpen` from one shell would have fixed that shell and left every other consumer of the primitive broken. The read therefore happens client-side, in the provider, as a lazy `useState` initialiser rather than a mount effect — the state has to be right on the first render, since a post-mount correction would still flash an expanded sidebar at the user.
+
+ Precedence is now pinned, in this order: a controlled `open` prop, then the cookie, then `defaultOpen`, then `true`. The cookie overrides the _default_, never a controlled usage. With no cookie present the behaviour is exactly what it was before, which is what keeps explicit `defaultOpen={false}` call sites — the marketing demos in `apps/site` — rendering unchanged; those cases are controls in the new test file and are green on both sides of the change.
+
+ Only the two values the writer produces are honoured (`"true"` / `"false"`), matched on an exact cookie name; anything else, including an absent or malformed value, falls through to `defaultOpen` rather than inventing a preference the user never expressed. The reader is SSR-safe, which `apps/site` needs: those primitives are `"use client"`, and Next still renders them on the server for the initial HTML, where there is no `document`.
+
+ Because `packages/components/src/ui/**` is regenerated from the Shadcn registry, the primitive itself only gains two anchored one-liners. All of the parsing lives in `packages/components/src/lib/sidebar-cookie.ts`, which the sync never touches, and the two edits are declared in `scripts/shadcn-local-patches.mjs` so `pnpm shadcn:update` re-applies them instead of silently reverting the fix — the same mechanism already used for the translated `Sheet`/`Dialog` close labels.
+
+- 5bf09fd: `ActionParamDialog`'s `select` branch no longer renders a hardcoded English `Select...` placeholder. The fallback used when an action param declares no `placeholder` of its own now reads the existing `common.select` pack key, so it is translated in all ten locales and carries the typographic ellipsis (U+2026) that #3878 converged the packs on. Authored `placeholder` metadata keeps priority, and no locale pack changed — the key was reused from `LookupField`'s identical select-trigger use.
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [1f9b905]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [5cc847c]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/sdui-parser@17.5.0
+ - @object-ui/react-runtime@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/components/package.json b/packages/components/package.json
index 326ba474fa..fe098c0558 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/components",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Standard UI component library for Object UI, built with Shadcn UI + Tailwind CSS",
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 06f062ea57..7b50f00a50 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,565 @@
# @object-ui/core
+## 17.5.0
+
+### Minor Changes
+
+- ee66e2e: Close `ActionDef` — delete the `[key: string]: any` index signature and converge `visible` / `disabled` on the spec's unified shape.
+
+ `ActionDef` accepted any key of any type, so a typo (`targt`) and a retired spec
+ key (`execute`) both type-checked and the runner then silently bound no handler
+ — the objectstack#2169 "Mark Done does nothing" shape. Step 1
+ (objectstack#4075) made that audible with a dev-mode warning; step 2 promoted
+ the 18 spec-owned keys to real fields. This is **step 3**, executing the
+ maintainer's 2026-08-06 ruling now that its upstream half shipped in
+ `@objectstack/spec` 17.0.0-rc.6 (objectstack#5970).
+
+ - **`visible` and `disabled` now have ONE shape, derived from the spec** —
+ `boolean | string(CEL) | { dialect, source }`. The ruling was "统一形状,spec
+ 采纳": boolean is the degenerate literal verdict, the string is CEL shorthand,
+ the envelope is the full form. `visible` loses its hand-written `| boolean`
+ (the spec adopted that arm, so restating it locally would be a second
+ contract), and `disabled` gains the envelope arm it never had — it was
+ `string | boolean`, which is why the envelope the spec emits could only be
+ read through a cast.
+ - **The index signature is gone.** `tsc` now rejects an unknown or retired key
+ at any site that authors an action literal in code.
+ - **Five keys the deletion surfaced, promoted to real fields.** `to`,
+ `external`, `newTab`, `replace` — the `navigation` alias's own spelling, ruled
+ legitimate by step 1 and listed in `NAVIGATION_ALIAS_KEYS` ever since, but
+ declared only as data; and `description`, which every action renderer forwards
+ (`check:action-forward-parity` requires it) and the param-collection dialog
+ reads for its subtitle (objectui#4192). These were the only two `TS2353`s the
+ deletion produced across the whole workspace.
+ - **`ActionContext` keeps its index signature**, deliberately. It is a runtime
+ data bag whose keys are genuinely open; `ActionDef` is a declared metadata
+ contract. That asymmetry is the point, and it is now pinned in both
+ directions.
+
+ **Breaking edge, deliberate — same class as step 2's, one step further.** An
+ `ActionDef` literal carrying a key this interface does not declare is now a
+ compile error where it previously compiled and did nothing at runtime. That
+ includes the retired `execute` (rename it to `target`; `os migrate meta --from
+16` rewrites it) and plain typos. Values that were only ever absorbed silently
+ are the ones that stop compiling, so the failure moves to where it can be fixed
+ rather than appearing as a button that does nothing.
+
+ **What did NOT retire with the index signature**, contrary to step 1's
+ expectation: the dev-mode `warnOnUnknownActionKeys` shim and `executeScript`'s
+ `execute` rename prescription both stay. `tsc` only ever sees actions authored
+ as TypeScript, while stored `sys_metadata` rows are rehydrated UNPARSED
+ (objectstack#3903) — which is the population `execute: 'markDone'` actually
+ lives in. The two mechanisms cover disjoint populations; retiring the runtime
+ half would have re-opened the gap it was written for.
+
+- 3fc2971: A null-keyed group renders as an explicit bucket instead of silently vanishing from a chart (objectui#4466)
+
+ `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`).
+
+ 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.
+
+ `@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`.
+
+ 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.
+
+ `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.
+
+- dde7283: `chatbot` and `chatbot-enhanced` now pass only whitelisted DOM props to their host element (objectui#4431)
+
+ 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.
+
+ 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.
+
+ `@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.
+
+ `@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` 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.
+
+- f279deb: fix(core): bare-string filter options — docs/examples stop teaching it, the runtime lift warns (objectui#4356)
+
+ `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.
+
+ 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.
+
+ **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).
+
+ **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.
+
+ **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.
+
+ 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.
+
+- eb7f586: Dashboard dataset measures follow the display locale (objectui#4566).
+
+ `formatMeasure` and `formatDimensionValue` in `@object-ui/core` formatted every
+ value with a bare `undefined` locale tag at all three of their `Intl` sites.
+ `undefined` is not "the user's locale", it is the MACHINE's — neither of the
+ repo's two locale channels. A German session read a dashboard KPI as `1,234.5`
+ next to a grid cell rendering the same number as `1.234,5`, and inverted
+ separators read as a different number, not as an unstyled one.
+
+ Both functions take the display locale as a new OPTIONAL LAST parameter, and
+ `DatasetWidget` threads `useDisplayLocale()` into every site it formats through:
+ the KPI, the grouped table's measure and dimension cells, and the cross-tab's
+ header labels and cells.
+
+ **English output does not move**, and that is the discriminator against the
+ sibling fix. These sites already went through `Intl` with default grouping, so
+ the only thing that changes is WHOSE locale is used:
+
+ | | before | after |
+ | ----------------- | ----------- | --------------------- |
+ | en, 1234.5 `0.0` | `1,234.5` | `1,234.5` (unchanged) |
+ | de, 1234.5 `0.0` | `1,234.5` | `1.234,5` |
+ | de, 1234.5 EUR | `€1,234.50` | `1.234,50 €` |
+ | de, 0.6083 `0.0%` | `60.8%` | `60,8%` |
+
+ Contrast objectui#4553, where `formatPercent` had never grouped at all and
+ moving en `1235%` → `1,235%` WAS the fix.
+
+ Omitting the new argument reproduces the previous output byte for byte, so
+ callers that do not thread a locale yet are unaffected.
+
+ Two behaviours are deliberately preserved rather than "improved" alongside the
+ locale fix, both measured:
+
+ - **Integers stay verbatim.** The integer branch renders no separator and no
+ decimal mark, so a locale has nothing to change there — and routing it through
+ `Intl` WOULD change it (a locale with its own numbering system re-digits it,
+ and `1e21` expands to 22 digits).
+ - **The percent sign stays a literal suffix.** `Intl`'s `style: 'percent'`
+ re-scales by 100, and that round trip loses precision at the top of the range
+ (en `100,000,000,000,000,000,000,000%` becomes
+ `99,999,999,999,999,990,000,000%`). The consequence — a German list cell
+ writing `1.234,5 %` with a no-break space where a dashboard measure writes
+ `1.234,5%` — is filed separately rather than smuggled in behind a locale fix.
+
+ `@object-ui/core` is `minor` because two of its ENTRY exports gained an optional
+ parameter (measured in the built `.d.ts`). `@object-ui/plugin-dashboard` is
+ `patch`: its published declarations are unchanged — `buildPivot`'s new optional
+ parameter is internal, as that function is not on the package's `exports`
+ surface.
+
+- e901131: `DatasetResultField` is now `@objectstack/spec`'s `AnalyticsResult.fields[]` element itself, not a hand-written restatement of it
+
+ `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.
+
+ **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.
+
+ 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.
+
+- d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
+ measured as declared-but-never-read at the branch point, and each is removed rather than
+ left as an authoring surface whose values nothing acts on.
+
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
+ view container's default `list` and keyed views by the authored bare key instead of the
+ composer's `.` identity. The live implementation — `MetadataProvider`'s, in
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
+ field was _required_, so five fixtures across three packages had been declaring an empty
+ array for a value nothing would ever look at. Role-attached grants are now a compile
+ error rather than silently ignored data. (#4288)
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
+ them — and only the provider's memo dependency list still named them. Record-level
+ loading and error state stays where it is actually expressed: each renderer's own data
+ source. (#3773)
+
+ No behaviour change, no request-count change:
+
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:')`
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
+ were permanent no-ops. The invalidations of the keys that do have readers
+ (`view::` for `getView`, `view-overrides:` for
+ `listViewOverrides`) are untouched and now pinned. (#3778)
+
+- 38ab505: Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888).
+
+ ## The retirement
+
+ `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.
+
+ 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.
+
+ - `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.
+ - `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.
+ - `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.
+ - `@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.
+
+ `@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.
+
+ ## The dependency move
+
+ 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.
+
+ 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.
+
+ ## Breaking, in FROM → TO form
+
+ - **`@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.
+ - **`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.
+ - **`@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.
+ - **`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.
+ - **`@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.
+
+- 92250d6: One home for the number-display policy — and a percent stops meaning two different things between a list cell and a dashboard measure
+
+ `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.
+
+ 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.
+
+ **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.
+
+ **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.
+
+ `@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.
+
+- c1d939f: One `SchemaNode`, and one label vocabulary — the union wins, and labels resolve where the locale lives
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 2459a3e: Retire `ActionEngine`'s event-mapping API (objectui#3368). `ActionEngine.addMapping()`,
+ `ActionEngine.dispatch()`, the private `mappings` registry behind them, and the exported
+ `ActionMapping` interface are removed under enforce-or-remove: all four were public surface
+ of `@object-ui/core` with zero production callers. Nothing in the repo ever registered a
+ mapping, so `dispatch()` had no reachable caller either, and every call site was in the
+ engine's own test file.
+
+ Breaking for anyone who typed against or called the removed declarations, marked `minor`
+ per this repository's version-alignment convention (the major tracks `@objectstack`, never
+ an API-break count). Actions are still entered by name (`executeAction`), by location
+ (`getActionsForLocation`), by shortcut (`handleShortcut`) and in bulk (`executeBulk`) —
+ only the event-keyed entry point is gone, and no runtime behaviour changes because no
+ runtime path reached it.
+
+ The three ways the retired condition gate had drifted from the `visible` contract that
+ `getActionsForLocation` implements die with the path rather than being fixed on it: it
+ entered on a raw truthy check (`condition: false` dispatched anyway), typed `condition` as
+ `string` only (a `{ dialect: 'cel', source }` envelope could not reach the canonical
+ `@objectstack/formula` engine), and evaluated without `throwOnError` (a throwing predicate
+ failed OPEN, the opposite of `visible`'s fail-closed posture). Aligning the contract of an
+ API nobody calls would only have widened behaviour nobody uses.
+
+- fe52a04: `rowHeightToDensityMode` answers only for the five spec row heights — the coerce-to-`comfortable` fallback is gone
+
+ Two surfaces narrow a list view's `rowHeight` onto the renderer's three-step
+ density vocabulary, and since objectui#4352 they answered differently for the
+ same off-spec input: `@object-ui/react`'s spec bridge declined to answer, while
+ `@object-ui/core`'s `rowHeightToDensityMode` rehabilitated anything unknown into
+ `comfortable`. One metadata-driven system, two answers for one input
+ (objectui#4440).
+
+ The strict answer wins, per AGENTS.md #0.1: a renderer-side rehabilitation of
+ off-spec metadata is a second de-facto contract, and one strict contract beats N
+ dialects — a bad `rowHeight` gets fixed at the producer, where the schema already
+ rejects it. The five mappings themselves are untouched (`compact`/`short` →
+ `compact`, `medium` → `comfortable`, `tall`/`extra_tall` → `spacious`), and the
+ table keeps its `Record< RowHeight, … >` typing, so a row height added upstream
+ still fails the build here.
+
+ **Breaking semantics, deliberately graded `minor`** (this repo never publishes
+ `major` — its major tracks `@objectstack`). Two things change:
+
+ - **Published type.** `rowHeightToDensityMode` is exported from
+ `@object-ui/core`, and its return widens from `DensityMode` to
+ `DensityMode | undefined`. A host assigning the result straight into a
+ `DensityMode` now has to say what an off-spec row height should mean to it.
+ - **Rendered output, for input the spec already rejects.** `ListView` — the one
+ in-repo caller — used to render an off-spec `rowHeight` one step looser than an
+ ABSENT one (`comfortable`, 40px rows, vs `compact`, 32px). It now renders it
+ exactly like an absent one, `compact`, which is also `ObjectGrid`'s own default.
+ A sweep of this repo, the `objectstack` example apps and one downstream app
+ found zero authored off-spec values, and the legacy `densityMode` alias cannot
+ produce one (`DENSITY_MODE_TO_ROW_HEIGHT` is typed
+ `Record< DensityMode, RowHeight >`).
+
+ Also closed while retiring the branch: the lookup guarded membership with `in`,
+ which walks the prototype chain, so `rowHeight: 'toString'` returned
+ `Object.prototype.toString` — a function — from something typed `DensityMode`. It
+ is an own-property check now.
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `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 |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+### Patch Changes
+
+- ee26e65: Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
+
+ 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.
+
+ 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.
+
+ 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).
+
+ 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.
+
+- 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
+
+ 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.)
+
+ 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...` — 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- aca27fa: The multi-dimension pivot branch buckets a null first-dimension value instead of dropping its bar (objectui#4497)
+
+ `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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 613b167: A dataset dimension on a dotted relationship path now renders its option labels instead of the raw stored enum
+
+ 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.
+
+ The label lookup read options as `baseObject.fields[]`, 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.
+
+ 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.
+
+ 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.
+
+ Applies to both surfaces that carried this lookup: dashboard dataset widgets (`DatasetWidget`) and the chart view's dataset path (`ObjectChart`).
+
+ 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.
+
+- 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
+
+ `@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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 7e4f0e5: fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032)
+
+ A `type: 'metric'` dashboard widget rendered raw English while every other widget
+ type on the same dashboard rendered the translation, and dashboard filter chips
+ rendered `[object Object]` or the raw stored value. Both come from the same
+ cause: authored labels reaching a render site that could not read the
+ vocabulary `@objectstack/spec` actually admits.
+
+ - **KPI cards rejoin the widget translation channel.** The self-contained
+ `metric` branch built its own label from the raw `widget.title`, so the
+ `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already
+ resolved was computed and thrown away. It now reads that channel like every
+ other widget header.
+ - **The three private `resolveLabel` copies** (`DashboardRenderer`,
+ `MetricWidget`, `MetricCard`) are gone. Each read the retired
+ `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so
+ handed the inline per-locale map the spec admits today they returned nothing —
+ a KPI card with a map title rendered the literal string `metric`. All three
+ now use `pickLocalized`, the resolver already used for this vocabulary
+ elsewhere in the package.
+ - **Dashboard filter labels and static option labels resolve per locale.**
+ `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar
+ resolves before rendering (fixing `[object Object]: All` in the trigger, and
+ in `aria-label` / `placeholder`), and the `def.label || def.name` gate now
+ tests the RESOLVED string — an object is always truthy, so it never reached
+ the fallback before.
+ - **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a
+ map label to the raw stored value in every locale, English included, so
+ `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`.
+ The pair shape is still normalized; the label vocabulary is preserved for the
+ render side to resolve.
+ - **`DashboardComponentSchema.globalFilters` is bound to the spec's
+ `GlobalFilter`** instead of restated by hand. The restatement was both too
+ narrow (`label?: string`, which is what made these read sites invisible to
+ `tsc`) and too wide (it declared a bare-string option shorthand the spec
+ rejects at publish).
+
+ Plain-string labels are unaffected and render byte-identically.
+
+- 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
+
+ objectstack#5473 / objectstack#5665 replaced the pivot's delimiter-joined ids
+ with `JSON.stringify`, because every delimiter that had been tried — an empty
+ string, a plain space, a control character — assumed the data would not contain
+ it, and each assumption failed on ordinary data. This closes the last place the
+ same assumption survived: the ids were JSON, but the VALUES fed into them were
+ spelled `String(row[d] ?? '∅')`, so an absent dimension value became the
+ ordinary string `"∅"` and shared a bucket with a row whose value literally is
+ that character (U+2205). One bucket, later row overwriting the earlier one — the
+ cell showed a different row's measure, the overwritten row was unreachable, and
+ drill-through followed the same wrong index into the wrong records, all without
+ an error. The trigger requires that character to appear as a dimension value, so
+ this is the assumption being removed rather than a defect users hit today.
+
+ An empty value now encodes as JSON `null`, which `JSON.stringify` renders as a
+ bare `null` that no string can spell. The normalization lives in
+ `@object-ui/core` as `pivotDimensionValue` (absent ⇒ `null`, everything else ⇒
+ its string form) rather than at each call site, because a placeholder spelled by
+ a caller is a placeholder that can collide again — which is exactly how this one
+ survived the previous fix. `pivotBucketId` accepts `Array`
+ accordingly; that is a widening, so existing callers passing `string[]` are
+ unaffected.
+
+ Both renderers' bucket keys move together, which the fix requires: a bucket id
+ and the subtotal map keyed by it are built from the same expression, so changing
+ one alone would split the headers while the subtotal map still merged, landing
+ every column subtotal under the wrong header. In `plugin-dashboard`'s
+ `DatasetWidget` that is the row bucket id, the column bucket id, the cell key,
+ and both the `rowTotalById` and `colTotalById` lookups; in `plugin-report`'s
+ `DatasetReportRenderer` the single `bucketId` helper already feeds all five.
+
+ The dashboard's column bucket id also stops being a bare string and becomes a
+ one-element tuple through the same shared encoder. It was the one id in the
+ family still built by hand, on the reasoning that a single value needs no
+ boundary — true of the boundary, false of everything else the encoder does, and
+ it is why the across axis kept carrying this collision after the row ids were
+ fixed.
+
+ No display change: these placeholders only ever entered ids, never labels. An
+ unset dimension still renders through `formatDimensionValue` exactly as before,
+ and data containing neither an absent value nor that character buckets
+ identically — the ids are opaque lookup keys, never parsed back into a value,
+ never shown, never persisted.
+
+- d6aa172: Retire `params.newTab` on a url action — `openIn: 'new-tab'` is the sanctioned spelling
+
+ `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.
+
+ 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.
+
+ `openIn: 'self' | 'new-tab'`, the legacy `navigate.newTab` modifier on the `navigation` shape, and the external/relative default are all unchanged.
+
+- 9461dd3: Form actions no longer carry a record id across an object boundary (#4292).
+
+ `ActionRunner.executeForm` forwarded `/forms/:name?recordId=` unconditionally,
+ and that URL says nothing about which object the id belongs to — so the form route
+ resolved it against the FormView's own target object. When an action fired from a
+ record of a DIFFERENT object and ids collide across objects (per-table integer
+ keys), the form silently prefilled and, since the route learned to honour the param,
+ `PATCH`ed a same-id record of the wrong object.
+
+ - **Producer**: the id is forwarded only when the firing context record's object
+ (`context.objectName`) matches the target view's object; on a mismatch no id is
+ forwarded, preserving create semantics. When it IS forwarded, the object travels
+ with it as `?recordObject=`.
+ - **Consumer**: `/forms/:name` refuses — no record read, no write — when
+ `recordObject` disagrees with the FormView's object. A URL without the param
+ behaves exactly as before, so existing deep links are unaffected.
+
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [92876f0]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [1f9b905]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [c1d939f]
+- Updated dependencies [bb68488]
+- Updated dependencies [ab04728]
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index 4f556704cb..ed3301a748 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/core",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"sideEffects": false,
"license": "MIT",
diff --git a/packages/create-plugin/CHANGELOG.md b/packages/create-plugin/CHANGELOG.md
index 5be14aa2d8..edc5e15205 100644
--- a/packages/create-plugin/CHANGELOG.md
+++ b/packages/create-plugin/CHANGELOG.md
@@ -1,5 +1,7 @@
# @object-ui/create-plugin
+## 17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/create-plugin/package.json b/packages/create-plugin/package.json
index c6f588f62b..feed0db374 100644
--- a/packages/create-plugin/package.json
+++ b/packages/create-plugin/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/create-plugin",
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "CLI tool to scaffold ObjectUI plugins",
"type": "module",
"license": "MIT",
diff --git a/packages/data-objectstack/CHANGELOG.md b/packages/data-objectstack/CHANGELOG.md
index 36169c9515..913d7da612 100644
--- a/packages/data-objectstack/CHANGELOG.md
+++ b/packages/data-objectstack/CHANGELOG.md
@@ -1,5 +1,354 @@
# @object-ui/data-objectstack
+## 17.5.0
+
+### Minor Changes
+
+- 932cbcd: An app you are not allowed to open now says so, instead of reporting that it may still be publishing
+
+ `GET /api/v1/meta/apps` is filtered per session server-side (`filterAppForUser`), so an app withheld by its `requiredPermissions` and an app that does not exist were byte-identical to the console: both simply absent from the list. With one fact and two conditions, `AppContent` rendered its only copy for an absent app — "This app is not available yet — it may still be publishing. Try again in a moment." — over a permanent authorization decision, under a Retry button that could never succeed.
+
+ That is not a cosmetic complaint. On a downstream acceptance round one role hit this screen while another opened the same app fine, and because the copy names a transient deployment state the finding was filed as a suspected platform defect and carried through two test batches before a clean-baseline investigation found the account was missing a permission-set binding. The gate had been working exactly as designed; the message is what sent everyone to the wrong place.
+
+ The maintainer ruling (2026-08-12) took the contract half first. objectstack#8013 made the BY-NAME route answer an explicit denial — `403` with the ADR-0112 catalog code `PERMISSION_DENIED` in the declared `{ success: false, error: { code, message } }` envelope — for an app that exists and whose `requiredPermissions` the session lacks, while the LIST route stays filtered exactly as before, with no `authorized: false` flag, so the enumeration surface is not widened past what a direct by-name probe already implies. Absence keeps answering `404 RESOURCE_NOT_FOUND`, and so do the two neighbouring refusals the same ruling deliberately left alone: an unpublished app (ADR-0045 §3 keeps it externally unobservable) and an app gated by an absent optional service (ADR-0057 D10 — nothing was denied to the caller).
+
+ This is the console half. When a requested app is missing from the list and the existing post-publish readiness re-check still cannot find it, the console asks the by-name route which of the two it is, through a new `ObjectStackAdapter.probeAppAccess(name)`. On the measured code it renders a plain authorization message with a way back to the launcher; on anything else — an absent app, an unreachable server, a host that injected a DataSource without the probe — today's publishing copy renders byte for byte, retry button included.
+
+ Two properties of that seam are load-bearing rather than incidental. It branches on the ADR-0112 **code**, never the status (objectui#4408): the two answers under test are both errors one status apart, and a status-reading implementation passes the happy path while going blind exactly where the defect lives. And only `denied` moves the copy: this bug exists because the console asserted a state it had not measured, so a probe that fails, times out or cannot be issued must leave the screen alone rather than guess in the other direction.
+
+ `probeAppAccess` is deliberately separate from `getApp` rather than a flag on it: `getApp` degrades every failure to `null` — the very conflation being undone — and memoises in the adapter's metadata cache, where a verdict about the CALLER would outlive the session it described. New public API on the adapter (`probeAppAccess`, `isAppPermissionDeniedError`, `APP_PERMISSION_DENIED_CODE`, `AppAccessVerdict`), purely additive; nothing existing changed shape. Three new `empty.*` keys ship in all ten locale packs.
+
+- 537a0d1: `deleteView` removes every home the view has — deleting a draft-only saved view no longer silently no-ops
+
+ A view has two possible homes: the pending per-item **draft** (`DELETE /api/v1/meta/view/:name?state=draft`) and the **published** overlay (`DELETE /api/v1/meta/view/:name`). `deleteView` addressed only the second, unqualified. Deleting a view that existed only as a draft therefore fired the delete at the published overlay, the server answered `200 {"success":true,"reset":false,"message":"No view '…' found — nothing to delete."}`, the draft survived untouched, and the tab was still there after a reload — while the receipt reported `{ deleted: false }` and nothing surfaced the refusal to the user.
+
+ That is not a corner case. ADR-0034's `persistRuntimeMetadata` (app-shell) stages **every** runtime edit as a draft, and a view created from the `+` tab lives ONLY as a draft until an explicit Publish — so both "a view you just made" and "a published view you have since edited" are routinely draft-carrying.
+
+ **Why this is not the mechanical mirror of #4139.** `updateView` probes the draft first and writes back to whichever home the read resolved; that is right for an update in all cases. Copying it here would have been wrong in one: on a published+draft pair a draft-first-_only_ delete discards the draft and leaves the published row still serving the view. That is not Delete view, it is **Discard draft** — a deliberately different operation that already exists (`discardRuntimeDraft`, documented as "the published overlay is untouched"). The asymmetry has a clean statement: for an update, one home is the right home; for a delete, "remove this view" is satisfied only when _no home is left serving it_.
+
+ So both homes are now deleted, **draft first**. The order is load-bearing on the failure path: a fault between the two calls leaves the published overlay intact, so the view is still served and the delete is cleanly retryable. The reverse order would strand a draft-only view — precisely the bug above.
+
+ **Two blind calls, no probe.** Measured against the framework's `deleteMetaItem`: a missing home is reported as a **200** carrying `reset:false` (`"No pending draft for view/x."` / `"No view 'x' found — nothing to delete."`), never a 404. There is nothing for a probe to protect against, and `updateView`'s probe exists for a different reason — its read must resolve the row the merge writes back to — which has no counterpart for a delete.
+
+ **One transport, one error contract.** Both halves now go through `MetadataClient.reset()`, the transport that can express the `?state=` qualifier and the one `updateView`'s draft half already uses. The published half previously went through `client.meta.deleteItem`; measured, that issues the byte-identical request (this adapter configures no environment scoping), so routing it here changes no addressing and collapses two error shapes into one `MetadataError`.
+
+ The receipt is widened **additively**: `{ deleted }` gains optional `draft` and `published` outcomes (`removed`, plus the server's `reset` / `message`). `deleted` is true only when no home is left serving the view _and_ at least one actually held a row — a view that existed in neither home still answers `false`, unchanged. A failure of the published half after the draft was discarded now throws (matching `updateView`'s convention of surfacing a fault rather than degrading) carrying the partial state on the error's `outcome`: "draft gone, overlay left" is exactly what the old `{ deleted: boolean }` could not express, and it is never rounded up to `true`.
+
+ Cache invalidation moves into a `finally`, so `invalidateViewKeys` fires exactly once per call on **every** outcome including the throw. After a half-failure the draft row really is gone, and objectui#4363's asymmetry decides it: an unnecessary invalidation costs one refetch, a missed one costs the cache's full 5-minute TTL of stale overrides.
+
+ Minor rather than patch: this moves published behavior for existing callers and adds two exported types, the same grading objectui#4271's `get()` unwrap and objectui#4495's `find()` resolve→reject took. The `.d.ts` diff is additive only — `deleteView`'s return widens from an inline `{ deleted: boolean }` to the new `DeleteViewResult`, which still carries `deleted: boolean` — so no consumer needs a code edit to keep compiling. A repo-wide census found one call site (app-shell's `ObjectView` delete handler), which awaits the call and does not read the receipt.
+
+- bec3e14: The `DataSource` contract carries `deleteView`'s per-home outcomes (#4564)
+
+ #4479 / PR #4562 widened the ObjectStack adapter's `deleteView` to return
+ `DeleteViewResult { deleted, draft?, published? }`, so a caller could finally tell a
+ partial delete ("draft gone, published overlay left") from a complete one. The shared
+ interface did not follow: `DataSource.deleteView?` still declared the narrow
+ `Promise<{ deleted: boolean }>`.
+
+ Nothing failed to compile, and that is exactly what made the gap invisible — a wider
+ return is assignable to a narrower declaration, so the adapter satisfied the interface
+ while every consumer reaching it **through** `DataSource` was handed a type with the
+ per-home outcomes already discarded. The one real call site today (app-shell's
+ `ObjectView` delete handler) awaits the call and reads nothing off the receipt, so the
+ loss was latent rather than broken.
+
+ `DeleteViewResult` and `ViewHomeDeleteOutcome` now live in `@object-ui/types`, beside
+ the `DataSource` interface that returns them, and `deleteView?`'s declared return is
+ `Promise`. The direction was forced: the dependency runs
+ `@object-ui/data-objectstack` to `@object-ui/types` and never the other way, so the
+ shapes could not be imported downward — moving them was the alternative to re-declaring
+ a structural twin in `types`, which the one-resolver rule rejects because a copy is
+ mutually assignable with the original for exactly as long as it takes to drift.
+
+ `@object-ui/data-objectstack` re-exports both names unchanged, so every importer PR
+ #4562 left pointing at it keeps compiling — and now resolves to the same declaration the
+ shared contract speaks rather than a look-alike. A repo-wide census before the move
+ found zero importers of either name outside the declaring file itself, PR #4562's own
+ suite included, so the re-export is insurance rather than a load-bearing shim.
+
+ `deleteView` stays **optional** on the interface and keeps both parameters; the growth is
+ to the return type only, and `deleted` is untouched, so a consumer reading only `deleted`
+ needs no edit.
+
+ Grading, per this repository's version-alignment convention (the major tracks
+ `@objectstack`, never an API-break count):
+
+ - `@object-ui/types` — **minor**: entry-reachable growth. Two new exported interfaces
+ plus a widened method return on `DataSource`, all reachable from the package entry.
+ - `@object-ui/data-objectstack` — **minor**, measured rather than assumed. Its emitted
+ `dist/index.d.ts` is **not** byte-identical after the swap: the two `interface` blocks
+ leave the file and are replaced by a re-export from `@object-ui/types` (121.61 KB to
+ 120.25 KB). Both names remain in the public export list, so no importer breaks, but the
+ declaration genuinely moved and the emitted types now depend on `@object-ui/types` for
+ it — that is a minor, not a patch.
+
+- 479cc7b: `MetadataClient.get()` returns the item body its docblock always promised — the field half of the permission matrix is alive again
+
+ `GET /api/v1/meta/:type/:name` answers the spec-declared envelope `{ type, name, item, …protection fields }` — one shape, for published and draft reads alike, since objectstack#5563 collapsed the read to it. `get()` handed that envelope straight back to callers while its own docblock declared it returned "the unwrapped item content". Every consumer reading `obj.fields` therefore read `undefined`.
+
+ The visible cost was the entire field-level half of the permission matrix: expanding any object in `/_console/apps/:app/metadata/permission/:set` reported "No fields registered for this object." with zero checkboxes, for every object, while the network showed that object's 21 fields arriving 200 OK. Reproduced against two objects on fresh loads, and proven not to be the read-only gate — a run with the editor fully writable (864 enabled checkboxes) still showed an empty field sub-table, which is exactly what a read resolving `undefined` predicts.
+
+ That was one symptom of nine. A census of every `get()` call site found **zero** deliberate readers of the envelope and nine consumers reading the body directly, all of them broken the same way: the RLS CEL editor's field lint and autocomplete resolved an empty field set; the dataset inspectors and the preview field/catalog hooks came back empty; the report drill-down's fallback path read `def.object` off the envelope, found nothing and silently returned; the record-page seed synthesized a default layout from an envelope instead of an object; and the Field Designer read `raw.fields` for display and then wrote `{ ...raw, fields }` back — saving the envelope over the object body. None of it was caught, because the test doubles across the repo were written against the docblock: they answered a bare `{ fields }` body, so the suite exercised the documented contract while production ran the other one.
+
+ The fix is at the producer, not the nine consumers. `get()` now unwraps the envelope once, at the client boundary — so every one of those call sites is repaired without being touched. Detection is by the PRESENCE of the three keys `GetMetaItemResponseSchema` declares (`type: string`, `name: string`, an `item` slot), never guessed from payload contents: a metadata document carrying its own `type` and `name` (a view is `{ name, type: 'grid', … }`) has no `item` and is left whole, and a document with an `item` property of its own but no envelope identity is likewise untouched. Key count is deliberately not part of the test, since a real envelope also spreads the ADR-0008 protection carriers. Anything that is not the envelope — an older server answering the bare document — passes through byte-for-byte, and 404 still reads as `null`.
+
+ `getDraft()` is unchanged and keeps returning the envelope, which its docblock declares and roughly eleven call sites depend on by reading `.item`. That asymmetry is now real rather than aspirational: the two methods share one private transport, and differ only in whether they unwrap. `unwrapDraftBody` (app-shell) and `unwrapViewDraft` (this package) remain the shared helpers for taking a draft body out, and both were already tolerant of either shape, so the two seams that reach a draft through `get()` keep their exact semantics — including reading an empty draft as "nothing pending".
+
+ Minor rather than patch: this moves published behavior for existing callers, the same grading `find()`'s resolve-to-reject change took. No signature changed — the `.d.ts` diff is documentation plus one private member — so nothing needs a code edit to keep compiling; a caller that had written its own `.item` compensator against the old behavior would need to drop it, and none exists in this repo.
+
+- 2776b11: data-objectstack: retire the phantom `CloudOperations` surface — the class, its three `Cloud*` types, and the module that claimed to integrate a cloud namespace no client has ever shipped
+
+ `src/cloud.ts` exported a `CloudOperations` class with four methods, all
+ re-exported from the package entry, so this was published surface of
+ `@object-ui/data-objectstack`. Every method optional-chained into
+ `client.cloud?.…`, and no released `@objectstack/client` has ever exported a
+ `cloud` namespace. Re-measured at `17.0.0-rc.6` before deleting: the module's
+ export list is `ObjectStackClient`, `ScopedProjectClient`, `RealtimeAPI`,
+ `QueryBuilder`, `FilterBuilder`, `createQuery`, `createFilter`, and a
+ constructed client's `.cloud` is `undefined`. The nearest real namespaces on the
+ instance — `projects` (which owns `/api/v1/cloud/environments`) and `packages`
+ (which owns marketplace installs) — are not what these methods reached for.
+
+ So every call resolved `undefined` and fell through to a literal:
+
+ | method | what it returned, always |
+ | :-------------------- | :------------------------------------------------------------ |
+ | `deploy` | `{ deploymentId: 'deploy-' + Date.now(), status: 'pending' }` |
+ | `getDeploymentStatus` | `{ status: 'unknown' }` |
+ | `searchMarketplace` | `[]` |
+ | `installPlugin` | `{ success: false }` |
+
+ The maintainer's 2026-08-11 ruling removed it rather than repairing it, and named
+ the reason: `deploy()` did not degrade to an error, it **manufactured a
+ plausible success**. A caller got a well-formed `deploymentId` for an operation
+ that never left the process and then polled it forever against
+ `{ status: 'unknown' }`. That is the most dangerous shape for an AI consumer,
+ which builds downstream logic on the fake id instead of getting suspicious.
+ Under the startup-focus principle a declared capability with no producer, no
+ consumer and no business pull is retired, not stubbed.
+
+ **Breaking, in FROM → TO form.** `CloudOperations`, `CloudDeploymentConfig`,
+ `CloudHostingConfig` and `CloudMarketplaceEntry` are no longer exported from
+ `@object-ui/data-objectstack`. It is a `minor` under this repo's version policy
+ (objectui's own breaking changes never declare `major`). Nothing broke that was
+ working: the only in-repo construction site was a test, and every method's
+ observable behaviour was a fabricated constant.
+
+ **No compile-compat stub was left.** The ruling allows one — throwing loud
+ `NotImplemented` — only where a compile need is demonstrated. Measured across the
+ whole repository, the sole importers were the package's own `index.ts`,
+ `v3-compat.test.ts` (three cases asserting the fallback had the right _keys_,
+ which is how the emptiness stayed green) and objectui#3720's vocabulary pin. No
+ app, no other package, no doc. With no consumer to keep compiling, a stub would
+ be a second phantom surface guarding the first.
+
+ The false module header went with it — it read `Cloud namespace integration for
+@objectstack/spec v3.0.0 / Replaces the legacy Hub namespace`, against a resolved
+ spec of `17.0.0-rc.6` and schemas this package never consumed.
+
+ **objectui#3720's pin retires with its subject.** `cloud-environment-vocabulary.pin.test.ts`
+ pinned the doc comment on `CloudDeploymentConfig.environment` — the deliberate
+ three-member deploy-target vocabulary and the `staging`-is-not-a-discovery-member
+ trap. Every fact it held was a claim _about_ that comment, and its spec-side
+ assertions existed only to keep those claims honest; with the type deleted they
+ would pin `@objectstack/spec`'s enums on behalf of no local reader — the same
+ phantom shape this change closes. #3720's conclusion is unaffected and now moot:
+ it found no producer-side deploy-target type to converge onto because the
+ producer did not exist, and this change removes the consumer that was waiting for
+ it. Its pending empty changeset (`cloud-deploy-environment-vocabulary-3720.md`,
+ never released) is removed too, since it announced a deliberate vocabulary on a
+ type this same release deletes.
+
+ A negative pin (`src/cloud-surface-retired-4152.pin.test.ts`) replaces the
+ retired cases and fails if any of the four names returns — reading both the
+ runtime export list (which catches the class) and `index.ts`'s source text
+ (which is the only instrument that can catch a returning `export type`).
+
+- 2e3b0c0: fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state
+
+ A list pointed at an object whose `enable` block withholds the API rendered its ordinary
+ empty state, so _"this page cannot work, and never could"_ reached the user as _"you have no
+ records"_ (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose
+ `sys_jwks` declares `enable.apiEnabled: false` — could not load for any persona and said so
+ to nobody. That is also why the upstream defect objectstack#7544 survived review for its
+ whole life: a merely unpopulated page invites nobody to click through.
+
+ The masking had two halves, in two packages, and neither package could see the other:
+
+ - **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded
+ **every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived
+ at the surface as a successful empty result, indistinguishable from a genuinely empty
+ object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED`
+ (404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one
+ would have pinned the object to "empty" for the rest of the session.
+ - **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405
+ half was never swallowed, so it already reached this panel, but classified as `network`:
+ _"check your connection and try again"_ for a condition no retry can change. It now says
+ the object is not exposed through the API, that this is a setting on the object rather than
+ a permission, and it offers **no Retry** button, because every retry re-fetches the
+ identical refusal.
+
+ Both denials are pure functions of the object's metadata — no user, no permission, no
+ context — so neither is transient or per-user, which is exactly the case where a silent empty
+ state is most misleading. Discrimination is on the ADR-0112 `code`, never the status: a
+ missing collection, a missing record and a disabled object are all 404.
+
+ **A genuinely empty object still renders the ordinary empty state**, and a backend without an
+ optional collection still degrades to empty — pinned in both directions, at the adapter, at
+ the view, and once end-to-end over a real adapter and a real `ListView`.
+
+ Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search`
+ branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`.
+ It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the
+ branch a list takes whenever it expands a lookup or runs a search is no longer anonymous.
+
+ New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the
+ `en` pack and mirrored in the list defaults map.
+
+ ## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch
+
+ Two independent reasons, either of which is sufficient under this repo's precedent
+ (objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift):
+
+ 1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown):
+boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple
+ `['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's
+ public surface. Additive surface growth is minor.
+ 2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now
+ **REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with
+ `{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that
+ relied on those two codes arriving as a successful empty result now receives a rejected
+ promise carrying `code` + `httpStatus`, and must handle it.
+
+ Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404
+ with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move
+ is scoped to the two denial codes named above and to nothing else.
+
+ Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks
+ `@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change
+ described in the body, which is what this note is.
+
+### Patch Changes
+
+- d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
+ measured as declared-but-never-read at the branch point, and each is removed rather than
+ left as an authoring surface whose values nothing acts on.
+
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
+ view container's default `list` and keyed views by the authored bare key instead of the
+ composer's `.` identity. The live implementation — `MetadataProvider`'s, in
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
+ field was _required_, so five fixtures across three packages had been declaring an empty
+ array for a value nothing would ever look at. Role-attached grants are now a compile
+ error rather than silently ignored data. (#4288)
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
+ them — and only the provider's memo dependency list still named them. Record-level
+ loading and error state stays where it is actually expressed: each renderer's own data
+ source. (#3773)
+
+ No behaviour change, no request-count change:
+
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:')`
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
+ were permanent no-ops. The invalidations of the keys that do have readers
+ (`view::` for `getView`, `view-overrides:` for
+ `listViewOverrides`) are untouched and now pinned. (#3778)
+
+- c0f9a4b: Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side
+
+ The framework's runtime authoring gate produces two kinds of verdict on a metadata write. Errors become a 422 and the author sees them. Advisories ride a **200** — the save succeeded, the row persisted, the version bumped — and until objectstack#7435 the server dropped them into a deduped `console.warn` behind a process-level set. That landing put them on the wire as an optional `advisories[]` on the save response, emitted only when non-empty, and objectui was still throwing them away one layer further out: `MetadataClient.save` parsed the body, returned it as an opaque `T`, and every call site awaited it for its side effect and discarded the value.
+
+ The measured case the fix is built on: a `nightly_purge` flow whose only defect is a `delete_record` node with `multi: true` and no filter yields `errors = 0 / advisories = 1`. The save returns 200, the flow goes live, and nothing anywhere tells the author it deletes every row. That matters most for exactly the authors Studio serves — a Studio tenant or an MCP/AI author has no `os lint` and no CLI config for `sys_metadata` overlay rows, so this gate is not the weakest of four doors, it is the only one.
+
+ `MetadataClient` now carries an `onSaveAdvisory` sink, invoked after a save whose response carried a non-empty `advisories[]`, and the console wires it in `useMetadataClient` — the one hook every app-shell write path takes its client from, so a single wiring covers `ResourceEditPage`, `StudioDesignSurface`, `EmbeddedItemEditor`, `DatasourceResourcePage`, `ObjectHooksPanel` and any future call site rather than a toast copied into twenty of them. The finding shape is re-exported from `@objectstack/spec` (`RuntimeAuthoringIssue`) rather than restated, so it cannot fork from the 422 `issues[]` it deliberately shares a declaration with.
+
+ The affordance is the warning tier and says "Saved" first. A successful save that reads as a failure is the specific defect this surface must not ship, so the toast acknowledges the write, lists `rule` + `message` + `hint` per finding with `where` as secondary context, and renders that text **verbatim** — `message` and `hint` are server prose composed by the gate's rules, not i18n keys. Only the frame around them is translated (`console.saveAdvisoryTitle`, ten packs). The sink is best-effort in both directions: a malformed finding is dropped rather than printed as blanks, and a throwing renderer cannot turn a save the server already committed into an error.
+
+ **What this does not surface yet, and why.** Studio's designer saves as a **draft** on every edit, and drafts are never gated — the framework returns at its D1 early-return (`if (args.state !== 'active') return null`) before running a single rule, so a draft save produces no findings at all rather than producing some that get withheld. The publish step that promotes a draft to active _does_ run the gate, but the publish route returns no `advisories` field until objectstack#7294 lands. So a draft-then-publish flow renders nothing today, at both of its doors, for two different reasons; the active-mode save door renders findings now. That gap is pinned as a test rather than left for a reader to rediscover.
+
+- 605b747: The second metadata client class surfaces the runtime authoring gate's advisories instead of discarding them
+
+ objectui#4133 (PR #4236) put the gate's advisory findings — the ones that ride a **200**, where the save succeeded and the row persisted — in front of Studio authors, but it covered only one of the two client classes that write through `PUT /api/v1/meta/:type/:name`. The wiring lifts at `useMetadataClient`, which is where every app-shell path takes its `MetadataClient` from. `ObjectStackClient.meta.saveItem` — the SDK client hanging off `ObjectStackAdapter` — is a different class reaching the same door, and every one of its callers awaited the call and discarded the response, so an `advisories[]` the server attached was parsed off the wire and dropped one layer further out.
+
+ Those callers all write in **active** mode, so this is not the draft case where the gate never runs: the gate does run for them, produces findings, and the author was told nothing. The list is `MetadataService` (five saves behind the Object Manager and Field Designer), `useNavigationSync`, plugin-designer's Create/EditAppPage, and the adapter's own `updateViewConfig` / view / `updateDashboard` paths.
+
+ `ObjectStackAdapter` now carries an `onSaveAdvisory(listener)` subscription and emits on it after a metadata save whose 200 carried a non-empty `advisories[]`; `AdapterProvider` subscribes once and renders through the same `emitSaveAdvisories` the other client class already uses, so both doors produce one wording on the warning tier that says "Saved" first. The emitter is installed **once at the adapter/client seam** rather than at the call sites: every caller above reaches the save door through the adapter's own long-lived `ObjectStackClient`, so one interception covers all of them, plus any future one, without a toast copied into a dozen places — the same reasoning that put #4133's sink at one factory instead of twenty call sites.
+
+ It is a sibling of the `onWriteWarning` channel (#3431/#3455) rather than a second payload pushed down it, which is what `MetadataSaveAdvisoryEvent` already said it was modelled on. `WriteWarningEvent` is a closed shape whose required `droppedFields` means "fields the write legally stripped", so carrying advisories on it would either force every existing subscriber to grow a branch or make the event lie about what happened. The seam's shape is reused; its event type is not. `readSaveAdvisories` is shared unchanged between the two clients — one reader, two call sites — which the response envelopes make possible: the spec puts `advisories` at the save body's top level, and the SDK returns that body verbatim (it strips its `{ success, data }` envelope only when a `data` key is present, and this body has none). That measurement is pinned by tests that drive a real SDK client through a fake `fetch` rather than stubbing the method under test.
+
+- b42558a: Renaming a freshly-created view now persists — `updateView` reads and writes the same row, instead of reading the published overlay and losing the edit into a rejected partial write
+
+ ADR-0034 stages every runtime-created view as a per-item **draft**: a view made from the `+` tab lives only in the draft row until an explicit Publish, and the UI reads it back through `?preview=draft`. `updateView` addressed neither half of that. Its read went to the published overlay (`client.meta.getItem`, no draft qualifier), which 404s for a draft-only view; a `catch {}` labelled "treat missing as create-equivalent" then substituted `current = {}`, so the read-merge-write cycle merged onto nothing. What went out was the fragment that merge produces — literally `{label, name, object}`, no `viewKind`, no `config` — which the server rejects as an invalid ViewItem (422). Nothing surfaced to the user, and the draft row still held the old label, so the rename simply did not happen. Create, pin and delete were unaffected: they never take this path.
+
+ The read now probes the draft row first and, on a hit, merges onto that body and writes it straight back with `mode: 'draft'`. Whichever row the read resolved is the row the write updates, so the two halves agree by construction rather than by coincidence. Probing the draft **before** the published overlay is what makes it correct for a view that has both: writing the published row while a draft is pending would put the edit somewhere the draft shadows, and Publish would later overwrite it with the pre-edit body — losing the change a second time, further from the cause. A draft edit stays a draft, preserving ADR-0037's guarantee that nothing the preview shows goes live until Publish. Renaming a published view with no draft pending is unchanged, published read to published write.
+
+ The silent catch is gone. A view that resolves in neither home now throws naming the view and the object (creating one is `createView`'s job — no caller of `updateView` relied on the create-equivalent behaviour), and a network, permission or server fault on either read propagates instead of degrading into the partial write that corrupted the row. This turns a class of failure that was previously invisible into an error the existing call sites already catch and surface.
+
+ Set-default and reorder drive the same read-merge-write cycle with `{isDefault}` / `{sortOrder}` patches, so they were emitting the same partial write and are fixed by the same change.
+
+- d2f6e6b: Publishing a view from the console no longer serves a five-minute-stale override map — every writer now routes through one invalidation seam
+
+ `ObjectStackAdapter` caches two view-shaped reads: `getView` under `view:{object}:{name}` and `listViewOverrides` under `view-overrides:{object}`, with `MetadataCache`'s default 5-minute TTL. objectui#4363 made the adapter's own four write paths drop both. But the console's real create-a-view flow never calls any of them: `ObjectView.handleViewCreate` writes through the ADR-0034 metadata seam (`createRuntimeMetadata` → `metadataClient.save`), and Publish goes `RuntimeDraftBar` → `publishRuntimeMetadata` → `metadataClient.publish`. Two writers into the same `/meta/view/:name` rows; only one of them invalidated anything.
+
+ Publish is the sharp end. A create lands an invisible per-item draft, and `listViewOverrides` enumerates published rows, so the map is still honest there. Publish promotes the row into exactly the world the map describes — and nothing dropped the key, so the object page kept applying its pre-publish snapshot for the rest of the TTL. It does not self-heal: `loadViewOverrides` treats a resolved map as authoritative and deliberately does not re-probe per view (objectui#3774, correct — re-probing reinstates the 404 flurry the batch read exists to remove), so the per-view `getView` fallback that would have masked a stale map is by design unreachable.
+
+ The fix is one seam rather than a fifth copy of the key list. `ObjectStackAdapter.invalidateViewKeys(objectName, viewName)` is now the only place that knows which keys a view-row write drops; the adapter's four write paths call it instead of restating the pair, app-shell's ADR-0034 persistence module calls it for `view` saves, creates, publishes and discards, and `MetadataService.saveMetadataItem` calls it when the category is `view` (where it previously named `view:{name}`, which no reader has). Restatement is what this repo keeps paying for — objectui#3778 removed five copies of a key no reader populated, objectui#4363 fixed four copies that named half the live set, and objectui#4373 is the measured proof that a new writer forgets the list by default. A pin suite can only guard writers that exist; a seam makes the next one unable to forget.
+
+ No cache key, no read path and no public signature changed. The adapter's eight existing invalidation pins pass unchanged, which is the evidence that routing four paths through a seam changed nothing observable; two new structural guards keep the key set from being restated again — one asserting each key template appears exactly twice in the adapter (its reader, and the seam), one asserting no app-shell file spells either.
+
+- 85a3082: Every view write path now invalidates the override map — a created, renamed or deleted view is no longer shadowed by a five-minute-stale batch read
+
+ `ObjectStackAdapter` caches two view-shaped reads: `getView` under `view:{object}:{viewId}`, and `listViewOverrides` under `view-overrides:{object}`. Four write paths touch view rows, and until now exactly one of them — `updateViewConfig` — invalidated the second key. `createView`, `updateView` and `deleteView` invalidated only the per-view key, so the batch override map kept answering from a snapshot taken up to `MetadataCache`'s default 5-minute TTL earlier.
+
+ That gap does not heal itself. `loadViewOverrides` in app-shell's `ObjectView` treats a resolved map as authoritative and deliberately does not re-probe per view — that is objectui#3774's fix, and it is correct, since re-probing reinstates the 404 flurry the batch read exists to remove. So the per-view `getView` fallback that would have masked a stale map is by design unreachable, and the stale map is served in full. Meanwhile `listViews` is uncached and answers fresh, so the view switcher could list a view whose override body came from a map written minutes earlier: the sharpest shape is the rename/pin path (`updateView`), where a user edits a view, returns to the object, and is served the pre-edit override.
+
+ All four paths now emit the same ordered pair — the per-view key, then the object's override map. The rule is uniform per method rather than per branch: `updateView`'s draft half invalidates both keys as its published half does, which is deliberate over-invalidation (both readers enumerate published rows, so a draft write stales neither) chosen because an unnecessary invalidation costs one refetch while a missed one costs the full TTL. `createView` names the per-view key too, because `saveItem` is an upsert and an explicit `spec.name` that already exists overwrites a published row a prior `getView` may hold.
+
+ No signature, no cache key and no read path changed; the only difference is which keys each write drops. The pin suite added by objectui#4328 now asserts the full invalidation key set for all five call sites, with the sweep's two pins kept as untouched controls: `listViews` stays uncached, and no write path names the retired `views:{object}` key.
+
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [1f9b905]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [ab04728]
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/data-objectstack/package.json b/packages/data-objectstack/package.json
index 0715bcb71a..69a5036b56 100644
--- a/packages/data-objectstack/package.json
+++ b/packages/data-objectstack/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/data-objectstack",
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "ObjectStack Data Adapter for Object UI",
"license": "MIT",
"type": "module",
diff --git a/packages/fields/CHANGELOG.md b/packages/fields/CHANGELOG.md
index cc718314e7..fd34dc00ae 100644
--- a/packages/fields/CHANGELOG.md
+++ b/packages/fields/CHANGELOG.md
@@ -1,5 +1,608 @@
# @object-ui/fields
+## 17.5.0
+
+### Minor Changes
+
+- ebb4e0e: The date formatter's last three en-US channels now follow the display locale
+ (objectui#4272).
+
+ objectui#4468 (PR #4512) pointed every date _renderer_ at `useDisplayLocale()`.
+ Three channels were out of its reach because they are properties of the
+ formatter's signature and of its callers rather than of any renderer, so a `zh`
+ console still met English dates in three places:
+
+ - **`formatDate`'s `'short'` branch** hardcoded
+ `toLocaleDateString('en-US', { month: 'short' })`, so it rendered an English
+ month even when the caller had threaded `options.locale` into that very call.
+ Its only consumers are ObjectGrid's two mobile-card date cells, which threaded
+ no locale — fixing either half alone moves nothing, so both land here.
+ - **`formatDateTime` took no options parameter at all**, so no caller could
+ localize it however hard it tried; it always handed `Intl` an `undefined` tag,
+ which means the MACHINE's locale — neither of the repo's two locale channels.
+ The parameter is optional and lands together with its consumers, plugin-gantt's
+ four tooltip call sites.
+ - **The lookup picker's MongoDB `$date` fallback** called a bare
+ `toLocaleDateString()` with no tag.
+
+ One resolver everywhere, as before: `useDisplayLocale()` (tenant regional
+ default → active UI language → `'en'`). `Intl` accepts `'zh'` verbatim, so there
+ is still no mapping table anywhere.
+
+ English output is byte-identical at every touched site — `en` and `en-US` agree
+ on all twelve short month names — and the `'short'` layout itself is unchanged:
+ only the month token is localized, the compact `"Jan 15, '24"` shape around it
+ is a deliberate fixed layout for narrow cards.
+
+ `@object-ui/fields` is `minor` because `formatDateTime`'s new optional parameter
+ is visible in the package's entry `.d.ts`; the plugin packages' own `.d.ts` files
+ are byte-identical, so their change is module-local.
+
+- 36310dc: `formatPercent` groups its output and follows the display locale — the last
+ tooltip/cell channel (objectui#4553).
+
+ PR #4557 threaded the gantt tooltip's number and currency rows and measured that
+ the percent row could not follow: `formatPercent(value, precision)` took no
+ locale parameter, and its whole body was
+ `${percentDisplayValue(value).toFixed(precision)}%`. It built no
+ `Intl.NumberFormat` and never reached `formatDisplayNumber` — so unlike its
+ siblings it did not render in the MACHINE's locale, it rendered in **no** locale:
+ an ASCII decimal mark, never a grouping separator, byte-identical on every
+ machine.
+
+ **English output MOVES, and that is the fix.** Because the function never
+ grouped, `1235%` was wrong in en-US too, not only in German. Grouping and locale
+ therefore land together:
+
+ | | before | after |
+ | ---------- | ------- | -------------- |
+ | en, 1234.5 | `1235%` | `1,235%` |
+ | de, 1234.5 | `1235%` | `1.235\u00a0%` |
+ | de, 80 | `80%` | `80\u00a0%` |
+
+ Values below the grouping threshold are unchanged in English (`80%`, `12.5%`,
+ `33.33%`), so the move is confined to four digits and up. German changes at every
+ magnitude, because the no-break space before the sign is part of the locale's
+ percent convention — which is what routing through `Intl` buys over appending a
+ literal `%`.
+
+ The scaling contract is untouched: `percentDisplayValue` still disambiguates a
+ fraction-stored percent (`0.8` → 80%) from a whole one, so the list cell and the
+ dashboard measure formatter still agree.
+
+ Consumers are threaded in the same change, the parameter never landing
+ speculatively:
+
+ - **fields** — `PercentCellRenderer`, on BOTH of its paths. Its whole-percent
+ branch (`progress` / `completion` fields, which store 0-100 and must skip the
+ fraction scaling) was a second bare `toFixed` call; leaving it behind would
+ have made one grid internally inconsistent, so both branches now share one
+ locale-aware body and differ only in the scaling policy.
+ - **plugin-gantt** — the tooltip percent row, completing objectui#4553's switch.
+ - **plugin-grid** — the mobile card's percent cell, which sits in the same
+ density row as a date cell objectui#4272 had already localized.
+ - **plugin-dashboard** — `renderFieldValue`'s percent branch. It is a plain
+ function rather than a component, so it takes the locale as an optional fourth
+ parameter beside the `tenantCurrency` already threaded that way, and both of
+ its callers pass it and declare it in their memo dependency arrays.
+
+ Bumps follow each package's own `.d.ts` diff, measured in both directions.
+ `@object-ui/fields` and `@object-ui/plugin-dashboard` are `minor` on the
+ objectui#4272 / PR #4544 precedent — quoted from that changeset: "`@object-ui/fields`
+ is `minor` because `formatDateTime`'s new optional parameter is visible in the
+ package's entry `.d.ts`; the plugin packages' own `.d.ts` files are
+ byte-identical, so their change is module-local." Here `formatPercent` and
+ `renderFieldValue` each gain an entry-visible optional parameter, while
+ plugin-gantt's and plugin-grid's `.d.ts` files are byte-identical and stay
+ `patch`.
+
+- 52d878a: fix(fields): `formatPercent` renders percentage points directly — ties round half-up and extremes keep every digit
+
+ `formatPercent` rendered a value that is already in percentage POINTS through
+ `Intl`'s `style: 'percent'`, which expects a FRACTION, so the body divided by
+ 100 for `Intl` to multiply straight back. That round trip is not
+ value-preserving: `Intl` formats from the shortest decimal representation of the
+ double it is handed, and the quotient's is not the authored one. A stored
+ `1.005` at 2 decimals rendered `1.00%` where half-up on the authored decimal is
+ `1.01%`; `1.45` at 1 decimal rendered `1.4%` for `1.5%`. Every case was a
+ last-digit off-by-one — the failure mode least likely to be noticed and most
+ likely to be trusted.
+
+ The body now renders through `style: 'percentPoints'` with no scaling round
+ trip. Measured on this repo's runner (node v22.22.2 / ICU 78.2), 27,577 of
+ 1,200,003 ordinary en-US forms move (0.005-step grid to 2,000, precisions
+ 0/1/2), and the same artefact at the top of the double range is gone too:
+ `Number.MAX_SAFE_INTEGER` percentage points rendered `9,007,199,254,740,990%`
+ and now render `9,007,199,254,740,991%`.
+
+ The locale percent CONVENTION is unchanged — this is a numeral move only.
+ `'percentPoints'` is `Intl`'s `style: 'unit'` / `unit: 'percent'`, re-measured on
+ this call shape across 720 combinations (10 locales x 18 values x 4 precisions):
+ 0 convention differences, 130 numeral differences. The no-break space in
+ de/fr/ru/sv, Turkish's prefixed sign, Arabic's own percent sign and Bengali's
+ digits all render exactly as before. Percent SCALING (a stored fraction below 1
+ scaling by 100) is upstream of the render and untouched.
+
+ A percentage point now reads identically in a list cell and in a dashboard
+ measure, which `formatMeasure` already rendered this way.
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `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 |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+### Patch Changes
+
+- e2e6360: A `Field.address` value now reads as a formatted postal address on the record detail page, instead of stringified JSON.
+
+ The display (read) registry mapped `address` straight to `JsonCellRenderer`, so a populated address rendered as `{"street":"中策路 1 号","city":"杭州",…}` — while a `location` field sitting next to it in the same field group rendered formatted, and the create/edit dialog rendered the very same value as proper Street / City / State / ZIP / Country inputs. The gap was display-side only: the input registry has always carried `address`. Both read surfaces the detail page exposes are affected and both are fixed, because they share one `displayValue` path — read mode, and the inline-edit read state (the row carrying the pencil affordance, before a field is actually being edited).
+
+ Layout is not invented for the read side. `AddressField`'s readonly branch already collapsed a stored address to a single line, and that rule — `Street, City, State ZIP, Country`, with `state` and the postal code sharing one comma group — is now the _only_ implementation, moved into a pure `address-format` module that both surfaces call. A readonly form and a detail page therefore cannot spell one stored address two ways; a second copy next to the renderer would have been a rule that drifts. The module is deliberately React-free, so the eagerly-loaded barrel can format a cell without pulling `AddressField` and its inputs out of the lazy widget chunk.
+
+ Partial values degrade the way the readonly line already did: absent, non-string and whitespace-only parts are dropped rather than spaced over, so a street-only address renders as `中策路 1 号` and never as `, , ,` or as the string `undefined`. Legacy records whose postal code was written under `zipCode` (objectstack#5143) still render it, matching what the input widget reads.
+
+ Nothing is silently swallowed by the change: a value the formatter cannot recognize — an object carrying no known part — keeps today's compact-JSON rendering rather than disappearing, and `{}` or a null value shows the usual empty placeholder. A plain string address passes straight through. `location`, `geolocation`, and the genuinely structural `json` / `object` types are untouched.
+
+ The address _input_ is unchanged on every surface, including the create/edit dialog.
+
+- dde7283: `chatbot` and `chatbot-enhanced` now pass only whitelisted DOM props to their host element (objectui#4431)
+
+ 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.
+
+ 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.
+
+ `@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.
+
+ `@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` 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.
+
+- 0f21348: Currency amounts now follow each currency's own ISO 4217 fraction-digit
+ convention instead of a hardcoded 2 (objectui#4361).
+
+ Both currency formatting paths in `@object-ui/fields` picked a fraction-digit
+ width and handed it to `Intl.NumberFormat`, which OVERRIDES the digit count
+ `Intl` already knows for the currency being rendered. `formatCurrency` derived
+ its width from the VALUE's wholeness alone (`isWhole ? 0 : 2` — a literal 2 for
+ every currency on earth), and `CurrencyField` defaulted an undeclared
+ `precision` to the same literal. So a yen amount was printed with cents the
+ currency does not have and a dinar amount with one digit fewer than it does:
+
+ | | before | after |
+ | ------------ | -------------- | ----------- |
+ | JPY `1234.5` | `¥1,234.50` | `¥1,235` |
+ | KWD `1.5` | `KWD 1.50` | `KWD 1.500` |
+ | CLP `1234.5` | `CLP 1,234.50` | `CLP 1,235` |
+ | BHD `2.5` | `BHD 2.50` | `BHD 2.500` |
+ | USD `1234.5` | `$1,234.50` | `$1,234.50` |
+ | USD `1234` | `$1,234` | `$1,234` |
+
+ Both call sites now derive the width from the currency itself
+ (`Intl.NumberFormat(undefined, { style: 'currency', currency })
+.resolvedOptions().maximumFractionDigits`, memoized per code) and switch
+ wholeness against THAT.
+
+ **The whole-number convention is extended, not retired.** Simply dropping both
+ bounds and letting `Intl` decide would have fixed the digit count while turning
+ `$1,234` back into `$1,234.00` — the Salesforce convention `formatCurrency`
+ documents and objectui#4033 pinned. A whole amount still drops the fraction, now
+ for every currency: `KWD 1` renders `KWD 1`, not `KWD 1.000`. Two-decimal
+ currencies are byte-identical to before, which is why the objectui#4033 and
+ objectui#4332 pins pass unchanged.
+
+ **On `CurrencyField`, an explicitly authored `precision` still wins** — it is
+ authored metadata and authored metadata keeps priority, so a JPY field declaring
+ `precision: 2` still renders `¥1,234.50`. Only an ABSENT `precision` derives from
+ the currency; because that derivation is the widget's one precision, it also
+ reaches the spinner `step` and the blur rounding, so a JPY field no longer offers
+ a `0.01` step for a currency with no minor unit. Whether a declared `precision`
+ that contradicts the currency's ISO 4217 digits should be REJECTED at publish
+ time is a contract question, filed upstream in `@objectstack/spec` rather than
+ answered here by overriding the author.
+
+ Reachable wherever the resolved currency is not a 2-decimal one — the field's
+ `currency`, `currencyConfig.defaultCurrency`, or the tenant default (ADR-0053).
+
+- d2e2caf: fix(fields): `formatCurrency` keeps both cents digits on a fractional amount
+
+ The symbol branch passed `minimumFractionDigits: 0` against a
+ wholeness-switched `maximumFractionDigits`, which handed `Intl` the range
+ `[0, 2]` — and `Intl` emits the shortest representation in range, so a real
+ cents value of `.50` was printed as `.5`. Any price ending in a zero cent digit
+ rendered one digit short: `$1,234.50` as `$1,234.5`, `$19.90` as `$19.9`,
+ `$0.50` as `$0.5` — money on a record page and in grid cells reading as a data
+ error rather than a formatting one.
+
+ Both bounds now take the same wholeness-switched width, so the function
+ delivers the contract its own doc comment states: a fractional amount shows
+ exactly two digits, a whole amount still drops `.00` (`$1,234`). The
+ no-currency branch and the bad-currency fallback already behaved this way; only
+ the symbol branch disagreed.
+
+ Reaches every consumer of the shared helper: `CurrencyCellRenderer`,
+ `ObjectGrid`, the dashboard `recordFields` and `ObjectGantt`.
+
+- 3a9021e: fields: the currency adornment has one symbol channel
+
+ `CurrencyField` carried the same one-entry fact twice — a dead `CURRENCY_SYMBOLS`
+ map that nothing read, and a live `currency === 'USD' ? '$' : currency` ternary
+ two lines below it. Both were hand copies of knowledge `Intl` already carries,
+ and both are gone: a new `currencySymbol(currency, locale)` beside
+ `currencyFractionDigits()` reads the `currency` part of the very format the
+ widget's readonly branch already renders amounts with.
+
+ USD is unchanged at the display-locale default. Other currencies now show their
+ real symbol instead of the bare ISO code — `€` for EUR, `¥` for JPY, `£` for
+ GBP — which is what the same widget's readonly mode has always displayed; the
+ edit adornment simply stopped disagreeing with it. Currencies CLDR has no symbol
+ for (KWD, BHD, CHF, ISK, CLP) still render their code, exactly as before.
+
+- 8f60d73: `@object-ui/fields` and `@object-ui/plugin-editor` stop publishing their test declarations
+
+ Both packages' build tsconfigs set `include: ["src"]` with no test exclude, so every test file entered the declaration program and its `.d.ts` was written into `dist/`. Both are published (`private` is false, `files` contains `dist`), so those declarations shipped: 85 from `@object-ui/fields` and one from `@object-ui/plugin-editor`. Adding the test exclude the other twenty-odd packages already use removes them.
+
+ Nothing else about either artifact moves. Measured by building each package both ways from a cleared `dist/`, then diffing the file lists: `@object-ui/fields` goes from 163 files to 78 and `@object-ui/plugin-editor` from 6 to 5, every one of the 86 disappearances is a `*.test.d.ts`, no file appears, and all 83 surviving files are byte-identical by sha256 — including each package's entry `dist/index.d.ts`. The entry type surface is therefore unchanged and no import can break; this is the tarball shedding files nothing resolved.
+
+ The type coverage those files were a side effect of did not go with them. Because the build program read the tests, these two packages counted as "tests type-checked" in `scripts/check-type-check-coverage.mjs` — a correct verdict reached through an emit nobody wanted. Excluding the tests alone would have silently dropped 86 test files out of every `tsc` program, so the same change adds a `tsconfig.test.json` per package, chained from each package's `type-check` script, and the coverage gate stays at 41 of 41 packages compiling their tests with zero declared debt on both sides of the change.
+
+- cb13400: One fullscreen long-text editor, hoisted to the package both render paths may import
+
+ The "expand to a full-height dialog" interaction had two independent implementations. `FullscreenTextarea` lived inside the form renderer's built-in (unregistered) `textarea` branch in `@object-ui/components`; `FullscreenFieldEditor` lived in `@object-ui/fields` and served the registered `TextAreaField` / `RichTextField` widgets. They exist because ONE form-level promise — `ObjectFormSchema.mobile.fullscreenLongText`, projected onto every long-text field as `mobile_fullscreen` — is honoured on two render paths, and each path grew its own answer.
+
+ Two copies of a state machine drift, and these did, in both directions: objectui#3400 measured a read-only long-text field that was fully editable through the built-in branch's dialog (and "Done" wrote the edit into form state), objectui#3402 measured the same write-back hole for `disabled` on the registered path, and objectui#3393 (the dialog title needs the field label) and objectui#3272 (the copy needs i18n) each landed on one side before the other. Every repair was correct and none of them scaled.
+
+ `@object-ui/components` now exports `FullscreenEditor`, a single primitive owning the affordance, the dialog, the draft/commit state machine and the copy. The direction follows the measured import graph rather than fighting it: `@object-ui/fields` depends on `@object-ui/components`, and `components` declares no dependency on `fields` in either `dependencies` or `peerDependencies`, so the shared code can only live in `components`. `FullscreenFieldEditor` becomes a thin wrapper over it and keeps its name, its props and its test-id namespaces, so both hosts and their pins are unchanged.
+
+ The load-bearing part of the merge is that the primitive DEFINES `readOnly` and `disabled` instead of inheriting them by accident. Neither copy defined both: the built-in one grew them under objectui#3400, while the fields one declared only `disabled` and was shielded from `readonly` by its hosts' early return — a single implementation cannot be shielded by one caller's control flow. So both are answered once, and both call paths inherit the same answers: `readOnly` renders no affordance at all (it means "shown plainly", so advertising an expand button the user cannot use is worse than showing none), `disabled` leaves an inert one (it means "not interactive, muted"). Neither relies on the toggle alone, because `disabled` also carries the form's `isSubmitting` and can flip to true while the dialog is already open — so opening refuses independently of the attribute, the injected editor is told, "Done" is disabled, and `onCommit` is gated as the single point where a value leaves for host state.
+
+ No copy changed and no locale pack needed an edit: the primitive consumes the same `form.fullscreen.*` / `common.cancel` keys both copies already read, through `createSafeTranslation` with English defaults byte-identical to the literals, so provider-less hosts render exactly what they did. The now-unread `form.fullscreen.*` defaults are dropped from `useFieldTranslation`, where they would have re-created in the defaults map precisely the duplication this change removes from the components.
+
+ `toggleClassName` is not carried into the new primitive. It was declared on `FullscreenFieldEditorProps` and written by nobody — zero producers repo-wide — and `FullscreenFieldEditor` is not exported from the `@object-ui/fields` barrel, so no consumer outside the package could ever have set it. Minting it as part of a NEW public export in `@object-ui/components` would have published a prop with no producer, the shape objectui#3232/#3233 keeps deleting.
+
+- bc64bfe: A dependency-gated option list no longer deletes the field's stored value on mount
+
+ The four fixed-option widgets (`SelectField`, `MultiSelectField`, `CheckboxesField`, `RadioField`) end their cascade resolution with a "drop what is no longer offered" effect, and the form renderer runs an equivalent clear of its own over every option field. Both read `resolveCascadingOptions`, which returns an **empty** offered set whenever the list is _gated_ — a declared `dependsOn` parent is still empty. Nothing the field held could be "still offered" against an empty set, so both paths wrote the field empty **on mount, with no interaction**, while the control rendered "Select Country first" beside it: it told the user it could not offer anything, and deleted what they had.
+
+ Gated means **unknown**, not invalid. The cascade clear exists (ADR-0058) so a user-driven parent change prunes a now-invalid child; a withheld list on mount is missing information — the record simply arrived with its controlling field empty (a later-cleared parent, an import, a partially-migrated row) — and that is not a reason to destroy stored data. Both clears now skip while gated, reading the resolver's own `gated` flag rather than re-deriving it from an empty offered set, which would collide with the distinct never-configured case guarded separately in objectui#4220.
+
+ Convergence stays exactly where it belongs: once the parent **is** chosen and the resolved set genuinely excludes the stored value, the prune applies unchanged — including at the moment the gate lifts, so picking a parent whose list does not contain the old value still clears it on that transition. The three states are pinned apart (never-configured / gated / resolved-and-excludes) across all four widgets and the form host, so a future edit cannot collapse them back into one empty-set test.
+
+ Reachable on every host that mounts these widgets with a live record: the form renderer, the grid's inline cell editor, and the detail page's inline editor — where each `onChange` went straight into the record draft the save bar commits.
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
+
+ **objectui#4375** — `ListView` and `LookupField` built their search placeholder as
+ `t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
+ in all ten locales on screens where objectui#3878 had converged everything else on
+ U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
+ was appended to right-to-left text). Both now read `table.search`, which is already
+ the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
+ `PeoplePicker` render it too — and is translated with the right ellipsis in all ten
+ packs. No new keys.
+
+ **objectui#4376** — `list.loading` served the English `Loading records…` in eight of
+ the nine translation packs (`zh` alone had translated it); `designer.undo` and
+ `designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
+ `ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
+ pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
+ inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
+
+- 433ff9f: An image field's declared `maxSize` is enforced before the upload starts, not after it finishes
+
+ `ImageField` received a `maxSize` and ignored it. `paramToField` copies `maxSize` onto the field config for every action param regardless of type, so an image param declared with a 5 MB limit handed the widget its constraint and the widget uploaded anyway: a 6.3 MB PNG fired the full `presigned → PUT → complete` chain and rendered a thumbnail, with no rejection anywhere. The sibling file param, declared the same way, refused the identical pick without a single request. Reported from a QA run driving the two side by side (objectui#4141).
+
+ Both of the widget's upload doors now check the limit first. The native picker rejects oversize picks before any request, keeping FileField's partial-acceptance rule — the in-limit members of a multi-select still upload, and only the oversize ones are reported. The crop dialog is the second door and needed the check in its own right: the cropper re-encodes to PNG, so cropping an in-limit JPEG can produce a blob _over_ the limit, and it is the crop's size that is uploaded. A rejected pick or crop now surfaces the same message the file widget has always shown, in a new error row — this widget had no surface for rejections before, because it never rejected anything.
+
+ The guard itself moved into one shared `maxSizeError` helper that both widgets call, rather than a second copy living in the image widget. The check is the only thing between a declared limit and a real upload, and a per-widget copy is what let these two drift apart unnoticed in the first place. Both widgets also share the existing `fields.file.exceedsMaxSize` message: it names a file and a limit, says nothing file-specific, and is already translated in all built-in locales, so no new key was added and no translation is pending. FileField's own behavior is unchanged — same threshold, same message, same partial acceptance.
+
+ An undeclared `maxSize` still means unrestricted; the falsy check is preserved deliberately, so a missing limit can never be read as a zero-byte one.
+
+- e7663f2: fix(detail): inline edit no longer destroys array values or flattens types on the record page
+
+ `InlineFieldInput`'s type switch ended in a raw text input, and every type it had
+ no branch for landed there: the value was displayed through `coerceToSafeValue`
+ and written back as whatever the user typed — a bare string.
+
+ Two damage classes survived the earlier passes. Array-valued fields (`tags`,
+ `checkboxes`, an options-less multi picklist) were offered for editing as
+ `"a, b"` — `coerceToSafeValue` joins arrays — and saved back as that string, so
+ the array was gone. Type-lossy scalars (`toggle`, `slider`, `progress`,
+ `rating`, `radio`) round-tripped through `String()`, so a boolean column
+ received `"true"`, a numeric one `"42"`, and `radio` accepted any free-typed
+ value its option list never offered.
+
+ Types the switch already routes keep their editors. Everything else that the
+ fields package can edit inline now falls back to `FieldEditWidget` — the same
+ control the form renders, `json` → the code editor included — and only genuinely
+ string-valued types (`text`, `textarea`, `email`, `phone`, `url`) keep the plain
+ input. A drift guard asserts every field type is exactly one of routed /
+ excluded / delegated / benign, so a new type can no longer inherit the
+ value-destroying default in silence.
+
+ `@object-ui/fields`: the four fixed-option widgets no longer clear the stored
+ value when the field declares no `options` at all. An empty offered set had two
+ opposite causes — a list that cascaded to zero (clear) and a list that was never
+ authored (nothing to decide) — and the second deleted the value on mount, which
+ the grid's inline cell editor has always been able to trigger. `FieldEditWidget`
+ also forwards `autoFocus` to the widget it renders.
+
+- b953a97: fix(detail): lookup field values link to the referenced record
+
+ A valued lookup on a record detail page rendered as plain text plus a copy
+ button — the referenced document's name was visible but unreachable, so users
+ copied the number and searched for it from the list page instead. Lookup cells
+ inside a related list that pointed at a third object were dead the same way.
+
+ `LookupCellRenderer` — the one cell renderer both surfaces resolve through —
+ now renders the display value as a link to the referenced record. The display
+ name resolution, the copy affordance and every non-lookup field are unchanged,
+ and a lookup with no value still renders its placeholder rather than an empty
+ link.
+
+ The URL is not assembled in the renderer. `RelatedRecordActionsContext` gains
+ an optional `recordHref` / `openRecord` pair, published by the console's
+ `RelatedRecordActionsBridge` from the SAME builder its related-list row
+ navigation already used, so there is one record-route shape rather than a
+ second one. A host that does not provide it (Studio designer, embedded
+ renderers, standalone grids) renders exactly what it rendered before.
+
+- 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
+
+ Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
+
+ The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
+
+ `useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
+
+ Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
+
+ Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
+
+ `formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
+
+- ac853ce: i18n: retire the reader-less `common.search` key from all ten locale packs
+
+ `common.search` (`Search`, no ellipsis) had exactly one consumer: `LookupField`
+ built its dialog placeholder by concatenating the key with three ASCII full
+ stops. objectui#4375 / PR #4391 retired that concatenation — the placeholder is
+ the reused `table.search` pack value (`Search…`, one U+2026 glyph), which is what
+ brought it under objectui#3878's glyph pin. That left `common.search` with zero
+ readers repo-wide while it still existed in all ten packs.
+
+ Re-verified before deleting, repo-wide: no `t()` call site in any package or app,
+ no MDX or JSON reference, and the one dynamic template-literal reader of the
+ `common` namespace takes a two-member union parameter (`'openChat' |
+'closeChat'`) that cannot resolve to it. No user-visible string changes — this key never rendered.
+
+ The dormant copy in `@object-ui/fields`' no-provider fallback table
+ (`useFieldTranslation.ts`'s `FIELD_DEFAULTS`) goes with it. That table is a
+ module-local `Record` read only when no `LocalizationProvider`
+ is mounted; it is not exported, so removing an entry no reader asks for changes
+ no rendered output and narrows no public type. Hence patch for that package,
+ while the pack change is a minor: deleting a key from `en` narrows the exported
+ `TranslationKeys` type (`typeof en`), so code indexing `TranslationKeys` at
+ `common.search` stops type-checking. Same grading, for the same reason, as
+ objectui#4145's `report.editor.*` retirement. No runtime consumer existed to
+ break.
+
+ Retiring a key from `common` was the ruled decision on objectui#4392 rather than
+ keeping it as vocabulary: nothing pins a dormant key's meaning, so its next
+ reader inherits an unreviewed contract, and a dormant key beside a live
+ `table.search` is where a second dialect gets started. The objectui#4328
+ dead-surface family has consistently chosen removal for zero-consumer surfaces.
+
+ The neighbouring `common.select` (minted one commit earlier by objectui#4386 /
+ PR #4397) is a different key and is untouched.
+
+ A negative pin (`packages/i18n/src/__tests__/common-search-retired-4392.test.ts`)
+ fails if the key returns to any pack, if any package reads or re-declares it, or
+ if a dynamic `common.*` reader grows a `search` member — every existing i18n gate
+ runs call site to key, and none of them can see a key with no call site.
+
+- 06915b0: fix(i18n): every date branch threads the active locale, so a `zh` session no longer renders half its dates in English
+
+ Date rendering had two locale channels and only one followed the user's
+ language, so the same row could read `逾期 6 天` in one column and `In 3 days`
+ in the next, with a datetime column showing `8/11/2026 12:00 am`
+ (objectui#4468).
+
+ The overdue phrase resolves through the translate fn (the active UI language),
+ while every `Intl` branch took its tag from the raw tenant locale
+ (`useLocalization().locale`) — which is `undefined` on any workspace that never
+ configured one, and `undefined` makes `Intl` use the _machine's_ locale.
+ `DateTimeCellRenderer` passed no tag at all.
+
+ Every date-formatting site in `@object-ui/fields` now resolves through the one
+ existing channel, `useDisplayLocale()` (tenant regional default → active UI
+ language → `en`): `DateCellRenderer` (relative past, relative future, near-today
+ and the beyond-±7-days absolute fallback), `DateTimeCellRenderer`, the read-only
+ `DateField` / `DateTimeField` / `FormulaField` faces, and the sub-grid's
+ temporal cells. English output is unchanged, and the already-localized overdue
+ wording is untouched.
+
+ No public signature changed. `@object-ui/i18n` carries a documentation
+ correction only: `useDisplayLocale`'s docstring claimed `DateCellRenderer`
+ already formatted from this channel, which was the very thing that was not true.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [5cc847c]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/providers@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/fields/package.json b/packages/fields/package.json
index 4d99a56ede..3cd97dc9bf 100644
--- a/packages/fields/package.json
+++ b/packages/fields/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/fields",
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "Field renderers and registry for Object UI",
"license": "MIT",
"type": "module",
diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md
index 8b94e715ae..03e7b68cf3 100644
--- a/packages/i18n/CHANGELOG.md
+++ b/packages/i18n/CHANGELOG.md
@@ -1,5 +1,634 @@
# @object-ui/i18n
+## 17.5.0
+
+### Minor Changes
+
+- 0e67b53: `/accept-invitation/:invitationId` is one route, one component, one namespace — the console now renders the invitation page that actually shows you the invitation
+
+ Two components shipped for this single URL. The console routed its own thin page, which offered nothing but an Accept and a Decline button: it never told the user which organization they had been invited to, in what role, or when the link expires, and accepting left them in whatever organization they were already in. App-shell's page — exported as `DefaultAcceptInvitationPage`, routed by nobody — fetches the invitation, shows the organization, the role and the expiry date, and switches the user into that organization on accept. Console now routes that one. The thin page is deleted.
+
+ Behind them sat two i18n namespaces for one screen: `acceptInvitation.*` (12 keys) for the thin page and `organization.accept.*` (14) for the richer one, both freshly translated into ten languages by different slices of objectui#3546, neither wrong when read on its own. That is 26 keys of duplicated copy with no gate to tell the next author which of the two to edit — the failure mode this repo already has an uncollected precedent for. `acceptInvitation.*` is removed from all ten packs, and its absence is pinned negatively so it cannot drift back: the slice-three test now asserts that no pack defines any of the 12 retired keys (nor an emptied namespace root left by a partial revert), and that neither consuming package asks `t()` for one.
+
+ One behavior needed repairing before the swap was safe rather than after. `?redirect=` is a basename-stripped path by contract in this console — `LoginPage` re-prefixes it with the mount before navigating — and the thin page built it from the route param, correctly. App-shell's page built it from `window.location.pathname`, which already carries the mount, so a console served under a ` ` would have sent the user back to `/console/console/accept-invitation/…` after signing in. It now reads the router (`useLocation`), like every other producer of that parameter in this repo. Under the default `/` mount the two spellings are identical, which is why only a basename case can see the difference; that case is now a test.
+
+ Nothing published was removed: `DefaultAcceptInvitationPage` keeps its export and simply becomes the routed implementation. Downstream apps mounting it get the redirect fix and are otherwise untouched.
+
+- 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
+
+ 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.
+
+ **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.
+
+ 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.
+
+ **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.
+
+ 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.
+
+ 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.
+
+- 66fb4fa: The console's language menu now asks the app which locales it actually ships, instead of always offering the same ten.
+
+ `LocaleSwitcher` built its items from a module-level `LANGUAGES` constant — exactly the ten codes `@object-ui/i18n` ships packs for — and never consulted the app, even though `GET /api/v1/i18n/locales` has been serving that list all along. It failed in both directions at once. An app shipping a locale outside those ten (`th`, a regional `pt-BR`) had no way to be selected from the console: the bundle could be complete and lint clean, and the menu simply had no entry for it. An app shipping only `en` and `zh` still listed all ten, so picking 日本語 handed the user the console's own chrome in Japanese with everything app-authored in the fallback language — a half-translated UI its author never opted into.
+
+ The menu is now the **intersection**: the app's own locale list ∩ what the renderer can actually resolve (built-in packs, `config.resources`, and — for an app that wires a dynamic `loadLanguage` loader, which is how the console gets its packs — the locales that loader can fetch). Both failure directions close in the same change: an app-shipped locale becomes offerable, and locales the app does not ship disappear. The endpoint is reached through a new `loadLocales` prop on `I18nProvider`, wired exactly like the existing `loadLanguage`: the app owns the transport, the provider owns what is done with the answer. An app that does not wire it keeps today's menu unchanged.
+
+ **The restore validation widened in lockstep, because otherwise this fix would have minted the next bug.** A restored language was validated against "the locales this provider can produce" — built-in packs plus `config.resources` — a bound that was correct only for as long as the menu offered exactly the built-in ten. The moment the menu grows to the app's real list, a locale the user can now pick is a locale that bound rejects, so a user-picked app locale would have been purged on the next page load. It now also accepts a locale a wired dynamic loader may be able to fetch, and the bound stays honest rather than absent: only well-formed BCP-47 tags qualify (`constructor`, `__proto__`, `en_US` are still rejected, as is any stored locale in an app with no loader), and the app's own locale list adjudicates the choice for real once it arrives — a locale the app has since dropped is reverted and purged rather than left locking the UI to a language with no translations.
+
+ Labels come from the built-in native names where they exist (`中文`, `日本語`, … are unchanged) and from `Intl.DisplayNames` for everything else, so an app locale is named in its own language rather than by its code. The endpoint's own `label` is deliberately not used for display: the server sets it to the code echoed back, which would have put `th` in the menu where `ไทย` belongs.
+
+ While the app's list is in flight the switcher renders nothing, following the sibling menus in the same folder — the ten never flash past on an app that only ships two. When there is no backend, the endpoint fails, or the app answers with nothing this renderer can produce, the built-in ten remain as the offline fallback, so the menu is never empty and never unusable.
+
+- 92250d6: One home for the number-display policy — and a percent stops meaning two different things between a list cell and a dashboard measure
+
+ `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.
+
+ 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.
+
+ **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.
+
+ **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.
+
+ `@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.
+
+- ac853ce: i18n: retire the reader-less `common.search` key from all ten locale packs
+
+ `common.search` (`Search`, no ellipsis) had exactly one consumer: `LookupField`
+ built its dialog placeholder by concatenating the key with three ASCII full
+ stops. objectui#4375 / PR #4391 retired that concatenation — the placeholder is
+ the reused `table.search` pack value (`Search…`, one U+2026 glyph), which is what
+ brought it under objectui#3878's glyph pin. That left `common.search` with zero
+ readers repo-wide while it still existed in all ten packs.
+
+ Re-verified before deleting, repo-wide: no `t()` call site in any package or app,
+ no MDX or JSON reference, and the one dynamic template-literal reader of the
+ `common` namespace takes a two-member union parameter (`'openChat' |
+'closeChat'`) that cannot resolve to it. No user-visible string changes — this key never rendered.
+
+ The dormant copy in `@object-ui/fields`' no-provider fallback table
+ (`useFieldTranslation.ts`'s `FIELD_DEFAULTS`) goes with it. That table is a
+ module-local `Record` read only when no `LocalizationProvider`
+ is mounted; it is not exported, so removing an entry no reader asks for changes
+ no rendered output and narrows no public type. Hence patch for that package,
+ while the pack change is a minor: deleting a key from `en` narrows the exported
+ `TranslationKeys` type (`typeof en`), so code indexing `TranslationKeys` at
+ `common.search` stops type-checking. Same grading, for the same reason, as
+ objectui#4145's `report.editor.*` retirement. No runtime consumer existed to
+ break.
+
+ Retiring a key from `common` was the ruled decision on objectui#4392 rather than
+ keeping it as vocabulary: nothing pins a dormant key's meaning, so its next
+ reader inherits an unreviewed contract, and a dormant key beside a live
+ `table.search` is where a second dialect gets started. The objectui#4328
+ dead-surface family has consistently chosen removal for zero-consumer surfaces.
+
+ The neighbouring `common.select` (minted one commit earlier by objectui#4386 /
+ PR #4397) is a different key and is untouched.
+
+ A negative pin (`packages/i18n/src/__tests__/common-search-retired-4392.test.ts`)
+ fails if the key returns to any pack, if any package reads or re-declares it, or
+ if a dynamic `common.*` reader grows a `search` member — every existing i18n gate
+ runs call site to key, and none of them can see a key with no call site.
+
+- fa51109: i18n: retire the orphaned `report.editor.*` namespace — 105 of its 106 keys, in all ten locale packs (~1050 translated strings)
+
+ The namespace labelled the hand-rolled report editor form. That form no longer
+ exists: `ReportConfigPanel`'s body is `ReportDefaultInspector`, a spec-driven
+ inspector whose labels come from the report spec's own metadata rather than from
+ a pack namespace. Until objectui#4137 the namespace had exactly one live reader,
+ and it was the objectui#4118 defect itself — the panel borrowing
+ `report.editor.title` (the label of the report's Title _field_) to name itself.
+ Moving that slot onto a purpose-built `report.editor.panelTitle` left the other
+ 105 keys with no reader anywhere.
+
+ Re-verified before deleting, repo-wide and per key: no `t()` call site, no
+ dynamic `t()` template form, and no JSON or MDX reference reads any of the 105.
+ No user-visible string changes — these keys never rendered.
+
+ `report.editor.panelTitle` survives in all ten packs and is untouched; the
+ deletion sweeps around it. `report.editor` therefore remains a live namespace
+ holding exactly that one key.
+
+ This narrows the exported `TranslationKeys` type (`typeof en`), which is why it
+ is a minor rather than a patch: code indexing `TranslationKeys` at a retired key
+ stops type-checking. No runtime consumer existed to break.
+
+ A negative pin (`packages/i18n/src/__tests__/report-editor-retired-4145.test.ts`)
+ names all 105 retired keys and fails if any returns to any pack, since every
+ existing i18n gate runs call site to key and none of them can see a key with no
+ call site.
+
+- 78fa331: console: seed the UI language from the tenant's server-side locale
+
+ `GET /auth/me/localization` has always been fetched on every boot, but its
+ `locale` only ever fed currency/date formatting — the UI language was decided
+ entirely client-side, so a tenant configured `zh-CN` still handed every new
+ device an English console until each user switched by hand.
+
+ The tenant locale now sits in the language precedence chain, between the user's
+ own choice and the browser's:
+
+ 1. the user's explicit choice (`objectui-locale`)
+ 2. the tenant's server locale, cached at `objectui-locale-seed`
+ 3. the browser language
+ 4. `en`
+
+ The server value is cached in a slot of its own and is never written into the
+ explicit-choice slot, so it can never masquerade as a preference the user
+ expressed: only a manual switch promotes a language to an explicit choice. A
+ cached seed applies synchronously at bootstrap, and the in-app fetch refreshes
+ that cache from every successful answer, so a tenant that changes its locale
+ reaches choice-less devices on their next boot without an old seed pinning
+ them. On a device's true first visit the fetch is raced against a ~500ms
+ timeout alongside the console's existing pre-mount round-trips and fails open
+ to the browser language; a seed that arrives after the bound is cached for the
+ next boot rather than re-languaging a live session. A tenant locale this build
+ ships no pack for falls through to the next tier instead of half-rendering.
+
+ No platform additions: no new endpoint, no client read/write API, and
+ `sys_user_preference` is untouched.
+
+- 0082db8: The timeline's gantt bucket labels and its row-label default speak the session language
+
+ objectui#4513 routed every `Intl` call in the timeline renderer through `useDisplayLocale()`, so a Chinese session renders `2026年8月` on the month axis and `2026年8月11日` on item dates. Three sibling strings in the same renderer never went through `Intl` at all and stayed English on that same Chinese axis: the `week` header (`Week 1`), the `quarter` header (`Q3 2026`), and the gantt row-label column default (`Items`). The half-fixed state was the visible one — a Chinese date axis with English bucket labels beside it.
+
+ They are a translation concern rather than a locale-resolver one, and that distinction is the fix: a locale TAG formats a date, only a TRANSLATION spells a word. All three now resolve through the package's existing channel — `useTimelineTranslation` / `TIMELINE_DEFAULT_TRANSLATIONS`, the `createSafeTranslation` factory `ObjectTimeline` already uses for `timeline.bucket.*` — under three new keys carried by all ten locale packs: `timeline.scale.week`, `timeline.scale.quarter`, `timeline.gantt.rowLabel`.
+
+ The week number and the quarter/year ride the channel's own `{{hole}}` parameters rather than being concatenated, because the word order belongs to the translation: Chinese puts the year first (`2026年第3季度`), which no `Q${q} ${year}` template can produce at all. Only the row-label DEFAULT moved — an author who writes `rowLabel` still supplies their own string, and the `year` scale stays a bare `String(getFullYear())` with no vocabulary in it to translate.
+
+ English output is byte-identical to the retired literals: the `en` pack values are the same two templates the code used to interpolate by hand. `generateTimeScaleHeaders` is a pure exported function and cannot host a hook, so the translate fn is threaded in as an optional fifth parameter on the seam #4513 opened for `locale`, defaulting to the package's own defaults table — the same lookup the channel serves with no `I18nProvider` mounted. Existing three- and four-argument call sites are unaffected.
+
+ One consequence is worth stating because it looks like a bug and is not: dates and vocabulary resolve through different channels on purpose. `useDisplayLocale()` puts the tenant's regional default first (how this organization writes dates), while `t` follows the UI language (what this user reads). A tenant configured `en` whose user reads Chinese chrome therefore sees `Aug 2026` beside `第 1 周` — the same split `timeline.bucket.*` has always had.
+
+### Patch Changes
+
+- 932cbcd: An app you are not allowed to open now says so, instead of reporting that it may still be publishing
+
+ `GET /api/v1/meta/apps` is filtered per session server-side (`filterAppForUser`), so an app withheld by its `requiredPermissions` and an app that does not exist were byte-identical to the console: both simply absent from the list. With one fact and two conditions, `AppContent` rendered its only copy for an absent app — "This app is not available yet — it may still be publishing. Try again in a moment." — over a permanent authorization decision, under a Retry button that could never succeed.
+
+ That is not a cosmetic complaint. On a downstream acceptance round one role hit this screen while another opened the same app fine, and because the copy names a transient deployment state the finding was filed as a suspected platform defect and carried through two test batches before a clean-baseline investigation found the account was missing a permission-set binding. The gate had been working exactly as designed; the message is what sent everyone to the wrong place.
+
+ The maintainer ruling (2026-08-12) took the contract half first. objectstack#8013 made the BY-NAME route answer an explicit denial — `403` with the ADR-0112 catalog code `PERMISSION_DENIED` in the declared `{ success: false, error: { code, message } }` envelope — for an app that exists and whose `requiredPermissions` the session lacks, while the LIST route stays filtered exactly as before, with no `authorized: false` flag, so the enumeration surface is not widened past what a direct by-name probe already implies. Absence keeps answering `404 RESOURCE_NOT_FOUND`, and so do the two neighbouring refusals the same ruling deliberately left alone: an unpublished app (ADR-0045 §3 keeps it externally unobservable) and an app gated by an absent optional service (ADR-0057 D10 — nothing was denied to the caller).
+
+ This is the console half. When a requested app is missing from the list and the existing post-publish readiness re-check still cannot find it, the console asks the by-name route which of the two it is, through a new `ObjectStackAdapter.probeAppAccess(name)`. On the measured code it renders a plain authorization message with a way back to the launcher; on anything else — an absent app, an unreachable server, a host that injected a DataSource without the probe — today's publishing copy renders byte for byte, retry button included.
+
+ Two properties of that seam are load-bearing rather than incidental. It branches on the ADR-0112 **code**, never the status (objectui#4408): the two answers under test are both errors one status apart, and a status-reading implementation passes the happy path while going blind exactly where the defect lives. And only `denied` moves the copy: this bug exists because the console asserted a state it had not measured, so a probe that fails, times out or cannot be issued must leave the screen alone rather than guess in the other direction.
+
+ `probeAppAccess` is deliberately separate from `getApp` rather than a flag on it: `getApp` degrades every failure to `null` — the very conflation being undone — and memoises in the adapter's metadata cache, where a verdict about the CALLER would outlive the session it described. New public API on the adapter (`probeAppAccess`, `isAppPermissionDeniedError`, `APP_PERMISSION_DENIED_CODE`, `AppAccessVerdict`), purely additive; nothing existing changed shape. Three new `empty.*` keys ship in all ten locale packs.
+
+- 734d186: The console's Applications page is localized — its own chrome only, never the
+ server's words (objectui#4307).
+
+ `AppManagementPage` was raw English end to end: headings, the search field, the
+ selection and bulk controls, the six per-row actions with their tooltip/ARIA
+ pairs, the status badges, and every toast. It was the last un-i18n'd system page,
+ and #4233 / PR #4300 had just given it four live mutations — so the gap became
+ user-visible on every non-English console at the moment operators started using
+ it. 45 keys land under `appManagement.*` in all ten packs, reached through
+ `useObjectTranslation` with the call site's `defaultValue` inline, which is the
+ convention the neighbouring system pages already follow.
+
+ The split that shapes this change is between the strings the PAGE authors and
+ the strings the SERVER authors. `PUT`/`DELETE /api/v1/meta/app/:name` is gated on
+ `manage_metadata` (ADR-0066 D1), so a refusal like `forbidden: manage_metadata
+required` is the server's diagnosis of one specific request; there is no fixed
+ catalogue of those sentences to key against. Each failure toast is therefore a
+ keyed template with a `{{reason}}` hole, and what fills the hole is passed
+ through byte for byte, untranslated. The one part that IS the page's own — what
+ it says when the server sent no message at all — is keyed as
+ `appManagement.toast.unknownError`.
+
+ Two smaller things follow from doing the conversion properly rather than
+ mechanically. The per-failure entry of a bulk toast and the separator between
+ entries are keys, not literals, because bracket style and list punctuation are
+ locale properties (the same rule, and the same past defect, as
+ `validation.formInvalidJoiner`). And the row's controls now name an app through
+ the resolver the visible heading two lines away already used, with `t` passed:
+ an app carrying objectui's keyed label form previously rendered `Select [object
+Object]` into its checkbox's ARIA label.
+
+- 3fc2971: A null-keyed group renders as an explicit bucket instead of silently vanishing from a chart (objectui#4466)
+
+ `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`).
+
+ 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.
+
+ `@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`.
+
+ 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.
+
+ `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.
+
+- f7c6430: The build-history panel tells an operator a 503 means "the commit store could not be reached — retry", instead of `commits HTTP 503`
+
+ `packages/app-shell/src/preview/commitHistory.ts` flattened every non-OK response to a bare status code (`commits HTTP {status}` for the read, `HTTP {status}` for the revert). Nothing was ever swallowed and no fictional "no history" was ever rendered — those fail-loud properties held, and still hold, which is why objectstack#5980's 503-ification (ADR-0110 D3) needed no follow-up here. What was lost is the meaning the backend already sends, on the one screen where it matters most: this is the rollback surface, read by an operator who is usually mid-incident. A 503 says the read/write did not happen and is worth retrying; a 404 says the store answered "no". They now read differently, and 404, 500 and 503 stay tellable apart.
+
+ Failures now throw a `CommitStoreError` carrying `status`, the ADR-0112 `code`, and a `retryable` flag, and the panel renders a sentence rather than a number. The revert half gets a deliberately different sentence: a write that could not reach the store may still have landed, and re-issuing it appends a _second_ revert commit to an append-only log, so the copy asks the operator to re-read the timeline before retrying rather than simply saying "try again".
+
+ Two details of the report this fixes were checked against the producer and came back different, and both are the reason the copy is authored client-side. The semantic code arrives at **`error.code`**, not `details.code` — `HttpDispatcher.errorFromThrown` parks it in `details` and `buildApiError`/`splitSemanticCode` lift it out and drop `details` (objectstack#3842) — so a consumer reading `details.code` would run a check that can only pass vacuously. And the envelope's own `message` for this class is _withheld_: `declaresServerFault` (objectstack#5811) is true for a 5xx carrying a string code, so the prose on the wire is the generic `Internal server error`. Rendering it would have been strictly worse than the bare status code it replaced. Classification therefore keys on the HTTP status first and treats the code as a second signal, which also means a 503 shed by a proxy with an HTML body still produces the retryable reading.
+
+ Adds `preview.history.loadFailedUnavailable` and `preview.history.revertUnavailable` to all ten locale packs.
+
+- 92876f0: Doc comments no longer cite `@objectstack/spec` symbols the pinned spec has retired
+
+ Eight exported declarations carried a doc comment claiming alignment with a
+ `@objectstack/spec` symbol that `17.0.0-rc.6` does not export — four locale
+ formatting shapes in `@object-ui/i18n` (`SpecPluralRule`, `SpecDateFormat`,
+ `SpecNumberFormat`, `SpecLocaleConfig`) and four activity-feed shapes in
+ `@object-ui/types` (`FieldChangeEntry`, `Mention`, `Reaction`,
+ `RecordSubscription`). A citation that points at nothing is worse than a stale
+ one: the next reader cannot tell whether the protocol retired the symbol,
+ renamed it, or never had it.
+
+ Measuring all eight against the published registry answered that question, and
+ the answer was not "these names never existed". Every one was a real export the
+ protocol retired on purpose, and every local key set was faithful to the schema
+ it named. The feed four left `@objectstack/spec/data` in the `16.0.0` major,
+ when the feed surface was replaced by the data API over `sys_comment` /
+ `sys_activity`. The i18n four left `@objectstack/spec/ui` in `17.0.0-rc.6`
+ itself — they were still present in `rc.5` — retired under ADR-0049
+ enforce-or-remove because no authorable shape carried them and nothing ever
+ parsed them.
+
+ Each comment now records that provenance, including the version the symbol left
+ and what (if anything) replaced it, so the shapes read as declarations these
+ packages own rather than as a view onto a protocol type. Type shapes, runtime
+ behaviour and exports are unchanged — the published `.d.ts` files differ only in
+ comment text, which is why this is graded `patch`.
+
+- 828549a: The gantt's conflict dialog shows the number of affected tasks again, not a literal `{2}`
+
+ `gantt.conflict.body` was resolved at the render site with a literal string replace on **single** braces — `t('gantt.conflict.body').replace('{count}', String(n))` — while all ten locale packs spell the placeholder the i18next way, `{{count}}`. `"…{{count}}…".replace("{count}", "2")` consumes the inner seven characters and leaves the outer pair behind, so every user on every loaded pack read "自动重新排程 **{2}** 个受影响的任务?". The dialog now interpolates through i18next (`t('gantt.conflict.body', { count })`), the idiom `gantt.delete.body` already used.
+
+ The two sibling keys three lines away in the same file, `gantt.autoScheduleDlg.body` and `.skipped`, were **not** broken — pack and call site both used single braces, and they rendered correctly. They are converted anyway, because that split is the whole mechanism: two write-confirmation dialogs in one component carried two different interpolation idioms, so `conflict.body` drifting to the i18next spelling in the packs (which is the correct spelling, and matches every other placeholder in the bundle) silently broke the render. Leaving the auto-schedule keys on the literal-replace idiom leaves the same trap armed for the next translator. All ten packs and the plugin's bundled English fallback table now agree on `{{count}}` for all three; only the braces moved, no translation was reworded.
+
+ `gantt.quickFilter.resultSummary` stays deliberately single-brace — its `ObjectGantt` call site really does resolve `{shown}`/`{total}` with a literal replace, and that convention is pinned by its own parity test. It is now the only key in the gantt namespace on that idiom, and the comments at both spellings say so.
+
+ Nothing caught this, and each gate was silent for its own reason: the cross-pack parity check compares en against each pack, and all eleven spellings agreed; the en-drift check compares a pack against its own history, and the packs were born matching. Both are **relative** comparisons, and the defect lived in the **absolute** relationship between a pack's spelling and the syntax the call site resolves. The existing render test asserted the dialog body contains `'1'` — which `{1}` satisfies. The new pin asserts the absolute form directly, under a real loaded pack, for every way a placeholder can survive to the screen.
+
+- e1ade8f: An illegal gantt dependency link now says why it was refused, instead of doing nothing
+
+ Dragging a dependency onto a target the gantt refuses — itself, a locked row, a group row, or one that would close a dependency cycle — produced no feedback of any kind: no toast, no dialog, no cursor change, no target outline, not even a console warning. The guard was right and completely invisible, so a user drawing a legitimate-looking dependency got a dead interaction and no way to learn the constraint. The rejection was silent in both places it could have shown: a refused bar never became the drop target, so it got no hover treatment at all, and the release handler only ran its body when a target _had_ been registered, so the drop itself was a no-op.
+
+ Both halves are now wired, and both read the **same** verdict. `canReceiveLink`'s four-branch boolean became `classifyLinkTarget`, which returns which branch refused (or `null`), with the boolean derived from it. The hover affordance and the drop toast are two consumers of that one classification, so the reason a user is shown cannot drift from the reason the link was actually refused — there is no second classifier to disagree. The branch names are the leaves of the new `gantt.link.rejected.*` keys, so a branch added later without a message surfaces as a missing key rather than as a plausible-but-wrong sentence.
+
+ During the drag, a refused bar under the pointer gets `cursor: not-allowed` and a destructive outline; on release it raises a toast naming the reason. Four messages, one per branch, in all ten packs. Both the cursor and the outline are driven from inline `style` rather than utility classes, matching the bar's existing read-only cursor three lines away and for the same reason recorded there: `cursor-not-allowed` and the ring alpha utilities are not emitted in the prebuilt components CSS, so a class would look correct in a DOM test and render nothing in a browser.
+
+ Deliberately unchanged: a host veto through `onBeforeDependencyCreate` stays silent. That rejection carries a reason only the host knows, and the gantt has none to show — surfacing it means exposing a rejection-reason output on the public component, which is a separate contract rather than a rider on this one. The four built-in reasons are the gantt's own policy and are the only ones it can explain.
+
+ One of the four, `group`, has no end-to-end path today: a `type: 'group'` row renders no bar, so the drag can never target it. The message is kept anyway — without it the branch would render a raw key on screen if it ever did fire — and the test pins the reachability fact, so it goes red the day group rows gain a bar. Filed as objectui#4209.
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
+
+ **objectui#4375** — `ListView` and `LookupField` built their search placeholder as
+ `t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
+ in all ten locales on screens where objectui#3878 had converged everything else on
+ U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
+ was appended to right-to-left text). Both now read `table.search`, which is already
+ the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
+ `PeoplePicker` render it too — and is translated with the right ellipsis in all ten
+ packs. No new keys.
+
+ **objectui#4376** — `list.loading` served the English `Loading records…` in eight of
+ the nine translation packs (`zh` alone had translated it); `designer.undo` and
+ `designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
+ `ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
+ pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
+ inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
+
+- 5cc847c: The console shows a standing impersonation banner, with an exit that fails loudly (#4467).
+
+ While `session.impersonatedBy` is present, `ConsoleShell` renders a banner naming BOTH
+ parties — the impersonated user, whose name every write is recorded under, and the
+ administrator who started it — plus a stop affordance. It derives from the session rather
+ than from client memory of the click, so it survives a full SPA reboot, a new tab and a
+ browser restart, and it cannot disagree with who the server thinks is acting. An ordinary
+ session renders `null` and its chrome is unchanged.
+
+ The exit calls `POST /auth/admin/stop-impersonating` over the same data lane and then
+ awaits a session refresh. The server restores the administrator from the `admin_session`
+ COOKIE, so a deployment that blocks cookies cannot exit this way — the banner says so and
+ stays up instead of appearing to succeed, which would leave the operator doing ordinary
+ work under someone else's identity.
+
+ Ten locale packs carry the banner's copy.
+
+- fa21254: Kanban: a drop that makes fields required now collects them instead of dead-ending
+
+ Dragging a card into a column whose value flips a field's `requiredWhen` predicate to TRUE used to PATCH the column value alone. The engine refused the whole update — correctly, that is what the predicate declares — and the board had no way to finish the move: the only path to closing a won deal was to abandon the board and open the record form. HotCRM's opportunity pipeline is the reported case (`win_reason` is required when `stage == "closed_won"`), but the dead end belonged to every board whose target column carries a conditional requirement.
+
+ The board now evaluates the target column's predicates BEFORE writing anything. If the move would make fields required while they are still empty, it opens a small dialog collecting exactly those fields, then submits the column value and everything collected as ONE PATCH — never two writes, which would leave the record in the refused state if the second one failed. A drop that triggers no predicate is untouched, down to the PATCH body.
+
+ The verdict comes from `@object-ui/core`'s `resolveFieldRuleState` — the same evaluator the record form, the wizard and the line-item grid already resolve `visibleWhen`/`readonlyWhen`/`requiredWhen` with, delegating to `@objectstack/formula`'s CEL engine. The board's prompt and the server's enforcement therefore reach the identical verdict rather than drifting through a second hand-rolled predicate evaluator. Emptiness is core's `isMissingForRequired`, the presence contract the form and the server share, so a `false` boolean and a `0` count as answers and are not re-asked.
+
+ Every control in the dialog is `@object-ui/fields`' `FieldEditWidget`, the same widget the record form renders for that field type — a select edits as a select, a date as a date picker — so this adds no second set of field-rendering decisions.
+
+ Four kinds of field are deliberately NOT collected, and each falls through to the unchanged PATCH where the server's refusal (legible since objectstack#7525) speaks for itself: one that already has a value, one `visibleWhen` hides, one that is readonly, and one whose type has no edit widget at all. A dialog row with no control would be a worse dead end than the one being fixed.
+
+ Cancelling writes nothing and leaves the card in its original column; a combined PATCH that is still refused for some other reason surfaces the refusal and rolls back exactly as a plain rejected move does, rather than looping the dialog on an arbitrary server error.
+
+ `@object-ui/i18n` carries two new `kanban.*` strings for the dialog, translated across all ten packs. Its public type surface is unchanged — the `.d.ts` was measured identical before and after — hence the patch bump.
+
+- 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
+
+ `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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 6d641c9: Members & invitations tabs gate their affordances by org role instead of letting the server's 403 be the UI (#4475)
+
+ A user whose organization role is `member` opened the workspace members page and
+ was shown an enabled **Invite member** button plus a per-row **Member actions**
+ menu carrying **Remove member** — on every row, the workspace Owner's included.
+ Nothing was hidden or disabled; the action only failed after the user had
+ committed to it. The Settings tab of the same page already gated correctly; the
+ members and invitations tabs never got the same treatment.
+
+ The affordances are now narrowed to the roles that can actually use them, keyed
+ on the active member's role — the same source the role-change menu on this page
+ already reads. Which roles those are is **measured against the routes that
+ enforce them**, not assumed to be "owner":
+
+ | affordance | route | permission | roles |
+ | ----------------- | --------------------------------- | ----------------------- | ----------------------------- |
+ | Invite member | `/organization/invite-member` | `invitation:["create"]` | owner, admin, delegated_admin |
+ | Remove member | `/organization/remove-member` | `member:["delete"]` | owner, admin |
+ | Cancel invitation | `/organization/cancel-invitation` | `invitation:["cancel"]` | owner, admin |
+
+ Three different gates, because `delegated_admin` holds `invitation:["create"]`
+ without `member:["delete"]` and deliberately without `cancel` — so it keeps the
+ invite button and the copy-link action while losing remove and cancel. A single
+ owner check could not express that.
+
+ An actor left with no row action at all gets no menu rather than a trigger that
+ opens onto nothing, and the members page explains the absence where the Invite
+ button used to sit, in the Settings tab's own voice. An unresolved role is
+ treated as the least privileged, so nothing privileged is offered to a viewer
+ whose membership could not be read.
+
+ Reading the pages is unaffected: the member list and the invitation ledger still
+ render in full. Whether `org_member` should be able to read the invitation
+ ledger at all is a separate, server-side question.
+
+- 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
+
+ Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
+
+ The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
+
+ `useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
+
+ Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
+
+ Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
+
+ `formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
+
+- 58bebf6: Organization & invitation console: translate the English holdouts a zh session was left reading (#4474)
+
+ Three families of string, one sweep over `console/organizations/`:
+
+ - **Role names** now come from the single shared `ORG_ROLE_LABELS` map at every
+ site. The role badges on the members and invitations pages were rendering the
+ raw server identifier (`owner`) under a CSS `capitalize` that made it look like
+ a label in English and left it untranslated everywhere else; the accept page
+ did the same in its role row and inside its otherwise-translated sentence. The
+ map's four `organization.roles.*` keys existed in no locale pack, so even the
+ dropdown that did consult it fell through to English — all ten packs now carry
+ them. An unrecognized role renders verbatim rather than blank.
+ - **Server-echoed errors** are mapped by better-auth's stable `code`, never by
+ matching its English text. `createAuthClient` was dropping that code for every
+ `organization.*` call while preserving it for sign-in/sign-up, so the console
+ had nothing to key on; all sixteen organization methods now go through the same
+ `toAuthError` helper. Messages are unchanged — the code simply stops being
+ thrown away. An unmapped code still shows the server's own sentence.
+ - **Icon-only `aria-label`s** (member actions, copy invitation link, cancel
+ invitation, and a fourth on the share-link copy button) are translated — for an
+ icon-only control this is the only name a screen reader gets.
+
+- 405e808: `pickLocalized` reads own properties only, and takes only string values, on every limb
+
+ The resolver read four of its six limbs — the exact tag, the base language, `default` and `en` — with a bare bracket access. Bare access walks the prototype chain, so a locale that happened to name an `Object.prototype` member resolved to that member and the function stringified it into the label: `pickLocalized({ en: 'Pricing' }, 'constructor')` returned `function Object() { [native code] }`, and the same held for `toString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable` and `toLocaleString`. Those same four limbs also skipped the `typeof === 'string'` filter the regional and last-resort limbs already applied, so a non-string value short-circuited the chain and rendered as `[object Object]`.
+
+ Both guards now apply uniformly. A guarded limb **misses** rather than aborting the resolution, so an unusable entry falls through to the next limb exactly as an absent one does — `pickLocalized({ en: 'Pricing' }, 'constructor')` is now `'Pricing'` (the `en` limb), and only a map with no usable entry at all resolves to `''`. An empty-string value is still a hit, because `''` is a label the author wrote.
+
+ No real language tag can observe this: no BCP-47 tag is an `Object.prototype` member, and the inline locale map is declared `z.record(, z.string())`, so every in-contract input resolves byte-identically to before. What it changes is agreement with the backend twin `resolveI18nLabel` (objectstack#6765), which shipped with exactly these two narrowings recorded as deliberate departures from this function because on a server the locale can arrive in an `Accept-Language` header. That recorded rule divergence is now zero; the only remaining difference is how each side spells a miss (`''` here for a text node, `undefined` there for a producer's fallback chain), which is pinned as an identity in the cross-resolver parity table.
+
+- c0f9a4b: Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side
+
+ The framework's runtime authoring gate produces two kinds of verdict on a metadata write. Errors become a 422 and the author sees them. Advisories ride a **200** — the save succeeded, the row persisted, the version bumped — and until objectstack#7435 the server dropped them into a deduped `console.warn` behind a process-level set. That landing put them on the wire as an optional `advisories[]` on the save response, emitted only when non-empty, and objectui was still throwing them away one layer further out: `MetadataClient.save` parsed the body, returned it as an opaque `T`, and every call site awaited it for its side effect and discarded the value.
+
+ The measured case the fix is built on: a `nightly_purge` flow whose only defect is a `delete_record` node with `multi: true` and no filter yields `errors = 0 / advisories = 1`. The save returns 200, the flow goes live, and nothing anywhere tells the author it deletes every row. That matters most for exactly the authors Studio serves — a Studio tenant or an MCP/AI author has no `os lint` and no CLI config for `sys_metadata` overlay rows, so this gate is not the weakest of four doors, it is the only one.
+
+ `MetadataClient` now carries an `onSaveAdvisory` sink, invoked after a save whose response carried a non-empty `advisories[]`, and the console wires it in `useMetadataClient` — the one hook every app-shell write path takes its client from, so a single wiring covers `ResourceEditPage`, `StudioDesignSurface`, `EmbeddedItemEditor`, `DatasourceResourcePage`, `ObjectHooksPanel` and any future call site rather than a toast copied into twenty of them. The finding shape is re-exported from `@objectstack/spec` (`RuntimeAuthoringIssue`) rather than restated, so it cannot fork from the 422 `issues[]` it deliberately shares a declaration with.
+
+ The affordance is the warning tier and says "Saved" first. A successful save that reads as a failure is the specific defect this surface must not ship, so the toast acknowledges the write, lists `rule` + `message` + `hint` per finding with `where` as secondary context, and renders that text **verbatim** — `message` and `hint` are server prose composed by the gate's rules, not i18n keys. Only the frame around them is translated (`console.saveAdvisoryTitle`, ten packs). The sink is best-effort in both directions: a malformed finding is dropped rather than printed as blanks, and a throwing renderer cannot turn a save the server already committed into an error.
+
+ **What this does not surface yet, and why.** Studio's designer saves as a **draft** on every edit, and drafts are never gated — the framework returns at its D1 early-return (`if (args.state !== 'active') return null`) before running a single rule, so a draft save produces no findings at all rather than producing some that get withheld. The publish step that promotes a draft to active _does_ run the gate, but the publish route returns no `advisories` field until objectstack#7294 lands. So a draft-then-publish flow renders nothing today, at both of its doors, for two different reasons; the active-mode save door renders findings now. That gap is pinned as a test rather than left for a reader to rediscover.
+
+- d46f9b8: i18n: `createSafeTranslation`'s provider-less fallback now honours a call site's inline `defaultValue`
+
+ `fallbackT` looked its key up in the hook's hand-written `defaults` map and, on a miss, rendered the
+ **raw key** to the user — then ran every option, `defaultValue` included, through the interpolation
+ loop as if it were a `{{defaultValue}}` variable. So `t('perm.facet.none', { defaultValue: 'None' })`
+ showed `perm.facet.none` on a host with no `I18nProvider`, which is a supported scenario (standalone
+ embedding and tests are the whole reason this factory exists).
+
+ The lookup order is now `defaults[key]` -> a string `defaultValue` -> the key, matching i18next,
+ which serves the provider path: the defaults map is the pack value's stand-in here, so it keeps the
+ pack's winning position. `defaultValue` is also excluded from the interpolation loop as a reserved
+ name — it selects the string, it does not fill holes in one. Non-string `defaultValue` is ignored.
+ The provider path is untouched.
+
+ Measured over all 26 `createSafeTranslation` hooks in the repo: 27 keys reach a hook whose defaults
+ map lacks them, 21 of those carrying an inline `defaultValue` that used to be dropped (16 keys in
+ `plugin-detail` alone). Those 21 now render their English instead of a raw key on provider-less
+ hosts; the other 6 pass no inline default and still need a map or pack entry.
+
+- 2fea4d2: `detail.showEmptyRelated` renders Russian and Arabic again — the "+N empty" button no longer falls through to English at the counts it takes most often
+
+ This was the repo's only pre-existing i18next plural family, and all ten packs defined exactly two slots: `_one` and `_other`. i18next asks `Intl.PluralRules` for the one suffix a language needs for that number, and when the pack has no such slot it walks `fallbackLng` to `en`. Russian has four plural categories and Arabic six, so `ru` at counts 2-4 (`few`) and 5-20, 25-30, … (`many`), and `ar` at 0, 2, 3-10 and 11-99, resolved nothing locally and rendered the English string. The call site is the collapsed-empties button in the record detail's reference rail, whose count is the number of empty related lists — 2 to 4 are the most common values it ever takes, so a Russian user essentially always read English.
+
+ The fix is a base key (no suffix) beside the two existing slots, in all ten packs. The base key is always in i18next's lookup chain, so every category a pack did not enumerate resolves to it, in that pack's own language — and, unlike adding `_few`/`_many` to `ru` alone, it keeps the ten packs' key sets identical, which full key parity requires. Same shape objectui#3546 slice six established for `perm.facet.*`. Where the base key is genuinely reachable it carries a count-invariant phrasing: `ru` uses the «Существительное: {{count}}» form the pack already writes 22 times, `ar` the «{{count}} مفرد(جمع)» marker it uses throughout. For `en`/`de`/`zh`/`ja`/`ko` the base key cannot be reached at all (their categories are covered by the two existing slots) and repeats `_other` for parity; `fr`/`es`/`pt` reach it only from a million up, where the plural form is already correct. No English copy moves.
+
+ The provider-less path needed the same row for a different reason: `createSafeTranslation`'s fallback resolves `defaults[key]` literally and never appends a plural suffix, so the two suffixed rows in plugin-detail's defaults table were unreachable through it and that path answered with the raw key. It now carries the base key too.
+
+ Parity across packs turned out to be necessary and not sufficient — ten identical key sets were green throughout, because the defect is one level below key names: the slot the language needs is not in the set. So the invariant "a plural family must carry a base key" is now asserted over all ten packs in `all-locales-key-parity.test.ts`, where it is pack-intrinsic and fails at PR time without needing a call site to exist. It went red on all ten packs before this change and names the family that is missing its base.
+
+- 7f1cb33: List sort: the relational hint stops recommending a formula field, the one type the server refuses to sort by
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 2e3b0c0: fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state
+
+ A list pointed at an object whose `enable` block withholds the API rendered its ordinary
+ empty state, so _"this page cannot work, and never could"_ reached the user as _"you have no
+ records"_ (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose
+ `sys_jwks` declares `enable.apiEnabled: false` — could not load for any persona and said so
+ to nobody. That is also why the upstream defect objectstack#7544 survived review for its
+ whole life: a merely unpopulated page invites nobody to click through.
+
+ The masking had two halves, in two packages, and neither package could see the other:
+
+ - **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded
+ **every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived
+ at the surface as a successful empty result, indistinguishable from a genuinely empty
+ object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED`
+ (404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one
+ would have pinned the object to "empty" for the rest of the session.
+ - **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405
+ half was never swallowed, so it already reached this panel, but classified as `network`:
+ _"check your connection and try again"_ for a condition no retry can change. It now says
+ the object is not exposed through the API, that this is a setting on the object rather than
+ a permission, and it offers **no Retry** button, because every retry re-fetches the
+ identical refusal.
+
+ Both denials are pure functions of the object's metadata — no user, no permission, no
+ context — so neither is transient or per-user, which is exactly the case where a silent empty
+ state is most misleading. Discrimination is on the ADR-0112 `code`, never the status: a
+ missing collection, a missing record and a disabled object are all 404.
+
+ **A genuinely empty object still renders the ordinary empty state**, and a backend without an
+ optional collection still degrades to empty — pinned in both directions, at the adapter, at
+ the view, and once end-to-end over a real adapter and a real `ListView`.
+
+ Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search`
+ branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`.
+ It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the
+ branch a list takes whenever it expands a lookup or runs a search is no longer anonymous.
+
+ New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the
+ `en` pack and mirrored in the list defaults map.
+
+ ## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch
+
+ Two independent reasons, either of which is sufficient under this repo's precedent
+ (objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift):
+
+ 1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown):
+boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple
+ `['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's
+ public surface. Additive surface growth is minor.
+ 2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now
+ **REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with
+ `{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that
+ relied on those two codes arriving as a successful empty result now receives a rejected
+ promise carrying `code` + `httpStatus`, and must handle it.
+
+ Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404
+ with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move
+ is scoped to the two denial codes named above and to nothing else.
+
+ Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks
+ `@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change
+ described in the body, which is what this note is.
+
+- 31ab1ac: fix(print): `window.print()` produces a usable page, and the Print buttons say what they do
+
+ The list, report and dashboard Print controls were bare `window.print()` calls with no
+ print stylesheet, so the browser printed the whole console — sidebar, top bar, chat rail,
+ toasts — with the data table clipped to a single viewport. With no label to the contrary
+ they were being accepted against "export to PDF" requirements, which they have never been.
+
+ - `@object-ui/app-shell/styles.css` gains a shared `@media print` block: it hides the shell
+ chrome, prints the active content area full-width, releases the viewport-height flex chain
+ so long tables paginate instead of clipping, repeats table headers on every sheet, and
+ neutralises dark mode (which otherwise prints white-on-white). One sheet serves list,
+ report and dashboard.
+ - The list and report Print buttons carry a tooltip and accessible name stating that they
+ open the browser's own print dialog and are not a PDF export (new `common.printDialogHint`,
+ translated in all ten locale packs).
+ - The dashboard's `export_dashboard_pdf` action no longer toasts "Preparing PDF export…" —
+ it names the print dialog it actually opens (`dashboardActions.pdfPreparing` is replaced by
+ `dashboardActions.printDialogOpening`).
+
+ No control was removed and no headless detection was added. A real print/PDF primitive
+ remains out of scope (`objectstack-ai/objectstack#1301`, closed NOT_PLANNED).
+
+- 06915b0: fix(i18n): every date branch threads the active locale, so a `zh` session no longer renders half its dates in English
+
+ Date rendering had two locale channels and only one followed the user's
+ language, so the same row could read `逾期 6 天` in one column and `In 3 days`
+ in the next, with a datetime column showing `8/11/2026 12:00 am`
+ (objectui#4468).
+
+ The overdue phrase resolves through the translate fn (the active UI language),
+ while every `Intl` branch took its tag from the raw tenant locale
+ (`useLocalization().locale`) — which is `undefined` on any workspace that never
+ configured one, and `undefined` makes `Intl` use the _machine's_ locale.
+ `DateTimeCellRenderer` passed no tag at all.
+
+ Every date-formatting site in `@object-ui/fields` now resolves through the one
+ existing channel, `useDisplayLocale()` (tenant regional default → active UI
+ language → `en`): `DateCellRenderer` (relative past, relative future, near-today
+ and the beyond-±7-days absolute fallback), `DateTimeCellRenderer`, the read-only
+ `DateField` / `DateTimeField` / `FormulaField` faces, and the sub-grid's
+ temporal cells. English output is unchanged, and the already-localized overdue
+ wording is untouched.
+
+ No public signature changed. `@object-ui/i18n` carries a documentation
+ correction only: `useDisplayLocale`'s docstring claimed `DateCellRenderer`
+ already formatted from this channel, which was the very thing that was not true.
+
+- ff84b05: Stop the report config panel being titled "Title", and the view-settings colour section "Color"
+
+ 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).
+
+ `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.
+
+ `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.
+
+ No `en` value of an existing key changed; `scripts/check-i18n-en-drift.mjs` reports 0 en values changed, 1 key added.
+
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f279deb]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [613b167]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+ - @object-ui/core@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/i18n/package.json b/packages/i18n/package.json
index 64efa2bf9a..35ab340db7 100644
--- a/packages/i18n/package.json
+++ b/packages/i18n/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/i18n",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"sideEffects": false,
"license": "MIT",
diff --git a/packages/layout/CHANGELOG.md b/packages/layout/CHANGELOG.md
index 60df063ad9..5d0a48f715 100644
--- a/packages/layout/CHANGELOG.md
+++ b/packages/layout/CHANGELOG.md
@@ -1,5 +1,228 @@
# @object-ui/layout
+## 17.5.0
+
+### Minor Changes
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `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 |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+### Patch Changes
+
+- f650253: `BaseSchema.ariaLabel` declares the keyed i18n vocabulary the renderer actually
+ resolves, `.disabled` accepts the predicate string it actually evaluates, and the
+ keyed shape finally has a name (objectui#4581)
+
+ Three slots on one base type had drifted from what the renderer does with them.
+ PR #4593 fixed `visible` and measured the rest; these are the rest.
+
+ `ariaLabel` was `string`, but `SchemaRenderer.tsx:111` resolves it with
+ `resolveKeyedI18nLabel`, whose input is the KEYED form
+ `{ key, defaultValue?, params? }` — a reference into a translation bundle. It is
+ now `string | KeyedI18nLabel`, and `KeyedI18nLabel` is a new exported type in
+ `@object-ui/types` rather than a fourth inline copy of one object literal: the
+ three that existed (`@object-ui/react`'s resolver, `@object-ui/layout`'s
+ `resolveLabel`, `@object-ui/app-shell`'s `t`-taking twin) were verified identical
+ in their object half first, and two of them now import the name.
+
+ The vocabulary matters more than the widening. `#4581` originally asked for
+ `string | I18nLabel`, and that spelling was withdrawn as measured-wrong: the
+ spec's `I18nLabel` is the INLINE LOCALE MAP (`string | Record`),
+ a different vocabulary resolved against a BCP-47 locale by a different function
+ of a confusingly similar name. Under it the shipped keyed fixture type-checked
+ only vacuously — as a locale map whose "locales" are named `key` and
+ `defaultValue` — the same label carrying `params` was rejected outright, and a
+ genuine `{ en: 'Owner' }` compiled while rendering an EMPTY `aria-label`. Naming
+ the keyed shape is the declaration half of the fix objectui#4167 started on the
+ naming side; `@object-ui/app-shell`'s copy keeps its inline spelling for now
+ because an open PR has a pending change to that file, and the comment there says
+ so.
+
+ `disabled` was `boolean` on a key the renderer never reads as one:
+ `SchemaRenderer.tsx:466` evaluates it through the same `evaluateCondition` as
+ `visible`, and a `disabledOn?: string` sibling exists for the same reason. It is
+ now `boolean | string`. The asymmetry with `visible` was accidental rather than
+ deliberate.
+
+ Both are widenings on authored-input-dominant properties: authors gain a
+ spelling, nothing that type-checked before stops doing so, and readers already
+ coped with `any` through `BaseSchema`'s index signature. Three test fixtures that
+ had been casting past these declarations with `as unknown as BaseSchema` state
+ their values directly now, and the declared unions are pinned invariantly so
+ neither a missing widening nor an overshoot to `any` can pass unnoticed.
+
+ Declaring the vocabulary honestly also surfaced a real one: the `toggle`
+ renderer writes `aria-label` itself instead of going through SchemaRenderer's
+ resolver, and it forwarded the raw value. Invoked directly it emitted
+ `aria-label="[object Object]"` for a keyed label — announced verbatim by a
+ screen reader. It resolves now. Through `SchemaRenderer` the bug was invisible,
+ because SchemaRenderer injects its own resolved `aria-label` afterwards; a
+ downstream type-check sweep found it, not a test.
+
+ `BaseSchema.label` and `.description` are deliberately unchanged and pinned that
+ way. They receive the spec's inline `I18nLabel` from the view bridges, which is a
+ real defect, but resolving it belongs at the spec-to-schema boundary rather than
+ in this declaration — and that work is still blocked on a design question about
+ where the display locale enters, so it is not in this release.
+
+- 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
+
+ rc.6 widened `I18nLabel` from `string` to `string | Record`, 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.
+
+ | package | what an author can now write and see |
+ | ----------------------------- | --------------------------------------------------------------------------------------- |
+ | `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
+ | `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
+ | `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
+ | `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
+ | `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
+
+ **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.
+
+ Two consequences worth knowing:
+
+ - **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.
+ - **`@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.
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [f5e1143]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [47f551b]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/layout/package.json b/packages/layout/package.json
index 220133f1a0..fc1b23d14c 100644
--- a/packages/layout/package.json
+++ b/packages/layout/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/layout",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"sideEffects": [
"./dist/index.js",
diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md
index 9c926a3d15..7364af552a 100644
--- a/packages/mobile/CHANGELOG.md
+++ b/packages/mobile/CHANGELOG.md
@@ -1,5 +1,36 @@
# @object-ui/mobile
+## 17.5.0
+
+### Patch Changes
+
+- c911544: `SpecResponsiveConfig` is now the spec's responsive config rather than a hand copy that said it was
+
+ `useResponsiveConfig.ts` declared its own interface over the four responsive keys — `breakpoint`, `hiddenOn`, `columns`, `order` — renamed off the schema's own symbol (`ResponsiveConfig` → `SpecResponsiveConfig`) and introduced by a comment that said it mirrored `ResponsiveConfigSchema`. There was no import, no `z.infer`, and no other compile-time tie: the sentence was the entire connection.
+
+ It agreed with the schema key-for-key on the day it was measured, and that is the reason this is worth a line rather than the reason it is not. The agreement was maintained by nobody and checked by nothing, while the comment told every later reader the copy was canonical — so a key added or retired upstream would have moved the two apart in silence, with the comment still vouching for the copy. `ViewNavigationConfig` read exactly like this until it had drifted on `mode`.
+
+ The type is now re-exported from `@object-ui/types`, which publishes it imported straight from `@objectstack/spec/ui`. That package is already this one's only runtime dependency, so the binding costs no new dependency edge, and `@object-ui/core`'s `ResponsiveProtocol` already reaches the same type the same way.
+
+ Nothing consumers see changes: the published name is the same, and the type it resolves to is invariant-equal to the interface that was there before — the entry `.d.ts` is byte-identical. What changes is where the four keys come from. They are now whatever the schema declares, so the next schema release reaches this package's authors as a type error instead of as silent disagreement.
+
+ A new parity test pins the chain to `@objectstack/spec/ui` directly, because the one link the re-export cannot see is `@object-ui/types` re-growing a hand copy of its own.
+
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [92876f0]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [1f9b905]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [c1d939f]
+- Updated dependencies [bb68488]
+- Updated dependencies [ab04728]
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/mobile/package.json b/packages/mobile/package.json
index 5eb4658521..d026ad356c 100644
--- a/packages/mobile/package.json
+++ b/packages/mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/mobile",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Mobile optimization for Object UI with responsive components, PWA support, and touch gesture handling.",
diff --git a/packages/permissions/CHANGELOG.md b/packages/permissions/CHANGELOG.md
index 9f255c0f82..f100d83251 100644
--- a/packages/permissions/CHANGELOG.md
+++ b/packages/permissions/CHANGELOG.md
@@ -1,5 +1,60 @@
# @object-ui/permissions
+## 17.5.0
+
+### Minor Changes
+
+- 2a40f69: Retire two post-retirement dead surfaces (#4364, #4368). Both were measured at this
+ branch point rather than taken from their cards, and one card's premise only half held.
+
+ Breaking for anyone who typed against the removed declaration, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/types` and `@object-ui/permissions` no longer export
+ `ObjectLevelPermission`. It declared a second, parallel home for object-scoped grants
+ (`{ object, actions, effect?, conditions? }`) that nothing constructed, accepted or
+ read once `RoleDefinition.permissions` was retired (#4288) — its only remaining
+ referents were its own definition and the two barrel lines. The wired home is
+ `ObjectPermissionConfig.roles`, whose inner grant shape is declared inline; that is
+ what the evaluator reads, and it is unchanged. `ObjectPermissionConfig`'s doc comment
+ now records the retirement so the surface is not re-declared. (#4364)
+
+ `PermissionCondition` was proposed for retirement on the same card and is **kept**: its
+ premise ("only referent is `ObjectLevelPermission.conditions`") did not hold at this
+ branch point. `evaluateCondition` in `@object-ui/permissions` takes it as a parameter
+ type and implements all eleven of its operators under a 26-case suite. `PermissionEffect`
+ is likewise untouched — `FieldLevelPermission.effect` still reads it.
+
+ No behaviour change, no public surface change:
+
+ - `@object-ui/console` drops `src/utils/metadataConverters.ts` and
+ `src/services/MetadataService.ts`. Both were console-local duplicates of live
+ `@object-ui/app-shell` modules and lost their last importer when the bespoke
+ object-detail widgets were retired (#4365). Both had already drifted behind the live
+ copies they duplicate — the console converter's `referenceTo` chain never read the
+ server's `reference` key, and the console service predates the view cache-invalidation
+ seam (#4373) — which is precisely the imitation trap the card recorded: an author
+ grepping for "the converter" could land on the unexercised copy. The app-shell copies
+ and their tests are untouched. (#4368)
+
+### Patch Changes
+
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [92876f0]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [1f9b905]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [c1d939f]
+- Updated dependencies [bb68488]
+- Updated dependencies [ab04728]
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/permissions/package.json b/packages/permissions/package.json
index d41eda9dfa..28befdb598 100644
--- a/packages/permissions/package.json
+++ b/packages/permissions/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/permissions",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "RBAC permission system for Object UI with object/field/row-level access control, permission guards, and hooks.",
diff --git a/packages/plugin-ai/CHANGELOG.md b/packages/plugin-ai/CHANGELOG.md
index d8b946c564..b2661f1b62 100644
--- a/packages/plugin-ai/CHANGELOG.md
+++ b/packages/plugin-ai/CHANGELOG.md
@@ -1,5 +1,80 @@
# @object-ui/plugin-ai
+## 17.5.0
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [f5e1143]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [47f551b]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-ai/package.json b/packages/plugin-ai/package.json
index 0b1c7709ff..33360c5807 100644
--- a/packages/plugin-ai/package.json
+++ b/packages/plugin-ai/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-ai",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"main": "dist/index.umd.cjs",
"module": "dist/index.js",
diff --git a/packages/plugin-calendar/CHANGELOG.md b/packages/plugin-calendar/CHANGELOG.md
index 045741752f..ac72c86c34 100644
--- a/packages/plugin-calendar/CHANGELOG.md
+++ b/packages/plugin-calendar/CHANGELOG.md
@@ -1,5 +1,222 @@
# @object-ui/plugin-calendar
+## 17.5.0
+
+### Minor Changes
+
+- 515328f: `calendar-view` has no declared-but-inert inputs left: `allowCreate` works, `colorMapping` is retired (objectui#4454, objectui#4493)
+
+ Two of this widget's registry inputs were declared and read by nobody — the one
+ state ADR-0049's enforce-or-remove framing says must not persist. Measurement
+ answered them in opposite directions.
+
+ **`allowCreate` is enforced.** The handler it would gate was already built in the
+ renderer — `handleAddClick`, dispatching `{ type: 'create', payload: {} }` on the
+ widget's own `onAction` channel — and simply never passed. `CalendarView` renders
+ its **New event** button behind `onAddClick`, so on the SDUI path that button
+ never existed and the handler was unreachable: both halves of one feature were
+ present and had never been introduced to each other. An authored
+ `allowCreate: true` now supplies the handler to `onAddClick`, and clicking the
+ button dispatches the create action.
+
+ The wiring goes through the declared `onAddClick` hatch rather than around it via
+ a second prop. That key is already one of the renderer's function-typed host
+ hatches (objectui#4453), so a React host could switch the affordance on today and
+ that path is untouched — a host handler still replaces the action dispatch rather
+ than running alongside it, the same precedence `onEventClick` keeps. An authored
+ `onAddClick` string is still dropped, so turning the affordance on cannot
+ reintroduce that card's uncaught handler crash.
+
+ Only the boolean `true` turns it on. Absent, `false`, and the off-type spellings
+ JSON invites (`'true'`, `1`, an object) all resolve to the absent-key answer, which
+ on this prop is literally what makes the button not render. Every node that never
+ authored the key renders exactly as before.
+
+ **`colorMapping` is removed.** It had no read site anywhere: the renderer's event
+ mapping takes the colour straight off the record (`color: record[colorField]`),
+ and `CalendarView` resolves a colour from `event.color`. An author who wrote the
+ documented `colorMapping: { meeting: 'blue' }` got no mapping, no warning and no
+ error — the raw field value was used as the colour, which for a picklist value
+ like `meeting` is not a colour at all. It is retired rather than implemented
+ because no measured app authors it, and a capability with no pull behind it is not
+ worth building. The `content/docs/plugins/plugin-calendar.mdx` schema-API line
+ documenting it is removed in the same change.
+
+ Retiring it is not a behaviour change — the key never had a read site to lose. It
+ becomes an ordinary unknown authored key, dropped at the renderer boundary like
+ any other.
+
+ **Grade.** Minor, not patch: measured both ways against the emitted bundle, the
+ published registry surface moves — `calendar-view`'s `inputs` array loses a member
+ (`colorMapping`: 1 emitted declaration before, 0 after), so the authorable
+ vocabulary this widget publishes narrows by one key, and a second declared input
+ starts producing a user-visible affordance. The emitted `.d.ts` is byte-identical
+ either way; the vocabulary lives in the runtime registry metadata, not in the type
+ surface.
+
+### Patch Changes
+
+- 395e154: authored ISO `currentDate` reaches the calendar as a `Date`; unparseable input falls back to the default instead of crashing
+
+ `plugin-calendar:calendar-view` declares the input `{ name: 'currentDate', type: 'string', description: 'ISO date string for initial calendar date' }`, while `CalendarViewProps.currentDate` is a `Date`. Nothing converted between the two: the authored string rode the renderer's trailing `{...props}` spread into `useState`'s initial `selectedDate`, and the header's `selectedDate.toLocaleDateString(…)` threw `selectedDate.toLocaleDateString is not a function` — the error boundary instead of the calendar. Writing the one spelling the input documents was the one spelling that could not work, and there was no correct authored value at all, since `type: 'string'` cannot express a `Date`.
+
+ The renderer now owes the conversion, at its own boundary. `currentDate` is destructured out of the incoming props so the spread can no longer carry the raw value (the consumed-key pattern from the `events` collision fix), parsed once per authored value, and passed to `CalendarView` as the `Date` its prop type declares. Off-spec input — an unparseable string, or any non-string that is not already a `Date` — gets the same answer as an absent key: the component's own default date. An `Invalid Date` is never manufactured and handed on; it does not throw, it renders the literal text "Invalid Date" into the header and the date picker, which is a silent wrong answer where the default is a usable calendar.
+
+ A `Date` instance passes through untouched, so a React host handing the widget its real declared prop type is unaffected.
+
+- c5756ff: `calendar-view` consumes or declares every prop it forwards — an authored `onEventClick` can no longer crash a click
+
+ `calendar-view`'s renderer ended in ` `, where `props` was everything `SchemaRenderer` hands a registered widget: the node's authored keys, the contents of its `props` container, the injected runtime props, and a host's trailing props. That is an unbounded set spread onto a component whose props are a closed list, and the worst collision on it was `onEventClick`: an authored `onEventClick: 'NOT-A-FUNCTION'` rendered a perfectly normal calendar and then threw `onEventClick is not a function` on the first click. React does not route event-handler errors to `SchemaErrorBoundary`, so it surfaced as an uncaught window error — the calendar kept looking fine while its click handling was dead. Both authoring channels reached it, the node's own key and a `props: { onEventClick }` container.
+
+ The forward set is now exactly `CalendarViewProps`, each key resolved to the type that prop declares; nothing else reaches the component. Declared registry inputs are consumed (`view` narrowed to its declared enum, `currentDate` parsed, `className` forwarded, the field-name inputs read off the schema); `CalendarView`'s callbacks are a declared, function-typed host escape hatch — a host-passed function is forwarded exactly as before, and a non-function value, which is all an SDUI author writing JSON can produce, is dropped, the same answer as an absent key; every other key is dropped.
+
+ Fixed with it, from the same boundary: an authored `onAction` string killed the same click through the renderer's own action channel; an authored `onDateClick` / `onNavigate` / `onViewChange` / `onEventDrop` / `onTimeRangeSelect` / `onAddClick` string killed its own gesture the same way; an authored `locale` that `Intl` rejects (`en_US`, the underscore spelling) took the whole render down to the error boundary with `RangeError: Incorrect locale information provided`; and an off-enum `view` (`agenda`) rendered a header with no calendar under it at all, where it now falls back to the component's `month` default.
+
+ No capability is removed and no authorable surface is added: every host path that worked keeps working, including the handler precedence the old spread produced (a host handler replaces the `onAction` dispatch rather than running alongside it). The package's emitted `.d.ts` is unchanged.
+
+- 49b9de6: fix(plugin-calendar): authoring `events` on a `calendar-view` node no longer takes the calendar down
+
+ `calendar-view`'s renderer computed a `CalendarEvent[]` from `schema.data`, passed it
+ as `events={…}`, then spread the remaining props **after** it. `SchemaRenderer`
+ forwards a node's `events` key as a plain prop, so a node authoring `events` — the
+ ordinary SDUI action metadata, legal on any node — landed its `{ onClick: [...] }`
+ object on the `events` array prop: `CalendarView` iterated it and threw
+ `events is not iterable`, and a spec-legal node rendered an error card instead of its
+ calendar.
+
+ The authored key is now destructured out before the spread, so the computed array
+ always wins. This also closes the quiet half of the same collision: an authored
+ `events` **array** never threw — it silently replaced the calendar's contents with
+ itself.
+
+ No capability is removed. Nothing in the renderer layer consumes a node's `events`
+ key (the action path is `properties.action` through `ActionRunner`), and the
+ component's own `onAction` channel is unaffected.
+
+- 3e579d6: `object-calendar` / `view:calendar`: the renderer now consumes or declares every prop it forwards, instead of spreading the authored node into `ObjectCalendar`
+
+ One shared renderer serves both registrations, and it ended in a raw spread of everything `SchemaRenderer` hands a widget — the node's authored keys, its `props` container, the injected runtime props and a host's trailing props — onto a component whose props are a closed list. `ObjectCalendarProps` declares eight callbacks and a `locale`, so an authored value under any of those names landed on the declared prop, and an SDUI author writing JSON can never produce a function:
+
+ - an authored `onDateClick` string threw `onDateClick is not a function` on an empty day-cell click, and an authored `onNavigate` string threw on **Next period** — both as _uncaught_ window errors, because React does not route event-handler errors to `SchemaErrorBoundary`, so the calendar kept looking fine while that gesture was dead;
+ - an authored `locale: 'en_US'` (the underscore spelling a producer writes by accident) threw `RangeError: Incorrect locale information provided` out of render and took the whole calendar to the error boundary.
+
+ The forward set is now exactly `ObjectCalendarProps`, each key resolved to the type that prop declares: the callback family is a declared, function-typed host escape hatch, `locale` is accepted only when `Intl.getCanonicalLocales` takes it, `data`/`loading` keep the parent pre-fetch path at their declared types, and everything else — including the open tail of authored keys — is dropped.
+
+ Host-passed functions are unaffected: a React host's handlers, and `ListView`'s `onRowClick`, still reach the component exactly as before.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [6d01319]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [ebb4e0e]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [8f60d73]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [63fe8fd]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [5cc847c]
+- Updated dependencies [6314e87]
+- Updated dependencies [5e2e9fa]
+- Updated dependencies [297534b]
+- Updated dependencies [e7663f2]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [e076fd5]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [c911544]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [36310dc]
+- Updated dependencies [52d878a]
+- Updated dependencies [456aac8]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [7d04b0e]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [c32a8a1]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [dad805d]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [35997ce]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [b388950]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/plugin-detail@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/mobile@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-calendar/package.json b/packages/plugin-calendar/package.json
index 817c3ba1fd..7878ef433c 100644
--- a/packages/plugin-calendar/package.json
+++ b/packages/plugin-calendar/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-calendar",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Calendar view plugins for Object UI - includes both ObjectQL-integrated and standalone calendar components",
diff --git a/packages/plugin-charts/CHANGELOG.md b/packages/plugin-charts/CHANGELOG.md
index cf02e7f50f..eb6d03c0ea 100644
--- a/packages/plugin-charts/CHANGELOG.md
+++ b/packages/plugin-charts/CHANGELOG.md
@@ -1,5 +1,180 @@
# @object-ui/plugin-charts
+## 17.5.0
+
+### Minor Changes
+
+- 5fac011: Publish `normalizeChartSchema` from the package entry.
+
+ `normalizeChartSchema` is the single place the author-facing chart schema is translated into the renderer's internal pipeline contract, and `ChartRenderer` calls it on every render. It was not reachable from the package's only entry point, so a consumer that wanted to assert what `AdvancedChartImpl` is actually handed had to restate the translation rather than run it. It is now exported from the entry, along with the `NormalizedChartSchema` type it returns.
+
+ Additive only: nothing is removed or renamed, and the module was already in the entry's eager import graph via `ChartRenderer`, so this publishes a name rather than shipping new bytes.
+
+### Patch Changes
+
+- 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
+
+ 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.)
+
+ 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...` — 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 3fc2971: A null-keyed group renders as an explicit bucket instead of silently vanishing from a chart (objectui#4466)
+
+ `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`).
+
+ 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.
+
+ `@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`.
+
+ 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.
+
+ `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.
+
+- aca27fa: The multi-dimension pivot branch buckets a null first-dimension value instead of dropping its bar (objectui#4497)
+
+ `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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 613b167: A dataset dimension on a dotted relationship path now renders its option labels instead of the raw stored enum
+
+ 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.
+
+ The label lookup read options as `baseObject.fields[]`, 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.
+
+ 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.
+
+ 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.
+
+ Applies to both surfaces that carried this lookup: dashboard dataset widgets (`DatasetWidget`) and the chart view's dataset path (`ObjectChart`).
+
+ 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.
+
+- 0b49d60: Analytics: `ObjectChart` consumes the shared label-net helpers instead of a third copy
+
+ objectui#4389 (PR #4404) named two copies of the analytics label-net glue — the dashboard's `DatasetWidget` and plugin-report's dataset block — and retired both into `@object-ui/core` + `@object-ui/react`. There was a THIRD, which that card did not name and its PR deliberately left out of scope: `packages/plugin-charts/src/ObjectChart.tsx` carried its own `translatorFor` closure, its own `buildDimensionLabelMap` loop, and its own base-object-read-then-walk composition. The `translatorFor` copy was logically identical to the two that were deleted, down to the comment explaining the binding.
+
+ `ObjectChart` now calls core's `dimensionOptionTranslator`, `deriveDimensionLabelMaps` and `loadDimensionFieldMeta` directly. Nothing about what a label IS changes — those helpers are the same code the two retired copies were rewritten onto, so the part that was genuinely duplicated three times is now written once.
+
+ Behaviour is unchanged by construction: same two metadata reads in the same order on the dataset path, same one read on the aggregate path, same best-effort fallback (an unresolvable path yields no entry and the raw value survives), same locale-applying memo boundary. `plugin-charts`' 22 test files / 170 assertions pass unchanged and their files are byte-identical to before, which is the acceptance evidence for a pure swap.
+
+ The card's second, optional step — moving the DATASET path's metadata read onto `@object-ui/react`'s `useDatasetDimensionMeta` — was attempted and declined on measurement; the shape blocker is recorded on objectui#4405 and in the PR. The two bug-fix properties the family exists to state (the read rides the host's authenticated `apiFetch`, objectui#4121; the fetched metadata stays locale-free, objectui#4030 / PR #4324) therefore remain stated locally in this file, exactly as before, and are undisturbed by this change.
+
+- bcd3e02: `ObjectChart`'s category option-color / dimension-label probe now rides the host's
+ authenticated fetch (`SchemaRendererContext.apiFetch`) instead of the bare global
+ `fetch`.
+
+ Both metadata reads the effect makes — `GET /api/v1/meta/dataset/{dataset}` and
+ `GET /api/v1/meta/object/{object}` — went out on the global `fetch`, so in a hosted
+ console they skipped whatever the host supplies on that channel (Authorization /
+ tenant headers, base-URL rewrite, draft-preview params). A bearer-token session
+ carries its credential in a header rather than a cookie, so `credentials: 'include'`
+ alone left these two reads unauthenticated. The effect is best-effort and swallows
+ every failure, which made the symptom silent: semantic option colors and dataset
+ dimension labels simply never applied, and the chart fell back to the positional
+ theme palette and raw stored values.
+
+ Standalone embeds are unaffected — with no provider (or a provider that supplies no
+ `apiFetch`) the probe still uses the global `fetch`, the same documented fallback
+ `useRecordEditable` and `provider: 'api'` view sources use.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [5cc847c]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-charts/package.json b/packages/plugin-charts/package.json
index 23a1c46bf0..9ea8c6bb19 100644
--- a/packages/plugin-charts/package.json
+++ b/packages/plugin-charts/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-charts",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Chart components plugin for Object UI, powered by Recharts",
diff --git a/packages/plugin-chatbot/CHANGELOG.md b/packages/plugin-chatbot/CHANGELOG.md
index 29354f0bfa..4c179d11aa 100644
--- a/packages/plugin-chatbot/CHANGELOG.md
+++ b/packages/plugin-chatbot/CHANGELOG.md
@@ -1,5 +1,145 @@
# @object-ui/plugin-chatbot
+## 17.5.0
+
+### Minor Changes
+
+- 3256b14: `@object-ui/plugin-chatbot`'s `ChatMessage` is now one type instead of two
+
+ The barrel exported two different `ChatMessage` types: a minimal one it declared itself (`id` / `role` / `content` / `timestamp` / `avatar` / `avatarFallback`) and the shape `` actually renders, re-exported under the alias `ChatbotEnhancedMessage`. The natural name resolved to the narrow one, so an importer reaching for `ChatMessage` silently got the wrong contract — and the compiler could not object, because both shapes existed on purpose and every construction site spreads the extra keys conditionally, which defeats excess-property checking. That is how app-shell's `AiChatPage` ended up unable to read `toolInvocations` off its own function's return value (objectui#4040; re-pointed in PR #4379, but the collision itself was left standing). objectui#4383.
+
+ **Breaking semantics** (declared `minor` per AGENTS.md §版本号策略 — objectui never declares `major` outside an `@objectstack` major sync): `ChatMessage` exported from `@object-ui/plugin-chatbot` now denotes the enhanced shape. In practice this is a widening rather than a removal — every field of the retired shape survives with the same type, and the enhanced shape adds only optional keys (`streaming`, `toolInvocations`, `reasoning`, `sources`, `traceId`, `buildProgress`, `blueprintProgress`, `charts`), so anything that was a valid `ChatMessage` still is, and ` ` keeps accepting the same values. Code that relied on the name meaning _exactly_ the six-key shape (exhaustive `keyof` maps, `Equal`-style assertions) is the case that changes.
+
+ `ChatbotEnhancedMessage` is kept as a `@deprecated` alias of the same type, so importers that spelled the disambiguating name keep compiling; new code should import `ChatMessage`. Pinned at compile time by `packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts`.
+
+- eec2e4f: `useObjectChat` declares the message shape it actually hands back
+
+ The hook typed `messages` — and the `onSend(content, messages)` callback fed from it — as `@object-ui/types`' authoring `ChatMessage`. That was true in local mode only. In API mode the values came out of the runtime mapper and were asserted into place with `as OuiChatMessage[]`, and the authoring contract declares none of what they carry: `buildProgress`, `blueprintProgress`, `charts`, and `pendingActionId` / `draftReview` / `proposedPlan` / `proposedChanges` / `builderHandoff` on every tool invocation. Those keys are the HITL approval card, the "Review N changes" affordance, the proposed-plan card, the build panel and the inline charts. They survived only because nothing on the path ever rebuilt a message; anyone writing the obvious thing — reconstruct a message field-by-field from its declared type — deleted all of them, with the compiler agreeing, because the declared type genuinely did not have them.
+
+ The declaration is now the truth, published as `ObjectChatMessage`. The survey behind it found the honest type to be neither of the two `ChatMessage` types on either side, because neither is true of both modes: it stays **wide** where local mode is wide (an authored `'tool'` role and the legacy `'partial-call'` / `'call'` / `'result'` tool states reach this surface unchanged and are folded only at the render seam), **narrow** where both modes are narrow (`timestamp` is `string`, never `Date` — API mode never produces one and local mode absorbs it before emitting), and adds the render-only keys API mode really carries. The `as OuiChatMessage[]` assertion is deleted rather than moved: the mapper's output satisfies the declared type, so the compiler checks that assignment instead of being told to stop looking.
+
+ Nothing about the values changed, and nothing correct breaks. `ObjectChatMessage` is a **subtype** of the authoring `ChatMessage` it replaces, so every consumer that accepted the old declaration still accepts these values — including a host `onSend` callback that types its parameter as `ChatMessage[]`, which keeps type-checking by contravariance. Naming `ObjectChatMessage` is what lets a host _read_ the keys above. The one observable narrowing is deliberate: code that branched on `timestamp instanceof Date` was handling a value this hook cannot emit, and now says so at compile time.
+
+ The seam below it (`chatMessageAdapter.ts`, from objectui#4399) is still necessary and unchanged in behaviour — `'tool'` and the legacy tool states still have to be narrowed for the renderers. What changed is that its pass-through is no longer an act of faith: its input type (`SeamChatMessage`, also exported, alongside `SeamToolInvocation`) names the render-only keys, so the spread preserves them as declared properties the compiler can see, and the pass-through tests type their API-mode fixture directly instead of casting it past the compiler. A cast returning to the hook is now caught by a test rather than by a future outage.
+
+ App-shell carries a comment-only correction on the same family: `AiChatPage` still described `@object-ui/plugin-chatbot` as exporting a second, minimal legacy `ChatMessage` alongside the enhanced one. That collision was retired in objectui#4383 — the barrel publishes one contract and `ChatbotEnhancedMessage` is a deprecated alias of it — so the paragraph was sending readers to look for a hazard that no longer exists.
+
+### Patch Changes
+
+- dde7283: `chatbot` and `chatbot-enhanced` now pass only whitelisted DOM props to their host element (objectui#4431)
+
+ 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.
+
+ 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.
+
+ `@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.
+
+ `@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` 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.
+
+- 37bbc42: Replace the three `messages as any` casts at the `@object-ui/types` ↔
+ `@object-ui/plugin-chatbot` `ChatMessage` boundary with one explicit typed
+ adapter (`toRuntimeMessages` / `authoredToRuntimeMessage`, now exported).
+
+ The authoring contract (`ChatbotSchema['messages']`) and the runtime contract
+ `` renders are both deliberate and deliberately different; the
+ casts erased ALL of that drift rather than the intentional parts, so a future
+ vocabulary move would have surfaced as rendering behaviour instead of a type
+ error. Each narrowing is now named, documented and tested: an authored
+ `role: 'tool'` message is an assistant message (unchanged rendering — the
+ implicit fallthrough is now the recorded decision), a `Date` timestamp becomes
+ its ISO string (one expression, consumed by both the seam and the hook's
+ `normalizeMessages`), and the legacy tool-invocation states
+ `'partial-call'`/`'call'`/`'result'` map to their AI SDK v6 equivalents as the
+ authoring type's own documentation declares — previously they reached the tool
+ chip unrecognised and rendered a status badge with no label.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [5cc847c]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/plugin-chatbot/package.json b/packages/plugin-chatbot/package.json
index 6593730dc0..565dab0898 100644
--- a/packages/plugin-chatbot/package.json
+++ b/packages/plugin-chatbot/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-chatbot",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Chatbot interface plugin for Object UI",
diff --git a/packages/plugin-dashboard/CHANGELOG.md b/packages/plugin-dashboard/CHANGELOG.md
index b5fe89d7d9..34a2085dd6 100644
--- a/packages/plugin-dashboard/CHANGELOG.md
+++ b/packages/plugin-dashboard/CHANGELOG.md
@@ -1,5 +1,608 @@
# @object-ui/plugin-dashboard
+## 17.5.0
+
+### Minor Changes
+
+- 7084f7d: `DashboardRenderer` and `ListView` serve the props they declare — the index signature stops erasing them
+
+ 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` was `string | number` and `ComponentProps['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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- b8bda9a: The editable dashboard grid renders dataset-bound widgets — and says so visibly when it cannot
+
+ `DashboardGridLayout` had no dataset path at all. It never read `widget.dataset`, never imported `DatasetWidget`, and took no `dataSource` prop — so a widget authored the way ADR-0021 says to author them (`{ id, type: 'bar', dataset: 'invoices', values: ['count'] }`) fell straight through to the static-data branch and rendered nothing. Measured on the node the grid handed `SchemaRenderer`, the silence had three flavours rather than the one reported: a `bar` became `{ type: 'chart', data: [] }` (a chart drawn over nothing), a `metric` became `{ type: 'metric', value: '—' }` (an em dash, which reads as a rendered value rather than an error), and a `table` became `{ type: 'data-table', data: [] }`. No data, no diagnostic, no path to fix — on the surface registered as the `dashboard-grid` SDUI component and exported by name from the package entry.
+
+ This is the defect objectui#4612 fixed for the RETIRED authoring shape, one level up: same surface, same silence, but the shape that is current. The sibling `DashboardRenderer` has routed these widgets through the governed `queryDataset` path since ADR-0021, so the cure is that surface's own mechanics rather than a second dispatch idiom — the `datasetBound` predicate decided per widget, and `DatasetWidget` picked at the render site.
+
+ `DashboardGridLayout` therefore gains an optional `dataSource` prop, forwarded to `DatasetWidget` for dataset-bound widgets. A dataset-bound metric now also takes the shared `Card` wrapper, matching the sibling: `DatasetWidget` renders just the value, so without the card it would show as bare text with no title beside its neighbours.
+
+ A dataset-bound widget arriving with NO data source renders a visible state, never a blank. No new placeholder was declared for it: `DatasetWidget`'s own no-capability rendering — an alert reading "This data source does not support dataset queries." — was measured to render visibly when handed no adapter, so routing through it unconditionally cures both halves with one diagnostic and one wording. That case is not hypothetical: `dashboard-grid`'s SDUI registration declares only `title` and `className` inputs, so schema-driven hosts render this component with no adapter at all, and every such host keeps working exactly as before.
+
+ Nothing else moves. The objectui#4612 legacy sentinel keeps its position and its verdict — the two conditions are mutually exclusive by construction, since the shared detector returns false the moment a widget carries `dataset` — and static-data widgets, `options.data` provider widgets and legacy-retired widgets all render as they did and never reach the dataset query. The new prop is additive and optional, so existing call sites are untouched.
+
+- 8640cec: One legacy detector, two dashboard surfaces — the editable grid stops rendering a silent blank chart
+
+ framework#3320 retired the pre-ADR-0021 inline-analytics widget shape (top-level `object` + `categoryField` / `valueField` / `aggregate`, pivot `rowField` / `columnField`) and shipped a graceful fallback for the stored metadata that still carries it: a visible tile reading "This widget uses a retired data format. Edit it to bind a dataset." The fallback was applied to `DashboardRenderer` and to nothing else. `DashboardGridLayout` — separately exported, and registered as the `dashboard-grid` SDUI component — had no sentinel at all, so the identical stored widget fell through to its static-data branch with `data: []`. Same metadata, same product, two different outcomes: a rebind prompt on one surface, a silent blank chart on the other. The blank is worse for the author than the pre-retirement state, because it carries no chart, no diagnostic and no path to fix — the exact outcome the retirement's own test header says must not happen.
+
+ The fix is not a second copy of the condition, because one copy is why the defect existed. The detector and the placeholder now live in a single module (`legacyRetiredWidget.ts`) that both surfaces import; `DashboardRenderer`'s observable behaviour is unchanged, pinned by its existing suite, and the new grid suite mirrors that suite's structure on the surface nobody had pinned. Four positive cases go from blank to placeholder, two of them the widget shapes stored byte-for-byte in the schema catalog's `filtered-dashboard` entry.
+
+ The negative controls are the load-bearing half, because the retired shape is one character away from a live one. `options.data = { provider: 'object', … }` carries its OWN nested `object` and `aggregate`: it is a different, still-live authoring surface, read off the widget's data rather than off the widget top level, and it keeps rendering untouched — as do dataset-bound widgets and static-data widgets, on both surfaces. `DashboardRenderer`'s pivot arm stays deliberately surface-local rather than shared: it returns the placeholder for the entire pivot family because that surface emits no pivot block at all, which is a fact about what it can draw, not about the widget being legacy. The grid does draw pivots, from static data and from the provider config, so exporting that arm would have retired two working branches. A legacy pivot is caught on both surfaces by the shared sentinel instead, via the top-level `object` it carries.
+
+- f244273: `MetricWidgetProps` / `MetricCardProps` declare the DOM pass-through their spread has always accepted
+
+ Both KPI components end their prop list with a `...domProps` spread onto the Shadcn `Card`, and objectui#4357 (PR #4428) kept that spread deliberately — it is their only accessibility pass-through, and removing it would delete the only way a host can put an `id`, a `role` or an `aria-label` on a KPI card. Neither props interface declared any of it. So the type refused what the runtime accepted: a JS consumer, and every SDUI author going through `SchemaRenderer` (untyped at that boundary), got the pass-through, while a TypeScript consumer importing the component directly got `error TS2322` on `id` / `role` / `aria-label` and needed a cast.
+
+ `MetricWidgetProps` now extends `React.HTMLAttributes`, and `MetricCardProps` extends the same minus `title`. That is the repo's measured convention for an exported props interface that spreads onto a host element (`PageHeaderComponentProps`, `ChatbotProps`, `ChatbotEnhancedProps`, `TypingIndicatorProps`, `RefreshIndicatorProps`, `FieldProps`, and shadcn's `BadgeProps`), and the `Omit` carve-out is `ComboboxProps`'s spelling for a name the component's own contract owns.
+
+ Graded `minor` rather than `patch` per the objectui#4403 precedent: two exported interfaces widen. The widening is purely additive for existing callers — every prop that compiled before still compiles, and nothing narrows — so no source change is required to upgrade.
+
+ Semantics worth knowing, because both are contract statements rather than incidental:
+
+ - **`MetricCard.title` stays the heading.** HTML's `title` is a tooltip; this card's `title` is its heading, in the `I18nLabel` vocabulary, destructured out and rendered into `CardTitle`. No `title` attribute has ever reached this element, so the inherited DOM `title` is omitted rather than declared and silently dropped — the "declared but not delivered" failure this repo treats as first-class (objectui#3290, objectui#3222). `MetricWidget` has no such collision (its heading is `label`) and extends the DOM attributes whole.
+ - **`MetricWidget.onClick` stays zero-arg**, narrower than the inherited `MouseEventHandler`, because the same handler is wired to Enter/Space where there is no mouse event to hand over. A zero-arg function is assignable to the inherited signature, so callers already passing `(e) => …` keep compiling.
+
+ Not declared, deliberately: the schema-shaped keys `SchemaRenderer` injects (`schema` / `bind` / `events` / `props` / `ariaLabel` / `ariaDescribedBy` / `dataSource`). None is an HTML attribute name, all seven are destructured out before the spread, and declaring them would re-assert as public contract exactly what PR #4428 stripped from the DOM. They stay in `SchemaHostProps`, intersected in at each component's own signature — accepted so the renderer can inject them, never part of the documented authoring surface.
+
+ Zero runtime change: no component body was touched, and PR #4428's pins pass untouched.
+
+- c1d939f: One `SchemaNode`, and one label vocabulary — the union wins, and labels resolve where the locale lives
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 36310dc: `formatPercent` groups its output and follows the display locale — the last
+ tooltip/cell channel (objectui#4553).
+
+ PR #4557 threaded the gantt tooltip's number and currency rows and measured that
+ the percent row could not follow: `formatPercent(value, precision)` took no
+ locale parameter, and its whole body was
+ `${percentDisplayValue(value).toFixed(precision)}%`. It built no
+ `Intl.NumberFormat` and never reached `formatDisplayNumber` — so unlike its
+ siblings it did not render in the MACHINE's locale, it rendered in **no** locale:
+ an ASCII decimal mark, never a grouping separator, byte-identical on every
+ machine.
+
+ **English output MOVES, and that is the fix.** Because the function never
+ grouped, `1235%` was wrong in en-US too, not only in German. Grouping and locale
+ therefore land together:
+
+ | | before | after |
+ | ---------- | ------- | -------------- |
+ | en, 1234.5 | `1235%` | `1,235%` |
+ | de, 1234.5 | `1235%` | `1.235\u00a0%` |
+ | de, 80 | `80%` | `80\u00a0%` |
+
+ Values below the grouping threshold are unchanged in English (`80%`, `12.5%`,
+ `33.33%`), so the move is confined to four digits and up. German changes at every
+ magnitude, because the no-break space before the sign is part of the locale's
+ percent convention — which is what routing through `Intl` buys over appending a
+ literal `%`.
+
+ The scaling contract is untouched: `percentDisplayValue` still disambiguates a
+ fraction-stored percent (`0.8` → 80%) from a whole one, so the list cell and the
+ dashboard measure formatter still agree.
+
+ Consumers are threaded in the same change, the parameter never landing
+ speculatively:
+
+ - **fields** — `PercentCellRenderer`, on BOTH of its paths. Its whole-percent
+ branch (`progress` / `completion` fields, which store 0-100 and must skip the
+ fraction scaling) was a second bare `toFixed` call; leaving it behind would
+ have made one grid internally inconsistent, so both branches now share one
+ locale-aware body and differ only in the scaling policy.
+ - **plugin-gantt** — the tooltip percent row, completing objectui#4553's switch.
+ - **plugin-grid** — the mobile card's percent cell, which sits in the same
+ density row as a date cell objectui#4272 had already localized.
+ - **plugin-dashboard** — `renderFieldValue`'s percent branch. It is a plain
+ function rather than a component, so it takes the locale as an optional fourth
+ parameter beside the `tenantCurrency` already threaded that way, and both of
+ its callers pass it and declare it in their memo dependency arrays.
+
+ Bumps follow each package's own `.d.ts` diff, measured in both directions.
+ `@object-ui/fields` and `@object-ui/plugin-dashboard` are `minor` on the
+ objectui#4272 / PR #4544 precedent — quoted from that changeset: "`@object-ui/fields`
+ is `minor` because `formatDateTime`'s new optional parameter is visible in the
+ package's entry `.d.ts`; the plugin packages' own `.d.ts` files are
+ byte-identical, so their change is module-local." Here `formatPercent` and
+ `renderFieldValue` each gain an entry-visible optional parameter, while
+ plugin-gantt's and plugin-grid's `.d.ts` files are byte-identical and stay
+ `patch`.
+
+### Patch Changes
+
+- ee26e65: Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
+
+ 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.
+
+ 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.
+
+ 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).
+
+ 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.
+
+- 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
+
+ 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.)
+
+ 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...` — 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 3c6e84c: Dashboard `combo` widgets draw as combos on the dataset path — the dataset owns the data, the author owns the presentation
+
+ A widget authoring the spec's own combo shape — `series[].type` plus `series[].yAxis: 'left'|'right'` and two `yAxis` entries — rendered as two bar series on one shared axis. Measured in the DOM: 2 bars, 0 lines, 1 y-axis, where 1 bar, 1 line and 2 axes were authored, so a percentage measure was plotted against a raw count's scale.
+
+ Two halves caused it, and fixing either alone leaves a worse state than before. `CHART_TYPE_MAP` had no `combo` entry, so a `combo` widget fell through its `?? 'bar'` default — bars, whatever the series said. And `chartConfigPresentation` refused to forward `series` / `xAxis` / `yAxis` at all, on the stated grounds that they are derived from the dataset selection, so the per-series mark and the axis binding could never reach the renderer even once the family resolved.
+
+ That belief was half right. The dataset does own the series MEMBERSHIP — which columns become series, which rows, which buckets — and it still does: an authored entry naming a measure the dataset did not select is ignored, and a derived series the author said nothing about keeps the family default. What the dataset never owned is the PRESENTATION carried on those same objects: the per-series mark, its left/right axis binding, label, colour, stack, and the axis definitions' title, format, min, max, step, grid and position. Those are the author's, and they now merge onto the derived bindings by name/key match with the explicit binding winning — one merge function, not a spread per attribute. The split runs through the two binding keys: `ChartSeries.name` and `ChartAxis.field` name a column and stay with the dataset; everything else on the object travels.
+
+ This is objectui#2880's S2 rule, which PR #2883 landed in `ObjectChart` and which the dataset path never carried over. Dropping `ChartAxis.field` on the way through is what makes forwarding the axes safe rather than merely guarded: it is the one key by which an authored axis could have named a series, since the renderer synthesises series from `yAxis[].field` when a chart declares none.
+
+ Two consequences beyond the reported bug. A non-combo widget can now declare one line series and get the combo the renderer already knew how to derive from disagreeing series types. And a `compareTo` overlay inherits its own measure's mark and axis, so the comparison of a bar-on-the-left measure no longer draws as a line on the right the moment the chart becomes a combo.
+
+ Dashboards that never authored `chartConfig.series` or `chartConfig.yAxis` emit exactly what they emitted before.
+
+- 0bf3f44: `DashboardRenderer`'s widget grid now passes only whitelisted DOM props to its container (objectui#4432)
+
+ `view:dashboard` resolves to this component, so `SchemaRenderer` handed it the dashboard node's own keys, the contents of the node's `props` container, the ARIA it resolved and the host's trailing props — and every key the component did not destructure was spread raw onto the grid container. React writes unknown lowercase attributes through in silence and stringifies object values, so the failure was invisible. Measured through the real SDUI path: **13 non-DOM attributes**, including `events="[object Object]"`, `props="[object Object]"` 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.
+
+ The container is now consume-or-whitelist per objectui#4425 phase 2: only `toDomProps`' output reaches the element, and it is spread FIRST so the component's own computed attributes stay authoritative. The resolved `aria-label` / `aria-describedby`, `role`, `id`, `tabIndex`, `className` and the `data-*` family still arrive — dropping them would have been an accessibility regression dressed as a leak fix, so the new pin asserts the delivered set exactly, not just the absent one. Both layout branches are covered: the responsive desktop grid and the mobile stack spread the same props onto the same host element.
+
+ Three behaviours move with the spread, all of them consequences of a trailing spread that used to override the component's own computed props:
+
+ - **`onClick` now has one carrier.** It is a declared DOM pass-through key AND this container computes a design-mode background handler, and the old spread let the incoming handler replace the computed one — so a host that passed `onClick` silently lost background deselection. Both run now, container affordance first. An authored non-function `onClick` (SDUI spells click behaviour `events: { onClick }`, which is data and is dropped) is ignored instead of handed to React, which used to throw on it.
+ - **An authored `style` no longer replaces the computed grid layout.** `style` is not in the SDUI pass-through set, and this container computes its own `gridTemplateColumns` / `gridAutoRows` / `gap`; an authored `style` used to overwrite all of it and collapse the grid.
+ - **An authored `data-user-actions` no longer overrides the value computed from the `userActions` prop.** The `data-*` family still passes the whitelist; only this one collision with a computed attribute resolves the other way now.
+
+ The injected `disabled` verdict is also dropped rather than forwarded. Nothing in this component ever read it: it only became a `disabled` attribute on a container element that has no such attribute, which is the leak, not a behaviour.
+
+- eb7f586: Dashboard dataset measures follow the display locale (objectui#4566).
+
+ `formatMeasure` and `formatDimensionValue` in `@object-ui/core` formatted every
+ value with a bare `undefined` locale tag at all three of their `Intl` sites.
+ `undefined` is not "the user's locale", it is the MACHINE's — neither of the
+ repo's two locale channels. A German session read a dashboard KPI as `1,234.5`
+ next to a grid cell rendering the same number as `1.234,5`, and inverted
+ separators read as a different number, not as an unstyled one.
+
+ Both functions take the display locale as a new OPTIONAL LAST parameter, and
+ `DatasetWidget` threads `useDisplayLocale()` into every site it formats through:
+ the KPI, the grouped table's measure and dimension cells, and the cross-tab's
+ header labels and cells.
+
+ **English output does not move**, and that is the discriminator against the
+ sibling fix. These sites already went through `Intl` with default grouping, so
+ the only thing that changes is WHOSE locale is used:
+
+ | | before | after |
+ | ----------------- | ----------- | --------------------- |
+ | en, 1234.5 `0.0` | `1,234.5` | `1,234.5` (unchanged) |
+ | de, 1234.5 `0.0` | `1,234.5` | `1.234,5` |
+ | de, 1234.5 EUR | `€1,234.50` | `1.234,50 €` |
+ | de, 0.6083 `0.0%` | `60.8%` | `60,8%` |
+
+ Contrast objectui#4553, where `formatPercent` had never grouped at all and
+ moving en `1235%` → `1,235%` WAS the fix.
+
+ Omitting the new argument reproduces the previous output byte for byte, so
+ callers that do not thread a locale yet are unaffected.
+
+ Two behaviours are deliberately preserved rather than "improved" alongside the
+ locale fix, both measured:
+
+ - **Integers stay verbatim.** The integer branch renders no separator and no
+ decimal mark, so a locale has nothing to change there — and routing it through
+ `Intl` WOULD change it (a locale with its own numbering system re-digits it,
+ and `1e21` expands to 22 digits).
+ - **The percent sign stays a literal suffix.** `Intl`'s `style: 'percent'`
+ re-scales by 100, and that round trip loses precision at the top of the range
+ (en `100,000,000,000,000,000,000,000%` becomes
+ `99,999,999,999,999,990,000,000%`). The consequence — a German list cell
+ writing `1.234,5 %` with a no-break space where a dashboard measure writes
+ `1.234,5%` — is filed separately rather than smuggled in behind a locale fix.
+
+ `@object-ui/core` is `minor` because two of its ENTRY exports gained an optional
+ parameter (measured in the built `.d.ts`). `@object-ui/plugin-dashboard` is
+ `patch`: its published declarations are unchanged — `buildPivot`'s new optional
+ parameter is internal, as that function is not on the package's `exports`
+ surface.
+
+- 54d34d2: A dashboard chart's null-value bucket now reads the app's language instead of the English `(None)`
+
+ `buildChartSeries` groups rows whose category value is `null` under a labelled bucket, so the group draws as a bar instead of vanishing off the axis (objectui#4466). The label comes from the caller: `@object-ui/core` is React-free, cannot read the locale bundle, and falls back to the English constant `(None)`. `ObjectChart` passes its resolved label and localizes; `DatasetWidget` called the same helper with no options, so a dashboard widget in a zh app labelled the bucket `(None)` while the standalone chart one panel over labelled it `(未指定)`. It now passes `chart.nullCategory` from the i18n channel, which every locale pack already carries.
+
+ The same label goes to `findChartSeriesRow`, and that half is what keeps the bar clickable. That helper is the inverse map behind segment-click drill-through: it compares the clicked category against its own copy of the bucket label, defaulting to the same English floor. Passing the localized label to only the forward call would draw a bar reading `(未指定)` while the drill matched `(None)` — the click resolves to no row and the drawer never opens, which is a worse outcome than the untranslated word this fixes. Both calls now read one binding, so they cannot drift apart.
+
+ Nothing else moves: non-null categories chart and drill exactly as before, an `en` app still reads `(None)` (now via its locale pack rather than the hardcoded floor), and a widget over data with no null group is untouched.
+
+- ee7a68d: `DatasetWidget`'s option-color / dimension-label probe now rides the host's
+ authenticated fetch (`SchemaRendererContext.apiFetch`) instead of the bare global
+ `fetch`.
+
+ The one metadata read the effect makes — `GET /api/v1/meta/object/{object}` — went
+ out on the global `fetch`, so in a hosted console it skipped whatever the host
+ supplies on that channel (Authorization / tenant headers, base-URL rewrite,
+ draft-preview params). A bearer-token session carries its credential in a header
+ rather than a cookie, so `credentials: 'include'` alone left this read
+ unauthenticated. The effect is best-effort and swallows every failure, which made
+ the symptom silent: a dataset chart's semantic per-category colors and its
+ dimensions' value → label maps simply never applied, and the widget fell back to
+ the positional theme palette and the raw stored values on the axis.
+
+ Standalone embeds are unaffected — with no provider (or a provider that supplies no
+ `apiFetch`) the probe still uses the global `fetch`, the same documented fallback
+ `useRecordEditable` and `provider: 'api'` view sources use.
+
+ This is the `plugin-dashboard` twin of the same fix made to `plugin-charts`'
+ `ObjectChart`.
+
+- 436681e: fix(dashboard): resolve a dotted dimension's labels on table and pivot dataset widgets
+
+ A dataset widget's client-side dimension-label safety net returned early for
+ `table` / `pivot` / metric widgets, so a DOTTED dimension (`crm_account.industry`)
+ rendered the raw stored enum (`education`) there — the same symptom objectui#4053
+ fixed for charts, on the widget types its fix did not reach.
+
+ The early return stays for LOCAL dimensions, which is what made it correct in the
+ first place: on a table the server resolves those labels (ADR-0021), so running
+ the client net for them would be a second resolution of an already-resolved
+ value. It now opens only for dotted paths — the case the server is silent on too —
+ reusing the existing `resolveDimensionFieldOptions` walk unchanged, multi-hop
+ paths included. A table with no dotted dimension resolves nothing and issues no
+ metadata read at all, so those widgets render byte-identically.
+
+ A pivot's marginal totals take the same relabel as its rows, because their bucket
+ ids are re-derived from the dimension values that the headers are built from; the
+ CSV export follows the table's cells for the same reason. Drill-through still
+ filters by the stored value — the relabel preserves row order and count, so the
+ raw rows it indexes stay aligned.
+
+ Metric widgets are unaffected by design: that branch renders one measure value
+ and its header label and puts no dimension value on screen, so it has nothing to
+ resolve.
+
+- 613b167: A dataset dimension on a dotted relationship path now renders its option labels instead of the raw stored enum
+
+ 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.
+
+ The label lookup read options as `baseObject.fields[]`, 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.
+
+ 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.
+
+ 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.
+
+ Applies to both surfaces that carried this lookup: dashboard dataset widgets (`DatasetWidget`) and the chart view's dataset path (`ObjectChart`).
+
+ 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.
+
+- 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
+
+ rc.6 widened `I18nLabel` from `string` to `string | Record`, 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.
+
+ | package | what an author can now write and see |
+ | ----------------------------- | --------------------------------------------------------------------------------------- |
+ | `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
+ | `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
+ | `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
+ | `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
+ | `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
+
+ **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.
+
+ Two consequences worth knowing:
+
+ - **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.
+ - **`@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.
+
+- 326a70f: Analytics: a LOCAL select dimension on a table / pivot widget — and on a dataset-bound report — now renders its option label through the locale bundle
+
+ A dashboard table grouped by a select field showed `Domestic` on a zh-CN console while the related list on the same screen showed 国内. The value was never untranslated by accident: the server resolves that dimension's display label (ADR-0021) and hands the row over carrying the object's AUTHORED English label. The locale bundle is keyed by the option's stored VALUE (`{ns}.fieldOptions...`), so translating one needs the option LIST — and the table path deliberately loaded no object metadata at all, which is why objectui#4030 / PR #4324 fixed charts and dotted dimensions and left this half open.
+
+ Table, pivot and the dataset report block now take the one metadata read that gives the bundle something to translate against, and feed it to the SAME seam #4324 landed (`resolveDimensionFieldMeta` → `localizeFieldOptions` / `buildDimensionLabelMap` → `relabelDimensions`). No second resolution dialect: the map carries both the stored value and the authored label as keys, and the relabel is value-wise and idempotent, so a value the server already resolved lands on the same display it would have from the raw value. Cells, pivot headers on both axes, the server's marginal totals, the CSV export and a report's embedded chart all read the one map, which is what keeps a subtotal's bucket lookup meeting the header it belongs to.
+
+ Untranslated apps are unchanged by construction: with no bundle entry the display equals the authored label, no key is emitted, and the rows come back by identity. Identity keys stay untranslated — a drilled row or cell still filters records by the values the server sent, and measures still export as bare numbers.
+
+ This deliberately amends the acceptance boundary objectui#4263 landed ("a local-only table issues no metadata read"), which was ruled for label RESOLUTION before the read had a second consumer. The pins that stated it are rewritten in place, in the same change, and say so.
+
+- 7e4f0e5: fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032)
+
+ A `type: 'metric'` dashboard widget rendered raw English while every other widget
+ type on the same dashboard rendered the translation, and dashboard filter chips
+ rendered `[object Object]` or the raw stored value. Both come from the same
+ cause: authored labels reaching a render site that could not read the
+ vocabulary `@objectstack/spec` actually admits.
+
+ - **KPI cards rejoin the widget translation channel.** The self-contained
+ `metric` branch built its own label from the raw `widget.title`, so the
+ `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already
+ resolved was computed and thrown away. It now reads that channel like every
+ other widget header.
+ - **The three private `resolveLabel` copies** (`DashboardRenderer`,
+ `MetricWidget`, `MetricCard`) are gone. Each read the retired
+ `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so
+ handed the inline per-locale map the spec admits today they returned nothing —
+ a KPI card with a map title rendered the literal string `metric`. All three
+ now use `pickLocalized`, the resolver already used for this vocabulary
+ elsewhere in the package.
+ - **Dashboard filter labels and static option labels resolve per locale.**
+ `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar
+ resolves before rendering (fixing `[object Object]: All` in the trigger, and
+ in `aria-label` / `placeholder`), and the `def.label || def.name` gate now
+ tests the RESOLVED string — an object is always truthy, so it never reached
+ the fallback before.
+ - **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a
+ map label to the raw stored value in every locale, English included, so
+ `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`.
+ The pair shape is still normalized; the label vocabulary is preserved for the
+ render side to resolve.
+ - **`DashboardComponentSchema.globalFilters` is bound to the spec's
+ `GlobalFilter`** instead of restated by hand. The restatement was both too
+ narrow (`label?: string`, which is what made these read sites invisible to
+ `tsc`) and too wide (it declared a bare-string option shorthand the spec
+ rejects at publish).
+
+ Plain-string labels are unaffected and render byte-identically.
+
+- 306c101: KPI cards no longer write their own schema onto the DOM — `MetricWidget` and
+ `MetricCard` keep `SchemaRenderer`'s schema-shaped props out of the `...props`
+ spread (objectui#4357).
+
+ Both components are two things at once: an SDUI block reached through
+ `SchemaRenderer`, and a plain React component a host may render directly. The
+ React half wants a `...props` spread on its root so callers can pass `aria-*`,
+ `data-*`, `id`, `role`. The SDUI half means that spread also received the node's
+ own metadata — and React writes unknown lowercase attributes straight to the DOM,
+ stringifying object values. Every KPI card therefore carried
+ `schema="[object Object]"`, and a widget authored with events, a binding or a
+ props container carried `events="[object Object]"`, `bind="data.revenue"` and
+ `props="[object Object]"` beside it.
+
+ Seven props were measured arriving at the call site that are not HTML attribute
+ names — `schema`, `events`, `props`, `bind`, `ariaLabel`, `ariaDescribedBy` (the
+ last two are the camelCase authored forms of ARIA the renderer already emits in
+ their dashed spelling) and `dataSource`. They are destructured out; the spread
+ survives untouched for everything that IS a DOM attribute: `id`, `name`, `role`,
+ `disabled`, `aria-*`, `data-*`, `className`. Nothing else about the render moves
+ — no text, no class, no element.
+
+ `dataSource` is the one that only a live dashboard shows. It is not a schema key
+ (the renderer strips the schema's own `dataSource` binding by name); it is the
+ injected adapter `DashboardRenderer` hands its `SchemaRenderer` call, which
+ arrives through the renderer's trailing props. Every fixture in this package
+ renders without an adapter, so it read `undefined` and wrote nothing — while
+ every deployment that actually loads data put `datasource="[object Object]"` on
+ the card. The pin renders a dashboard with an adapter so the case that only
+ production had is now a test.
+
+ The cost of this was never visible; it was that the defect poisoned the
+ assertion this area attracts. objectui#4163 pins
+ `not.toContain('[object Object]')` on the dashboard grid, and objectui#4032
+ wanted the same pin on the metric path but could not write it: the card carried
+ the attribute before and after any i18n fix, so the container assertion was red
+ for a reason unrelated to labels and the tempting repair was to loosen it. That
+ suite asserted on the card heading instead, with a comment. The workaround is
+ now removed and the container assertion is back.
+
+ The exported `MetricWidgetProps` / `MetricCardProps` interfaces are unchanged —
+ the components' accepted props widen only by the optional, ignored
+ `SchemaHostProps` keys, so no consumer type narrows.
+
+- 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
+
+ Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
+
+ The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
+
+ `useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
+
+ Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
+
+ Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
+
+ `formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
+
+- 844ed3a: Dashboard global filters sourced from `optionsFrom` now commit the RAW value instead of the display label.
+
+ The option source is a server GROUP BY whose response carries both forms of every grouped value: `rows` holds the resolved display labels (`{status: 'In Review'}`) and the index-aligned `drillRawRows` holds the raw stored values (`{status: 'in_review'}`). `DashboardFilterBar` read the value off `rows`, so picking an option broadcast a label no record carries into every bound widget's `runtimeFilter` and each widget repainted to "No rows". Options are now paired index-wise — value from `drillRawRows`, label from the displayed row — mirroring how the drill path has always read the same response. The trigger still displays the label, and statically declared `options` are unaffected. When the raw rows are absent, disagree in length with `rows`, or carry no such field, the previous read is kept rather than guessing at a pairing.
+
+- 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
+
+ objectstack#5473 / objectstack#5665 replaced the pivot's delimiter-joined ids
+ with `JSON.stringify`, because every delimiter that had been tried — an empty
+ string, a plain space, a control character — assumed the data would not contain
+ it, and each assumption failed on ordinary data. This closes the last place the
+ same assumption survived: the ids were JSON, but the VALUES fed into them were
+ spelled `String(row[d] ?? '∅')`, so an absent dimension value became the
+ ordinary string `"∅"` and shared a bucket with a row whose value literally is
+ that character (U+2205). One bucket, later row overwriting the earlier one — the
+ cell showed a different row's measure, the overwritten row was unreachable, and
+ drill-through followed the same wrong index into the wrong records, all without
+ an error. The trigger requires that character to appear as a dimension value, so
+ this is the assumption being removed rather than a defect users hit today.
+
+ An empty value now encodes as JSON `null`, which `JSON.stringify` renders as a
+ bare `null` that no string can spell. The normalization lives in
+ `@object-ui/core` as `pivotDimensionValue` (absent ⇒ `null`, everything else ⇒
+ its string form) rather than at each call site, because a placeholder spelled by
+ a caller is a placeholder that can collide again — which is exactly how this one
+ survived the previous fix. `pivotBucketId` accepts `Array`
+ accordingly; that is a widening, so existing callers passing `string[]` are
+ unaffected.
+
+ Both renderers' bucket keys move together, which the fix requires: a bucket id
+ and the subtotal map keyed by it are built from the same expression, so changing
+ one alone would split the headers while the subtotal map still merged, landing
+ every column subtotal under the wrong header. In `plugin-dashboard`'s
+ `DatasetWidget` that is the row bucket id, the column bucket id, the cell key,
+ and both the `rowTotalById` and `colTotalById` lookups; in `plugin-report`'s
+ `DatasetReportRenderer` the single `bucketId` helper already feeds all five.
+
+ The dashboard's column bucket id also stops being a bare string and becomes a
+ one-element tuple through the same shared encoder. It was the one id in the
+ family still built by hand, on the reasoning that a single value needs no
+ boundary — true of the boundary, false of everything else the encoder does, and
+ it is why the across axis kept carrying this collision after the row ids were
+ fixed.
+
+ No display change: these placeholders only ever entered ids, never labels. An
+ unset dimension still renders through `formatDimensionValue` exactly as before,
+ and data containing neither an absent value nor that character buckets
+ identically — the ids are opaque lookup keys, never parsed back into a value,
+ never shown, never persisted.
+
+- a3ae404: fix(components,plugin-dashboard): a static-data `table` widget renders instead of crashing
+
+ A dashboard widget authored as `{ type: 'table', options: { data: [ … ] } }` fell into the
+ error boundary with "Maximum update depth exceeded" the moment its tile re-rendered, while
+ every chart family on the identical static surface rendered clean.
+
+ - `data-table` no longer re-renders itself to death. Its `columns` / `data` fallbacks are
+ module-scope empties instead of per-render array literals, and the prop→state column sync
+ re-seeds on a value change rather than on a new identity — so a consumer that derives its
+ columns each render (which both dashboard surfaces do) costs the table nothing.
+ - Both dashboard surfaces now give the static table the `columns` key `DataTableSchema`
+ requires, derived from the rows when the author declared none — the same derivation the
+ `provider: 'object'` half of the widget family already performed. Previously such a table
+ drew one empty row per record: no headers, no cells.
+ - `DashboardGridLayout` reads an authored `options.data` ARRAY for its static table, which
+ its `widgetData?.items` expression resolved to `[]`. `DashboardRenderer` had the arm all
+ along.
+
+- 3f5f87c: `SchemaRenderer` states its real contract — a typed, required `schema` and a deliberate forwarding surface
+
+ `SchemaRenderer` is the renderer loop: every registered SDUI component is rendered through it. It handed `forwardRef` a props type of `{ schema: SchemaNode } & Record`, which puts `string` into `keyof Props`, so `'ref' extends keyof Props` was always true, React's `PropsWithoutRef` took its `Omit` branch, and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared prop was erased. Measured on the pre-fix source: `keyof ComponentProps` was `string` and `ComponentProps['schema']` was `any`, while the type argument went on declaring `SchemaNode`. The other half is the same defect seen from the call site — ` ` with no schema at all, ` `, and an arbitrary misspelled prop each type-checked in silence. This is objectui#4422 / PR #4438's trap in the most central component in the repo, spelled `Record` rather than `[key: string]: any`, which is why every previous sweep's grep and both shipped guards' detector reported the site as clean.
+
+ Graded **minor, not major**, on objectui#4528's reasoning: the type argument has always DECLARED `schema`; the index signature erased it from the resolved type, and restoring what the declaration documents is a fix to the published contract rather than a contract break.
+
+ **The forwarding surface is kept, deliberately.** This component forwards every prop it does not read to the component the schema names, resolved at runtime from a plugin-extensible registry — `packages/react/README.md` documents exactly that, and `@object-ui/components`' form renderer consumes the `onSubmit` it shows being forwarded. Closing that surface would state a false contract and would force every leaf plugin's props into this package. So the two halves are separated: the `forwardRef` type argument is the honest `SchemaRendererProps`, with no index signature for `PropsWithoutRef` to collapse, and the open surface is stated once in an explicit export annotation, which nothing routes through `Omit`. The published `.d.ts` shows the erasure disappearing: `ForwardRefExoticComponent, "ref"> & RefAttributes>` becomes `ForwardRefExoticComponent & RefAttributes>`.
+
+ `SchemaRendererProps.schema` is declared as `BaseSchema | string | null | undefined` — what this component actually handles. It previously declared `@object-ui/core`'s `SchemaNode` interface, which requires `type: string` and so contradicted the component's own early returns for strings and nullish, while every caller held `@object-ui/types`' wider union. The erasure hid that mismatch completely.
+
+ **One declared behaviour change.** A non-object, non-string primitive schema now renders as its own text. It previously fell through to the shallow copy `{ ...schema }`, which spreads a primitive to an empty object, lost the `type` the renderer then looked up, and surfaced the red "Unknown component type: undefined" box — an accident of the spread rather than a decision. The declared props type excludes `number` / `boolean` so no author is invited to pass them; the runtime handling is defence-in-depth for untyped callers and stored metadata. Strings, `null`, `undefined`, `0` and `false` render exactly as before, and an object naming an unregistered type still gets the error box; all four are pinned.
+
+ Latent defects the erasure had been hiding, each surfaced by the repo-wide type-check and fixed at its call site: `DashboardRenderer` cast its widget schema to `Record`, dropping the `type` every branch of `getComponentSchema` sets; `DashboardGridLayout`'s equivalent now states its return type instead of inferring a union that admitted a shape with no `type`; and `ReportViewer` handed a section's `content` array to the renderer whole, so a multi-node section rendered the unknown-component box instead of its content — arrays are mapped rather than widened into the renderer's declared input.
+
+ A repo-wide structural guard replaces the two per-package siblings' blocked direction: it judges every `forwardRef` in `packages/*/src` (219 sites) and its detector resolves `Record` and `string`-keyed mapped types in addition to literal index signatures — the spelling the previous detector went blind on. It judges the type argument only, where an index signature is an accidental eraser, and never an export annotation, where one is a stated contract.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [ebb4e0e]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [8f60d73]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [5cc847c]
+- Updated dependencies [e7663f2]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [36310dc]
+- Updated dependencies [52d878a]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-dashboard/package.json b/packages/plugin-dashboard/package.json
index 965d0682a3..7ebc0c119a 100644
--- a/packages/plugin-dashboard/package.json
+++ b/packages/plugin-dashboard/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-dashboard",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Dashboard plugin for Object UI",
diff --git a/packages/plugin-designer/CHANGELOG.md b/packages/plugin-designer/CHANGELOG.md
index f40784f9eb..58555e6e72 100644
--- a/packages/plugin-designer/CHANGELOG.md
+++ b/packages/plugin-designer/CHANGELOG.md
@@ -1,5 +1,268 @@
# @object-ui/plugin-designer
+## 17.5.0
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- 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
+
+ `@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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 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
+
+ rc.6 widened `I18nLabel` from `string` to `string | Record`, 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.
+
+ | package | what an author can now write and see |
+ | ----------------------------- | --------------------------------------------------------------------------------------- |
+ | `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
+ | `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
+ | `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
+ | `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
+ | `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
+
+ **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.
+
+ Two consequences worth knowing:
+
+ - **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.
+ - **`@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.
+
+- e076fd5: Inline-edit toggle reads "Edit fields" without an I18nProvider, matching every locale pack
+
+ `DETAIL_DEFAULT_TRANSLATIONS` said `Edit fields inline` where all ten packs say
+ `Edit fields`, so `InlineEditSaveBar`'s toggle announced two different names for one
+ control — the map's on provider-less hosts (standalone embeds, the preview gallery),
+ the pack's in the console. The pack wins; the map row now mirrors it byte for byte.
+
+ The three ungated defaults maps (`plugin-detail`, `plugin-list`, `plugin-designer`) are
+ now compared key-by-key against the `en` pack by a new gate, generalizing the
+ collaboration-only precedent from objectui#3440. `LIST_DEFAULT_TRANSLATIONS` and
+ `DESIGNER_DEFAULT_TRANSLATIONS` are exported for it, as `DETAIL_DEFAULT_TRANSLATIONS`
+ and `COLLAB_DEFAULT_TRANSLATIONS` already were.
+
+- dad805d: Six i18n keys no longer render as raw key strings on hosts with no `I18nProvider` (objectui#4396)
+
+ `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.
+
+ 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.
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `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 |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [7ffd616]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [77d6f28]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [ebb4e0e]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [537a0d1]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [8f60d73]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [51ab34e]
+- Updated dependencies [24bb2de]
+- Updated dependencies [0ca6096]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [5cc847c]
+- Updated dependencies [e7663f2]
+- Updated dependencies [fa21254]
+- Updated dependencies [f565418]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [479cc7b]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [51ac39f]
+- Updated dependencies [5e514c4]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [36310dc]
+- Updated dependencies [52d878a]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [4270c11]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [2776b11]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [c32a8a1]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [605b747]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [b42558a]
+- Updated dependencies [d2f6e6b]
+- Updated dependencies [ab04728]
+- Updated dependencies [85a3082]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/data-objectstack@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/plugin-grid@17.5.0
+ - @object-ui/plugin-form@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-designer/package.json b/packages/plugin-designer/package.json
index b251fe3c91..2da3718561 100644
--- a/packages/plugin-designer/package.json
+++ b/packages/plugin-designer/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-designer",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Visual designer plugin for Object UI with page, data model, process, and report designers plus collaborative editing.",
diff --git a/packages/plugin-detail/CHANGELOG.md b/packages/plugin-detail/CHANGELOG.md
index ea8f126c3e..eb3fe81e67 100644
--- a/packages/plugin-detail/CHANGELOG.md
+++ b/packages/plugin-detail/CHANGELOG.md
@@ -1,5 +1,307 @@
# @object-ui/plugin-detail
+## 17.5.0
+
+### Patch Changes
+
+- ceccdcf: Action confirm dialogs and success toasts now honour the bundle's translated
+ `confirmText` / `successMessage`, not just `label` (objectui#4265).
+
+ A TranslationBundle entry for an action carries three keys under one
+ `_actions.` node — `label`, `confirmText`, `successMessage` — and
+ `useObjectLabel()` has always exposed a resolver for each. What had drifted was
+ the call sites: `page:header` (authored record pages), `record:quick_actions`
+ and the related-list row menu resolved the button `label` only and dispatched
+ the authored `confirmText` / `successMessage` untouched. One bundle entry met
+ two fates: the button rendered the translation, the confirm dialog rendered the
+ authored English.
+
+ All action-rendering surfaces now go through one resolver,
+ `useActionTextLocalizer()` (new, exported from `@object-ui/react`), which
+ applies the existing `actionLabel` / `actionConfirm` / `actionSuccess`
+ resolvers over the three keys together. Fallback is unchanged: with no bundle
+ entry — or an entry lacking a key — the authored text renders. A bundle cannot
+ introduce a `confirmText` or `successMessage` the metadata never declared.
+
+- 6d01319: Inline edit no longer offers a record picker for a spec-spelled `autonumber` field that carries a `reference_to`
+
+ `TEXTUAL_REF_FALLBACK_TYPES` — the detail page's one definition of "machine-computed" — spelled the auto-number type `auto_number` only. `@objectstack/spec`, the designer and the metadata importer all spell it `autonumber`, and the set is matched by RAW spelling, so it carried half the type.
+
+ The reader that had no gate in front of it is `InlineFieldInput`'s reference fallback, `!!field.reference_to && !TEXTUAL_REF_FALLBACK_TYPES.has(type)`, on exported public API. A field typed `autonumber` keeps a `reference_to` for relational metadata — which is the entire reason this set exists — so it took the lookup branch and rendered the RECORD PICKER: a searchable list of records offered as replacements for a machine-generated identity. The `auto_number` spelling of the identical field rendered the textual fallback, as intended. Both spellings are now members, matching how `plugin-form` carries both in each of its non-input sets.
+
+ The editability half of the same report (objectui#4219) was already closed from another direction by #4228, whose shared exclusion resolves aliases before matching — a field typed `autonumber` offers no inline affordance in either host. The two gates are a union, so this fix also removes the union's dependence on which spelling the metadata happens to use: previously `autonumber` was held by the exclusion gate alone and `auto_number` by both, and losing either gate would have re-opened a different half of the defect depending on how the field was authored.
+
+ Pins land with it: the reference fallback for `autonumber` (red before this change — the picker really did render), `auto_number` and a real `lookup` as controls in both directions, and set membership asserted directly so the union statement is checked rather than described.
+
+- 63fe8fd: `record:related_list` and the detail synthesizer now declare two shapes they already accepted at runtime.
+
+ `RecordRelatedListRenderer`'s `schema` prop made `objectName` required, which rejected the exact authoring shape the per-element `dataSource` binding exists to support (`{ relationshipField, dataSource: { object, view } }`) — the gate maps the binding onto `objectName` before the body reads it, so the key is supplied, not missing. It is optional on the wrapper's input now, and required everywhere else.
+
+ `ObjectDefLike.fieldGroups` is derived from the spec's authorable field group instead of restating it. The hand-written list had drifted: it omitted `icon` and `description`, both of which the synthesizer passes through to detail section descriptors, so an object definition declaring the group icon the code honours did not type-check against it.
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- 6314e87: Inline-editing an `address` on the record detail page now edits it as real sub-fields, instead of collapsing it to one text box reading `[Object]` and saving a string over the structured value.
+
+ `InlineFieldInput`'s type switch routed the scalar and relational families to their dedicated widgets; every structured-object type matched nothing and fell through to the terminal raw text input at the end of the component. That fallback stringifies an object value through `coerceToSafeValue`, whose general-object case extracts `name || label || externalId || id || _id` and otherwise returns the literal `[Object]`. A stored address carries none of those keys, so the edit box read `[Object]`.
+
+ The display half was cosmetic; the write half was not. The fallback is a plain input wired to `onChange(v)`, so whatever the user typed was emitted as a **string** that replaced the whole `{ street, city, state, postalCode, country }` object on save — and `[Object]` was what the user saw as the current value they were correcting, which makes typing over it the natural gesture. An ordinary double-click inline edit therefore destroyed the sub-field structure. This is the input path only: objectui#4037 fixed the display registry, and read mode (including the inline-edit read state before editing starts) already rendered a formatted address.
+
+ `location` and `geolocation` are fixed with it. Both store objects too (`{ latitude, longitude }`), both reached the same terminal input, and both produced the identical `[Object]`-then-overwrite pair — one defect in three spellings, not three defects.
+
+ No new editor was written and no consumer-side tolerance was added. All three route to the widgets the create/edit dialog already uses (`AddressField` / `LocationField` / `GeolocationField`, the form's own structured-value editors), so the two entry points cannot diverge on the value shape they write back, and `coerceToSafeValue` is left untouched — the routing is what stops an address from ever reaching it. `autoFocus` follows the numeric branches' convention and lands on each widget's first sub-input (street / the coordinate box / latitude).
+
+ String-valued types are unchanged: `text`, `textarea`, `email`, `phone`, `url`, `color`, `code`, `time`, `qrcode` and the rest keep the terminal text input, where stringification is the identity and nothing is lost.
+
+- 5e2e9fa: A `password` or `secret` field on the record detail page is no longer inline-editable: it renders no pencil / double-click affordance and produces no editor, on both the details body and the highlights strip.
+
+ Both types are **masked on read** — `getCellRenderer` returns a fixed bullet run for either — so the value the row could hand an editor was never the credential. `InlineFieldInput` had no branch for either type, so both reached the terminal raw text input at the end of the component, and the row's payload value was seeded into a `type="text"` box: rendered in clear, selectable and copyable, in a control the user reads as holding their credential. Committing the row then wrote that placeholder back verbatim over the field. For `secret` the overwritten value is an opaque reference into an encrypted store (ADR-0100), so the write destroyed the pointer, not just the display. Nothing in the flow said so; the failure surfaced later, wherever that credential was used.
+
+ The decision already existed one package over. `INLINE_EXCLUDED_FIELD_TYPES` in `@object-ui/fields` excludes both types with exactly this reasoning, and the grid honours it through `isInlineExcludedFieldType()`. The detail hosts gated on readonly / computed / system only and never consulted it, so the detail page reproduced the precise failure the set exists to prevent. Both hosts now consult that same alias-aware contract (`isInlineExcludedDetailFieldType`, a narrow-only union of the authored and the object type, matching the computed gate under objectui#3355) rather than growing a second hand-maintained list — so the rule cannot drift between the grid and the detail page again.
+
+ Consulting the shared set closes the container family on the detail page with it: `object`, `composite`, `record`, `grid`, `repeater` and `vector` rode the same plain-text fallback with an object-shaped value, and are now excluded too. The spec spelling `autonumber` is likewise excluded, where the detail computed gate only knew the `auto_number` spelling. The heavy-editor family (`markdown`, `html`, `richtext`) loses its one-line text box on the detail page — those are authored in the record form, which has the real editors.
+
+ The binary/attachment family is deliberately exempt and keeps its detail editor. It is in the shared set for a grid-cell reason — a cell cannot host an upload dropzone — while `InlineFieldInput` routes `image` / `avatar` / `signature` / `file` (and the `video` / `audio` spellings) to the same upload widgets the record form uses. That exemption is pinned against the routing it claims, so it cannot outlive it.
+
+ Re-authoring a credential is unchanged and still belongs in the record form, which has the widget for it (`PasswordField`).
+
+- 297534b: Align 43 inline `defaultValue` strings with the `en` pack, and make the call-site gate enforce it (objectui#3810)
+
+ `t(key, { defaultValue: 'English text' })` only renders that text when i18next
+ **misses** the key. Where the key exists in `packages/i18n/src/locales/en.ts` the
+ pack value always wins, so the inline string is dead code — and 43 of those dead
+ strings said something different from the sentence users actually read.
+
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) now compares the two
+ whenever a call site carries a literal `defaultValue` for a key `en` defines, and
+ fails on any byte of difference. It is a hard rule with **no baseline**: the
+ repo-wide census measured 43 sites in 19 files out of 851 literal inline defaults,
+ and all 43 are aligned here, so there is no debt for a ratchet to hold. A
+ `defaultValue` on a key that is _not_ yet in `en` stays legal — that transition
+ runs for months (objectui#3546) and belongs to the existing `missing-key` rule,
+ which keeps reporting it alone.
+
+ Every fix moved the CALL SITE to the pack's wording. `en.ts` is untouched: its
+ values are what users read today, and changing one would oblige the same change in
+ the nine other packs (`scripts/check-i18n-en-drift.mjs`, objectui#3650). Six of the
+ 43 differed only in an ellipsis (`...` against U+2026) — invisible in review, which
+ is how they survived three i18n gates that are each blind to this class by
+ construction.
+
+ The visible effect is confined to hosts that render these components with **no**
+ `I18nProvider` and no initialised i18next instance. There, react-i18next's
+ not-ready `t` returns the `defaultValue`, so the inline string was the rendered
+ one; it now matches what a provider-backed app has always shown. Inside the
+ console — provider mounted — nothing users see changes. The clearest converging
+ examples: the workspaces screen was written as "Organizations" at nine call sites
+ while every user has been reading "Workspaces"; the forgot-password success line
+ was written as "If an account exists, a reset link has been sent." while the pack
+ asserts "We've sent a password reset link to {{email}}."
+
+- e7663f2: fix(detail): inline edit no longer destroys array values or flattens types on the record page
+
+ `InlineFieldInput`'s type switch ended in a raw text input, and every type it had
+ no branch for landed there: the value was displayed through `coerceToSafeValue`
+ and written back as whatever the user typed — a bare string.
+
+ Two damage classes survived the earlier passes. Array-valued fields (`tags`,
+ `checkboxes`, an options-less multi picklist) were offered for editing as
+ `"a, b"` — `coerceToSafeValue` joins arrays — and saved back as that string, so
+ the array was gone. Type-lossy scalars (`toggle`, `slider`, `progress`,
+ `rating`, `radio`) round-tripped through `String()`, so a boolean column
+ received `"true"`, a numeric one `"42"`, and `radio` accepted any free-typed
+ value its option list never offered.
+
+ Types the switch already routes keep their editors. Everything else that the
+ fields package can edit inline now falls back to `FieldEditWidget` — the same
+ control the form renders, `json` → the code editor included — and only genuinely
+ string-valued types (`text`, `textarea`, `email`, `phone`, `url`) keep the plain
+ input. A drift guard asserts every field type is exactly one of routed /
+ excluded / delegated / benign, so a new type can no longer inherit the
+ value-destroying default in silence.
+
+ `@object-ui/fields`: the four fixed-option widgets no longer clear the stored
+ value when the field declares no `options` at all. An empty offered set had two
+ opposite causes — a list that cascaded to zero (clear) and a list that was never
+ authored (nothing to decide) — and the second deleted the value on mount, which
+ the grid's inline cell editor has always been able to trigger. `FieldEditWidget`
+ also forwards `autoFocus` to the widget it renders.
+
+- e076fd5: Inline-edit toggle reads "Edit fields" without an I18nProvider, matching every locale pack
+
+ `DETAIL_DEFAULT_TRANSLATIONS` said `Edit fields inline` where all ten packs say
+ `Edit fields`, so `InlineEditSaveBar`'s toggle announced two different names for one
+ control — the map's on provider-less hosts (standalone embeds, the preview gallery),
+ the pack's in the console. The pack wins; the map row now mirrors it byte for byte.
+
+ The three ungated defaults maps (`plugin-detail`, `plugin-list`, `plugin-designer`) are
+ now compared key-by-key against the `en` pack by a new gate, generalizing the
+ collaboration-only precedent from objectui#3440. `LIST_DEFAULT_TRANSLATIONS` and
+ `DESIGNER_DEFAULT_TRANSLATIONS` are exported for it, as `DETAIL_DEFAULT_TRANSLATIONS`
+ and `COLLAB_DEFAULT_TRANSLATIONS` already were.
+
+- 456aac8: `@object-ui/plugin-detail` now declares `react-router-dom` as a peer dependency (`^6.0.0 || ^7.0.0`), the range its three siblings already use.
+
+ It has been importing the router all along — `PermissionFacetLink.tsx` and `record-reference-rail.tsx` both take `Link` and `useParams` from it — while its manifest named it in no field at all. That resolved locally for a reason that does not travel: the workspace root declares `react-router-dom` in its own `devDependencies`, so a `node_modules/react-router-dom` symlink exists at the root of this repository and Node's upward directory walk reaches it from every package directory. A consumer's install has no such root, and this package's rollup config externalises every bare specifier, so the published `dist/index.js` carried an import of a package the manifest never asked for.
+
+ Consumers already installing `@object-ui/app-shell`, `@object-ui/layout` or `@object-ui/plugin-designer` were unaffected — all three declare the same peer — so this closes the case of a consumer that pulls `plugin-detail` on its own.
+
+ A new repository gate, `pnpm check:phantom-deps`, now asserts that every bare specifier a released package imports under `src/` is declared by that package rather than merely resolvable from it, so the next one of these fails on the pull request that introduces it (objectui#4394).
+
+- 7d04b0e: `record:details` stops publishing a `layout` key the spec removed and the renderer never honoured
+
+ `record:details` declared `layout: enum ['auto','custom']` with `defaultValue: 'auto'` and the description "auto uses the object highlightFields; custom uses explicit sections". None of that was ever implemented. The renderer's only `schema.layout` read tested `'inline'` | `'compact'` — two values the schema never permitted — so both legal values fell through the same ternary and the key selected nothing. `auto` and `custom` have behaved identically for as long as both have existed.
+
+ Two directions were wrong with zero diagnostics: `layout: 'auto'` plus explicit `sections` still rendered the sections, and `layout: 'custom'` with no sections silently fell back to the flat body rather than reporting the missing groups. Because the input carried a `defaultValue`, this was not stale documentation — it was the manifest, the generated `sdui-intrinsics.d.ts` and the designer panel actively offering the key. An AI author writing `layout: 'custom'` believed it took effect.
+
+ `@objectstack/spec` 17.0.0 removed the property (objectstack#6946, ADR-0087 D2); `17.0.0-rc.6` is pinned here, so the key is already rejected on parse with a named migration message pointing at `os migrate meta --from 16`. This release completes the objectui half of that retirement: the input declaration is gone, and so is the dead `inline`/`compact` branch — the synthesized layout is now the constant it always resolved to.
+
+ Nothing that worked stops working. The body-source contract is unchanged and is now the only one declared: **`sections` renders the explicit groups; omitting it falls back to the flat body derived from the object's fields.** That is pinned in both directions, plus the empty-array boundary between them, in `recordDetailsBodySource.test.tsx`.
+
+ One gate got sharper on the way through. The parity test's "declares no top-level input the spec does not accept" check read raw `.shape` keys — but an ADR-0087 D2 tombstone stays _in_ the shape as a `z.never()`, so a retired key still answers "is this declared?" with yes. That is precisely why this input survived the rc.6 pin bump with every derived gate green. The check now filters tombstoned members out, so it catches the next D2 retirement instead of waving it through.
+
+- c32a8a1: `richtext` fields are placed like the long-form fields they are — four layout sets stopped spelling the type three ways the spec rejects
+
+ `@objectstack/spec` spells the WYSIWYG type `richtext`, one word, and **rejects** `rich_text` and `rich-text`: both exist only as typo keys in the spec's own `suggestFieldType` table, so `FieldSchema` refuses a field declared with either. Four sets that place fields by matching the RAW type string carried nothing else — `SKIP_TYPES` in the related list spelled it `rich_text`, both `WIDE_FIELD_TYPES` and `SECONDARY_FIELD_TYPES` spelled it `rich-text` — so each set was inert for the only spelling a producer can emit, and every one of them named the type it was failing to handle.
+
+ For a real `richtext` field that meant: it was auto-derived into a related-list column, it never spanned the full row in a multi-column detail section or form (unlike `markdown` and `html` sitting right beside it in the same sets), and it stayed in the dense primary section of the record page instead of dropping into "More details". All four move together — half of them would have left the detail page and the form disagreeing about the same field, which is worse than the uniform gap.
+
+ The dead spellings are dropped rather than kept alongside the live one: the alias table is the single place aliases belong, and a set that carries both invites the next drift. The pins are derived from the spec's own `FieldType` vocabulary instead of enumerated, so a member that stops being a real type name fails by name — replacing an assertion that was green only because the set contained the string it asked about.
+
+ `markdown` joins `richtext` and `html` in the related list's `SKIP_TYPES`, on a measurement rather than on the assumption that it renders raw. It does not: markdown and richtext both render through `MarkdownCellRenderer`, formatted and sanitized. The reason none of the three works in a table is that the formatted output is block-level — a heading, paragraphs, a list — inside a single-line truncating cell, so a document shows as one clipped heading with the rest invisible. `textarea` stays derived for the same reason read the other way: it renders as plain truncated text, which is a useful column. Author-declared columns are untouched — this set only filters the zero-config auto-derive walk.
+
+- 3f5f87c: `SchemaRenderer` states its real contract — a typed, required `schema` and a deliberate forwarding surface
+
+ `SchemaRenderer` is the renderer loop: every registered SDUI component is rendered through it. It handed `forwardRef` a props type of `{ schema: SchemaNode } & Record`, which puts `string` into `keyof Props`, so `'ref' extends keyof Props` was always true, React's `PropsWithoutRef` took its `Omit` branch, and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared prop was erased. Measured on the pre-fix source: `keyof ComponentProps` was `string` and `ComponentProps['schema']` was `any`, while the type argument went on declaring `SchemaNode`. The other half is the same defect seen from the call site — ` ` with no schema at all, ` `, and an arbitrary misspelled prop each type-checked in silence. This is objectui#4422 / PR #4438's trap in the most central component in the repo, spelled `Record` rather than `[key: string]: any`, which is why every previous sweep's grep and both shipped guards' detector reported the site as clean.
+
+ Graded **minor, not major**, on objectui#4528's reasoning: the type argument has always DECLARED `schema`; the index signature erased it from the resolved type, and restoring what the declaration documents is a fix to the published contract rather than a contract break.
+
+ **The forwarding surface is kept, deliberately.** This component forwards every prop it does not read to the component the schema names, resolved at runtime from a plugin-extensible registry — `packages/react/README.md` documents exactly that, and `@object-ui/components`' form renderer consumes the `onSubmit` it shows being forwarded. Closing that surface would state a false contract and would force every leaf plugin's props into this package. So the two halves are separated: the `forwardRef` type argument is the honest `SchemaRendererProps`, with no index signature for `PropsWithoutRef` to collapse, and the open surface is stated once in an explicit export annotation, which nothing routes through `Omit`. The published `.d.ts` shows the erasure disappearing: `ForwardRefExoticComponent, "ref"> & RefAttributes>` becomes `ForwardRefExoticComponent & RefAttributes>`.
+
+ `SchemaRendererProps.schema` is declared as `BaseSchema | string | null | undefined` — what this component actually handles. It previously declared `@object-ui/core`'s `SchemaNode` interface, which requires `type: string` and so contradicted the component's own early returns for strings and nullish, while every caller held `@object-ui/types`' wider union. The erasure hid that mismatch completely.
+
+ **One declared behaviour change.** A non-object, non-string primitive schema now renders as its own text. It previously fell through to the shallow copy `{ ...schema }`, which spreads a primitive to an empty object, lost the `type` the renderer then looked up, and surfaced the red "Unknown component type: undefined" box — an accident of the spread rather than a decision. The declared props type excludes `number` / `boolean` so no author is invited to pass them; the runtime handling is defence-in-depth for untyped callers and stored metadata. Strings, `null`, `undefined`, `0` and `false` render exactly as before, and an object naming an unregistered type still gets the error box; all four are pinned.
+
+ Latent defects the erasure had been hiding, each surfaced by the repo-wide type-check and fixed at its call site: `DashboardRenderer` cast its widget schema to `Record`, dropping the `type` every branch of `getComponentSchema` sets; `DashboardGridLayout`'s equivalent now states its return type instead of inferring a union that admitted a shape with no `type`; and `ReportViewer` handed a section's `content` array to the renderer whole, so a multi-node section rendered the unknown-component box instead of its content — arrays are mapped rather than widened into the renderer's declared input.
+
+ A repo-wide structural guard replaces the two per-package siblings' blocked direction: it judges every `forwardRef` in `packages/*/src` (219 sites) and its detector resolves `Record` and `string`-keyed mapped types in addition to literal index signatures — the spelling the previous detector went blind on. It judges the type argument only, where an index signature is an accidental eraser, and never an export annotation, where one is a stated contract.
+
+- 2fea4d2: `detail.showEmptyRelated` renders Russian and Arabic again — the "+N empty" button no longer falls through to English at the counts it takes most often
+
+ This was the repo's only pre-existing i18next plural family, and all ten packs defined exactly two slots: `_one` and `_other`. i18next asks `Intl.PluralRules` for the one suffix a language needs for that number, and when the pack has no such slot it walks `fallbackLng` to `en`. Russian has four plural categories and Arabic six, so `ru` at counts 2-4 (`few`) and 5-20, 25-30, … (`many`), and `ar` at 0, 2, 3-10 and 11-99, resolved nothing locally and rendered the English string. The call site is the collapsed-empties button in the record detail's reference rail, whose count is the number of empty related lists — 2 to 4 are the most common values it ever takes, so a Russian user essentially always read English.
+
+ The fix is a base key (no suffix) beside the two existing slots, in all ten packs. The base key is always in i18next's lookup chain, so every category a pack did not enumerate resolves to it, in that pack's own language — and, unlike adding `_few`/`_many` to `ru` alone, it keeps the ten packs' key sets identical, which full key parity requires. Same shape objectui#3546 slice six established for `perm.facet.*`. Where the base key is genuinely reachable it carries a count-invariant phrasing: `ru` uses the «Существительное: {{count}}» form the pack already writes 22 times, `ar` the «{{count}} مفرد(جمع)» marker it uses throughout. For `en`/`de`/`zh`/`ja`/`ko` the base key cannot be reached at all (their categories are covered by the two existing slots) and repeats `_other` for parity; `fr`/`es`/`pt` reach it only from a million up, where the plural form is already correct. No English copy moves.
+
+ The provider-less path needed the same row for a different reason: `createSafeTranslation`'s fallback resolves `defaults[key]` literally and never appends a plural suffix, so the two suffixed rows in plugin-detail's defaults table were unreachable through it and that path answered with the raw key. It now carries the base key too.
+
+ Parity across packs turned out to be necessary and not sufficient — ten identical key sets were green throughout, because the defect is one level below key names: the slot the language needs is not in the set. So the invariant "a plural family must carry a base key" is now asserted over all ten packs in `all-locales-key-parity.test.ts`, where it is pack-intrinsic and fails at PR time without needing a call site to exist. It went red on all ten packs before this change and names the family that is missing its base.
+
+- dad805d: Six i18n keys no longer render as raw key strings on hosts with no `I18nProvider` (objectui#4396)
+
+ `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.
+
+ 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.
+
+- 35997ce: fix(plugin-detail): synthesize page components in the spec's `properties` carrier so Studio page-create can persist
+
+ Creating a page in Studio never completed. The create path seeds a record
+ page's `regions` from `buildDefaultPageSchema(objectDef)` and PUTs the result,
+ and every node that synthesizer emitted carried its widget props at the TOP
+ level of the component — `{ type: 'page:header', recordChrome: true }`,
+ `{ type: 'page:tabs', items: [...] }`, and the same for `record:highlights`,
+ `record:path`, `record:details`, `record:related_list`, `record:history` and
+ `record:reference_rail`. ADR-0089 D3a closed `PageComponentSchema` with
+ `.strict()`, so those keys are not stripped, they are a parse error
+ (`Unrecognized key(s) on this view/page schema: 'recordChrome', 'actions'`).
+ The server refused the body and no page row was ever stored.
+
+ The props now go where the spec declares them — the node's `properties` bag,
+ which is where `ComponentPropsMap` defines `page:header.recordChrome` and
+ `page:tabs.items` in the first place. Nothing is dropped and nothing changes on
+ screen: a header still defaults to record chrome ON, an author's
+ `recordChrome: false` is still carried (and now actually persists), the tabs
+ keep their items, and `SchemaRenderer` hoists `properties` back onto the node
+ before dispatch, so every renderer receives exactly the props it did before.
+
+ One code path does the wrapping for every node the synthesizer builds, so there
+ is a single answer to "what may go in a page write". Slot overrides are
+ untouched — a node handed in by a caller is still placed verbatim.
+
+- b388950: fix(plugin-detail): the record detail header honors `userActions` predicates
+
+ `userActions.delete` reached the record detail header in its **boolean** form
+ but not in its **predicate** form. `userActions: { delete: false }` removed
+ Delete from the row kebab, the selection bar and the detail header, because the
+ host lowers the boolean into `schema.showDelete`. `userActions: { delete: {
+visibleWhen: … } }` reached only the row kebab: the header ANDed
+ `schema.showDelete ∧ objectAllowsDelete ∧ canDeleteRecord` and never evaluated
+ the predicate, so an author upgrading from "nobody may delete this object" to
+ "these records may not be deleted" silently lost the surface they were most
+ likely to have tested on.
+
+ The header now folds the per-record predicates in as a fourth conjunct,
+ evaluated against the open record through the same helper family the row
+ surfaces use — `userActionPredicates` from `@object-ui/core` for the parse (the
+ import `RelatedList` already makes) and `useRowPredicate` from
+ `@object-ui/react` for the evaluation:
+
+ - `delete.visibleWhen` / `edit.visibleWhen` false → the header affordance is
+ hidden (fails closed; `visibleWhen: false` counts as a declared gate).
+ - `delete.disabledWhen` / `edit.disabledWhen` true → the header affordance
+ renders disabled rather than disappearing, matching the row kebab.
+
+ The existing permission and record-writability gates are unchanged, so a
+ predicate that holds can never resurrect a button the user may not press. The
+ boolean form stays the host's channel — a bare boolean yields no predicate.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [3fc2971]
+- Updated dependencies [f7c6430]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [5cc847c]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [6d641c9]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [58bebf6]
+- Updated dependencies [405e808]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [78fa331]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/plugin-detail/package.json b/packages/plugin-detail/package.json
index 47be391cd9..9bb73c969b 100644
--- a/packages/plugin-detail/package.json
+++ b/packages/plugin-detail/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-detail",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "DetailView plugin for Object UI - comprehensive detail page with sections, tabs, and related lists",
diff --git a/packages/plugin-editor/CHANGELOG.md b/packages/plugin-editor/CHANGELOG.md
index c6b179977f..49bb5eb96b 100644
--- a/packages/plugin-editor/CHANGELOG.md
+++ b/packages/plugin-editor/CHANGELOG.md
@@ -1,5 +1,77 @@
# @object-ui/plugin-editor
+## 17.5.0
+
+### Patch Changes
+
+- 8f60d73: `@object-ui/fields` and `@object-ui/plugin-editor` stop publishing their test declarations
+
+ Both packages' build tsconfigs set `include: ["src"]` with no test exclude, so every test file entered the declaration program and its `.d.ts` was written into `dist/`. Both are published (`private` is false, `files` contains `dist`), so those declarations shipped: 85 from `@object-ui/fields` and one from `@object-ui/plugin-editor`. Adding the test exclude the other twenty-odd packages already use removes them.
+
+ Nothing else about either artifact moves. Measured by building each package both ways from a cleared `dist/`, then diffing the file lists: `@object-ui/fields` goes from 163 files to 78 and `@object-ui/plugin-editor` from 6 to 5, every one of the 86 disappearances is a `*.test.d.ts`, no file appears, and all 83 surviving files are byte-identical by sha256 — including each package's entry `dist/index.d.ts`. The entry type surface is therefore unchanged and no import can break; this is the tarball shedding files nothing resolved.
+
+ The type coverage those files were a side effect of did not go with them. Because the build program read the tests, these two packages counted as "tests type-checked" in `scripts/check-type-check-coverage.mjs` — a correct verdict reached through an emit nobody wanted. Excluding the tests alone would have silently dropped 86 test files out of every `tsc` program, so the same change adds a `tsconfig.test.json` per package, chained from each package's `type-check` script, and the coverage gate stays at 41 of 41 packages compiling their tests with zero declared debt on both sides of the change.
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [f5e1143]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [47f551b]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-editor/package.json b/packages/plugin-editor/package.json
index 2b5cfa90ec..a3ad42fc5c 100644
--- a/packages/plugin-editor/package.json
+++ b/packages/plugin-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-editor",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Rich text editor plugin for Object UI, powered by Monaco Editor",
diff --git a/packages/plugin-form/CHANGELOG.md b/packages/plugin-form/CHANGELOG.md
index ca50027fd6..2edbef3113 100644
--- a/packages/plugin-form/CHANGELOG.md
+++ b/packages/plugin-form/CHANGELOG.md
@@ -1,5 +1,132 @@
# @object-ui/plugin-form
+## 17.5.0
+
+### Patch Changes
+
+- 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
+
+ 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.
+
+ **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.
+
+ 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.
+
+ **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.
+
+ 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.
+
+ 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.
+
+- c32a8a1: `richtext` fields are placed like the long-form fields they are — four layout sets stopped spelling the type three ways the spec rejects
+
+ `@objectstack/spec` spells the WYSIWYG type `richtext`, one word, and **rejects** `rich_text` and `rich-text`: both exist only as typo keys in the spec's own `suggestFieldType` table, so `FieldSchema` refuses a field declared with either. Four sets that place fields by matching the RAW type string carried nothing else — `SKIP_TYPES` in the related list spelled it `rich_text`, both `WIDE_FIELD_TYPES` and `SECONDARY_FIELD_TYPES` spelled it `rich-text` — so each set was inert for the only spelling a producer can emit, and every one of them named the type it was failing to handle.
+
+ For a real `richtext` field that meant: it was auto-derived into a related-list column, it never spanned the full row in a multi-column detail section or form (unlike `markdown` and `html` sitting right beside it in the same sets), and it stayed in the dense primary section of the record page instead of dropping into "More details". All four move together — half of them would have left the detail page and the form disagreeing about the same field, which is worse than the uniform gap.
+
+ The dead spellings are dropped rather than kept alongside the live one: the alias table is the single place aliases belong, and a set that carries both invites the next drift. The pins are derived from the spec's own `FieldType` vocabulary instead of enumerated, so a member that stops being a real type name fails by name — replacing an assertion that was green only because the set contained the string it asked about.
+
+ `markdown` joins `richtext` and `html` in the related list's `SKIP_TYPES`, on a measurement rather than on the assumption that it renders raw. It does not: markdown and richtext both render through `MarkdownCellRenderer`, formatted and sanitized. The reason none of the three works in a table is that the formatted output is block-level — a heading, paragraphs, a list — inside a single-line truncating cell, so a document shows as one clipped heading with the rest invisible. `textarea` stays derived for the same reason read the other way: it renders as plain truncated text, which is a useful column. Author-declared columns are untouched — this set only filters the zero-config auto-derive walk.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [ebb4e0e]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [8f60d73]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [5cc847c]
+- Updated dependencies [e7663f2]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [36310dc]
+- Updated dependencies [52d878a]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/permissions@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/plugin-form/package.json b/packages/plugin-form/package.json
index 6613a8b8df..85181131f1 100644
--- a/packages/plugin-form/package.json
+++ b/packages/plugin-form/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-form",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Form plugin for Object UI",
diff --git a/packages/plugin-gantt/CHANGELOG.md b/packages/plugin-gantt/CHANGELOG.md
index 05306688ff..a9b0ed1f9d 100644
--- a/packages/plugin-gantt/CHANGELOG.md
+++ b/packages/plugin-gantt/CHANGELOG.md
@@ -1,5 +1,313 @@
# @object-ui/plugin-gantt
+## 17.5.0
+
+### Patch Changes
+
+- ebb4e0e: The date formatter's last three en-US channels now follow the display locale
+ (objectui#4272).
+
+ objectui#4468 (PR #4512) pointed every date _renderer_ at `useDisplayLocale()`.
+ Three channels were out of its reach because they are properties of the
+ formatter's signature and of its callers rather than of any renderer, so a `zh`
+ console still met English dates in three places:
+
+ - **`formatDate`'s `'short'` branch** hardcoded
+ `toLocaleDateString('en-US', { month: 'short' })`, so it rendered an English
+ month even when the caller had threaded `options.locale` into that very call.
+ Its only consumers are ObjectGrid's two mobile-card date cells, which threaded
+ no locale — fixing either half alone moves nothing, so both land here.
+ - **`formatDateTime` took no options parameter at all**, so no caller could
+ localize it however hard it tried; it always handed `Intl` an `undefined` tag,
+ which means the MACHINE's locale — neither of the repo's two locale channels.
+ The parameter is optional and lands together with its consumers, plugin-gantt's
+ four tooltip call sites.
+ - **The lookup picker's MongoDB `$date` fallback** called a bare
+ `toLocaleDateString()` with no tag.
+
+ One resolver everywhere, as before: `useDisplayLocale()` (tenant regional
+ default → active UI language → `'en'`). `Intl` accepts `'zh'` verbatim, so there
+ is still no mapping table anywhere.
+
+ English output is byte-identical at every touched site — `en` and `en-US` agree
+ on all twelve short month names — and the `'short'` layout itself is unchanged:
+ only the month token is localized, the compact `"Jan 15, '24"` shape around it
+ is a deliberate fixed layout for narrow cards.
+
+ `@object-ui/fields` is `minor` because `formatDateTime`'s new optional parameter
+ is visible in the package's entry `.d.ts`; the plugin packages' own `.d.ts` files
+ are byte-identical, so their change is module-local.
+
+- 828549a: The gantt's conflict dialog shows the number of affected tasks again, not a literal `{2}`
+
+ `gantt.conflict.body` was resolved at the render site with a literal string replace on **single** braces — `t('gantt.conflict.body').replace('{count}', String(n))` — while all ten locale packs spell the placeholder the i18next way, `{{count}}`. `"…{{count}}…".replace("{count}", "2")` consumes the inner seven characters and leaves the outer pair behind, so every user on every loaded pack read "自动重新排程 **{2}** 个受影响的任务?". The dialog now interpolates through i18next (`t('gantt.conflict.body', { count })`), the idiom `gantt.delete.body` already used.
+
+ The two sibling keys three lines away in the same file, `gantt.autoScheduleDlg.body` and `.skipped`, were **not** broken — pack and call site both used single braces, and they rendered correctly. They are converted anyway, because that split is the whole mechanism: two write-confirmation dialogs in one component carried two different interpolation idioms, so `conflict.body` drifting to the i18next spelling in the packs (which is the correct spelling, and matches every other placeholder in the bundle) silently broke the render. Leaving the auto-schedule keys on the literal-replace idiom leaves the same trap armed for the next translator. All ten packs and the plugin's bundled English fallback table now agree on `{{count}}` for all three; only the braces moved, no translation was reworded.
+
+ `gantt.quickFilter.resultSummary` stays deliberately single-brace — its `ObjectGantt` call site really does resolve `{shown}`/`{total}` with a literal replace, and that convention is pinned by its own parity test. It is now the only key in the gantt namespace on that idiom, and the comments at both spellings say so.
+
+ Nothing caught this, and each gate was silent for its own reason: the cross-pack parity check compares en against each pack, and all eleven spellings agreed; the en-drift check compares a pack against its own history, and the packs were born matching. Both are **relative** comparisons, and the defect lived in the **absolute** relationship between a pack's spelling and the syntax the call site resolves. The existing render test asserted the dialog body contains `'1'` — which `{1}` satisfies. The new pin asserts the absolute form directly, under a real loaded pack, for every way a placeholder can survive to the screen.
+
+- e1ade8f: An illegal gantt dependency link now says why it was refused, instead of doing nothing
+
+ Dragging a dependency onto a target the gantt refuses — itself, a locked row, a group row, or one that would close a dependency cycle — produced no feedback of any kind: no toast, no dialog, no cursor change, no target outline, not even a console warning. The guard was right and completely invisible, so a user drawing a legitimate-looking dependency got a dead interaction and no way to learn the constraint. The rejection was silent in both places it could have shown: a refused bar never became the drop target, so it got no hover treatment at all, and the release handler only ran its body when a target _had_ been registered, so the drop itself was a no-op.
+
+ Both halves are now wired, and both read the **same** verdict. `canReceiveLink`'s four-branch boolean became `classifyLinkTarget`, which returns which branch refused (or `null`), with the boolean derived from it. The hover affordance and the drop toast are two consumers of that one classification, so the reason a user is shown cannot drift from the reason the link was actually refused — there is no second classifier to disagree. The branch names are the leaves of the new `gantt.link.rejected.*` keys, so a branch added later without a message surfaces as a missing key rather than as a plausible-but-wrong sentence.
+
+ During the drag, a refused bar under the pointer gets `cursor: not-allowed` and a destructive outline; on release it raises a toast naming the reason. Four messages, one per branch, in all ten packs. Both the cursor and the outline are driven from inline `style` rather than utility classes, matching the bar's existing read-only cursor three lines away and for the same reason recorded there: `cursor-not-allowed` and the ring alpha utilities are not emitted in the prebuilt components CSS, so a class would look correct in a DOM test and render nothing in a browser.
+
+ Deliberately unchanged: a host veto through `onBeforeDependencyCreate` stays silent. That rejection carries a reason only the host knows, and the gantt has none to show — surfacing it means exposing a rejection-reason output on the public component, which is a separate contract rather than a rider on this one. The four built-in reasons are the gantt's own policy and are the only ones it can explain.
+
+ One of the four, `group`, has no end-to-end path today: a `type: 'group'` row renders no bar, so the drag can never target it. The message is kept anyway — without it the branch would render a raw key on screen if it ever did fire — and the test pins the reachability fact, so it goes red the day group rows gain a bar. Filed as objectui#4209.
+
+- db4ad6b: Gantt tooltip currency re-formats when the tenant currency resolves
+ (objectui#4542).
+
+ ObjectGantt's `tasks` memo builds every tooltip string eagerly inside its
+ callback, and the `'currency'` case resolves its code down to the tenant
+ default (`resolveFieldCurrency(def, tenantCurrency)`). `tenantCurrency` was
+ not in the memo's dependency array, so the value was read but never watched.
+
+ That default comes from `GET /api/v1/auth/me/localization`, which is cosmetic
+ and non-blocking and therefore answers AFTER first paint. The context change
+ re-rendered ObjectGantt, but with none of `data` / `ganttConfig` /
+ `objectSchema` / `displayLocale` changed the memo handed back its cached task
+ array — so a tooltip amount kept the pre-resolution rendering (a plain
+ `1,234.50` instead of `€1,234.50`) until something unrelated invalidated the
+ memo.
+
+ This is the currency twin of objectui#4272, which added `displayLocale` to
+ this same array for the same reason, and it is not covered by that dep: the
+ producer writes currency and locale from one response, so a tenant that
+ configures BOTH re-runs the memo through the locale channel — but a tenant
+ that configures a currency and no locale (the common shape, since the tenant
+ locale is frequently unset) leaves `displayLocale` untouched and the currency
+ stale.
+
+ Module-local: the fix is one dependency, the package's `.d.ts` files are
+ byte-identical, and rendering is unchanged whenever the channel resolves
+ before first paint or a field carries its own currency code.
+
+- a908882: Gantt tooltip numbers and currency follow the display locale (objectui#4553).
+
+ `formatFieldValue`, the tooltip value formatter inside ObjectGantt's `tasks`
+ memo, had its four TEMPORAL call sites threaded with `useDisplayLocale()` by
+ objectui#4272. The numeric cases beside them passed no locale, so they reached
+ `new Intl.NumberFormat(undefined, …)` — the MACHINE's locale, which is neither
+ of the repo's two locale channels.
+
+ One tooltip therefore rendered two conventions. A German session read
+ `5. Jan. 2024` on the date row and `1,234.50` on the amount row directly below
+ it, where German groups with `.` and marks the decimal with `,`. Inverted
+ separators do not read as an unstyled number; they read as a different number.
+ The currency row was affected in the symbol's POSITION too — `1.234,50 EUR`
+ rather than `EUR1,234.50` — while the currency CODE itself was already resolved
+ correctly (objectui#4542 made the memo watch it); only the locale rendering that
+ code was missing.
+
+ `number` / `integer` / `float` / `decimal` and `currency` now pass the
+ `displayLocale` already read at component level, using each formatter's existing
+ locale parameter. No formatter signature changed and no memo dependency changed
+ (`displayLocale` has been in that array since objectui#4272), so this is
+ consumer-side threading only: the package's `.d.ts` files are byte-identical and
+ English output is unchanged at every touched site.
+
+ Known gap, tracked on objectui#4553: the `percent` row still does not follow the
+ display locale. `formatPercent(value, precision)` takes no locale parameter —
+ it is `${percentDisplayValue(value).toFixed(precision)}%`, so it builds no
+ `Intl.NumberFormat` at all and renders in NO locale rather than the machine's
+ (ASCII decimal mark, never grouped, identical on every machine). Closing that
+ needs a `@object-ui/fields` signature change, which is outside this change's
+ ruled surface, and is pinned by a test here so the gap cannot drift unnoticed.
+
+- 0ca6096: A gantt task titled `A$&B` no longer prints `{{title}}` back into its own delete dialog — the two hand-rolled provider-less fallback interpolators are literal, like i18next
+
+ objectui#3418 fixed the shared helper's fallback interpolator: `String.prototype.replace` became `split(needle).join(value)`, because `replace` and `replaceAll` both interpret `$&`, `` $` ``, `$'` and `$$` in the **replacement** string and i18next does not. Two hand-rolled copies of that interpolator never got the fix. Both are deliberate non-users of `createSafeTranslation` — each falls back per key so a host dictionary that covers the common keys but lags on newer ones still resolves what it has — so the shared fix had no path to reach them.
+
+ The reachable one is gantt's. `gantt.delete.body` is `'"{{title}}" will be permanently removed. …'` and its call site interpolates the record's own title, which is user data:
+
+ | task title | rendered before | rendered now |
+ | ---------- | --------------------------------------------------------------------- | ----------------------------------------- |
+ | `A$&B` | `"A{{title}}B" will be permanently removed.` | `"A$&B" will be permanently removed.` |
+ | `` x$`y `` | `"x"y" will be permanently removed.` | `` "x$`y" will be permanently removed. `` |
+ | `p$$q` | `"p$q" will be permanently removed.` | `"p$$q" will be permanently removed.` |
+ | `u$'v` | `"u" will be permanently removed. …v" will be permanently removed. …` | `"u$'v" will be permanently removed.` |
+
+ The first row is the ugly one: `$&` expands to the matched text, so the placeholder itself is printed back to the user inside the record's own name. Gantt's copy also carried the other half of the same defect — a bare string needle substitutes only the **first** occurrence, where i18next substitutes every one — and `split`/`join` fixes both at once.
+
+ The import wizard's copy used a `g`-flagged `RegExp`, which covered the repeated-placeholder half but could not touch the `$`-pattern half: that harm lives in the replacement string, not the needle. Its values are authored metadata — field labels and type names spliced into `grid.import.missingRequiredHint` and `grid.import.legacyReferenceBlocked` — so a label containing `$&` corrupted the hint the same way. Retiring the `RegExp` also retires an unescaped needle, since the placeholder name went into the pattern uninterpolated; that was inert while every placeholder name is a bare identifier, and is now structurally impossible.
+
+ This is the provider-less path only (standalone embedding, unit tests). With an `I18nProvider` mounted, i18next serves these keys and was already literal on both sides — which is exactly why the divergence was invisible. No pack, key or call site changed; the three `{{count}}` gantt keys take numbers and were never affected, and `gantt.quickFilter.resultSummary`'s deliberate single-brace idiom is resolved by its call site rather than this interpolator and is untouched.
+
+- 36310dc: `formatPercent` groups its output and follows the display locale — the last
+ tooltip/cell channel (objectui#4553).
+
+ PR #4557 threaded the gantt tooltip's number and currency rows and measured that
+ the percent row could not follow: `formatPercent(value, precision)` took no
+ locale parameter, and its whole body was
+ `${percentDisplayValue(value).toFixed(precision)}%`. It built no
+ `Intl.NumberFormat` and never reached `formatDisplayNumber` — so unlike its
+ siblings it did not render in the MACHINE's locale, it rendered in **no** locale:
+ an ASCII decimal mark, never a grouping separator, byte-identical on every
+ machine.
+
+ **English output MOVES, and that is the fix.** Because the function never
+ grouped, `1235%` was wrong in en-US too, not only in German. Grouping and locale
+ therefore land together:
+
+ | | before | after |
+ | ---------- | ------- | -------------- |
+ | en, 1234.5 | `1235%` | `1,235%` |
+ | de, 1234.5 | `1235%` | `1.235\u00a0%` |
+ | de, 80 | `80%` | `80\u00a0%` |
+
+ Values below the grouping threshold are unchanged in English (`80%`, `12.5%`,
+ `33.33%`), so the move is confined to four digits and up. German changes at every
+ magnitude, because the no-break space before the sign is part of the locale's
+ percent convention — which is what routing through `Intl` buys over appending a
+ literal `%`.
+
+ The scaling contract is untouched: `percentDisplayValue` still disambiguates a
+ fraction-stored percent (`0.8` → 80%) from a whole one, so the list cell and the
+ dashboard measure formatter still agree.
+
+ Consumers are threaded in the same change, the parameter never landing
+ speculatively:
+
+ - **fields** — `PercentCellRenderer`, on BOTH of its paths. Its whole-percent
+ branch (`progress` / `completion` fields, which store 0-100 and must skip the
+ fraction scaling) was a second bare `toFixed` call; leaving it behind would
+ have made one grid internally inconsistent, so both branches now share one
+ locale-aware body and differ only in the scaling policy.
+ - **plugin-gantt** — the tooltip percent row, completing objectui#4553's switch.
+ - **plugin-grid** — the mobile card's percent cell, which sits in the same
+ density row as a date cell objectui#4272 had already localized.
+ - **plugin-dashboard** — `renderFieldValue`'s percent branch. It is a plain
+ function rather than a component, so it takes the locale as an optional fourth
+ parameter beside the `tenantCurrency` already threaded that way, and both of
+ its callers pass it and declare it in their memo dependency arrays.
+
+ Bumps follow each package's own `.d.ts` diff, measured in both directions.
+ `@object-ui/fields` and `@object-ui/plugin-dashboard` are `minor` on the
+ objectui#4272 / PR #4544 precedent — quoted from that changeset: "`@object-ui/fields`
+ is `minor` because `formatDateTime`'s new optional parameter is visible in the
+ package's entry `.d.ts`; the plugin packages' own `.d.ts` files are
+ byte-identical, so their change is module-local." Here `formatPercent` and
+ `renderFieldValue` each gain an entry-visible optional parameter, while
+ plugin-gantt's and plugin-grid's `.d.ts` files are byte-identical and stay
+ `patch`.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [6d01319]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [ebb4e0e]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [8f60d73]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [63fe8fd]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [5cc847c]
+- Updated dependencies [6314e87]
+- Updated dependencies [5e2e9fa]
+- Updated dependencies [297534b]
+- Updated dependencies [e7663f2]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [e076fd5]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [36310dc]
+- Updated dependencies [52d878a]
+- Updated dependencies [456aac8]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [7d04b0e]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [c32a8a1]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [dad805d]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [35997ce]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [b388950]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/plugin-detail@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-gantt/package.json b/packages/plugin-gantt/package.json
index f8de33d464..5fd6aee524 100644
--- a/packages/plugin-gantt/package.json
+++ b/packages/plugin-gantt/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-gantt",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Gantt chart plugin for Object UI",
diff --git a/packages/plugin-grid/CHANGELOG.md b/packages/plugin-grid/CHANGELOG.md
index c36c7c5735..eb965f08fc 100644
--- a/packages/plugin-grid/CHANGELOG.md
+++ b/packages/plugin-grid/CHANGELOG.md
@@ -1,5 +1,413 @@
# @object-ui/plugin-grid
+## 17.5.0
+
+### Minor Changes
+
+- 7ffd616: fix(plugin-grid): cross-page "select all N matching" replays the host's real query — or abstains — instead of fanning out unfiltered
+
+ `resolveBulkRows` re-issues the view's query in 500-record pages so a bulk action
+ receives the whole match set rather than the visible window. The query it
+ replayed came from `lastFindParamsRef`, whose only writer is ObjectGrid's own
+ data loader. Under a host that fetches the rows itself — ListView passing `data`
+ plus `manualPagination` and `rowCount`, which is what the console does — that
+ loader never runs, so the ref was not the query behind the rows on screen:
+ absent, or stale from an earlier own-fetch. Either way the `?? {}` default let
+ the fan-out ask the server for the WHOLE OBJECT — no `$filter`, no `$orderby`,
+ no `$search` — and hand up to 5000 unmatched records to a destructive executor
+ (`onBulkDelete`) while the bar read "All N matching records are selected".
+
+ The host now hands its query down as the new optional `findParams` prop on
+ `ObjectGridExternalPaginationProps` (the same shape the internal loader stores),
+ and the fan-out reads whichever side owns the fetch. There is deliberately no
+ grid-side default: when no query is available for the current data path the
+ escalation is **not offered at all** — a host that forgets `findParams` loses
+ the affordance rather than silently collecting the whole object, which is what
+ makes the unfiltered fan-out structurally unreachable rather than merely
+ currently-wired-right. A changed `findParams` also resets the escalation,
+ mirroring the `setSelectAllMatching(false)` the internal loader runs next to its
+ own params write, so "All N matching" cannot survive the host's filter, search,
+ sort or page changing; the comparison is by content, so a host re-render that
+ rebuilds an equal object does not drop the user's escalation.
+
+ The internal-loader path is unchanged: with the ref populated the fan-out issues
+ the same params it always did, and the `selection.type: 'single'` suppression is
+ untouched.
+
+- 24bb2de: grid row menu — the built-in Edit/Delete predicate declarations are derived from the spec-owned authoring type, not hand-restated
+
+ `packages/plugin-grid/src/components/RowActionMenu.tsx` carried its own `BuiltinRowActionPredicates` interface (`{ visibleWhen?: unknown; disabledWhen?: unknown }`) and read it at six declaration sites: both `RowActionMenuProps` predicate props, the shared `isBuiltinRowActionVisible` gate, both `planRowActionMenu` parameters, and the `BuiltinRowActionItem` component. Nothing tied any of them to the type whose values they receive, so a rename at the source would have left every one compiling against a shape that no longer existed — the objectui#3009 hand-copy family, and the mirror of what PR #4423 collapsed in the data-table.
+
+ **Measured true source.** These predicates do NOT flow from `DataTableSchema.rowEditPredicates` / `rowDeletePredicates` — this surface is never handed those keys. `ObjectGrid` resolves the object's `userActions.edit` / `delete` through `resolveRowCrudAffordances`, which returns `CrudAffordances['editPredicates']` / `['deletePredicates']`: the spec-owned `RowCrudPredicates` (ADR-0103, `@objectstack/spec/data`), parsed in exactly one place and re-exported by `@object-ui/core`. Each site now derives from that — per-key `Pick` for the planner (visibility is all it decides), one union alias for the two consumers that serve both built-ins. Measured: with `visibleWhen` renamed at the source, the previous hand-written declarations produce ZERO diagnostics in this package while the derived ones fail to compile at the declarations themselves.
+
+ **Graded `minor` rather than `patch`** because a published type narrows (the objectui#4403 criterion). `RowActionMenuProps.editPredicates` / `deletePredicates` move from `unknown`-valued keys to the spec's `Expression | ExpressionInput` — the authored CEL shorthand or its `{ dialect, source }` envelope — so a consumer passing an `unknown`-typed value, or a bare boolean, stops type-checking. No runtime consumer breaks and no behavior changes; `@object-ui/core` retired the same `unknown` imprecision at its own seam, and this was the last copy of it. (PR #4423's data-table twin stayed `patch` because `DataTableSchema`'s keys were already declared `unknown` — deriving there narrowed nothing.)
+
+ No runtime code was changed, and the package's suite passes unchanged. Alongside it, the "a disabled item still counts toward the menu" rule gains the pin it never had where a user meets it: a row whose only action is `disabledWhen`-gated keeps its "⋮" trigger, and that trigger opens the item, present and `aria-disabled`. The two halves of that rule live in different functions, and each half's own test stayed green while the other regressed. The planner-level case that claimed to pin this is renamed to the verdict it actually decides — the planner never reads `disabledWhen`, so its fixture behaved identically to `{}`.
+
+- 51ac39f: ObjectGrid's host-driven pagination mode is a declared interface instead of twelve `(rest as any)` reads
+
+ `ObjectGridProps` declared twelve members while the component read twelve more out of `...rest`, each through an `as any` cast: `data`, `manualPagination`, `rowCount`, `page`, `pageSize`, `onPageChange`, `onPageSizeChange`, `sort`, `onSortChange`, `search`, `onSearchChange` and `onColumnStateChange`. They are not accidental — together they are the host-driven external-pagination path from framework#2212, where a host has already fetched one window of a larger collection and drives the page/sort/search controls itself, and the component's own comment said so. They were simply declared nowhere, so no call site could be checked against them and no editor could offer them.
+
+ Nothing had caught it because the only untyped caller is `ObjectGridRenderer`, whose `{ schema: any; [key: string]: any }` index signature accepts anything; every typed caller happens to pass only declared props; and the test that exercises the path was compiled by nothing.
+
+ They now live on a named `ObjectGridExternalPaginationProps`, which `ObjectGridProps` extends — a separate interface rather than twelve more members flattened into the authoring surface, so the "advanced host-driven mode" boundary stays visible. The eleven members that already have a counterpart on `DataTableSchema` — the type ObjectGrid forwards them to — are **type-derived** from that declaration (`Partial< Pick< DataTableSchema, … > >`) rather than hand-copied, so the two cannot drift apart; only `onColumnStateChange` is declared explicitly, because the table vocabulary reports per-event `onColumnResize` / `onColumnReorder` rather than the merged `{ order, widths }` layout this reports. `ObjectGridColumnState` is exported for that payload.
+
+ Purely additive for callers: every member is optional, so existing code compiles unchanged, and hosts that were already passing these props now get them checked instead of silently accepted. Runtime behavior is unchanged.
+
+### Patch Changes
+
+- 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
+
+ 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.
+
+ **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.
+
+ 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.
+
+ **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.
+
+ 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.
+
+ 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.
+
+- 77d6f28: fix(plugin-grid): the cross-page "Select all N matching" banner works under external pagination
+
+ `BulkActionBar`'s cross-page affordance was gated on ObjectGrid's `totalMatching`
+ state, whose only writer is the component's own data loader. Under a host that
+ fetches the rows itself — ListView passing `manualPagination` + `rowCount`, which
+ is what the console does — that loader never runs, so the total stayed
+ `undefined` and the banner was permanently absent for any match-set size, even
+ though the pager two lines away was already rendering the correct page count from
+ the host's total.
+
+ The pager's derivation is now hoisted to a single `resolvedTotalMatching` value
+ that both the pager and `BulkActionBar` consume, so the affordance reports the
+ real server total on both paths. The `selection.type: 'single'` suppression is
+ unchanged.
+
+- ebb4e0e: The date formatter's last three en-US channels now follow the display locale
+ (objectui#4272).
+
+ objectui#4468 (PR #4512) pointed every date _renderer_ at `useDisplayLocale()`.
+ Three channels were out of its reach because they are properties of the
+ formatter's signature and of its callers rather than of any renderer, so a `zh`
+ console still met English dates in three places:
+
+ - **`formatDate`'s `'short'` branch** hardcoded
+ `toLocaleDateString('en-US', { month: 'short' })`, so it rendered an English
+ month even when the caller had threaded `options.locale` into that very call.
+ Its only consumers are ObjectGrid's two mobile-card date cells, which threaded
+ no locale — fixing either half alone moves nothing, so both land here.
+ - **`formatDateTime` took no options parameter at all**, so no caller could
+ localize it however hard it tried; it always handed `Intl` an `undefined` tag,
+ which means the MACHINE's locale — neither of the repo's two locale channels.
+ The parameter is optional and lands together with its consumers, plugin-gantt's
+ four tooltip call sites.
+ - **The lookup picker's MongoDB `$date` fallback** called a bare
+ `toLocaleDateString()` with no tag.
+
+ One resolver everywhere, as before: `useDisplayLocale()` (tenant regional
+ default → active UI language → `'en'`). `Intl` accepts `'zh'` verbatim, so there
+ is still no mapping table anywhere.
+
+ English output is byte-identical at every touched site — `en` and `en-US` agree
+ on all twelve short month names — and the `'short'` layout itself is unchanged:
+ only the month token is localized, the compact `"Jan 15, '24"` shape around it
+ is a deliberate fixed layout for narrow cards.
+
+ `@object-ui/fields` is `minor` because `formatDateTime`'s new optional parameter
+ is visible in the package's entry `.d.ts`; the plugin packages' own `.d.ts` files
+ are byte-identical, so their change is module-local.
+
+- 1f9b905: `exportOptions` is the spec's object form: `streaming` is declared, `'pdf'` is retired, and the alignment comment is finally true
+
+ `ObjectGridSchema.exportOptions` carried four keys under a comment claiming alignment with `@objectstack/spec`'s `ListViewSchema.exportOptions`. The comment was false in both directions. The spec declared a bare format ARRAY, not an object, so no authored document could satisfy both spellings at once; and `ObjectGrid` read a fifth key — `streaming`, the opt-out that forces the client-side export path — which appeared in no declaration anywhere, reachable only through an `as any` cast in the renderer. An author had no way to discover the key except by reading the renderer's source, and no schema would have refused it or honoured it.
+
+ objectstack#8010 closed that upstream by declaring `ListViewExportOptionsSchema` with exactly the five keys this renderer reads. This change lands the objectui half of the reconciliation:
+
+ - The five keys are now one exported type, `ListViewExportOptions` — `formats`, `maxRecords`, `includeHeaders`, `fileNamePrefix`, `streaming` — shared by `ObjectGridSchema` and by a saved `NamedListView`, so the two authoring surfaces cannot grow apart. The comment above it names the spec symbol and version it mirrors, which makes it checkable rather than reassuring.
+ - `streaming` is declared, and the renderer's `as any` casts are gone. Removing them against the old four-key type produced two `TS2339: Property 'streaming' does not exist` errors — that red is what the declaration fixes.
+ - `'pdf'` is retired from the local format union, published as `ListViewExportFormat`. PDF export was declined platform-side (objectstack#1301 NOT_PLANNED) and the value left the spec's format enum in `@objectstack/spec` 17.0.0, where authoring it is now a parse-time refusal carrying `os migrate meta --from 16`. No ObjectUI path has ever produced a PDF: a declared `'pdf'` reached the user only as a browser console line.
+
+ Runtime behavior of the export menu is unchanged. The filter that drops undeliverable formats is format-agnostic — it keeps what the active path can deliver — so it still hides `xlsx` when no server stream is available, and it still hides a legacy `'pdf'` that pre-17 stored metadata carries until the migration rewrites it. There was no `'pdf'`-specific branch to delete.
+
+ Two guards keep the contract from re-opening. On the type side, a compile-time assertion pins the interface's key set to exactly the spec's five, so a sixth key fails the build. On the renderer side, a source scan collects every property `ObjectGrid` reads off `exportOptions` — through the alias it binds, and through any cast, since a cast is how `streaming` stayed invisible — and fails if the renderer reads anything the type does not declare.
+
+ `@object-ui/types` is a minor: `ListViewExportFormat` and `ListViewExportOptions` are new exports, `streaming` is a new optional key, and `formats` no longer admits `'pdf'`. Anything still writing that value was authoring metadata the platform now refuses at publish.
+
+- 51ab34e: ObjectGrid's bulk-bar **Clear** now unticks the row checkboxes, instead of only removing the toolbar
+
+ Selecting rows and pressing Clear emptied the bulk-actions bar but left every row checkbox at `data-state="checked"` (the header checkbox stuck at `indeterminate` on a partial pick). The user was stranded on a page of ticked rows with no toolbar left to act on them, and the only way out was a reload or re-selecting and clearing through some other path.
+
+ The selection lives in two places: `selectedRows`, which is the grid's own state and drives the toolbar, and the row checkboxes, which live inside the embedded data-table and only clear when `selectionResetKey` moves. `resetSelection()` writes all three, and the delete / dispatch / dialog-close paths have gone through it since the reset-key mechanism was introduced. Both `BulkActionBar` mount sites, however, hand-wrote their `onClearSelection` as `setSelectedRows([]); setSelectAllMatching(false);` — exactly `resetSelection()` minus the key bump — so Clear updated one source and left the other ticked. Both sites now call `resetSelection()`, so there is one reset for every path that clears a selection rather than three hand-copied ones, and the cross-page "all matching" state drops with it.
+
+- 0ca6096: A gantt task titled `A$&B` no longer prints `{{title}}` back into its own delete dialog — the two hand-rolled provider-less fallback interpolators are literal, like i18next
+
+ objectui#3418 fixed the shared helper's fallback interpolator: `String.prototype.replace` became `split(needle).join(value)`, because `replace` and `replaceAll` both interpret `$&`, `` $` ``, `$'` and `$$` in the **replacement** string and i18next does not. Two hand-rolled copies of that interpolator never got the fix. Both are deliberate non-users of `createSafeTranslation` — each falls back per key so a host dictionary that covers the common keys but lags on newer ones still resolves what it has — so the shared fix had no path to reach them.
+
+ The reachable one is gantt's. `gantt.delete.body` is `'"{{title}}" will be permanently removed. …'` and its call site interpolates the record's own title, which is user data:
+
+ | task title | rendered before | rendered now |
+ | ---------- | --------------------------------------------------------------------- | ----------------------------------------- |
+ | `A$&B` | `"A{{title}}B" will be permanently removed.` | `"A$&B" will be permanently removed.` |
+ | `` x$`y `` | `"x"y" will be permanently removed.` | `` "x$`y" will be permanently removed. `` |
+ | `p$$q` | `"p$q" will be permanently removed.` | `"p$$q" will be permanently removed.` |
+ | `u$'v` | `"u" will be permanently removed. …v" will be permanently removed. …` | `"u$'v" will be permanently removed.` |
+
+ The first row is the ugly one: `$&` expands to the matched text, so the placeholder itself is printed back to the user inside the record's own name. Gantt's copy also carried the other half of the same defect — a bare string needle substitutes only the **first** occurrence, where i18next substitutes every one — and `split`/`join` fixes both at once.
+
+ The import wizard's copy used a `g`-flagged `RegExp`, which covered the repeated-placeholder half but could not touch the `$`-pattern half: that harm lives in the replacement string, not the needle. Its values are authored metadata — field labels and type names spliced into `grid.import.missingRequiredHint` and `grid.import.legacyReferenceBlocked` — so a label containing `$&` corrupted the hint the same way. Retiring the `RegExp` also retires an unescaped needle, since the placeholder name went into the pattern uninterpolated; that was inert while every placeholder name is a bare identifier, and is now structurally impossible.
+
+ This is the provider-less path only (standalone embedding, unit tests). With an `I18nProvider` mounted, i18next serves these keys and was already literal on both sides — which is exactly why the divergence was invisible. No pack, key or call site changed; the three `{{count}}` gantt keys take numbers and were never affected, and `gantt.quickFilter.resultSummary`'s deliberate single-brace idiom is resolved by its call site rather than this interpolator and is untouched.
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- f565418: fix(plugin-grid): the list link column renders a real anchor when the host publishes record URLs
+
+ The list's `link: true` column (and the auto-linked primary field) rendered as
+ a `span role="link"` with no `href`, navigating only through a click handler.
+ So the surface users actually open records from had none of a link's native
+ affordances — no middle-click / ⌘-click open-in-new-tab, no "copy link
+ address", no hover status-bar URL — and `role="link"` without an href is a
+ weaker contract for assistive tech than a real anchor. It was also the odd one
+ out: the previous release gave record-detail and related-list lookup VALUES
+ real anchors, leaving the list column as the weakest of the three surfaces.
+
+ `LinkCell` now renders a real `` with the same click split: a plain
+ left click is prevented and handed to the existing in-app navigation, so drawer
+ / modal / page behavior is completely unchanged, while modifier and
+ middle-clicks are left to the browser.
+
+ The URL is not assembled in the grid. The object list page publishes its own
+ record-URL builder through `RelatedRecordActionsContext.recordHref` — the same
+ seam the lookup links use, and the same expression its "open in new window"
+ action already navigated with, so the anchor and that action cannot address
+ different records. A host that publishes no URL renders exactly what it
+ rendered before: the Studio designer, embedded renderers and standalone grids
+ are untouched.
+
+ Neither package's published `dist/index.d.ts` changes (measured both ways —
+ byte-identical), so this is a patch on both: the list host's new helpers are
+ module-level exports behind a barrel that re-exports only `ObjectView`.
+
+- 5e514c4: standalone ObjectGrid resolves off-spec `rowHeight` to compact, matching ListView and the spec bridge, instead of silently styling it as medium
+
+ One component answered one question two ways. `ObjectGrid` seeded its density state with `schema.rowHeight ?? 'compact'`, so an ABSENT `rowHeight` landed on `compact` while an OFF-SPEC one skipped every arm of the density ternaries and came out at their terminal `else` — the `medium` styling. That is the absent-vs-off-spec split objectui#4440 removed from `ListView`, and it made a standalone grid the third answer to a question the rest of the system had already settled: `@object-ui/core`'s `rowHeightToDensityMode` abstains for an off-spec value, the `@object-ui/react` spec bridge abstains, and `ListView` defaults the abstention to `compact`. Off-spec now renders exactly like absent, everywhere.
+
+ Only a standalone grid was affected. When `ListView` owns the grid it overwrites the prop with a value derived from `density.mode`, so nothing off-spec survives that hop.
+
+ The narrowing happens at the state boundary, not in the ternaries. `medium` is still a real row height with its own styling arm, and a leaf renderer's terminal `else` is still legitimate styling — what changes is that nothing unrecognized can reach it. Membership is tested against `ROW_HEIGHT_TO_DENSITY_MODE`, so the admitted values keep one definition in the repo and the build fails if the spec grows a sixth row height without teaching the resolver about it. Both entry points go through the resolver: the initial state and the effect that re-syncs when the `rowHeight` prop changes.
+
+ Two off-spec spellings behaved differently before this, which the report of the defect did not distinguish, and the boundary fix covers both:
+
+ - A plain off-spec value (`'garbage'`) was not a key of the toolbar's row-height icon map either. That map is looked up by the same unvalidated state, so `rowHeightIcons[mode]` was `undefined` and rendering ` ` threw `Element type is invalid` — a standalone grid with an off-spec `rowHeight` did not render at all, rather than rendering as `medium`. The toolbar is shown precisely when `schema.rowHeight` is defined, so the crash and the off-spec case coincide exactly.
+ - A prototype member (`'toString'`) WAS reachable through that map's prototype chain, resolving to `Object.prototype.toString` — a function, which React accepts as a component — so it survived to the ternaries and rendered as `medium`, the defect as filed. The resolver uses `hasOwnProperty` rather than `in` for this reason, the same reason `@object-ui/core` does.
+
+ Both are now inert: the state can only ever hold one of the five admitted row heights, so the icon lookup is total and the ternaries never fall through.
+
+- 36310dc: `formatPercent` groups its output and follows the display locale — the last
+ tooltip/cell channel (objectui#4553).
+
+ PR #4557 threaded the gantt tooltip's number and currency rows and measured that
+ the percent row could not follow: `formatPercent(value, precision)` took no
+ locale parameter, and its whole body was
+ `${percentDisplayValue(value).toFixed(precision)}%`. It built no
+ `Intl.NumberFormat` and never reached `formatDisplayNumber` — so unlike its
+ siblings it did not render in the MACHINE's locale, it rendered in **no** locale:
+ an ASCII decimal mark, never a grouping separator, byte-identical on every
+ machine.
+
+ **English output MOVES, and that is the fix.** Because the function never
+ grouped, `1235%` was wrong in en-US too, not only in German. Grouping and locale
+ therefore land together:
+
+ | | before | after |
+ | ---------- | ------- | -------------- |
+ | en, 1234.5 | `1235%` | `1,235%` |
+ | de, 1234.5 | `1235%` | `1.235\u00a0%` |
+ | de, 80 | `80%` | `80\u00a0%` |
+
+ Values below the grouping threshold are unchanged in English (`80%`, `12.5%`,
+ `33.33%`), so the move is confined to four digits and up. German changes at every
+ magnitude, because the no-break space before the sign is part of the locale's
+ percent convention — which is what routing through `Intl` buys over appending a
+ literal `%`.
+
+ The scaling contract is untouched: `percentDisplayValue` still disambiguates a
+ fraction-stored percent (`0.8` → 80%) from a whole one, so the list cell and the
+ dashboard measure formatter still agree.
+
+ Consumers are threaded in the same change, the parameter never landing
+ speculatively:
+
+ - **fields** — `PercentCellRenderer`, on BOTH of its paths. Its whole-percent
+ branch (`progress` / `completion` fields, which store 0-100 and must skip the
+ fraction scaling) was a second bare `toFixed` call; leaving it behind would
+ have made one grid internally inconsistent, so both branches now share one
+ locale-aware body and differ only in the scaling policy.
+ - **plugin-gantt** — the tooltip percent row, completing objectui#4553's switch.
+ - **plugin-grid** — the mobile card's percent cell, which sits in the same
+ density row as a date cell objectui#4272 had already localized.
+ - **plugin-dashboard** — `renderFieldValue`'s percent branch. It is a plain
+ function rather than a component, so it takes the locale as an optional fourth
+ parameter beside the `tenantCurrency` already threaded that way, and both of
+ its callers pass it and declare it in their memo dependency arrays.
+
+ Bumps follow each package's own `.d.ts` diff, measured in both directions.
+ `@object-ui/fields` and `@object-ui/plugin-dashboard` are `minor` on the
+ objectui#4272 / PR #4544 precedent — quoted from that changeset: "`@object-ui/fields`
+ is `minor` because `formatDateTime`'s new optional parameter is visible in the
+ package's entry `.d.ts`; the plugin packages' own `.d.ts` files are
+ byte-identical, so their change is module-local." Here `formatPercent` and
+ `renderFieldValue` each gain an entry-visible optional parameter, while
+ plugin-gantt's and plugin-grid's `.d.ts` files are byte-identical and stay
+ `patch`.
+
+- 4270c11: ObjectGrid's record-detail date fallback follows the display locale
+ (objectui#4541).
+
+ `renderRecordDetail`'s type-inference fallback rendered date-like values with
+ a bare `formatDate(value)` — no options at all. `formatDate` then handed `Intl`
+ an `undefined` tag, and `undefined` is not "the user's locale", it is the
+ **machine's**, which is neither of the repo's two locale channels. On a `zh`
+ console that one cell rendered `Mar 15, 2024` while every neighbouring date
+ cell rendered `2024年3月15日`.
+
+ This was the third `formatDate` site in the file. objectui#4272 (PR #4544)
+ ruled its plugin-grid surface to "ONLY the two date-cell call sites" — the two
+ that pass `'short'` in the mobile card view — and this one was never among
+ them, so it was filed rather than fixed there.
+
+ The fix is pure consumption, not plumbing: the component already reads
+ `useDisplayLocale()` at component level (landed in PR #4544), and
+ `renderRecordDetail` is a plain arrow in the component body that already closes
+ over `tenantCurrency` from that same scope, so the call site simply gains
+ `{ locale: displayLocale }`. No hook was added, and the function is not
+ memoized, so there is no dependency array to keep in step.
+
+ One resolver everywhere, as before: `useDisplayLocale()` (tenant regional
+ default → active UI language → `'en'`). English output is byte-identical — the
+ runner's `en-US` and `en` agree on this branch — and the two `'short'` cells
+ PR #4544 threaded are untouched.
+
+ `patch` rather than `minor`: the package's own `.d.ts` files are byte-identical
+ across the change, so this is module-local (the objectui#4496 precedent).
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [ebb4e0e]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [8f60d73]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [5cc847c]
+- Updated dependencies [e7663f2]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [c911544]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [36310dc]
+- Updated dependencies [52d878a]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/permissions@17.5.0
+ - @object-ui/mobile@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-grid/package.json b/packages/plugin-grid/package.json
index 522cb5407e..264bb227c4 100644
--- a/packages/plugin-grid/package.json
+++ b/packages/plugin-grid/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-grid",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Grid plugin for Object UI",
diff --git a/packages/plugin-kanban/CHANGELOG.md b/packages/plugin-kanban/CHANGELOG.md
index 52b809bca4..4a4891266d 100644
--- a/packages/plugin-kanban/CHANGELOG.md
+++ b/packages/plugin-kanban/CHANGELOG.md
@@ -1,5 +1,175 @@
# @object-ui/plugin-kanban
+## 17.5.0
+
+### Minor Changes
+
+- fa21254: Kanban: a drop that makes fields required now collects them instead of dead-ending
+
+ Dragging a card into a column whose value flips a field's `requiredWhen` predicate to TRUE used to PATCH the column value alone. The engine refused the whole update — correctly, that is what the predicate declares — and the board had no way to finish the move: the only path to closing a won deal was to abandon the board and open the record form. HotCRM's opportunity pipeline is the reported case (`win_reason` is required when `stage == "closed_won"`), but the dead end belonged to every board whose target column carries a conditional requirement.
+
+ The board now evaluates the target column's predicates BEFORE writing anything. If the move would make fields required while they are still empty, it opens a small dialog collecting exactly those fields, then submits the column value and everything collected as ONE PATCH — never two writes, which would leave the record in the refused state if the second one failed. A drop that triggers no predicate is untouched, down to the PATCH body.
+
+ The verdict comes from `@object-ui/core`'s `resolveFieldRuleState` — the same evaluator the record form, the wizard and the line-item grid already resolve `visibleWhen`/`readonlyWhen`/`requiredWhen` with, delegating to `@objectstack/formula`'s CEL engine. The board's prompt and the server's enforcement therefore reach the identical verdict rather than drifting through a second hand-rolled predicate evaluator. Emptiness is core's `isMissingForRequired`, the presence contract the form and the server share, so a `false` boolean and a `0` count as answers and are not re-asked.
+
+ Every control in the dialog is `@object-ui/fields`' `FieldEditWidget`, the same widget the record form renders for that field type — a select edits as a select, a date as a date picker — so this adds no second set of field-rendering decisions.
+
+ Four kinds of field are deliberately NOT collected, and each falls through to the unchanged PATCH where the server's refusal (legible since objectstack#7525) speaks for itself: one that already has a value, one `visibleWhen` hides, one that is readonly, and one whose type has no edit widget at all. A dialog row with no control would be a worse dead end than the one being fixed.
+
+ Cancelling writes nothing and leaves the card in its original column; a combined PATCH that is still refused for some other reason surfaces the refusal and rolls back exactly as a plain rejected move does, rather than looping the dialog on an arbitrary server error.
+
+ `@object-ui/i18n` carries two new `kanban.*` strings for the dialog, translated across all ten packs. Its public type surface is unchanged — the `.d.ts` was measured identical before and after — hence the patch bump.
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- 2c8ad7c: A rejected Kanban drag rolls the card back on both data ownerships, not just when the board owns its own records
+
+ Dragging a card into a column the server refuses (`PATCH` 400 `invalid_transition`) left the card sitting in the target column until a manual reload, whenever the board was hosted by a parent that supplies records through the `data` prop — the ListView/console path, which is the one real users meet. The toast fired and the server value was untouched, so the board was showing a move that had not happened.
+
+ `handleCardMove` performed its failure revert only inside `if (!hasExternalData)`. The reasoning recorded next to it was that the parent handles the refresh, and for an accepted move it does — the parent's mutation subscription refetches and the new value propagates. A _rejected_ move changes nothing server-side, so no refetch is ever triggered and nothing un-said the optimistic move.
+
+ The revert is now unconditional, which is also what makes it a single code path rather than two. The card's on-screen position does not live in `ObjectKanban` at all: the board component moves the card inside its own column state before reporting the move upward, and re-syncs that state from its `columns` prop whenever the prop's identity changes — which any re-render of `ObjectKanban` produces, since the renderer re-buckets the records into fresh column arrays. On the internal path the revert corrects the record and re-renders; on the external path it re-renders against the parent's records, which the server never changed, and the re-bucket puts the card back where it started.
+
+ The optimistic write on the way _in_ stays gated on internal data deliberately, and the asymmetry is now pinned by tests: writing it on the external path would re-render against the unchanged parent records and snap an accepted move back before the server had answered. Accepted moves on both paths, and the existing rejection toast, are covered by controls alongside the regression test.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [6d01319]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [ebb4e0e]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [8f60d73]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [63fe8fd]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [5cc847c]
+- Updated dependencies [6314e87]
+- Updated dependencies [5e2e9fa]
+- Updated dependencies [297534b]
+- Updated dependencies [e7663f2]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [e076fd5]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [36310dc]
+- Updated dependencies [52d878a]
+- Updated dependencies [456aac8]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [7d04b0e]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [c32a8a1]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [dad805d]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [35997ce]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [b388950]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/plugin-detail@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-kanban/package.json b/packages/plugin-kanban/package.json
index 10bd7f9f1b..233c47b84c 100644
--- a/packages/plugin-kanban/package.json
+++ b/packages/plugin-kanban/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-kanban",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Kanban board plugin for Object UI, powered by dnd-kit",
diff --git a/packages/plugin-list/CHANGELOG.md b/packages/plugin-list/CHANGELOG.md
index 20faebf550..087dc10fae 100644
--- a/packages/plugin-list/CHANGELOG.md
+++ b/packages/plugin-list/CHANGELOG.md
@@ -1,5 +1,368 @@
# @object-ui/plugin-list
+## 17.5.0
+
+### Minor Changes
+
+- 7084f7d: `DashboardRenderer` and `ListView` serve the props they declare — the index signature stops erasing them
+
+ 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` was `string | number` and `ComponentProps['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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- fe52a04: `rowHeightToDensityMode` answers only for the five spec row heights — the coerce-to-`comfortable` fallback is gone
+
+ Two surfaces narrow a list view's `rowHeight` onto the renderer's three-step
+ density vocabulary, and since objectui#4352 they answered differently for the
+ same off-spec input: `@object-ui/react`'s spec bridge declined to answer, while
+ `@object-ui/core`'s `rowHeightToDensityMode` rehabilitated anything unknown into
+ `comfortable`. One metadata-driven system, two answers for one input
+ (objectui#4440).
+
+ The strict answer wins, per AGENTS.md #0.1: a renderer-side rehabilitation of
+ off-spec metadata is a second de-facto contract, and one strict contract beats N
+ dialects — a bad `rowHeight` gets fixed at the producer, where the schema already
+ rejects it. The five mappings themselves are untouched (`compact`/`short` →
+ `compact`, `medium` → `comfortable`, `tall`/`extra_tall` → `spacious`), and the
+ table keeps its `Record< RowHeight, … >` typing, so a row height added upstream
+ still fails the build here.
+
+ **Breaking semantics, deliberately graded `minor`** (this repo never publishes
+ `major` — its major tracks `@objectstack`). Two things change:
+
+ - **Published type.** `rowHeightToDensityMode` is exported from
+ `@object-ui/core`, and its return widens from `DensityMode` to
+ `DensityMode | undefined`. A host assigning the result straight into a
+ `DensityMode` now has to say what an off-spec row height should mean to it.
+ - **Rendered output, for input the spec already rejects.** `ListView` — the one
+ in-repo caller — used to render an off-spec `rowHeight` one step looser than an
+ ABSENT one (`comfortable`, 40px rows, vs `compact`, 32px). It now renders it
+ exactly like an absent one, `compact`, which is also `ObjectGrid`'s own default.
+ A sweep of this repo, the `objectstack` example apps and one downstream app
+ found zero authored off-spec values, and the legacy `densityMode` alias cannot
+ produce one (`DENSITY_MODE_TO_ROW_HEIGHT` is typed
+ `Record< DensityMode, RowHeight >`).
+
+ Also closed while retiring the branch: the lookup guarded membership with `in`,
+ which walks the prototype chain, so `rowHeight: 'toString'` returned
+ `Object.prototype.toString` — a function — from something typed `DensityMode`. It
+ is an own-property check now.
+
+### Patch Changes
+
+- 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
+
+ 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.
+
+ **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.
+
+ 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.
+
+ **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.
+
+ 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.
+
+ 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.
+
+- bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
+
+ **objectui#4375** — `ListView` and `LookupField` built their search placeholder as
+ `t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
+ in all ten locales on screens where objectui#3878 had converged everything else on
+ U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
+ was appended to right-to-left text). Both now read `table.search`, which is already
+ the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
+ `PeoplePicker` render it too — and is translated with the right ellipsis in all ten
+ packs. No new keys.
+
+ **objectui#4376** — `list.loading` served the English `Loading records…` in eight of
+ the nine translation packs (`zh` alone had translated it); `designer.undo` and
+ `designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
+ `ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
+ pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
+ inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
+
+- 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
+
+ rc.6 widened `I18nLabel` from `string` to `string | Record`, 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.
+
+ | package | what an author can now write and see |
+ | ----------------------------- | --------------------------------------------------------------------------------------- |
+ | `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
+ | `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
+ | `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
+ | `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
+ | `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
+
+ **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.
+
+ Two consequences worth knowing:
+
+ - **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.
+ - **`@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.
+
+- 297534b: Align 43 inline `defaultValue` strings with the `en` pack, and make the call-site gate enforce it (objectui#3810)
+
+ `t(key, { defaultValue: 'English text' })` only renders that text when i18next
+ **misses** the key. Where the key exists in `packages/i18n/src/locales/en.ts` the
+ pack value always wins, so the inline string is dead code — and 43 of those dead
+ strings said something different from the sentence users actually read.
+
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) now compares the two
+ whenever a call site carries a literal `defaultValue` for a key `en` defines, and
+ fails on any byte of difference. It is a hard rule with **no baseline**: the
+ repo-wide census measured 43 sites in 19 files out of 851 literal inline defaults,
+ and all 43 are aligned here, so there is no debt for a ratchet to hold. A
+ `defaultValue` on a key that is _not_ yet in `en` stays legal — that transition
+ runs for months (objectui#3546) and belongs to the existing `missing-key` rule,
+ which keeps reporting it alone.
+
+ Every fix moved the CALL SITE to the pack's wording. `en.ts` is untouched: its
+ values are what users read today, and changing one would oblige the same change in
+ the nine other packs (`scripts/check-i18n-en-drift.mjs`, objectui#3650). Six of the
+ 43 differed only in an ellipsis (`...` against U+2026) — invisible in review, which
+ is how they survived three i18n gates that are each blind to this class by
+ construction.
+
+ The visible effect is confined to hosts that render these components with **no**
+ `I18nProvider` and no initialised i18next instance. There, react-i18next's
+ not-ready `t` returns the `defaultValue`, so the inline string was the rendered
+ one; it now matches what a provider-backed app has always shown. Inside the
+ console — provider mounted — nothing users see changes. The clearest converging
+ examples: the workspaces screen was written as "Organizations" at nine call sites
+ while every user has been reading "Workspaces"; the forgot-password success line
+ was written as "If an account exists, a reset link has been sent." while the pack
+ asserts "We've sent a password reset link to {{email}}."
+
+- f8595a0: A list emptied by the view's own filter says "no records match", instead of inviting you to create your first record
+
+ `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.
+
+ 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.
+
+ 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.
+
+- 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
+
+ `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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 7ffd616: fix(plugin-list): ListView hands the child grid the query behind the window it passes down
+
+ ListView owns the fetch on the external-pagination path — it holds the filter,
+ the search term and the sort, and it is the side that calls `dataSource.find`.
+ The grid it hands the window to has a cross-page "select all N matching"
+ escalation that RE-ISSUES that query to collect the whole match set, and with
+ nothing handed down it replayed its own never-written params ref and so asked the
+ server for the entire object, feeding unmatched records to bulk delete.
+
+ The params object is now hoisted out of the `find` call — one object, one query,
+ no reconstruction that could drift from what was actually asked — recorded past
+ the stale-request guard so it is always the query that produced the rows on
+ screen, and forwarded as `findParams` in the same handoff block as `rowCount`,
+ `page` and `onPageChange`. No public API of `ListView` changes.
+
+- 37cd8e4: `list-view` now reads its `dataSource` binding through the shared `ElementDataSourceGate` instead of a private copy of the precedence table
+
+ objectstack#5576 landed the per-element `dataSource` binding on `list-view` by
+ writing the precedence table — binding keys override the component's, a `view` is
+ only a baseline, `filter` AND-combines rather than replaces, an authored-but-empty
+ `columns` counts as unauthored, the row cap lands on `pagination.pageSize`, and
+ `viewType` is taken only when the component declared none — inline in
+ `ListViewBlock`. objectstack#6953 then needed the same table for the other eight
+ object-bound blocks and lifted it into `@object-ui/react`
+ (`useElementDataSourceSchema` / `ElementDataSourceGate`), deliberately not
+ touching `ListViewBlock`: refactoring already-merged code inside a wiring PR
+ would have been an out-of-scope regression surface.
+
+ That left one table with two implementations — `list-view` on the private copy,
+ every other block on the shared one. Nothing was wrong for a user today; the risk
+ is the next person to change the rules changing one side, which is how the spec's
+ "_additional_ filter criteria" becomes two dialects and a per-element filter
+ quietly starts replacing a saved view's instead of narrowing it.
+
+ `ListViewBlock` now contributes only what is genuinely its own — the names of the
+ keys `ListView` reads:
+
+ ```ts
+ const LIST_VIEW_DATA_SOURCE: ElementDataSourceMapping = {
+ columns: true,
+ filter: true,
+ sort: true,
+ limit: "pagination.pageSize",
+ viewType: true,
+ };
+ ```
+
+ and the ~45-line `useMemo` mapping block is deleted, along with the block's
+ hand-rolled error and loading panels (the shared
+ `ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel` render with the
+ `list-view` testId prefix, so `list-view-datasource-error` and
+ `list-view-resolving-view` are unchanged down to the byte, and the error heading
+ is passed through as `errorTitle`).
+
+ **No behaviour changes in either direction**, and that is the acceptance
+ criterion rather than a hoped-for outcome: objectstack#5576's entire suite passes
+ untouched, with no assertion edited — had any single case needed adapting, the
+ two implementations would have been proven to disagree, which is a defect to
+ re-grade rather than a refactor detail to absorb. New pins cover the mapping
+ table key by key at the block/gate seam, plus the shared loading panel, which
+ neither implementation had ever asserted.
+
+- e076fd5: Inline-edit toggle reads "Edit fields" without an I18nProvider, matching every locale pack
+
+ `DETAIL_DEFAULT_TRANSLATIONS` said `Edit fields inline` where all ten packs say
+ `Edit fields`, so `InlineEditSaveBar`'s toggle announced two different names for one
+ control — the map's on provider-less hosts (standalone embeds, the preview gallery),
+ the pack's in the console. The pack wins; the map row now mirrors it byte for byte.
+
+ The three ungated defaults maps (`plugin-detail`, `plugin-list`, `plugin-designer`) are
+ now compared key-by-key against the `en` pack by a new gate, generalizing the
+ collaboration-only precedent from objectui#3440. `LIST_DEFAULT_TRANSLATIONS` and
+ `DESIGNER_DEFAULT_TRANSLATIONS` are exported for it, as `DETAIL_DEFAULT_TRANSLATIONS`
+ and `COLLAB_DEFAULT_TRANSLATIONS` already were.
+
+- a84385b: `NavigationConfig.mode` is optional — the type now says what the hook does
+
+ `@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.
+
+ 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.
+
+ The alias is now the spec's authored config verbatim, with no divergence of its own:
+
+ ```ts
+ export type NavigationConfig = SpecAuthoredInput<
+ typeof NavigationConfigSchema
+ >;
+ ```
+
+ 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.
+
+ **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.
+
+ **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.
+
+- dad805d: Six i18n keys no longer render as raw key strings on hosts with no `I18nProvider` (objectui#4396)
+
+ `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.
+
+ 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.
+
+- 7f1cb33: List sort: the relational hint stops recommending a formula field, the one type the server refuses to sort by
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 2e3b0c0: fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state
+
+ A list pointed at an object whose `enable` block withholds the API rendered its ordinary
+ empty state, so _"this page cannot work, and never could"_ reached the user as _"you have no
+ records"_ (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose
+ `sys_jwks` declares `enable.apiEnabled: false` — could not load for any persona and said so
+ to nobody. That is also why the upstream defect objectstack#7544 survived review for its
+ whole life: a merely unpopulated page invites nobody to click through.
+
+ The masking had two halves, in two packages, and neither package could see the other:
+
+ - **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded
+ **every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived
+ at the surface as a successful empty result, indistinguishable from a genuinely empty
+ object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED`
+ (404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one
+ would have pinned the object to "empty" for the rest of the session.
+ - **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405
+ half was never swallowed, so it already reached this panel, but classified as `network`:
+ _"check your connection and try again"_ for a condition no retry can change. It now says
+ the object is not exposed through the API, that this is a setting on the object rather than
+ a permission, and it offers **no Retry** button, because every retry re-fetches the
+ identical refusal.
+
+ Both denials are pure functions of the object's metadata — no user, no permission, no
+ context — so neither is transient or per-user, which is exactly the case where a silent empty
+ state is most misleading. Discrimination is on the ADR-0112 `code`, never the status: a
+ missing collection, a missing record and a disabled object are all 404.
+
+ **A genuinely empty object still renders the ordinary empty state**, and a backend without an
+ optional collection still degrades to empty — pinned in both directions, at the adapter, at
+ the view, and once end-to-end over a real adapter and a real `ListView`.
+
+ Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search`
+ branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`.
+ It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the
+ branch a list takes whenever it expands a lookup or runs a search is no longer anonymous.
+
+ New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the
+ `en` pack and mirrored in the list defaults map.
+
+ ## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch
+
+ Two independent reasons, either of which is sufficient under this repo's precedent
+ (objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift):
+
+ 1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown):
+boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple
+ `['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's
+ public surface. Additive surface growth is minor.
+ 2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now
+ **REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with
+ `{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that
+ relied on those two codes arriving as a successful empty result now receives a rejected
+ promise carrying `code` + `httpStatus`, and must handle it.
+
+ Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404
+ with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move
+ is scoped to the two denial codes named above and to nothing else.
+
+ Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks
+ `@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change
+ described in the body, which is what this note is.
+
+- 31ab1ac: fix(print): `window.print()` produces a usable page, and the Print buttons say what they do
+
+ The list, report and dashboard Print controls were bare `window.print()` calls with no
+ print stylesheet, so the browser printed the whole console — sidebar, top bar, chat rail,
+ toasts — with the data table clipped to a single viewport. With no label to the contrary
+ they were being accepted against "export to PDF" requirements, which they have never been.
+
+ - `@object-ui/app-shell/styles.css` gains a shared `@media print` block: it hides the shell
+ chrome, prints the active content area full-width, releases the viewport-height flex chain
+ so long tables paginate instead of clipping, repeats table headers on every sheet, and
+ neutralises dark mode (which otherwise prints white-on-white). One sheet serves list,
+ report and dashboard.
+ - The list and report Print buttons carry a tooltip and accessible name stating that they
+ open the browser's own print dialog and are not a PDF export (new `common.printDialogHint`,
+ translated in all ten locale packs).
+ - The dashboard's `export_dashboard_pdf` action no longer toasts "Preparing PDF export…" —
+ it names the print dialog it actually opens (`dashboardActions.pdfPreparing` is replaced by
+ `dashboardActions.printDialogOpening`).
+
+ No control was removed and no headless detection was added. A real print/PDF primitive
+ remains out of scope (`objectstack-ai/objectstack#1301`, closed NOT_PLANNED).
+
+- ff84b05: Stop the report config panel being titled "Title", and the view-settings colour section "Color"
+
+ 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).
+
+ `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.
+
+ `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.
+
+ No `en` value of an existing key changed; `scripts/check-i18n-en-drift.mjs` reports 0 en values changed, 1 key added.
+
## 17.4.0
### Minor Changes
diff --git a/packages/plugin-list/package.json b/packages/plugin-list/package.json
index 14c8043c88..45d05e7551 100644
--- a/packages/plugin-list/package.json
+++ b/packages/plugin-list/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-list",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "ListView plugin for Object UI - unified view component with view type switching",
diff --git a/packages/plugin-map/CHANGELOG.md b/packages/plugin-map/CHANGELOG.md
index 60a9daf511..f8b2a9478c 100644
--- a/packages/plugin-map/CHANGELOG.md
+++ b/packages/plugin-map/CHANGELOG.md
@@ -1,5 +1,92 @@
# @object-ui/plugin-map
+## 17.5.0
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- b388d0e: `object-map` reads its configuration from the declared `map` input only — `filter` is the query filter, and a map authored with both stopped rendering markers
+
+ `getMapConfig` probed every filter for a `map` key and, on a hit, used it as the MapConfig: `schema.filter.map`, plus a `schema.filter.map.style` half in the style chain. That shape predates the `{ name: 'map', type: 'object' }` input both registrations declare, and it gave `filter` two meanings inside one block — the query filter at `$filter: schema.filter`, and a configuration slot.
+
+ The probe was written as `'map' in schema.filter`, and `in` walks the prototype chain. The ordinary filter is an **array**, and every array inherits `Array.prototype.map` — so the probe matched, handed the component a _function_ as its map configuration, and the spread of a function is `{}`. The declared `schema.map` was never reached (it sat in the `else` branch), so a map authored with both `map` and `filter` — two documented inputs, no legacy shape required — lost `latitudeField` / `longitudeField` / `titleField`, failed `extractCoordinates` on every record, and rendered zero markers under a "N records with missing or invalid coordinates excluded from the map" banner. The only console output was `[ObjectMap] Invalid map configuration:` from the Zod parse of a function.
+
+ This was reachable two ways and both are fixed by the same deletion: an author writing `filter` alongside `map`, and the `dataSource` binding of objectstack#7121, whose merged filter is an `and` node — `['and', [...], [...]]`, still an array, still carrying `Array.prototype.map`.
+
+ Both legacy reads are gone; the map consumes only what it declares. The `map` config, the top-level `locationField` / `latitudeField` branch, and the `style` / `mapStyle` reads are untouched, and `filter` is passed to the query verbatim — a field genuinely named `map` still filters on it, and nothing is stripped from the author's filter.
+
+ A schema still carrying the legacy `filter.map` stash now gets a dev-mode warning naming the shape and pointing at `schema.map`, rather than silently falling back to the default field names. It is deliberately narrow: own properties only (so an inherited `map` method never triggers it) and object-valued only (so `filter: { map: 'x' }` reads as a filter on a field named `map`), and it warns once per distinct stash because `getMapConfig` runs on every render. Production behavior is unchanged beyond the configuration no longer being read.
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [f5e1143]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [47f551b]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-map/package.json b/packages/plugin-map/package.json
index a8cf2102a7..dbaf25e2ce 100644
--- a/packages/plugin-map/package.json
+++ b/packages/plugin-map/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-map",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Map visualization plugin for Object UI",
diff --git a/packages/plugin-markdown/CHANGELOG.md b/packages/plugin-markdown/CHANGELOG.md
index ecec7d908e..584eb8048d 100644
--- a/packages/plugin-markdown/CHANGELOG.md
+++ b/packages/plugin-markdown/CHANGELOG.md
@@ -1,5 +1,69 @@
# @object-ui/plugin-markdown
+## 17.5.0
+
+### Patch Changes
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [f5e1143]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [47f551b]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-markdown/package.json b/packages/plugin-markdown/package.json
index dd8c3503ac..6c3886c8cd 100644
--- a/packages/plugin-markdown/package.json
+++ b/packages/plugin-markdown/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-markdown",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Markdown rendering plugin for Object UI, powered by react-markdown",
diff --git a/packages/plugin-report/CHANGELOG.md b/packages/plugin-report/CHANGELOG.md
index 5367c1ed84..160461f0af 100644
--- a/packages/plugin-report/CHANGELOG.md
+++ b/packages/plugin-report/CHANGELOG.md
@@ -1,5 +1,277 @@
# @object-ui/plugin-report
+## 17.5.0
+
+### Patch Changes
+
+- ee26e65: Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
+
+ 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.
+
+ 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.
+
+ 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).
+
+ 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.
+
+- 326a70f: Analytics: a LOCAL select dimension on a table / pivot widget — and on a dataset-bound report — now renders its option label through the locale bundle
+
+ A dashboard table grouped by a select field showed `Domestic` on a zh-CN console while the related list on the same screen showed 国内. The value was never untranslated by accident: the server resolves that dimension's display label (ADR-0021) and hands the row over carrying the object's AUTHORED English label. The locale bundle is keyed by the option's stored VALUE (`{ns}.fieldOptions...`), so translating one needs the option LIST — and the table path deliberately loaded no object metadata at all, which is why objectui#4030 / PR #4324 fixed charts and dotted dimensions and left this half open.
+
+ Table, pivot and the dataset report block now take the one metadata read that gives the bundle something to translate against, and feed it to the SAME seam #4324 landed (`resolveDimensionFieldMeta` → `localizeFieldOptions` / `buildDimensionLabelMap` → `relabelDimensions`). No second resolution dialect: the map carries both the stored value and the authored label as keys, and the relabel is value-wise and idempotent, so a value the server already resolved lands on the same display it would have from the raw value. Cells, pivot headers on both axes, the server's marginal totals, the CSV export and a report's embedded chart all read the one map, which is what keeps a subtotal's bucket lookup meeting the header it belongs to.
+
+ Untranslated apps are unchanged by construction: with no bundle entry the display equals the authored label, no key is emitted, and the rows come back by identity. Identity keys stay untranslated — a drilled row or cell still filters records by the values the server sent, and measures still export as bare numbers.
+
+ This deliberately amends the acceptance boundary objectui#4263 landed ("a local-only table issues no metadata read"), which was ruled for label RESOLUTION before the read had a second consumer. The pins that stated it are rewritten in place, in the same change, and say so.
+
+- 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
+
+ objectstack#5473 / objectstack#5665 replaced the pivot's delimiter-joined ids
+ with `JSON.stringify`, because every delimiter that had been tried — an empty
+ string, a plain space, a control character — assumed the data would not contain
+ it, and each assumption failed on ordinary data. This closes the last place the
+ same assumption survived: the ids were JSON, but the VALUES fed into them were
+ spelled `String(row[d] ?? '∅')`, so an absent dimension value became the
+ ordinary string `"∅"` and shared a bucket with a row whose value literally is
+ that character (U+2205). One bucket, later row overwriting the earlier one — the
+ cell showed a different row's measure, the overwritten row was unreachable, and
+ drill-through followed the same wrong index into the wrong records, all without
+ an error. The trigger requires that character to appear as a dimension value, so
+ this is the assumption being removed rather than a defect users hit today.
+
+ An empty value now encodes as JSON `null`, which `JSON.stringify` renders as a
+ bare `null` that no string can spell. The normalization lives in
+ `@object-ui/core` as `pivotDimensionValue` (absent ⇒ `null`, everything else ⇒
+ its string form) rather than at each call site, because a placeholder spelled by
+ a caller is a placeholder that can collide again — which is exactly how this one
+ survived the previous fix. `pivotBucketId` accepts `Array`
+ accordingly; that is a widening, so existing callers passing `string[]` are
+ unaffected.
+
+ Both renderers' bucket keys move together, which the fix requires: a bucket id
+ and the subtotal map keyed by it are built from the same expression, so changing
+ one alone would split the headers while the subtotal map still merged, landing
+ every column subtotal under the wrong header. In `plugin-dashboard`'s
+ `DatasetWidget` that is the row bucket id, the column bucket id, the cell key,
+ and both the `rowTotalById` and `colTotalById` lookups; in `plugin-report`'s
+ `DatasetReportRenderer` the single `bucketId` helper already feeds all five.
+
+ The dashboard's column bucket id also stops being a bare string and becomes a
+ one-element tuple through the same shared encoder. It was the one id in the
+ family still built by hand, on the reasoning that a single value needs no
+ boundary — true of the boundary, false of everything else the encoder does, and
+ it is why the across axis kept carrying this collision after the row ids were
+ fixed.
+
+ No display change: these placeholders only ever entered ids, never labels. An
+ unset dimension still renders through `formatDimensionValue` exactly as before,
+ and data containing neither an absent value nor that character buckets
+ identically — the ids are opaque lookup keys, never parsed back into a value,
+ never shown, never persisted.
+
+- cb315f2: Report and dataset-preview measures follow the display locale (objectui#4575)
+
+ objectui#4566 gave `formatMeasure` / `formatDimensionValue` in `@object-ui/core`
+ an optional trailing `locale` and threaded `useDisplayLocale()` through the
+ dashboard's `DatasetWidget`. The parameter is OPTIONAL by design, so the
+ producer could land without dragging every consumer with it — which left the
+ consumers it did not reach still formatting in the MACHINE's locale. A German
+ session read a report measure as `1,234.5` directly beside a dashboard measure
+ that, after #4566, rendered `1.234,5`: one number, two spellings, on the same
+ screen. That is a sharper inconsistency than the one before #4566, when both
+ surfaces were uniformly wrong.
+
+ The remaining thirteen call sites now thread `useDisplayLocale()`:
+
+ - `plugin-report`'s `DatasetReportRenderer` (ten) — the grouped table's measure,
+ dimension and grand-total cells, the embedded single-value chart's metric, and
+ the cross-tab's across-axis header, down-axis cell, measure cell, row total,
+ column total and grand total;
+ - `app-shell`'s metadata-admin `DatasetPreview` (two) — the preview table's
+ measure and dimension cells;
+ - `app-shell`'s `DatasetDefaultInspector` (one) — the measure format-hint
+ sample, which is a preview of authored formatting and so has to be rendered
+ through the channel it previews.
+
+ **English output does not move**, and that is the discriminator against the
+ sibling fix. These sites already went through `Intl` with default grouping, so
+ the only thing that changes is WHOSE locale is used — contrast objectui#4553,
+ where `formatPercent` had never grouped at all and moving en `1235%` to
+ `1,235%` WAS the fix. Every new case pins the same value in de AND in en, so
+ at least one half must fail on any runner: before the change both render in the
+ machine's locale, which is what makes the machine locale stop being a test
+ input.
+
+ Two details worth recording:
+
+ - **The cross-tab's header labels are built inside a `useMemo`**, so the locale
+ joins that dependency array. Threading it into the call alone would leave the
+ headers frozen in whatever locale they were first built with — measured, and
+ pinned by a case that changes only the locale and asserts the header
+ re-labels. Removing just the dependency entry turns exactly that one case red
+ and leaves the other nine green.
+ - **The metadata designer's `locale` prop is deliberately not used.** It carries
+ the designer's own chrome language (`useMetadataLocale()`, which resolves to
+ exactly `en-US` or `zh-CN`), not a number-formatting locale — a German session
+ gets `en-US` from it. The preview's numbers have to match what the report and
+ dashboard render for the same dataset, which is `useDisplayLocale()`.
+
+ Both packages are `patch`: their published declarations are unchanged (measured
+ against the built `.d.ts` with `dist/` cleared between builds). The threading is
+ module-local, and the one signature that gained a parameter — the file-local
+ `bucketLabel` helper — is not exported.
+
+ A side effect of the fallback: these surfaces are now DETERMINISTIC where they
+ previously followed whatever locale the machine happened to run in.
+ `useDisplayLocale` ends at a concrete `'en'` rather than the `undefined` that
+ hands `Intl` the machine's locale.
+
+- 3f5f87c: `SchemaRenderer` states its real contract — a typed, required `schema` and a deliberate forwarding surface
+
+ `SchemaRenderer` is the renderer loop: every registered SDUI component is rendered through it. It handed `forwardRef` a props type of `{ schema: SchemaNode } & Record`, which puts `string` into `keyof Props`, so `'ref' extends keyof Props` was always true, React's `PropsWithoutRef` took its `Omit` branch, and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared prop was erased. Measured on the pre-fix source: `keyof ComponentProps` was `string` and `ComponentProps['schema']` was `any`, while the type argument went on declaring `SchemaNode`. The other half is the same defect seen from the call site — ` ` with no schema at all, ` `, and an arbitrary misspelled prop each type-checked in silence. This is objectui#4422 / PR #4438's trap in the most central component in the repo, spelled `Record` rather than `[key: string]: any`, which is why every previous sweep's grep and both shipped guards' detector reported the site as clean.
+
+ Graded **minor, not major**, on objectui#4528's reasoning: the type argument has always DECLARED `schema`; the index signature erased it from the resolved type, and restoring what the declaration documents is a fix to the published contract rather than a contract break.
+
+ **The forwarding surface is kept, deliberately.** This component forwards every prop it does not read to the component the schema names, resolved at runtime from a plugin-extensible registry — `packages/react/README.md` documents exactly that, and `@object-ui/components`' form renderer consumes the `onSubmit` it shows being forwarded. Closing that surface would state a false contract and would force every leaf plugin's props into this package. So the two halves are separated: the `forwardRef` type argument is the honest `SchemaRendererProps`, with no index signature for `PropsWithoutRef` to collapse, and the open surface is stated once in an explicit export annotation, which nothing routes through `Omit`. The published `.d.ts` shows the erasure disappearing: `ForwardRefExoticComponent, "ref"> & RefAttributes>` becomes `ForwardRefExoticComponent & RefAttributes>`.
+
+ `SchemaRendererProps.schema` is declared as `BaseSchema | string | null | undefined` — what this component actually handles. It previously declared `@object-ui/core`'s `SchemaNode` interface, which requires `type: string` and so contradicted the component's own early returns for strings and nullish, while every caller held `@object-ui/types`' wider union. The erasure hid that mismatch completely.
+
+ **One declared behaviour change.** A non-object, non-string primitive schema now renders as its own text. It previously fell through to the shallow copy `{ ...schema }`, which spreads a primitive to an empty object, lost the `type` the renderer then looked up, and surfaced the red "Unknown component type: undefined" box — an accident of the spread rather than a decision. The declared props type excludes `number` / `boolean` so no author is invited to pass them; the runtime handling is defence-in-depth for untyped callers and stored metadata. Strings, `null`, `undefined`, `0` and `false` render exactly as before, and an object naming an unregistered type still gets the error box; all four are pinned.
+
+ Latent defects the erasure had been hiding, each surfaced by the repo-wide type-check and fixed at its call site: `DashboardRenderer` cast its widget schema to `Record`, dropping the `type` every branch of `getComponentSchema` sets; `DashboardGridLayout`'s equivalent now states its return type instead of inferring a union that admitted a shape with no `type`; and `ReportViewer` handed a section's `content` array to the renderer whole, so a multi-node section rendered the unknown-component box instead of its content — arrays are mapped rather than widened into the renderer's declared input.
+
+ A repo-wide structural guard replaces the two per-package siblings' blocked direction: it judges every `forwardRef` in `packages/*/src` (219 sites) and its detector resolves `Record` and `string`-keyed mapped types in addition to literal index signatures — the spelling the previous detector went blind on. It judges the type argument only, where an index signature is an accidental eraser, and never an export annotation, where one is a stated contract.
+
+- 31ab1ac: fix(print): `window.print()` produces a usable page, and the Print buttons say what they do
+
+ The list, report and dashboard Print controls were bare `window.print()` calls with no
+ print stylesheet, so the browser printed the whole console — sidebar, top bar, chat rail,
+ toasts — with the data table clipped to a single viewport. With no label to the contrary
+ they were being accepted against "export to PDF" requirements, which they have never been.
+
+ - `@object-ui/app-shell/styles.css` gains a shared `@media print` block: it hides the shell
+ chrome, prints the active content area full-width, releases the viewport-height flex chain
+ so long tables paginate instead of clipping, repeats table headers on every sheet, and
+ neutralises dark mode (which otherwise prints white-on-white). One sheet serves list,
+ report and dashboard.
+ - The list and report Print buttons carry a tooltip and accessible name stating that they
+ open the browser's own print dialog and are not a PDF export (new `common.printDialogHint`,
+ translated in all ten locale packs).
+ - The dashboard's `export_dashboard_pdf` action no longer toasts "Preparing PDF export…" —
+ it names the print dialog it actually opens (`dashboardActions.pdfPreparing` is replaced by
+ `dashboardActions.printDialogOpening`).
+
+ No control was removed and no headless detection was added. A real print/PDF primitive
+ remains out of scope (`objectstack-ai/objectstack#1301`, closed NOT_PLANNED).
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [7ffd616]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [77d6f28]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [ebb4e0e]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [8f60d73]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [51ab34e]
+- Updated dependencies [24bb2de]
+- Updated dependencies [0ca6096]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [5cc847c]
+- Updated dependencies [e7663f2]
+- Updated dependencies [fa21254]
+- Updated dependencies [f565418]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [51ac39f]
+- Updated dependencies [5e514c4]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [36310dc]
+- Updated dependencies [52d878a]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [4270c11]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/plugin-grid@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-report/package.json b/packages/plugin-report/package.json
index 10316f63d9..bb617e05a7 100644
--- a/packages/plugin-report/package.json
+++ b/packages/plugin-report/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-report",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"main": "dist/index.umd.cjs",
"module": "dist/index.js",
diff --git a/packages/plugin-timeline/CHANGELOG.md b/packages/plugin-timeline/CHANGELOG.md
index c19bd426e0..b02d4975c4 100644
--- a/packages/plugin-timeline/CHANGELOG.md
+++ b/packages/plugin-timeline/CHANGELOG.md
@@ -1,5 +1,137 @@
# @object-ui/plugin-timeline
+## 17.5.0
+
+### Minor Changes
+
+- 01c9188: fix(plugin-timeline): dates follow the active locale instead of a hardcoded en-US
+
+ A `zh` console rendered a fully Chinese timeline widget whose axis read
+ `Aug 11` / `Sep 2026` and whose item dates read `August 11, 2026`
+ (objectui#4513). `renderer.tsx` handed `Intl` a literal `'en-US'` at four sites
+ — the hour, day and month gantt headers, and the `long` item date — so nothing
+ a user or a tenant configured could reach them.
+
+ A fifth site was the same defect spelled as an omission: the `short` item date
+ called `toLocaleDateString()` with no tag at all, which means the _machine's_
+ locale. It agreed with the other four only by the accident of an en-US runner,
+ and rendered a third locale on anyone else's machine.
+
+ All five now resolve through `useDisplayLocale()` from `@object-ui/i18n`
+ (tenant regional default → active UI language → `en`) — the one channel every
+ field, number and currency renderer already uses, converged there in
+ objectui#4468. The locale is read once in `TimelineRenderer` and threaded into
+ the two module-level date helpers, which cannot host a hook themselves.
+
+ English output is byte-identical at all five sites: `'en'` and the retired
+ `'en-US'` produce the same forms, and `generateTimeScaleHeaders` gained an
+ optional trailing `locale` parameter that defaults to `'en'`, so existing
+ three-argument callers are unaffected. The locale-free header vocabularies
+ (`Week n`, `Qn YYYY`, `YYYY`) and all non-date rendering are untouched.
+
+- 0082db8: The timeline's gantt bucket labels and its row-label default speak the session language
+
+ objectui#4513 routed every `Intl` call in the timeline renderer through `useDisplayLocale()`, so a Chinese session renders `2026年8月` on the month axis and `2026年8月11日` on item dates. Three sibling strings in the same renderer never went through `Intl` at all and stayed English on that same Chinese axis: the `week` header (`Week 1`), the `quarter` header (`Q3 2026`), and the gantt row-label column default (`Items`). The half-fixed state was the visible one — a Chinese date axis with English bucket labels beside it.
+
+ They are a translation concern rather than a locale-resolver one, and that distinction is the fix: a locale TAG formats a date, only a TRANSLATION spells a word. All three now resolve through the package's existing channel — `useTimelineTranslation` / `TIMELINE_DEFAULT_TRANSLATIONS`, the `createSafeTranslation` factory `ObjectTimeline` already uses for `timeline.bucket.*` — under three new keys carried by all ten locale packs: `timeline.scale.week`, `timeline.scale.quarter`, `timeline.gantt.rowLabel`.
+
+ The week number and the quarter/year ride the channel's own `{{hole}}` parameters rather than being concatenated, because the word order belongs to the translation: Chinese puts the year first (`2026年第3季度`), which no `Q${q} ${year}` template can produce at all. Only the row-label DEFAULT moved — an author who writes `rowLabel` still supplies their own string, and the `year` scale stays a bare `String(getFullYear())` with no vocabulary in it to translate.
+
+ English output is byte-identical to the retired literals: the `en` pack values are the same two templates the code used to interpolate by hand. `generateTimeScaleHeaders` is a pure exported function and cannot host a hook, so the translate fn is threaded in as an optional fifth parameter on the seam #4513 opened for `locale`, defaulting to the package's own defaults table — the same lookup the channel serves with no `I18nProvider` mounted. Existing three- and four-argument call sites are unaffected.
+
+ One consequence is worth stating because it looks like a bug and is not: dates and vocabulary resolve through different channels on purpose. `useDisplayLocale()` puts the tenant's regional default first (how this organization writes dates), while `t` follows the UI language (what this user reads). A tenant configured `en` whose user reads Chinese chrome therefore sees `Aug 2026` beside `第 1 周` — the same split `timeline.bucket.*` has always had.
+
+### Patch Changes
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [5cc847c]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [c911544]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/mobile@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-timeline/package.json b/packages/plugin-timeline/package.json
index ef8d156438..56f7e25414 100644
--- a/packages/plugin-timeline/package.json
+++ b/packages/plugin-timeline/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-timeline",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Timeline component plugin for Object UI",
diff --git a/packages/plugin-tree/CHANGELOG.md b/packages/plugin-tree/CHANGELOG.md
index 14013bbe6a..4deb189a0d 100644
--- a/packages/plugin-tree/CHANGELOG.md
+++ b/packages/plugin-tree/CHANGELOG.md
@@ -1,5 +1,96 @@
# @object-ui/plugin-tree
+## 17.5.0
+
+### Patch Changes
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [5cc847c]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-tree/package.json b/packages/plugin-tree/package.json
index 62dc8843ab..cda28cd5d4 100644
--- a/packages/plugin-tree/package.json
+++ b/packages/plugin-tree/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-tree",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Tree / tree-grid visualization plugin for Object UI",
diff --git a/packages/plugin-view/CHANGELOG.md b/packages/plugin-view/CHANGELOG.md
index 3e1740baa8..52ec78a9bb 100644
--- a/packages/plugin-view/CHANGELOG.md
+++ b/packages/plugin-view/CHANGELOG.md
@@ -1,5 +1,137 @@
# @object-ui/plugin-view
+## 17.5.0
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- 3f5f87c: `SchemaRenderer` states its real contract — a typed, required `schema` and a deliberate forwarding surface
+
+ `SchemaRenderer` is the renderer loop: every registered SDUI component is rendered through it. It handed `forwardRef` a props type of `{ schema: SchemaNode } & Record`, which puts `string` into `keyof Props`, so `'ref' extends keyof Props` was always true, React's `PropsWithoutRef` took its `Omit` branch, and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared prop was erased. Measured on the pre-fix source: `keyof ComponentProps` was `string` and `ComponentProps['schema']` was `any`, while the type argument went on declaring `SchemaNode`. The other half is the same defect seen from the call site — ` ` with no schema at all, ` `, and an arbitrary misspelled prop each type-checked in silence. This is objectui#4422 / PR #4438's trap in the most central component in the repo, spelled `Record` rather than `[key: string]: any`, which is why every previous sweep's grep and both shipped guards' detector reported the site as clean.
+
+ Graded **minor, not major**, on objectui#4528's reasoning: the type argument has always DECLARED `schema`; the index signature erased it from the resolved type, and restoring what the declaration documents is a fix to the published contract rather than a contract break.
+
+ **The forwarding surface is kept, deliberately.** This component forwards every prop it does not read to the component the schema names, resolved at runtime from a plugin-extensible registry — `packages/react/README.md` documents exactly that, and `@object-ui/components`' form renderer consumes the `onSubmit` it shows being forwarded. Closing that surface would state a false contract and would force every leaf plugin's props into this package. So the two halves are separated: the `forwardRef` type argument is the honest `SchemaRendererProps`, with no index signature for `PropsWithoutRef` to collapse, and the open surface is stated once in an explicit export annotation, which nothing routes through `Omit`. The published `.d.ts` shows the erasure disappearing: `ForwardRefExoticComponent, "ref"> & RefAttributes>` becomes `ForwardRefExoticComponent & RefAttributes>`.
+
+ `SchemaRendererProps.schema` is declared as `BaseSchema | string | null | undefined` — what this component actually handles. It previously declared `@object-ui/core`'s `SchemaNode` interface, which requires `type: string` and so contradicted the component's own early returns for strings and nullish, while every caller held `@object-ui/types`' wider union. The erasure hid that mismatch completely.
+
+ **One declared behaviour change.** A non-object, non-string primitive schema now renders as its own text. It previously fell through to the shallow copy `{ ...schema }`, which spreads a primitive to an empty object, lost the `type` the renderer then looked up, and surfaced the red "Unknown component type: undefined" box — an accident of the spread rather than a decision. The declared props type excludes `number` / `boolean` so no author is invited to pass them; the runtime handling is defence-in-depth for untyped callers and stored metadata. Strings, `null`, `undefined`, `0` and `false` render exactly as before, and an object naming an unregistered type still gets the error box; all four are pinned.
+
+ Latent defects the erasure had been hiding, each surfaced by the repo-wide type-check and fixed at its call site: `DashboardRenderer` cast its widget schema to `Record`, dropping the `type` every branch of `getComponentSchema` sets; `DashboardGridLayout`'s equivalent now states its return type instead of inferring a union that admitted a shape with no `type`; and `ReportViewer` handed a section's `content` array to the renderer whole, so a multi-node section rendered the unknown-component box instead of its content — arrays are mapped rather than widened into the renderer's declared input.
+
+ A repo-wide structural guard replaces the two per-package siblings' blocked direction: it judges every `forwardRef` in `packages/*/src` (219 sites) and its detector resolves `Record` and `string`-keyed mapped types in addition to literal index signatures — the spelling the previous detector went blind on. It judges the type argument only, where an index signature is an accidental eraser, and never an export annotation, where one is a stated contract.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [7ffd616]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [77d6f28]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [ebb4e0e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [51ab34e]
+- Updated dependencies [24bb2de]
+- Updated dependencies [0ca6096]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [5cc847c]
+- Updated dependencies [fa21254]
+- Updated dependencies [f565418]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [6d641c9]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [51ac39f]
+- Updated dependencies [5e514c4]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [36310dc]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [4270c11]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [c32a8a1]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [47f551b]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/plugin-grid@17.5.0
+ - @object-ui/plugin-form@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-view/package.json b/packages/plugin-view/package.json
index e8217ba256..2863057fe7 100644
--- a/packages/plugin-view/package.json
+++ b/packages/plugin-view/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-view",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Object View plugin for Object UI",
diff --git a/packages/providers/CHANGELOG.md b/packages/providers/CHANGELOG.md
index 925ac91e27..9fdefb0fbb 100644
--- a/packages/providers/CHANGELOG.md
+++ b/packages/providers/CHANGELOG.md
@@ -1,5 +1,24 @@
# @object-ui/providers — Changelog
+## 17.5.0
+
+### Patch Changes
+
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [92876f0]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [1f9b905]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [c1d939f]
+- Updated dependencies [bb68488]
+- Updated dependencies [ab04728]
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/providers/package.json b/packages/providers/package.json
index 59a457403a..6cf30e9245 100644
--- a/packages/providers/package.json
+++ b/packages/providers/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/providers",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Reusable context providers for ObjectUI applications",
diff --git a/packages/react-runtime/CHANGELOG.md b/packages/react-runtime/CHANGELOG.md
index 640eee7168..e1e71ce10f 100644
--- a/packages/react-runtime/CHANGELOG.md
+++ b/packages/react-runtime/CHANGELOG.md
@@ -1,5 +1,7 @@
# @object-ui/react-runtime
+## 17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/react-runtime/package.json b/packages/react-runtime/package.json
index 48a786aa77..9b7efae0ef 100644
--- a/packages/react-runtime/package.json
+++ b/packages/react-runtime/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/react-runtime",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"sideEffects": false,
"license": "MIT",
diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md
index 71df3fc5f3..4c06fe24ea 100644
--- a/packages/react/CHANGELOG.md
+++ b/packages/react/CHANGELOG.md
@@ -1,5 +1,465 @@
# @object-ui/react
+## 17.5.0
+
+### Minor Changes
+
+- d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
+ measured as declared-but-never-read at the branch point, and each is removed rather than
+ left as an authoring surface whose values nothing acts on.
+
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
+ view container's default `list` and keyed views by the authored bare key instead of the
+ composer's `.` identity. The live implementation — `MetadataProvider`'s, in
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
+ field was _required_, so five fixtures across three packages had been declaring an empty
+ array for a value nothing would ever look at. Role-attached grants are now a compile
+ error rather than silently ignored data. (#4288)
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
+ them — and only the provider's memo dependency list still named them. Record-level
+ loading and error state stays where it is actually expressed: each renderer's own data
+ source. (#3773)
+
+ No behaviour change, no request-count change:
+
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:')`
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
+ were permanent no-ops. The invalidations of the keys that do have readers
+ (`view::` for `getView`, `view-overrides:` for
+ `listViewOverrides`) are untouched and now pinned. (#3778)
+
+- b953a97: fix(detail): lookup field values link to the referenced record
+
+ A valued lookup on a record detail page rendered as plain text plus a copy
+ button — the referenced document's name was visible but unreachable, so users
+ copied the number and searched for it from the list page instead. Lookup cells
+ inside a related list that pointed at a third object were dead the same way.
+
+ `LookupCellRenderer` — the one cell renderer both surfaces resolve through —
+ now renders the display value as a link to the referenced record. The display
+ name resolution, the copy affordance and every non-lookup field are unchanged,
+ and a lookup with no value still renders its placeholder rather than an empty
+ link.
+
+ The URL is not assembled in the renderer. `RelatedRecordActionsContext` gains
+ an optional `recordHref` / `openRecord` pair, published by the console's
+ `RelatedRecordActionsBridge` from the SAME builder its related-list row
+ navigation already used, so there is one record-route shape rather than a
+ second one. A host that does not provide it (Studio designer, embedded
+ renderers, standalone grids) renders exactly what it rendered before.
+
+- d7f3e30: `bridgeListView` maps the five row heights the spec admits, and only those — the four dead spellings are gone
+
+ `mapDensity` carried a nine-key table: `compact`, `short`, `comfortable`,
+ `spacious`, `small`, `medium`, `large`, `tall`, `extra_tall`. `RowHeightSchema`
+ in `@objectstack/spec` admits five — `short | compact | medium | tall |
+extra_tall` — so `comfortable`, `spacious`, `small` and `large` were unreachable
+ from any spec-valid list view. The bridge's own parameter type said as much
+ (`Partial< ListView >`), and `mapDensity` widened it back to `rowHeight?: string`
+ to let them in. They survived because the fixture asserting three of them
+ compiled against nothing: the package's build tsconfig excludes tests and no
+ other `tsc` read them, so four branches of renderer-side dialect read as live
+ capability (objectui#4352, surfaced by objectui#4040 / PR #4351).
+
+ They are deleted. The parameter takes the spec type honestly, and the table is
+ now `Record< RowHeight, … >`, so a row height added upstream fails the build here
+ instead of arriving with no density. This is AGENTS.md #0.1: a lenient reading
+ for off-spec metadata is a second de-facto contract, and one strict contract
+ beats N dialects — a bad `rowHeight` gets fixed at the producer, where the schema
+ already rejects it.
+
+ **Breaking semantics, deliberately graded `minor`** (this repo never publishes
+ `major` — its major tracks `@objectstack`). Nothing narrows in the published type
+ surface: `mapDensity` is module-local and never appeared in the emitted `.d.ts`,
+ and `bridgeListView`'s declaration is unchanged. What changes is runtime output,
+ and only for input the spec already rejects: a host handing the bridge
+ `rowHeight: 'comfortable'` (or `'spacious'` / `'small'` / `'large'`) used to get
+ `density: 'comfortable'` / `'spacious'` / `'compact'` back, and now gets no
+ `density` key at all, so the renderer's own default applies. A sweep of this repo,
+ the `objectstack` example apps and the console's view metadata found zero authored
+ uses of any of the four; the legacy `densityMode` alias cannot produce one either,
+ since `DENSITY_MODE_TO_ROW_HEIGHT` is typed `Record< DensityMode, RowHeight >` and
+ folds onto `compact` / `medium` / `tall`.
+
+- a84385b: `NavigationConfig.mode` is optional — the type now says what the hook does
+
+ `@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.
+
+ 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.
+
+ The alias is now the spec's authored config verbatim, with no divergence of its own:
+
+ ```ts
+ export type NavigationConfig = SpecAuthoredInput<
+ typeof NavigationConfigSchema
+ >;
+ ```
+
+ 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.
+
+ **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.
+
+ **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.
+
+- c1d939f: One `SchemaNode`, and one label vocabulary — the union wins, and labels resolve where the locale lives
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 3f5f87c: `SchemaRenderer` states its real contract — a typed, required `schema` and a deliberate forwarding surface
+
+ `SchemaRenderer` is the renderer loop: every registered SDUI component is rendered through it. It handed `forwardRef` a props type of `{ schema: SchemaNode } & Record`, which puts `string` into `keyof Props`, so `'ref' extends keyof Props` was always true, React's `PropsWithoutRef` took its `Omit` branch, and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared prop was erased. Measured on the pre-fix source: `keyof ComponentProps` was `string` and `ComponentProps['schema']` was `any`, while the type argument went on declaring `SchemaNode`. The other half is the same defect seen from the call site — ` ` with no schema at all, ` `, and an arbitrary misspelled prop each type-checked in silence. This is objectui#4422 / PR #4438's trap in the most central component in the repo, spelled `Record` rather than `[key: string]: any`, which is why every previous sweep's grep and both shipped guards' detector reported the site as clean.
+
+ Graded **minor, not major**, on objectui#4528's reasoning: the type argument has always DECLARED `schema`; the index signature erased it from the resolved type, and restoring what the declaration documents is a fix to the published contract rather than a contract break.
+
+ **The forwarding surface is kept, deliberately.** This component forwards every prop it does not read to the component the schema names, resolved at runtime from a plugin-extensible registry — `packages/react/README.md` documents exactly that, and `@object-ui/components`' form renderer consumes the `onSubmit` it shows being forwarded. Closing that surface would state a false contract and would force every leaf plugin's props into this package. So the two halves are separated: the `forwardRef` type argument is the honest `SchemaRendererProps`, with no index signature for `PropsWithoutRef` to collapse, and the open surface is stated once in an explicit export annotation, which nothing routes through `Omit`. The published `.d.ts` shows the erasure disappearing: `ForwardRefExoticComponent, "ref"> & RefAttributes>` becomes `ForwardRefExoticComponent & RefAttributes>`.
+
+ `SchemaRendererProps.schema` is declared as `BaseSchema | string | null | undefined` — what this component actually handles. It previously declared `@object-ui/core`'s `SchemaNode` interface, which requires `type: string` and so contradicted the component's own early returns for strings and nullish, while every caller held `@object-ui/types`' wider union. The erasure hid that mismatch completely.
+
+ **One declared behaviour change.** A non-object, non-string primitive schema now renders as its own text. It previously fell through to the shallow copy `{ ...schema }`, which spreads a primitive to an empty object, lost the `type` the renderer then looked up, and surfaced the red "Unknown component type: undefined" box — an accident of the spread rather than a decision. The declared props type excludes `number` / `boolean` so no author is invited to pass them; the runtime handling is defence-in-depth for untyped callers and stored metadata. Strings, `null`, `undefined`, `0` and `false` render exactly as before, and an object naming an unregistered type still gets the error box; all four are pinned.
+
+ Latent defects the erasure had been hiding, each surfaced by the repo-wide type-check and fixed at its call site: `DashboardRenderer` cast its widget schema to `Record`, dropping the `type` every branch of `getComponentSchema` sets; `DashboardGridLayout`'s equivalent now states its return type instead of inferring a union that admitted a shape with no `type`; and `ReportViewer` handed a section's `content` array to the renderer whole, so a multi-node section rendered the unknown-component box instead of its content — arrays are mapped rather than widened into the renderer's declared input.
+
+ A repo-wide structural guard replaces the two per-package siblings' blocked direction: it judges every `forwardRef` in `packages/*/src` (219 sites) and its detector resolves `Record` and `string`-keyed mapped types in addition to literal index signatures — the spelling the previous detector went blind on. It judges the type argument only, where an index signature is an accidental eraser, and never an export annotation, where one is a stated contract.
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `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 |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+### Patch Changes
+
+- ceccdcf: Action confirm dialogs and success toasts now honour the bundle's translated
+ `confirmText` / `successMessage`, not just `label` (objectui#4265).
+
+ A TranslationBundle entry for an action carries three keys under one
+ `_actions.` node — `label`, `confirmText`, `successMessage` — and
+ `useObjectLabel()` has always exposed a resolver for each. What had drifted was
+ the call sites: `page:header` (authored record pages), `record:quick_actions`
+ and the related-list row menu resolved the button `label` only and dispatched
+ the authored `confirmText` / `successMessage` untouched. One bundle entry met
+ two fates: the button rendered the translation, the confirm dialog rendered the
+ authored English.
+
+ All action-rendering surfaces now go through one resolver,
+ `useActionTextLocalizer()` (new, exported from `@object-ui/react`), which
+ applies the existing `actionLabel` / `actionConfirm` / `actionSuccess`
+ resolvers over the three keys together. Fallback is unchanged: with no bundle
+ entry — or an entry lacking a key — the authored text renders. A bundle cannot
+ introduce a `confirmText` or `successMessage` the metadata never declared.
+
+- ee26e65: Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
+
+ 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.
+
+ 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.
+
+ 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).
+
+ 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.
+
+- f650253: `BaseSchema.ariaLabel` declares the keyed i18n vocabulary the renderer actually
+ resolves, `.disabled` accepts the predicate string it actually evaluates, and the
+ keyed shape finally has a name (objectui#4581)
+
+ Three slots on one base type had drifted from what the renderer does with them.
+ PR #4593 fixed `visible` and measured the rest; these are the rest.
+
+ `ariaLabel` was `string`, but `SchemaRenderer.tsx:111` resolves it with
+ `resolveKeyedI18nLabel`, whose input is the KEYED form
+ `{ key, defaultValue?, params? }` — a reference into a translation bundle. It is
+ now `string | KeyedI18nLabel`, and `KeyedI18nLabel` is a new exported type in
+ `@object-ui/types` rather than a fourth inline copy of one object literal: the
+ three that existed (`@object-ui/react`'s resolver, `@object-ui/layout`'s
+ `resolveLabel`, `@object-ui/app-shell`'s `t`-taking twin) were verified identical
+ in their object half first, and two of them now import the name.
+
+ The vocabulary matters more than the widening. `#4581` originally asked for
+ `string | I18nLabel`, and that spelling was withdrawn as measured-wrong: the
+ spec's `I18nLabel` is the INLINE LOCALE MAP (`string | Record`),
+ a different vocabulary resolved against a BCP-47 locale by a different function
+ of a confusingly similar name. Under it the shipped keyed fixture type-checked
+ only vacuously — as a locale map whose "locales" are named `key` and
+ `defaultValue` — the same label carrying `params` was rejected outright, and a
+ genuine `{ en: 'Owner' }` compiled while rendering an EMPTY `aria-label`. Naming
+ the keyed shape is the declaration half of the fix objectui#4167 started on the
+ naming side; `@object-ui/app-shell`'s copy keeps its inline spelling for now
+ because an open PR has a pending change to that file, and the comment there says
+ so.
+
+ `disabled` was `boolean` on a key the renderer never reads as one:
+ `SchemaRenderer.tsx:466` evaluates it through the same `evaluateCondition` as
+ `visible`, and a `disabledOn?: string` sibling exists for the same reason. It is
+ now `boolean | string`. The asymmetry with `visible` was accidental rather than
+ deliberate.
+
+ Both are widenings on authored-input-dominant properties: authors gain a
+ spelling, nothing that type-checked before stops doing so, and readers already
+ coped with `any` through `BaseSchema`'s index signature. Three test fixtures that
+ had been casting past these declarations with `as unknown as BaseSchema` state
+ their values directly now, and the declared unions are pinned invariantly so
+ neither a missing widening nor an overshoot to `any` can pass unnoticed.
+
+ Declaring the vocabulary honestly also surfaced a real one: the `toggle`
+ renderer writes `aria-label` itself instead of going through SchemaRenderer's
+ resolver, and it forwarded the raw value. Invoked directly it emitted
+ `aria-label="[object Object]"` for a keyed label — announced verbatim by a
+ screen reader. It resolves now. Through `SchemaRenderer` the bug was invisible,
+ because SchemaRenderer injects its own resolved `aria-label` afterwards; a
+ downstream type-check sweep found it, not a test.
+
+ `BaseSchema.label` and `.description` are deliberately unchanged and pinned that
+ way. They receive the spec's inline `I18nLabel` from the view bridges, which is a
+ real defect, but resolving it belongs at the spec-to-schema boundary rather than
+ in this declaration — and that work is still blocked on a design question about
+ where the display locale enters, so it is not in this release.
+
+- 8f85f8b: The spec bridge abstains on prototype-member `rowHeight` spellings instead of leaking a
+ function into `density`.
+
+ `bridgeListView`'s `mapDensity` indexed a plain object literal with an unchecked key, so
+ the lookup reached `Object.prototype`. The parameter is typed `RowHeight`, but the
+ boundary a host's stored view definition actually crosses is `SpecBridge.transformListView`,
+ whose parameter is `any` — so `rowHeight: 'toString'` came back as `Object.prototype.toString`,
+ a **function**, out of a read whose return type is three strings or nothing. `bridgeListView`
+ then writes the key under `if (density)`, and a function is truthy, so the bad value was not
+ merely returned: it was stored on a `SchemaNode` whose renderer expects
+ `'compact' | 'comfortable' | 'spacious'`. Same for `constructor`, `valueOf`,
+ `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable` and `toLocaleString`.
+
+ The lookup is now guarded with `Object.prototype.hasOwnProperty.call(...)` — the same guard
+ `@object-ui/core`'s `rowHeightToDensityMode` grew in objectui#4440, and the repo's existing
+ convention at eight other sites. Both `rowHeight` surfaces now abstain identically on every
+ off-spec **string** spelling, and objectui#4440's agreement pin covers the prototype-member
+ family instead of excluding it (objectui#4442).
+
+ Runtime-only: no public type moved, and no spec-valid `rowHeight` changes its answer.
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- f148a64: SpecBridge lifts a legacy bare `exportOptions` array to the spec's object form, so a
+ spec-authored view's declared export formats reach the grid (objectui#4585).
+
+ A spec `ListView` may spell `exportOptions` either way, and `@objectstack/spec` lifts the
+ legacy bare format array to `{ formats: [...] }` when it parses one (objectstack#8010).
+ That lift never ran on the bridge path: the bridge's input is a TypeScript type, not a
+ parsed value — there is no `parse`/`safeParse` anywhere under `spec-bridge/` — so a host
+ forwarding raw stored metadata handed the array straight through, and the bridge copied it
+ onto the `object-grid` node verbatim. ObjectGrid reads the object form and only that, and
+ `.formats` on an array is `undefined`, so the renderer's `['csv', 'json']` default won.
+
+ A view declaring `['csv', 'xlsx']` therefore rendered an export menu offering CSV and
+ JSON: the declared xlsx never appeared, an undeclared json did, and nothing said so — the
+ export button still showed, because a non-empty array is truthy. The bridge now applies
+ the spec's own transform at the assignment site, so both spellings leave it as one shape.
+
+ Deliberately narrow: this mirrors the spec's lift and nothing else. The object form passes
+ through by reference, unread and unrewritten; a view with no `exportOptions` is untouched;
+ and a `'pdf'` stored before its retirement is carried rather than filtered, because the
+ spec refuses that value at parse with a migration prescription instead of silently
+ dropping it — such a format still dies downstream in ObjectGrid's format-agnostic menu
+ filter (objectui#4535). The fix is at the producer for the same reason: a tolerant
+ `Array.isArray` fallback in the renderer would make a second de-facto contract out of one
+ spec key.
+
+ One behavior follows from reading the lift literally: `exportOptions: []` now lifts to
+ `{ formats: [] }` and the export button is hidden, where before the unreadable `[]` was
+ merely truthy and produced a menu built entirely from the `['csv', 'json']` default. A
+ view that declares zero formats now offers zero.
+
+- 47f551b: fix(react): the spec bridge abstains on a non-string `rowHeight` instead of coercing it to a density, matching core
+
+ `mapDensity` opened with a truthiness guard, so any **truthy non-string** survived it and was
+ then coerced into a lookup key — both `Object.prototype.hasOwnProperty.call` and the table index
+ run `String(...)`. `rowHeight: ['compact']`, a boxed `String('compact')` or
+ `{ toString: () => 'compact' }` therefore each selected a real density, while
+ `@object-ui/core`'s `rowHeightToDensityMode` — which opens with `typeof rowHeight !== 'string'` —
+ abstained for the same input. Two published surfaces, two answers for one input.
+
+ The bridge now opens with core's type guard. An off-spec non-string `rowHeight` renders exactly
+ like an absent one, and the producer is where it gets fixed (AGENTS.md #0.1). Behaviour for the
+ five spec row heights and for off-spec **strings** is unchanged; `''` keeps its answer by a
+ different route (a string now, refused one line later because it is not one of the five keys).
+
+ Note the direction against the previous fix in this function: that leak returned a _function_,
+ visibly wrong to everything downstream. This one returned a legitimate-looking `'compact'` that
+ nothing downstream could tell apart from an authored density.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [932cbcd]
+- Updated dependencies [734d186]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [f7c6430]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [537a0d1]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [1f9b905]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [5cc847c]
+- Updated dependencies [fa21254]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [6d641c9]
+- Updated dependencies [479cc7b]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [58bebf6]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [2459a3e]
+- Updated dependencies [2776b11]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [605b747]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [2e3b0c0]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [31ab1ac]
+- Updated dependencies [0082db8]
+- Updated dependencies [b42558a]
+- Updated dependencies [d2f6e6b]
+- Updated dependencies [ab04728]
+- Updated dependencies [85a3082]
+- Updated dependencies [06915b0]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/data-objectstack@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/react/package.json b/packages/react/package.json
index 70129ca4c1..4a3b08dc54 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/react",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "React bindings and SchemaRenderer component for Object UI",
diff --git a/packages/runner/CHANGELOG.md b/packages/runner/CHANGELOG.md
index 873ec0eff6..9cd03f4f51 100644
--- a/packages/runner/CHANGELOG.md
+++ b/packages/runner/CHANGELOG.md
@@ -1,5 +1,87 @@
# @object-ui/runner
+## 17.5.0
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [ae10a01]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [4b70d28]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [1f9b905]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [2c8ad7c]
+- Updated dependencies [fa21254]
+- Updated dependencies [b953a97]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [a84385b]
+- Updated dependencies [45e1949]
+- Updated dependencies [0b49d60]
+- Updated dependencies [bcd3e02]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [a3ae404]
+- Updated dependencies [5fac011]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [3f5f87c]
+- Updated dependencies [f5e1143]
+- Updated dependencies [f148a64]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [47f551b]
+- Updated dependencies [ab04728]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/plugin-charts@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/plugin-kanban@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/runner/package.json b/packages/runner/package.json
index c692a4734b..8d77164141 100644
--- a/packages/runner/package.json
+++ b/packages/runner/package.json
@@ -1,7 +1,7 @@
{
"name": "@object-ui/runner",
"private": false,
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "Universal Object UI Application Runner",
"type": "module",
"homepage": "https://www.objectui.org/docs/utilities/runner",
diff --git a/packages/sdui-parser/CHANGELOG.md b/packages/sdui-parser/CHANGELOG.md
index f908c561c4..af016abf94 100644
--- a/packages/sdui-parser/CHANGELOG.md
+++ b/packages/sdui-parser/CHANGELOG.md
@@ -1,5 +1,7 @@
# @object-ui/sdui-parser
+## 17.5.0
+
## 17.4.0
## 17.3.0
diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json
index f6f51f7259..a2334ac7be 100644
--- a/packages/sdui-parser/package.json
+++ b/packages/sdui-parser/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/sdui-parser",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"sideEffects": false,
"license": "MIT",
diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md
index f2c3bf7d45..cadab32f61 100644
--- a/packages/types/CHANGELOG.md
+++ b/packages/types/CHANGELOG.md
@@ -1,5 +1,459 @@
# @object-ui/types
+## 17.5.0
+
+### Minor Changes
+
+- f650253: `BaseSchema.ariaLabel` declares the keyed i18n vocabulary the renderer actually
+ resolves, `.disabled` accepts the predicate string it actually evaluates, and the
+ keyed shape finally has a name (objectui#4581)
+
+ Three slots on one base type had drifted from what the renderer does with them.
+ PR #4593 fixed `visible` and measured the rest; these are the rest.
+
+ `ariaLabel` was `string`, but `SchemaRenderer.tsx:111` resolves it with
+ `resolveKeyedI18nLabel`, whose input is the KEYED form
+ `{ key, defaultValue?, params? }` — a reference into a translation bundle. It is
+ now `string | KeyedI18nLabel`, and `KeyedI18nLabel` is a new exported type in
+ `@object-ui/types` rather than a fourth inline copy of one object literal: the
+ three that existed (`@object-ui/react`'s resolver, `@object-ui/layout`'s
+ `resolveLabel`, `@object-ui/app-shell`'s `t`-taking twin) were verified identical
+ in their object half first, and two of them now import the name.
+
+ The vocabulary matters more than the widening. `#4581` originally asked for
+ `string | I18nLabel`, and that spelling was withdrawn as measured-wrong: the
+ spec's `I18nLabel` is the INLINE LOCALE MAP (`string | Record`),
+ a different vocabulary resolved against a BCP-47 locale by a different function
+ of a confusingly similar name. Under it the shipped keyed fixture type-checked
+ only vacuously — as a locale map whose "locales" are named `key` and
+ `defaultValue` — the same label carrying `params` was rejected outright, and a
+ genuine `{ en: 'Owner' }` compiled while rendering an EMPTY `aria-label`. Naming
+ the keyed shape is the declaration half of the fix objectui#4167 started on the
+ naming side; `@object-ui/app-shell`'s copy keeps its inline spelling for now
+ because an open PR has a pending change to that file, and the comment there says
+ so.
+
+ `disabled` was `boolean` on a key the renderer never reads as one:
+ `SchemaRenderer.tsx:466` evaluates it through the same `evaluateCondition` as
+ `visible`, and a `disabledOn?: string` sibling exists for the same reason. It is
+ now `boolean | string`. The asymmetry with `visible` was accidental rather than
+ deliberate.
+
+ Both are widenings on authored-input-dominant properties: authors gain a
+ spelling, nothing that type-checked before stops doing so, and readers already
+ coped with `any` through `BaseSchema`'s index signature. Three test fixtures that
+ had been casting past these declarations with `as unknown as BaseSchema` state
+ their values directly now, and the declared unions are pinned invariantly so
+ neither a missing widening nor an overshoot to `any` can pass unnoticed.
+
+ Declaring the vocabulary honestly also surfaced a real one: the `toggle`
+ renderer writes `aria-label` itself instead of going through SchemaRenderer's
+ resolver, and it forwarded the raw value. Invoked directly it emitted
+ `aria-label="[object Object]"` for a keyed label — announced verbatim by a
+ screen reader. It resolves now. Through `SchemaRenderer` the bug was invisible,
+ because SchemaRenderer injects its own resolved `aria-label` afterwards; a
+ downstream type-check sweep found it, not a test.
+
+ `BaseSchema.label` and `.description` are deliberately unchanged and pinned that
+ way. They receive the spec's inline `I18nLabel` from the view bridges, which is a
+ real defect, but resolving it belongs at the spec-to-schema boundary rather than
+ in this declaration — and that work is still blocked on a design question about
+ where the display locale enters, so it is not in this release.
+
+- 3d9769a: `BaseSchema.visible` accepts the predicate string the renderer evaluates
+
+ `visible` was declared `boolean`, but the renderer never read it as one: it
+ evaluates the key — `SchemaRenderer.tsx:382` calls
+ `evaluator.evaluateCondition(schema.visible)`, and `evaluateCondition` is
+ declared `(condition: string | boolean | undefined, context?) => boolean`. The
+ sibling keys `visibleWhen` and the deprecated `visibleOn` are `string` for that
+ same reason; `visible` simply under-reported a capability it already had, and
+ fixtures exercising it had to cast past the declaration.
+
+ Now `boolean | string` — exactly what the evaluator accepts, no wider.
+
+ Graded **minor** by position analysis of the published `.d.ts`: the only diff is
+ `visible?: boolean` becoming `visible?: boolean | string` on an
+ authored-input-dominant property, with no union member removed and no other
+ declaration touched — the same shape as #4586/#4591. Authors gain a spelling;
+ nothing that previously type-checked stops doing so. Code that READS
+ `schema.visible` was already coping with `any` through `BaseSchema`'s index
+ signature.
+
+- d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
+ measured as declared-but-never-read at the branch point, and each is removed rather than
+ left as an authoring surface whose values nothing acts on.
+
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
+ view container's default `list` and keyed views by the authored bare key instead of the
+ composer's `.` identity. The live implementation — `MetadataProvider`'s, in
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
+ field was _required_, so five fixtures across three packages had been declaring an empty
+ array for a value nothing would ever look at. Role-attached grants are now a compile
+ error rather than silently ignored data. (#4288)
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
+ them — and only the provider's memo dependency list still named them. Record-level
+ loading and error state stays where it is actually expressed: each renderer's own data
+ source. (#3773)
+
+ No behaviour change, no request-count change:
+
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:')`
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
+ were permanent no-ops. The invalidations of the keys that do have readers
+ (`view::` for `getView`, `view-overrides:` for
+ `listViewOverrides`) are untouched and now pinned. (#3778)
+
+- 2a40f69: Retire two post-retirement dead surfaces (#4364, #4368). Both were measured at this
+ branch point rather than taken from their cards, and one card's premise only half held.
+
+ Breaking for anyone who typed against the removed declaration, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/types` and `@object-ui/permissions` no longer export
+ `ObjectLevelPermission`. It declared a second, parallel home for object-scoped grants
+ (`{ object, actions, effect?, conditions? }`) that nothing constructed, accepted or
+ read once `RoleDefinition.permissions` was retired (#4288) — its only remaining
+ referents were its own definition and the two barrel lines. The wired home is
+ `ObjectPermissionConfig.roles`, whose inner grant shape is declared inline; that is
+ what the evaluator reads, and it is unchanged. `ObjectPermissionConfig`'s doc comment
+ now records the retirement so the surface is not re-declared. (#4364)
+
+ `PermissionCondition` was proposed for retirement on the same card and is **kept**: its
+ premise ("only referent is `ObjectLevelPermission.conditions`") did not hold at this
+ branch point. `evaluateCondition` in `@object-ui/permissions` takes it as a parameter
+ type and implements all eleven of its operators under a 26-case suite. `PermissionEffect`
+ is likewise untouched — `FieldLevelPermission.effect` still reads it.
+
+ No behaviour change, no public surface change:
+
+ - `@object-ui/console` drops `src/utils/metadataConverters.ts` and
+ `src/services/MetadataService.ts`. Both were console-local duplicates of live
+ `@object-ui/app-shell` modules and lost their last importer when the bespoke
+ object-detail widgets were retired (#4365). Both had already drifted behind the live
+ copies they duplicate — the console converter's `referenceTo` chain never read the
+ server's `reference` key, and the console service predates the view cache-invalidation
+ seam (#4373) — which is precisely the imitation trap the card recorded: an author
+ grepping for "the converter" could land on the unexercised copy. The app-shell copies
+ and their tests are untouched. (#4368)
+
+- bec3e14: The `DataSource` contract carries `deleteView`'s per-home outcomes (#4564)
+
+ #4479 / PR #4562 widened the ObjectStack adapter's `deleteView` to return
+ `DeleteViewResult { deleted, draft?, published? }`, so a caller could finally tell a
+ partial delete ("draft gone, published overlay left") from a complete one. The shared
+ interface did not follow: `DataSource.deleteView?` still declared the narrow
+ `Promise<{ deleted: boolean }>`.
+
+ Nothing failed to compile, and that is exactly what made the gap invisible — a wider
+ return is assignable to a narrower declaration, so the adapter satisfied the interface
+ while every consumer reaching it **through** `DataSource` was handed a type with the
+ per-home outcomes already discarded. The one real call site today (app-shell's
+ `ObjectView` delete handler) awaits the call and reads nothing off the receipt, so the
+ loss was latent rather than broken.
+
+ `DeleteViewResult` and `ViewHomeDeleteOutcome` now live in `@object-ui/types`, beside
+ the `DataSource` interface that returns them, and `deleteView?`'s declared return is
+ `Promise`. The direction was forced: the dependency runs
+ `@object-ui/data-objectstack` to `@object-ui/types` and never the other way, so the
+ shapes could not be imported downward — moving them was the alternative to re-declaring
+ a structural twin in `types`, which the one-resolver rule rejects because a copy is
+ mutually assignable with the original for exactly as long as it takes to drift.
+
+ `@object-ui/data-objectstack` re-exports both names unchanged, so every importer PR
+ #4562 left pointing at it keeps compiling — and now resolves to the same declaration the
+ shared contract speaks rather than a look-alike. A repo-wide census before the move
+ found zero importers of either name outside the declaring file itself, PR #4562's own
+ suite included, so the re-export is insurance rather than a load-bearing shim.
+
+ `deleteView` stays **optional** on the interface and keeps both parameters; the growth is
+ to the return type only, and `deleted` is untouched, so a consumer reading only `deleted`
+ needs no edit.
+
+ Grading, per this repository's version-alignment convention (the major tracks
+ `@objectstack`, never an API-break count):
+
+ - `@object-ui/types` — **minor**: entry-reachable growth. Two new exported interfaces
+ plus a widened method return on `DataSource`, all reachable from the package entry.
+ - `@object-ui/data-objectstack` — **minor**, measured rather than assumed. Its emitted
+ `dist/index.d.ts` is **not** byte-identical after the swap: the two `interface` blocks
+ leave the file and are replaced by a re-export from `@object-ui/types` (121.61 KB to
+ 120.25 KB). Both names remain in the public export list, so no importer breaks, but the
+ declaration genuinely moved and the emitted types now depend on `@object-ui/types` for
+ it — that is a minor, not a patch.
+
+- 1f9b905: `exportOptions` is the spec's object form: `streaming` is declared, `'pdf'` is retired, and the alignment comment is finally true
+
+ `ObjectGridSchema.exportOptions` carried four keys under a comment claiming alignment with `@objectstack/spec`'s `ListViewSchema.exportOptions`. The comment was false in both directions. The spec declared a bare format ARRAY, not an object, so no authored document could satisfy both spellings at once; and `ObjectGrid` read a fifth key — `streaming`, the opt-out that forces the client-side export path — which appeared in no declaration anywhere, reachable only through an `as any` cast in the renderer. An author had no way to discover the key except by reading the renderer's source, and no schema would have refused it or honoured it.
+
+ objectstack#8010 closed that upstream by declaring `ListViewExportOptionsSchema` with exactly the five keys this renderer reads. This change lands the objectui half of the reconciliation:
+
+ - The five keys are now one exported type, `ListViewExportOptions` — `formats`, `maxRecords`, `includeHeaders`, `fileNamePrefix`, `streaming` — shared by `ObjectGridSchema` and by a saved `NamedListView`, so the two authoring surfaces cannot grow apart. The comment above it names the spec symbol and version it mirrors, which makes it checkable rather than reassuring.
+ - `streaming` is declared, and the renderer's `as any` casts are gone. Removing them against the old four-key type produced two `TS2339: Property 'streaming' does not exist` errors — that red is what the declaration fixes.
+ - `'pdf'` is retired from the local format union, published as `ListViewExportFormat`. PDF export was declined platform-side (objectstack#1301 NOT_PLANNED) and the value left the spec's format enum in `@objectstack/spec` 17.0.0, where authoring it is now a parse-time refusal carrying `os migrate meta --from 16`. No ObjectUI path has ever produced a PDF: a declared `'pdf'` reached the user only as a browser console line.
+
+ Runtime behavior of the export menu is unchanged. The filter that drops undeliverable formats is format-agnostic — it keeps what the active path can deliver — so it still hides `xlsx` when no server stream is available, and it still hides a legacy `'pdf'` that pre-17 stored metadata carries until the migration rewrites it. There was no `'pdf'`-specific branch to delete.
+
+ Two guards keep the contract from re-opening. On the type side, a compile-time assertion pins the interface's key set to exactly the spec's five, so a sixth key fails the build. On the renderer side, a source scan collects every property `ObjectGrid` reads off `exportOptions` — through the alias it binds, and through any cast, since a cast is how `streaming` stayed invisible — and fails if the renderer reads anything the type does not declare.
+
+ `@object-ui/types` is a minor: `ListViewExportFormat` and `ListViewExportOptions` are new exports, `streaming` is a new optional key, and `formats` no longer admits `'pdf'`. Anything still writing that value was authoring metadata the platform now refuses at publish.
+
+- 38ab505: Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888).
+
+ ## The retirement
+
+ `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.
+
+ 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.
+
+ - `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.
+ - `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.
+ - `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.
+ - `@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.
+
+ `@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.
+
+ ## The dependency move
+
+ 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.
+
+ 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.
+
+ ## Breaking, in FROM → TO form
+
+ - **`@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.
+ - **`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.
+ - **`@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.
+ - **`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.
+ - **`@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.
+
+- c1d939f: One `SchemaNode`, and one label vocabulary — the union wins, and labels resolve where the locale lives
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `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 |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+- ab04728: `ViewNavigationConfig` IS the spec's navigation config — the second spelling stops requiring `mode` (objectui#4588)
+
+ `@object-ui/types` published **two** types for one spec object, and they disagreed
+ about whether `mode` may be omitted. `index.ts` re-exports the spec's
+ `NavigationConfig` unchanged, while `objectql.ts` hand-declared a
+ `ViewNavigationConfig` covering the same six keys with `mode` **required** — under
+ a doc comment that itself claimed `@default 'page'`.
+
+ The spec never asked for that. `@objectstack/spec` declares
+ `mode: NavigationModeSchema.default('page')` in `NavigationConfigSchema`, and a
+ `.default()` lands on the **authoring** side as `| undefined`, which is why the
+ spec publishes its own type as the schema's `z.input`. So
+ `navigation: { view: 'summary_view' }` is legal authored metadata that lets the
+ mode default — and the hand copy refused it, at the three schema interfaces that
+ spell `navigation?: ViewNavigationConfig` (`ObjectGridSchema`, `ObjectViewSchema`,
+ `NamedListView`). Authoring one meant inventing a `mode` the renderer was going to
+ default anyway, or writing an assertion.
+
+ `ViewNavigationConfig` is now that spec type, per this file's own standing rule —
+ "Never Redefine Types. ALWAYS import them." Measured against the published spec
+ build, the hand copy had drifted on `mode` and nothing else: the other five keys
+ carried the spec's exact value domains. The per-key documentation now lives with
+ the schema in the spec instead of being restated here, so the `'page'` default no
+ longer has a third place to fall out of sync.
+
+ **No runtime behaviour changes.** A census of every `.mode` read in the repo found
+ all of them to be `=== 'x'` comparisons or `navigation?.mode ?? 'page'` — no reader
+ of this alias reads `mode` unguarded, so nothing observes the difference at run
+ time. This is objectui#4550 / PR objectui#4586 one package over: that one collapsed
+ `@object-ui/react`'s `NavigationConfig` to the same spec input, and this is the
+ remaining half.
+
+ Graded `minor` on the published-position analysis: in the built `.d.ts`
+ `ViewNavigationConfig` occurs **only in input positions** — the three `navigation?:`
+ properties of authored schema interfaces — and in **no** return type, since this
+ package publishes no function that hands one back. For consumers the change is
+ therefore purely permissive: everything that compiled still compiles, and
+ spec-shaped configs that previously needed an invented `mode` now compile without
+ one. That gained input shape is a capability rather than an internal repair, which
+ is more than `patch` describes. The reader-side narrowing (`mode` is now
+ `| undefined`) is real but secondary, and in-repo it has no affected reader.
+
+### Patch Changes
+
+- 92876f0: Doc comments no longer cite `@objectstack/spec` symbols the pinned spec has retired
+
+ Eight exported declarations carried a doc comment claiming alignment with a
+ `@objectstack/spec` symbol that `17.0.0-rc.6` does not export — four locale
+ formatting shapes in `@object-ui/i18n` (`SpecPluralRule`, `SpecDateFormat`,
+ `SpecNumberFormat`, `SpecLocaleConfig`) and four activity-feed shapes in
+ `@object-ui/types` (`FieldChangeEntry`, `Mention`, `Reaction`,
+ `RecordSubscription`). A citation that points at nothing is worse than a stale
+ one: the next reader cannot tell whether the protocol retired the symbol,
+ renamed it, or never had it.
+
+ Measuring all eight against the published registry answered that question, and
+ the answer was not "these names never existed". Every one was a real export the
+ protocol retired on purpose, and every local key set was faithful to the schema
+ it named. The feed four left `@objectstack/spec/data` in the `16.0.0` major,
+ when the feed surface was replaced by the data API over `sys_comment` /
+ `sys_activity`. The i18n four left `@objectstack/spec/ui` in `17.0.0-rc.6`
+ itself — they were still present in `rc.5` — retired under ADR-0049
+ enforce-or-remove because no authorable shape carried them and nothing ever
+ parsed them.
+
+ Each comment now records that provenance, including the version the symbol left
+ and what (if anything) replaced it, so the shapes read as declarations these
+ packages own rather than as a view onto a protocol type. Type shapes, runtime
+ behaviour and exports are unchanged — the published `.d.ts` files differ only in
+ comment text, which is why this is graded `patch`.
+
+- 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
+
+ `@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.
+
+ 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.
+
+ 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.
+
+ 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.
+
+- 7e4f0e5: fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032)
+
+ A `type: 'metric'` dashboard widget rendered raw English while every other widget
+ type on the same dashboard rendered the translation, and dashboard filter chips
+ rendered `[object Object]` or the raw stored value. Both come from the same
+ cause: authored labels reaching a render site that could not read the
+ vocabulary `@objectstack/spec` actually admits.
+
+ - **KPI cards rejoin the widget translation channel.** The self-contained
+ `metric` branch built its own label from the raw `widget.title`, so the
+ `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already
+ resolved was computed and thrown away. It now reads that channel like every
+ other widget header.
+ - **The three private `resolveLabel` copies** (`DashboardRenderer`,
+ `MetricWidget`, `MetricCard`) are gone. Each read the retired
+ `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so
+ handed the inline per-locale map the spec admits today they returned nothing —
+ a KPI card with a map title rendered the literal string `metric`. All three
+ now use `pickLocalized`, the resolver already used for this vocabulary
+ elsewhere in the package.
+ - **Dashboard filter labels and static option labels resolve per locale.**
+ `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar
+ resolves before rendering (fixing `[object Object]: All` in the trigger, and
+ in `aria-label` / `placeholder`), and the `def.label || def.name` gate now
+ tests the RESOLVED string — an object is always truthy, so it never reached
+ the fallback before.
+ - **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a
+ map label to the raw stored value in every locale, English included, so
+ `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`.
+ The pair shape is still normalized; the label vocabulary is preserved for the
+ render side to resolve.
+ - **`DashboardComponentSchema.globalFilters` is bound to the spec's
+ `GlobalFilter`** instead of restated by hand. The restatement was both too
+ narrow (`label?: string`, which is what made these read sites invisible to
+ `tsc`) and too wide (it declared a bare-string option shorthand the spec
+ rejects at publish).
+
+ Plain-string labels are unaffected and render byte-identically.
+
## 17.4.0
### Minor Changes
diff --git a/packages/types/package.json b/packages/types/package.json
index ebf2a9058a..995ad699eb 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/types",
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "Pure TypeScript type definitions for Object UI - The Protocol Layer",
"type": "module",
"sideEffects": false,
diff --git a/packages/vscode-extension/CHANGELOG.md b/packages/vscode-extension/CHANGELOG.md
index d0100c86cd..a33e27d173 100644
--- a/packages/vscode-extension/CHANGELOG.md
+++ b/packages/vscode-extension/CHANGELOG.md
@@ -1,5 +1,41 @@
# Changelog
+## 17.5.0
+
+### Patch Changes
+
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [f650253]
+- Updated dependencies [3d9769a]
+- Updated dependencies [3fc2971]
+- Updated dependencies [aca27fa]
+- Updated dependencies [dde7283]
+- Updated dependencies [92876f0]
+- Updated dependencies [f279deb]
+- Updated dependencies [eb7f586]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [bec3e14]
+- Updated dependencies [613b167]
+- Updated dependencies [1f9b905]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [92250d6]
+- Updated dependencies [c1d939f]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [ab04728]
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json
index bb07ebed0d..410cb5c565 100644
--- a/packages/vscode-extension/package.json
+++ b/packages/vscode-extension/package.json
@@ -2,7 +2,7 @@
"name": "object-ui",
"displayName": "Object UI",
"description": "VSCode extension for Object UI - Schema-driven UI development with IntelliSense, validation, and live preview",
- "version": "17.4.0",
+ "version": "17.5.0",
"publisher": "objectui",
"private": true,
"icon": "icon.svg",