@object-ui/react 17.5.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +334 -0
  2. package/README.md +9 -8
  3. package/dist/LazyPluginLoader.d.ts +14 -7
  4. package/dist/LazyPluginLoader.js +14 -7
  5. package/dist/SchemaRenderer.js +434 -22
  6. package/dist/context/HostNavigationContext.d.ts +82 -0
  7. package/dist/context/HostNavigationContext.js +65 -0
  8. package/dist/context/RelatedRecordActionsContext.d.ts +15 -0
  9. package/dist/context/index.d.ts +13 -12
  10. package/dist/context/index.js +13 -12
  11. package/dist/element-data-source/ElementDataSourceGate.d.ts +75 -2
  12. package/dist/element-data-source/ElementDataSourceGate.js +81 -2
  13. package/dist/hooks/index.d.ts +35 -35
  14. package/dist/hooks/index.js +35 -35
  15. package/dist/hooks/useActionEngine.js +1 -1
  16. package/dist/hooks/useCapabilityGate.js +6 -5
  17. package/dist/hooks/useClientNotifications.js +2 -2
  18. package/dist/hooks/useCrudShortcuts.js +1 -1
  19. package/dist/hooks/useDatasetDimensionLabels.js +1 -1
  20. package/dist/hooks/useDiscovery.js +1 -1
  21. package/dist/hooks/useElementDataSource.js +5 -2
  22. package/dist/hooks/useExpression.d.ts +24 -3
  23. package/dist/hooks/useExpression.js +56 -4
  24. package/dist/hooks/useNavigationOverlay.d.ts +1 -1
  25. package/dist/hooks/usePageAssignment.js +1 -1
  26. package/dist/hooks/usePageTransition.js +1 -1
  27. package/dist/hooks/usePageVariableActionBridge.js +2 -2
  28. package/dist/hooks/useViewData.js +1 -1
  29. package/dist/hooks/useViewTransition.js +1 -1
  30. package/dist/index.d.ts +11 -11
  31. package/dist/index.js +10 -10
  32. package/dist/schema-input.d.ts +16 -5
  33. package/dist/schema-input.js +15 -4
  34. package/dist/spec-bridge/SpecBridge.d.ts +1 -1
  35. package/dist/spec-bridge/SpecBridge.js +2 -2
  36. package/dist/spec-bridge/bridges/form-view.d.ts +99 -23
  37. package/dist/spec-bridge/bridges/form-view.js +51 -7
  38. package/dist/spec-bridge/bridges/list-view.d.ts +1 -1
  39. package/dist/spec-bridge/bridges/list-view.js +37 -10
  40. package/dist/spec-bridge/index.d.ts +4 -4
  41. package/dist/spec-bridge/index.js +3 -3
  42. package/dist/utils/error-message.d.ts +59 -0
  43. package/dist/utils/error-message.js +116 -1
  44. package/dist/utils/unevaluatedExpression.d.ts +69 -0
  45. package/dist/utils/unevaluatedExpression.js +194 -0
  46. package/dist/utils/visibilityDiagnostic.d.ts +119 -0
  47. package/dist/utils/visibilityDiagnostic.js +284 -0
  48. package/package.json +7 -7
@@ -87,6 +87,25 @@ export { toPredicateInput } from '@object-ui/core';
87
87
  * and the four generic action renderers carried the same root-only binding
88
88
  * until objectui#4075.
89
89
  *
90
+ * ## The canon is `record.*` (objectui#5330, ruled 2026-08-20)
91
+ *
92
+ * All three spellings are bound here, and that is unchanged — but they are NOT
93
+ * peers. `record.*` is the CONTRACT; the bare shorthand and `data.*` are
94
+ * tolerances in a deprecation window, kept because stored metadata carries
95
+ * them, reported once by `warnNonCanonicalRowSpelling` from `useCondition`
96
+ * below, and removable only after a stored-metadata survey sizes the window
97
+ * (⛔ no removal before the survey — part of the same ruling).
98
+ *
99
+ * The canon states the SERVER's accept set, which is narrower than this bag:
100
+ * measured on `@objectstack/formula@17.1.0`, `buildScope({ record })` mounts
101
+ * exactly `['record']`, so of the three spellings this helper binds, only
102
+ * `record.*` evaluates server-side — a bare field faults `Unknown variable:
103
+ * status` and `data.*` faults `Unknown variable: data`. This three-way bag has
104
+ * no server counterpart at all, which is exactly why the warning belongs on
105
+ * this side. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
106
+ * full measurement and the layer scoping (`data` stays canonical one layer
107
+ * over, in a metadata-editing form — ADR-0089 D3).
108
+ *
90
109
  * `record` and `data` are written AFTER the spread deliberately: a row that
91
110
  * happens to carry a field literally named `record` or `data` must not shadow
92
111
  * the root every predicate is written against. Same precedence as
@@ -140,9 +159,11 @@ export declare function useCondition(condition: string | boolean | undefined | {
140
159
  * routes to `@object-ui/core`'s `evalRowPredicate`: a bare string is CEL (the
141
160
  * spec contract for `ActionSchema.visible`), a `{ dialect: 'cel', source }`
142
161
  * envelope is always CEL, and only a legacy-dialect string falls back to the
143
- * old engine (with a deprecation warning). The row is bound as `record.*` and
144
- * bare fields; the ambient predicate scope (`features` / `user` / …) is merged
145
- * alongside so deployment-level gates keep resolving.
162
+ * old engine (with a deprecation warning). The row is bound as `record.*` — the
163
+ * CANON (objectui#5330) — and, for stored metadata only, as bare fields and
164
+ * `data.*`, both of which now warn once; the ambient predicate scope
165
+ * (`features` / `user` / …) is merged alongside so deployment-level gates keep
166
+ * resolving.
146
167
  *
147
168
  * @param pred The raw predicate: `boolean` (returned as-is), a CEL string,
148
169
  * an `{ dialect, source }` envelope, or `null`/`undefined`/`''`.
@@ -6,7 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { createContext, createElement, useContext, useMemo } from 'react';
9
- import { ExpressionEvaluator, evalRowPredicate } from '@object-ui/core';
9
+ import { ExpressionEvaluator, evalRowPredicate, isLegacyDialectSource, warnNonCanonicalRowSpelling, } from '@object-ui/core';
10
10
  /**
11
11
  * Global predicate scope — populated by host shells (e.g. app-shell's
12
12
  * `ExpressionProvider`) so renderers like action buttons can resolve
@@ -101,6 +101,25 @@ export { toPredicateInput } from '@object-ui/core';
101
101
  * and the four generic action renderers carried the same root-only binding
102
102
  * until objectui#4075.
103
103
  *
104
+ * ## The canon is `record.*` (objectui#5330, ruled 2026-08-20)
105
+ *
106
+ * All three spellings are bound here, and that is unchanged — but they are NOT
107
+ * peers. `record.*` is the CONTRACT; the bare shorthand and `data.*` are
108
+ * tolerances in a deprecation window, kept because stored metadata carries
109
+ * them, reported once by `warnNonCanonicalRowSpelling` from `useCondition`
110
+ * below, and removable only after a stored-metadata survey sizes the window
111
+ * (⛔ no removal before the survey — part of the same ruling).
112
+ *
113
+ * The canon states the SERVER's accept set, which is narrower than this bag:
114
+ * measured on `@objectstack/formula@17.1.0`, `buildScope({ record })` mounts
115
+ * exactly `['record']`, so of the three spellings this helper binds, only
116
+ * `record.*` evaluates server-side — a bare field faults `Unknown variable:
117
+ * status` and `data.*` faults `Unknown variable: data`. This three-way bag has
118
+ * no server counterpart at all, which is exactly why the warning belongs on
119
+ * this side. `@object-ui/core`'s `evaluator/rowPredicateCanon.ts` carries the
120
+ * full measurement and the layer scoping (`data` stays canonical one layer
121
+ * over, in a metadata-editing form — ADR-0089 D3).
122
+ *
104
123
  * `record` and `data` are written AFTER the spread deliberately: a row that
105
124
  * happens to carry a field literally named `record` or `data` must not shadow
106
125
  * the root every predicate is written against. Same precedence as
@@ -169,6 +188,37 @@ export function useCondition(condition, context = {}, options) {
169
188
  const scope = usePredicateScope();
170
189
  // We evaluate directly without caching the evaluator to avoid issues with context changes
171
190
  return useMemo(() => {
191
+ // objectui#5330 Phase 1 — report a deprecated row-predicate spelling on
192
+ // the `useCondition` tier. This is the BINDING half's evaluation entry:
193
+ // `usePredicateRecordContext` sees the row but never the predicate text,
194
+ // so it structurally cannot detect a spelling, and this is the first
195
+ // point where the two meet.
196
+ //
197
+ // Two guards, both of which can only remove a report:
198
+ // - the bag must carry the `usePredicateRecordContext` signature, `data`
199
+ // and `record` being the SAME object by identity. A host scope that
200
+ // merely happens to carry a `data` key cannot satisfy that, so a
201
+ // non-row `useCondition` call is never judged as a row predicate.
202
+ // - the source must be CEL. In this tier's legacy `${…}` dialect
203
+ // (`'${data.status === "active"}'` — this hook's own doc example)
204
+ // `data.*` is the NORMAL spelling, and reporting it would be a false
205
+ // positive on every legacy predicate.
206
+ const bag = context;
207
+ const boundRow = bag.record != null && typeof bag.record === 'object' && bag.record === bag.data
208
+ ? bag.record
209
+ : undefined;
210
+ if (boundRow !== undefined) {
211
+ const celSource = typeof condition === 'string'
212
+ ? isLegacyDialectSource(condition)
213
+ ? undefined
214
+ : condition
215
+ : condition && typeof condition === 'object' && condition.dialect === 'cel'
216
+ ? condition.source
217
+ : undefined;
218
+ if (typeof celSource === 'string') {
219
+ warnNonCanonicalRowSpelling(celSource, boundRow, true, options?.label);
220
+ }
221
+ }
172
222
  const evaluator = new ExpressionEvaluator({ ...scope, ...context });
173
223
  if (options?.throwOnError) {
174
224
  // Fail-closed: a predicate that can't be evaluated hides/disables
@@ -206,9 +256,11 @@ export function useCondition(condition, context = {}, options) {
206
256
  * routes to `@object-ui/core`'s `evalRowPredicate`: a bare string is CEL (the
207
257
  * spec contract for `ActionSchema.visible`), a `{ dialect: 'cel', source }`
208
258
  * envelope is always CEL, and only a legacy-dialect string falls back to the
209
- * old engine (with a deprecation warning). The row is bound as `record.*` and
210
- * bare fields; the ambient predicate scope (`features` / `user` / …) is merged
211
- * alongside so deployment-level gates keep resolving.
259
+ * old engine (with a deprecation warning). The row is bound as `record.*` — the
260
+ * CANON (objectui#5330) — and, for stored metadata only, as bare fields and
261
+ * `data.*`, both of which now warn once; the ambient predicate scope
262
+ * (`features` / `user` / …) is merged alongside so deployment-level gates keep
263
+ * resolving.
212
264
  *
213
265
  * @param pred The raw predicate: `boolean` (returned as-is), a CEL string,
214
266
  * an `{ dialect, source }` envelope, or `null`/`undefined`/`''`.
@@ -6,7 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import type { NavigationConfigSchema, NavigationMode as SpecNavigationMode } from '@objectstack/spec/ui';
9
- import type { SpecAuthoredInput } from '../spec-input';
9
+ import type { SpecAuthoredInput } from '../spec-input.js';
10
10
  /**
11
11
  * The spec's `NavigationConfigSchema`, authoring side — by reference, with no
12
12
  * divergence of its own.
@@ -24,7 +24,7 @@
24
24
  * when no record Page is authored.
25
25
  */
26
26
  import { useEffect, useMemo, useState } from 'react';
27
- import { useMetadata } from '../context/AppShellContext';
27
+ import { useMetadata } from '../context/AppShellContext.js';
28
28
  function matchesAssignment(_page, _opts) {
29
29
  // Placeholder — every page matches until rules-based assignment lands.
30
30
  return true;
@@ -6,7 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { useMemo } from 'react';
9
- import { useReducedMotion } from './useReducedMotion';
9
+ import { useReducedMotion } from './useReducedMotion.js';
10
10
  // ---------------------------------------------------------------------------
11
11
  // Constants
12
12
  // ---------------------------------------------------------------------------
@@ -6,8 +6,8 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import * as React from 'react';
9
- import { usePageVariables } from './usePageVariables';
10
- import { useAction } from '../context';
9
+ import { usePageVariables } from './usePageVariables.js';
10
+ import { useAction } from '../context/index.js';
11
11
  /**
12
12
  * PageVariableActionBridge — publishes the live page-variable snapshot into the
13
13
  * shared ActionRunner context as `pageVariables`, so a submit action (`api` /
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { useState, useEffect, useCallback, useMemo, useContext } from 'react';
12
12
  import { resolveDataSource } from '@object-ui/core';
13
- import { SchemaRendererContext } from '../context/SchemaRendererContext';
13
+ import { SchemaRendererContext } from '../context/SchemaRendererContext.js';
14
14
  // ---------------------------------------------------------------------------
15
15
  // Hook
16
16
  // ---------------------------------------------------------------------------
@@ -6,7 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { useState, useCallback, useMemo } from 'react';
9
- import { useReducedMotion } from './useReducedMotion';
9
+ import { useReducedMotion } from './useReducedMotion.js';
10
10
  // ---------------------------------------------------------------------------
11
11
  // Helpers
12
12
  // ---------------------------------------------------------------------------
package/dist/index.d.ts CHANGED
@@ -5,15 +5,15 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- export * from './SchemaRenderer';
9
- export * from './schema-input';
10
- export * from './hooks';
11
- export * from './context';
12
- export * from './LazyPluginLoader';
13
- export * from './spec-bridge';
14
- export * from './data-invalidation';
15
- export * from './element-data-source/ElementDataSourceGate';
16
- export { resolveKeyedI18nLabel } from './utils/i18n';
17
- export { extractWriteErrorMessage, isPermissionError, extractFieldErrors } from './utils/error-message';
18
- export type { WriteFieldError } from './utils/error-message';
8
+ export * from './SchemaRenderer.js';
9
+ export * from './schema-input.js';
10
+ export * from './hooks/index.js';
11
+ export * from './context/index.js';
12
+ export * from './LazyPluginLoader.js';
13
+ export * from './spec-bridge/index.js';
14
+ export * from './data-invalidation.js';
15
+ export * from './element-data-source/ElementDataSourceGate.js';
16
+ export { resolveKeyedI18nLabel } from './utils/i18n.js';
17
+ export { extractWriteErrorMessage, isPermissionError, extractFieldErrors, classifyLoadError, declaredUserMessage } from './utils/error-message.js';
18
+ export type { WriteFieldError, LoadErrorKind } from './utils/error-message.js';
19
19
  export { I18nProvider, useObjectTranslation, useSafeTranslate, useObjectLabel, useSafeFieldLabel, useI18nContext, createI18n, getDirection, getAvailableLanguages, formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatNumber, type I18nConfig, type I18nProviderProps, type TranslationKeys, type DateFormatOptions, type CurrencyFormatOptions, type NumberFormatOptions, } from '@object-ui/i18n';
package/dist/index.js CHANGED
@@ -5,20 +5,20 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- export * from './SchemaRenderer';
9
- export * from './schema-input';
10
- export * from './hooks'; // will be empty for now
11
- export * from './context'; // will be empty for now
12
- export * from './LazyPluginLoader';
13
- export * from './spec-bridge';
14
- export * from './data-invalidation';
8
+ export * from './SchemaRenderer.js';
9
+ export * from './schema-input.js';
10
+ export * from './hooks/index.js'; // will be empty for now
11
+ export * from './context/index.js'; // will be empty for now
12
+ export * from './LazyPluginLoader.js';
13
+ export * from './spec-bridge/index.js';
14
+ export * from './data-invalidation.js';
15
15
  // PageComponentSchema.dataSource — mapping the spec's per-element data binding
16
16
  // onto the schema keys each object-bound block reads (objectstack#6953).
17
- export * from './element-data-source/ElementDataSourceGate';
17
+ export * from './element-data-source/ElementDataSourceGate.js';
18
18
  // i18n utilities
19
- export { resolveKeyedI18nLabel } from './utils/i18n';
19
+ export { resolveKeyedI18nLabel } from './utils/i18n.js';
20
20
  // Write-error surfacing utilities (shared by drag-write plugins so a failed
21
21
  // PATCH — e.g. an RLS 403 — is never silently swallowed).
22
- export { extractWriteErrorMessage, isPermissionError, extractFieldErrors } from './utils/error-message';
22
+ export { extractWriteErrorMessage, isPermissionError, extractFieldErrors, classifyLoadError, declaredUserMessage } from './utils/error-message.js';
23
23
  // Built-in i18n support
24
24
  export { I18nProvider, useObjectTranslation, useSafeTranslate, useObjectLabel, useSafeFieldLabel, useI18nContext, createI18n, getDirection, getAvailableLanguages, formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatNumber, } from '@object-ui/i18n';
@@ -6,7 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import type { BaseSchema } from '@object-ui/types';
9
- import type { SchemaRendererProps } from './SchemaRenderer';
9
+ import type { SchemaRendererProps } from './SchemaRenderer.js';
10
10
  /**
11
11
  * Narrow a loosely-typed metadata node onto {@link SchemaRendererProps.schema}.
12
12
  *
@@ -23,9 +23,20 @@ import type { SchemaRendererProps } from './SchemaRenderer';
23
23
  * tells no lie — the value a caller forwards renders identically whether or not
24
24
  * it passes through here.
25
25
  *
26
- * It exists because the two competing repo-wide `SchemaNode` spellings
27
- * (`@object-ui/core`'s interface vs `@object-ui/types`' union) have not been
28
- * reconciled; that reconciliation is tracked separately. When it lands, the
29
- * call sites using this can go back to forwarding directly.
26
+ * The bridge is PERMANENT — not scaffolding awaiting a merge. The two competing
27
+ * repo-wide `SchemaNode` spellings ARE reconciled (objectui#4580, PR #4608):
28
+ * `@object-ui/core` stopped hand-declaring its own interface and now re-exports
29
+ * `@object-ui/types`' union, so one declaration is left to disagree with. That
30
+ * reconciliation resolved in favour of the UNION, while `SchemaRenderer`'s prop
31
+ * stays deliberately narrow (objectui#4548 ruling Q2 — it declares no `number`
32
+ * or `boolean`), so a `SchemaNode` is now LESS assignable to that prop than it
33
+ * was, not more. This step therefore bridges two intentionally different types,
34
+ * and it stays. ⛔ Do not "tidy" a call site back into a direct forward: the
35
+ * five `apps/site` sites that were forwarding directly when PR #4608 landed are
36
+ * what kept `Build Docs` red on `main` for ~5 hours — each one a TS2322 naming
37
+ * `number` against this function's return type, spelled
38
+ * `string | BaseSchema | null | undefined` in objectui#4617's logs — until
39
+ * PR #4621 routed all five through here. An earlier revision of this paragraph
40
+ * said the reconciliation was still pending and invited exactly that edit.
30
41
  */
31
42
  export declare function toRenderableSchema(node: BaseSchema | string | number | boolean | null | undefined): SchemaRendererProps['schema'];
@@ -21,10 +21,21 @@
21
21
  * tells no lie — the value a caller forwards renders identically whether or not
22
22
  * it passes through here.
23
23
  *
24
- * It exists because the two competing repo-wide `SchemaNode` spellings
25
- * (`@object-ui/core`'s interface vs `@object-ui/types`' union) have not been
26
- * reconciled; that reconciliation is tracked separately. When it lands, the
27
- * call sites using this can go back to forwarding directly.
24
+ * The bridge is PERMANENT — not scaffolding awaiting a merge. The two competing
25
+ * repo-wide `SchemaNode` spellings ARE reconciled (objectui#4580, PR #4608):
26
+ * `@object-ui/core` stopped hand-declaring its own interface and now re-exports
27
+ * `@object-ui/types`' union, so one declaration is left to disagree with. That
28
+ * reconciliation resolved in favour of the UNION, while `SchemaRenderer`'s prop
29
+ * stays deliberately narrow (objectui#4548 ruling Q2 — it declares no `number`
30
+ * or `boolean`), so a `SchemaNode` is now LESS assignable to that prop than it
31
+ * was, not more. This step therefore bridges two intentionally different types,
32
+ * and it stays. ⛔ Do not "tidy" a call site back into a direct forward: the
33
+ * five `apps/site` sites that were forwarding directly when PR #4608 landed are
34
+ * what kept `Build Docs` red on `main` for ~5 hours — each one a TS2322 naming
35
+ * `number` against this function's return type, spelled
36
+ * `string | BaseSchema | null | undefined` in objectui#4617's logs — until
37
+ * PR #4621 routed all five through here. An earlier revision of this paragraph
38
+ * said the reconciliation was still pending and invited exactly that edit.
28
39
  */
29
40
  export function toRenderableSchema(node) {
30
41
  return typeof node === 'number' || typeof node === 'boolean' ? String(node) : node;
@@ -6,7 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import type { BaseSchema } from '@object-ui/types';
9
- import type { BridgeContext, BridgeFn } from './types';
9
+ import type { BridgeContext, BridgeFn } from './types.js';
10
10
  export declare class SpecBridge {
11
11
  private bridges;
12
12
  private context;
@@ -5,8 +5,8 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- import { bridgeListView } from './bridges/list-view';
9
- import { bridgeFormView } from './bridges/form-view';
8
+ import { bridgeListView } from './bridges/list-view.js';
9
+ import { bridgeFormView } from './bridges/form-view.js';
10
10
  export class SpecBridge {
11
11
  constructor(context = {}) {
12
12
  Object.defineProperty(this, "bridges", {
@@ -5,8 +5,68 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- import type { BridgeFn } from '../types';
9
- interface FormField {
8
+ /**
9
+ * Form-view bridge input — the documented SUBSET of `@objectstack/spec`'s
10
+ * FormViewSchema this bridge consumes, with every drift-prone key's TYPE bound
11
+ * to the contract instead of restated (objectui#5652).
12
+ *
13
+ * The subset stays hand-listed on purpose: this declaration is load-bearing
14
+ * documentation, and its NON-declarations are a retirement ledger
15
+ * (`defaultSort` / `aria`, objectui#3901 / #3974) that a blanket
16
+ * `Omit<FormView, ...>` would erase. What was never deliberate is the TYPE each
17
+ * declared key was given. Three of them had drifted from what the contract
18
+ * accepts, and nothing ever compared the two descriptions — the mirror shape
19
+ * `scripts/check-spec-symbol-derivation.mjs` exists to catch. The sibling
20
+ * bridge in this directory (`list-view.ts`) was converted the same way and for
21
+ * the same reason (objectui#2231): the shape derives, so it cannot drift.
22
+ *
23
+ * ## What the contract actually answers
24
+ *
25
+ * Measured with `safeParse` against the installed `@objectstack/spec` while
26
+ * fixing this, NOT copied from the finding — two of its three rows were wrong,
27
+ * in ways that change the repair:
28
+ *
29
+ * FormSection.columns `1 | 2 | 3 | 4 | '1' | '2' | '3' | '4'`. The string
30
+ * arm IS admitted, and the section schema's own pipe
31
+ * folds it to a number. The bridge declared `number`
32
+ * and refused it.
33
+ * FormView.columns `number`, with NO string arm: `FormViewSchema`
34
+ * rejects `columns: '3'` at the FORM level while
35
+ * `FormSectionSchema` accepts it at the SECTION level.
36
+ * The bridge's `number` was already right here — it is
37
+ * bound to the contract so the asymmetry cannot rot,
38
+ * and the two answers are pinned side by side.
39
+ * FormField.dependsOn a BARE parent-field name. The array arm is rejected
40
+ * by the contract outright. The bridge declared
41
+ * `string[]`, so it admitted only the arm the contract
42
+ * refuses and refused the only arm it admits — the one
43
+ * configuration that makes `field-selector` and
44
+ * `dynamic-config` work (objectui#5040).
45
+ * *.visibleWhen `string | { dialect, source?, ast?, meta? }`, with
46
+ * `dialect` REQUIRED and enum-typed. `{ dialect?,
47
+ * source }` is a DIFFERENT layer's type — the
48
+ * evaluator's `FieldRulePredicate` (`@object-ui/core`,
49
+ * ADR-0089) — and the contract does not accept it: an
50
+ * object arm without `dialect` is refused. The bridge
51
+ * declared `string` and refused the object arm
52
+ * entirely.
53
+ *
54
+ * ## Declaring an arm is not consuming it
55
+ *
56
+ * A key that is declarable but dropped on the way out is the same defect one
57
+ * layer over (objectui#5542 / #5594), so each widened arm is followed all the
58
+ * way onto the `object-form` node by `FormViewWidenedArms.test.ts`, and the one
59
+ * arm that needs a translation to get there gets it — see `normalizeColumns`.
60
+ */
61
+ import type { DependsOnInput } from '@object-ui/types';
62
+ import type { FormFieldInput, FormSection, FormView } from '@objectstack/spec/ui';
63
+ import type { BridgeFn } from '../types.js';
64
+ /**
65
+ * A form field as authored. Exported so the derivation pins in
66
+ * `__tests__/FormViewWidenedArms.test.ts` can name the declared types; NOT
67
+ * re-exported by `spec-bridge/index.ts`, so the package surface is unchanged.
68
+ */
69
+ export interface FormFieldSpec {
10
70
  field: string;
11
71
  /** Field type (spec FormFieldSchema reuses Data.FieldType; auto-infers widget). */
12
72
  type?: string;
@@ -22,23 +82,45 @@ interface FormField {
22
82
  hidden?: boolean;
23
83
  colSpan?: number;
24
84
  widget?: string;
25
- dependsOn?: string[];
26
- /** Canonical conditional-visibility predicate (ADR-0089). */
27
- visibleWhen?: string;
28
- /** @deprecated ADR-0089 → `visibleWhen`. */
29
- visibleOn?: string;
85
+ /**
86
+ * Sibling field name whose value this field's widget reads (objectui#5040).
87
+ *
88
+ * The contract admits a BARE NAME and refuses an array. This is the wider
89
+ * {@link DependsOnInput} rather than the contract's `string` alone because
90
+ * the value is FORWARDED, unread, into the node slot that is declared with
91
+ * exactly this type (`FormField.dependsOn`, `@object-ui/types`) and read by
92
+ * `resolveCascadingOptions` / `resolveDependsOnFields` (`@object-ui/core`),
93
+ * whose parameter is also exactly this type (framework#4074). Narrowing to
94
+ * the contract's `string` would refuse array-authored layouts that the
95
+ * runtime has always honoured, on the same never-parsed inputs `groups` and
96
+ * `visibleOn` below are still read for. The contract's arm is a subset of
97
+ * this one, which is what the derivation pin asserts — so a spec that widens
98
+ * `dependsOn` fails the pin instead of silently outgrowing the declaration.
99
+ */
100
+ dependsOn?: DependsOnInput;
101
+ /** Canonical conditional-visibility predicate (ADR-0089), bound to the contract. */
102
+ visibleWhen?: FormFieldInput['visibleWhen'];
103
+ /** @deprecated ADR-0089 -> `visibleWhen`. */
104
+ visibleOn?: FormFieldInput['visibleOn'];
30
105
  }
31
- interface FormSection {
106
+ /** One section of a form layout, as authored. */
107
+ export interface FormSectionSpec {
32
108
  /** Stable section identifier for i18n lookup (spec FormSectionSchema.name). */
33
109
  name?: string;
34
110
  label?: string;
35
111
  description?: string;
36
112
  collapsible?: boolean;
37
113
  collapsed?: boolean;
38
- columns?: number;
39
- /** Section-level conditional-visibility predicate (ADR-0089). */
40
- visibleWhen?: string;
41
- fields?: FormField[];
114
+ /** Bound to the contract: the string spelling of a column count is admitted. */
115
+ columns?: FormSection['columns'];
116
+ /** Section-level conditional-visibility predicate (ADR-0089), bound to the contract. */
117
+ visibleWhen?: FormSection['visibleWhen'];
118
+ /**
119
+ * The authored field list. A bare string is the spec's shorthand for "this
120
+ * object's own field, rendered with its defaults" — the same shorthand
121
+ * `mapColumn` already honours on the list bridge.
122
+ */
123
+ fields?: Array<string | FormFieldSpec>;
42
124
  }
43
125
  /**
44
126
  * The subset of `@objectstack/spec` FormViewSchema the bridge consumes.
@@ -46,10 +128,11 @@ interface FormSection {
46
128
  * or listed here with an explicit reason for being ignored — the bridge must
47
129
  * never silently drop spec configuration (#2545).
48
130
  */
49
- interface FormViewSpec {
131
+ export interface FormViewSpec {
50
132
  type?: string;
51
133
  layout?: string;
52
- columns?: number;
134
+ /** Bound to the contract, which admits NO string arm here — unlike a section. */
135
+ columns?: FormView['columns'];
53
136
  title?: string;
54
137
  description?: string;
55
138
  defaultTab?: string;
@@ -63,20 +146,13 @@ interface FormViewSpec {
63
146
  drawerWidth?: string;
64
147
  modalSize?: string;
65
148
  data?: any;
66
- sections?: FormSection[];
149
+ sections?: FormSectionSpec[];
67
150
  /** Legacy alias of `sections` (spec: "Legacy support → alias to sections"). */
68
- groups?: FormSection[];
151
+ groups?: FormSectionSpec[];
69
152
  /** Inline master-detail child collections. */
70
153
  subforms?: any[];
71
- defaultSort?: any;
72
154
  sharing?: any;
73
- aria?: {
74
- ariaLabel?: string;
75
- ariaDescribedBy?: string;
76
- role?: string;
77
- };
78
155
  submitBehavior?: any;
79
156
  }
80
157
  /** Transforms a FormView spec into a Form SchemaNode */
81
158
  export declare const bridgeFormView: BridgeFn<FormViewSpec>;
82
- export {};
@@ -5,6 +5,21 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ /**
9
+ * Fold the contract's string spelling of a column count onto the number the
10
+ * node carries.
11
+ *
12
+ * `FormSectionSchema` accepts `'2'` and `2` and normalises the string away in
13
+ * its own pipe; the `object-form` node's section declares `columns?: 1|2|3|4`
14
+ * and `FormSectionContainer` indexes its grid-class map by that number. Its
15
+ * header states where the fold belongs: "The normalisation belongs at the seam
16
+ * that parses authored metadata, not in the container's props." This bridge is
17
+ * that seam. Forwarding `'2'` verbatim hands every downstream renderer a value
18
+ * outside the type it declares.
19
+ */
20
+ function normalizeColumns(columns) {
21
+ return typeof columns === 'string' ? Number(columns) : columns;
22
+ }
8
23
  function mapField(field) {
9
24
  const mapped = {
10
25
  name: field.field,
@@ -30,12 +45,23 @@ function mapField(field) {
30
45
  mapped.colSpan = field.colSpan;
31
46
  if (field.widget)
32
47
  mapped.widget = field.widget;
48
+ // Forwarded unread into the node's `dependsOn`, which is declared with the
49
+ // same type and read by `@object-ui/core`'s cascading-option resolver.
33
50
  if (field.dependsOn)
34
51
  mapped.dependsOn = field.dependsOn;
35
52
  // ADR-0089: `visibleWhen` is the canonical view-form-field visibility predicate
36
53
  // (the spec folds the deprecated `visibleOn` into it at parse). Prefer it and
37
54
  // fall back to `visibleOn` for raw / un-normalized metadata. The ObjectForm
38
55
  // renderer reads this view-level predicate from the node's `visibleOn` slot.
56
+ //
57
+ // Both contract arms travel whole: the bare CEL string, and the expression
58
+ // object. `evalFieldPredicate` reads `{ dialect, source }` back out of the
59
+ // object arm, which is how a spec-authored `{ dialect: 'cel', source: ... }`
60
+ // now drives visibility instead of being refused by this declaration. An
61
+ // `ast`-only expression (legal metadata: the contract requires `dialect` plus
62
+ // one of `source` / `ast`) is forwarded whole rather than dropped, but no
63
+ // evaluator in this repo reads an `ast` today — that gap is the evaluator's,
64
+ // and the bridge must not hide it by discarding the key.
39
65
  const visiblePredicate = field.visibleWhen ?? field.visibleOn;
40
66
  if (visiblePredicate)
41
67
  mapped.visibleOn = visiblePredicate;
@@ -43,7 +69,12 @@ function mapField(field) {
43
69
  }
44
70
  function mapSection(section) {
45
71
  const mapped = {
46
- fields: (section.fields ?? []).map(mapField),
72
+ // A bare field name is the spec's shorthand and is forwarded verbatim: the
73
+ // node's `fields` slot admits it and `normalizeSectionField`
74
+ // (@object-ui/plugin-form) resolves it against the object schema. Running
75
+ // it through `mapField` instead produced `{ name: undefined }` — a field
76
+ // with no identity — for the most ordinary section a form can declare.
77
+ fields: (section.fields ?? []).map((field) => typeof field === 'string' ? field : mapField(field)),
47
78
  };
48
79
  if (section.name)
49
80
  mapped.name = section.name;
@@ -56,7 +87,8 @@ function mapSection(section) {
56
87
  if (section.collapsed != null)
57
88
  mapped.collapsed = section.collapsed;
58
89
  if (section.columns != null)
59
- mapped.columns = section.columns;
90
+ mapped.columns = normalizeColumns(section.columns);
91
+ // Whole, both arms — same predicate contract as the field above.
60
92
  if (section.visibleWhen)
61
93
  mapped.visibleWhen = section.visibleWhen;
62
94
  return mapped;
@@ -113,14 +145,26 @@ export const bridgeFormView = (spec, _context) => {
113
145
  if (spec[key] != null)
114
146
  node[key] = spec[key];
115
147
  }
116
- if (spec.defaultSort)
117
- node.defaultSort = spec.defaultSort;
118
148
  if (spec.submitBehavior)
119
149
  node.submitBehavior = spec.submitBehavior;
120
- // P1.6 — i18n & ARIA
150
+ // P1.6 — sharing
121
151
  if (spec.sharing)
122
152
  node.sharing = spec.sharing;
123
- if (spec.aria)
124
- node.aria = spec.aria;
153
+ // `defaultSort` (#3974) and `aria` (#3901) USED to be copied here. Both are
154
+ // `retiredKey()` tombstones on spec 17's FormViewSchema, so a FormView that
155
+ // parsed can never carry either — the guards were unreachable, and their
156
+ // shape invited the next reader to copy a dead pattern. Removed under the
157
+ // maintainer's 2026-08-11 enforce-or-remove ruling; measured dormant at BOTH
158
+ // ends before removal:
159
+ // - producer: `FormViewSchema.safeParse` rejects both keys by name
160
+ // (`form.defaultSort` / `form.aria` removed in the #3896 close-out).
161
+ // - consumer: no form renderer reads either off the node — plugin-form
162
+ // reads neither, and `SchemaRenderer`'s generic ARIA injection resolves
163
+ // FLAT `ariaLabel`/`ariaDescribedBy`/`role` (SchemaRenderer.tsx:109-119),
164
+ // never a nested `aria` object.
165
+ // The nested `aria` copy is still correct on the LIST bridge
166
+ // (`list-view.ts:214`), whose carrier stayed live and IS consumed at
167
+ // `plugin-list/src/ListView.tsx:2389-2392` — do not "align" the two.
168
+ // Pinned by `__tests__/FormViewRetiredKeys.test.ts`.
125
169
  return node;
126
170
  };
@@ -5,7 +5,7 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
- import type { BridgeFn } from '../types';
8
+ import type { BridgeFn } from '../types.js';
9
9
  import type { ListView } from '@objectstack/spec/ui';
10
10
  /**
11
11
  * Bridge input: the spec-canonical ListView (#2231 — the former hand-written