@object-ui/react 17.3.0 → 17.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +1242 -0
  2. package/README.md +48 -21
  3. package/dist/LazyPluginLoader.d.ts +14 -7
  4. package/dist/LazyPluginLoader.js +14 -7
  5. package/dist/SchemaRenderer.d.ts +78 -5
  6. package/dist/SchemaRenderer.js +572 -34
  7. package/dist/context/AppShellContext.d.ts +21 -2
  8. package/dist/context/HostNavigationContext.d.ts +82 -0
  9. package/dist/context/HostNavigationContext.js +65 -0
  10. package/dist/context/RecordContext.d.ts +0 -4
  11. package/dist/context/RecordContext.js +0 -2
  12. package/dist/context/RelatedRecordActionsContext.d.ts +44 -0
  13. package/dist/context/RelatedRecordActionsContext.js +6 -0
  14. package/dist/context/index.d.ts +13 -12
  15. package/dist/context/index.js +13 -12
  16. package/dist/element-data-source/ElementDataSourceGate.d.ts +272 -0
  17. package/dist/element-data-source/ElementDataSourceGate.js +274 -0
  18. package/dist/hooks/index.d.ts +35 -32
  19. package/dist/hooks/index.js +42 -32
  20. package/dist/hooks/useActionEngine.js +1 -1
  21. package/dist/hooks/useActionTextLocalizer.d.ts +69 -0
  22. package/dist/hooks/useActionTextLocalizer.js +91 -0
  23. package/dist/hooks/useCapabilityGate.js +6 -5
  24. package/dist/hooks/useClientNotifications.js +2 -2
  25. package/dist/hooks/useCrudShortcuts.js +1 -1
  26. package/dist/hooks/useDatasetDimensionLabels.d.ts +104 -0
  27. package/dist/hooks/useDatasetDimensionLabels.js +154 -0
  28. package/dist/hooks/useDiscovery.js +1 -1
  29. package/dist/hooks/useElementDataSource.d.ts +68 -0
  30. package/dist/hooks/useElementDataSource.js +141 -0
  31. package/dist/hooks/useExpression.d.ts +82 -3
  32. package/dist/hooks/useExpression.js +121 -4
  33. package/dist/hooks/useNavigationOverlay.d.ts +59 -11
  34. package/dist/hooks/useOffline.d.ts +62 -36
  35. package/dist/hooks/useOffline.js +2 -2
  36. package/dist/hooks/usePageAssignment.js +5 -5
  37. package/dist/hooks/usePageTransition.js +1 -1
  38. package/dist/hooks/usePageVariableActionBridge.js +2 -2
  39. package/dist/hooks/useViewData.js +1 -1
  40. package/dist/hooks/useViewTransition.js +1 -1
  41. package/dist/index.d.ts +11 -9
  42. package/dist/index.js +12 -8
  43. package/dist/schema-input.d.ts +42 -0
  44. package/dist/schema-input.js +42 -0
  45. package/dist/spec-bridge/SpecBridge.d.ts +12 -6
  46. package/dist/spec-bridge/SpecBridge.js +9 -3
  47. package/dist/spec-bridge/bridges/form-view.d.ts +99 -23
  48. package/dist/spec-bridge/bridges/form-view.js +51 -7
  49. package/dist/spec-bridge/bridges/list-view.d.ts +1 -1
  50. package/dist/spec-bridge/bridges/list-view.js +143 -24
  51. package/dist/spec-bridge/index.d.ts +4 -4
  52. package/dist/spec-bridge/index.js +3 -3
  53. package/dist/spec-bridge/types.d.ts +24 -3
  54. package/dist/utils/error-message.d.ts +59 -0
  55. package/dist/utils/error-message.js +116 -1
  56. package/dist/utils/i18n.d.ts +43 -8
  57. package/dist/utils/i18n.js +42 -4
  58. package/dist/utils/unevaluatedExpression.d.ts +69 -0
  59. package/dist/utils/unevaluatedExpression.js +194 -0
  60. package/dist/utils/visibilityDiagnostic.d.ts +119 -0
  61. package/dist/utils/visibilityDiagnostic.js +284 -0
  62. package/package.json +8 -8
@@ -5,18 +5,24 @@
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 { SchemaNode } from '@object-ui/core';
9
- import type { BridgeContext, BridgeFn } from './types';
8
+ import type { BaseSchema } from '@object-ui/types';
9
+ import type { BridgeContext, BridgeFn } from './types.js';
10
10
  export declare class SpecBridge {
11
11
  private bridges;
12
12
  private context;
13
13
  constructor(context?: BridgeContext);
14
14
  register(specType: string, bridge: BridgeFn): void;
15
- /** Transform a spec schema into a SchemaNode tree */
16
- transform(specType: string, spec: any): SchemaNode;
15
+ /**
16
+ * Transform a spec schema into a schema node tree.
17
+ *
18
+ * Returns `BaseSchema` rather than `SchemaNode` — see {@link BridgeFn} for
19
+ * the ruling (objectui#4580 Q4-B) and the measurement behind it. A registered
20
+ * bridge always produces an object; the union only made callers narrow.
21
+ */
22
+ transform(specType: string, spec: any): BaseSchema;
17
23
  /** Transform a ListView spec */
18
- transformListView(spec: any): SchemaNode;
24
+ transformListView(spec: any): BaseSchema;
19
25
  /** Transform a FormView spec */
20
- transformFormView(spec: any): SchemaNode;
26
+ transformFormView(spec: any): BaseSchema;
21
27
  updateContext(ctx: Partial<BridgeContext>): void;
22
28
  }
@@ -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", {
@@ -28,7 +28,13 @@ export class SpecBridge {
28
28
  register(specType, bridge) {
29
29
  this.bridges.set(specType, bridge);
30
30
  }
31
- /** Transform a spec schema into a SchemaNode tree */
31
+ /**
32
+ * Transform a spec schema into a schema node tree.
33
+ *
34
+ * Returns `BaseSchema` rather than `SchemaNode` — see {@link BridgeFn} for
35
+ * the ruling (objectui#4580 Q4-B) and the measurement behind it. A registered
36
+ * bridge always produces an object; the union only made callers narrow.
37
+ */
32
38
  transform(specType, spec) {
33
39
  const bridge = this.bridges.get(specType);
34
40
  if (!bridge) {
@@ -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
@@ -5,15 +5,48 @@
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
+ * A spec `ListColumn` in, the same column out — in the spelling the spec
10
+ * declares (objectui#5068).
11
+ *
12
+ * This used to down-translate every column to the data-table adapter's
13
+ * `{ accessorKey, header }` before handing it to an `object-grid` node, and
14
+ * `ObjectGrid` translated it straight back through a tolerance branch that
15
+ * sniffed `columns[0]`. A round trip whose input was ALREADY canonical: the
16
+ * bridge's own parameter is `@objectstack/spec/ui`'s `ListColumn`, where
17
+ * `field` is required and `accessorKey` / `header` are refused by name
18
+ * (`ListColumnSchema` is a strict object). So the producer emitted a spelling
19
+ * the contract rejects, and the renderer grew a second de-facto contract to
20
+ * read it back — exactly the shape AGENTS.md #0.1 forbids, and the disposition
21
+ * objectui#3951 already settled: unify at the producer, no consumer-side alias.
22
+ *
23
+ * `accessorKey` is not retired as a concept — it is the TanStack adapter key
24
+ * `@object-ui/core` deliberately keeps OUT of the metadata identity fold
25
+ * (`column-identity.ts`'s `TABLE_ADAPTER_COLUMN_KEY`). `ObjectGrid` still
26
+ * applies it on the way OUT, at the one boundary that owns it. Metadata
27
+ * vocabulary comes in; adapter vocabulary goes out; one translation, one place.
28
+ *
29
+ * Nothing is invented on the way through, which is the second half of the fix.
30
+ * `header: col.label ?? col.field` used to turn "no label declared" into "the
31
+ * machine name was declared as the label", and that synthesized value then
32
+ * pre-empted `ObjectGrid`'s own header chain — `col.label` → the OBJECT
33
+ * FIELD's label → the prettified name — whose whole purpose is a column
34
+ * authored as a bare `{ field }`, and whose middle step exists so a localized
35
+ * field label wins over the name-derived English one on a non-English app. A
36
+ * bridged view therefore rendered raw machine names where a directly authored
37
+ * `object-grid` rendered the field's real, localizable label. It now forwards
38
+ * the declaration and lets the renderer resolve what was left unsaid.
39
+ */
8
40
  function mapColumn(col) {
9
- // Spec-legacy shorthand: a bare field name stands for a default column.
41
+ // Spec shorthand: a bare field name stands for a default column.
10
42
  if (typeof col === 'string') {
11
- return { accessorKey: col, header: col };
43
+ return { field: col };
12
44
  }
13
45
  const mapped = {
14
- accessorKey: col.field,
15
- header: col.label ?? col.field,
46
+ field: col.field,
16
47
  };
48
+ if (col.label != null)
49
+ mapped.label = col.label;
17
50
  if (col.width != null)
18
51
  mapped.width = col.width;
19
52
  if (col.align)
@@ -38,21 +71,113 @@ function mapColumn(col) {
38
71
  mapped.action = col.action;
39
72
  return mapped;
40
73
  }
74
+ /**
75
+ * The spec's five row heights collapsed onto the renderer's three densities.
76
+ *
77
+ * `Record<RowHeight, …>` on purpose (objectui#4352): the key set is the spec's,
78
+ * so a row height added upstream fails the build here instead of silently
79
+ * arriving with no density. The table used to carry four more keys —
80
+ * `comfortable`, `spacious`, `small`, `large` — which `RowHeightSchema` does
81
+ * not admit, so no spec-valid list view could ever reach them; they only
82
+ * survived because the parameter was widened back to `string` and the fixture
83
+ * asserting three of them compiled against nothing. Deleting them is AGENTS.md
84
+ * #0.1: a renderer-side dialect for off-spec metadata is a second de-facto
85
+ * contract, and one strict contract beats N. An off-spec `rowHeight` now falls
86
+ * through to `undefined` — the producer is where it gets fixed.
87
+ */
88
+ const ROW_HEIGHT_TO_DENSITY = {
89
+ compact: 'compact',
90
+ short: 'compact',
91
+ medium: 'comfortable',
92
+ tall: 'spacious',
93
+ extra_tall: 'spacious',
94
+ };
95
+ /**
96
+ * Runtime reader for {@link ROW_HEIGHT_TO_DENSITY} — the five spec row heights
97
+ * and **nothing else**.
98
+ *
99
+ * `hasOwnProperty`, not a bare index (objectui#4442): the table is a plain
100
+ * object literal, so indexing it with an unchecked key reaches
101
+ * `Object.prototype`. The parameter is typed `RowHeight`, but the boundary a
102
+ * host's stored view definition actually crosses is `SpecBridge.transformListView`,
103
+ * whose parameter is `any` — so `rowHeight: 'toString'` used to come back as
104
+ * `Object.prototype.toString`, a FUNCTION returned from a signature that
105
+ * promises three strings or nothing. `bridgeListView` then writes the key under
106
+ * `if (density)`, and a function is truthy, so it landed on the SchemaNode.
107
+ *
108
+ * This is the same guard `@object-ui/core`'s `rowHeightToDensityMode` grew in
109
+ * objectui#4440, and the repo's existing convention (`freeze-schema.ts:135`,
110
+ * `metadata-admin/predicate.ts:305`, six more). Both rowHeight surfaces now
111
+ * abstain identically on every off-spec spelling; the agreement is pinned by
112
+ * `__tests__/RowHeightDensityAgreement.test.ts`.
113
+ *
114
+ * `typeof rowHeight !== 'string'`, not `!rowHeight` (objectui#4459): the
115
+ * truthiness guard this replaces rejected only FALSY values, so every truthy
116
+ * non-string walked past it into a lookup that coerces its key — both
117
+ * `hasOwnProperty.call` and the index run `String(...)`. `['compact']`, a boxed
118
+ * `String('compact')` and `{ toString: () => 'compact' }` therefore each
119
+ * selected a real density, where core's twin abstained
120
+ * (`packages/core/src/utils/normalize-list-view.ts:83`, which has opened with
121
+ * the type guard since #4440). Note the direction against #4442: that leak produced a
122
+ * FUNCTION, visibly wrong to everything downstream; this one produced a
123
+ * legitimate-looking `'compact'` that nothing can tell apart from an authored
124
+ * value. The type guard subsumes the old one — `undefined`, `null`, `0` and
125
+ * `false` are all non-strings — and `''` keeps its answer while changing route:
126
+ * a string, so it passes here and is refused by `hasOwnProperty` one line down.
127
+ */
41
128
  function mapDensity(rowHeight) {
42
- if (!rowHeight)
129
+ if (typeof rowHeight !== 'string')
43
130
  return undefined;
44
- const map = {
45
- compact: 'compact',
46
- short: 'compact',
47
- comfortable: 'comfortable',
48
- spacious: 'spacious',
49
- small: 'compact',
50
- medium: 'comfortable',
51
- large: 'spacious',
52
- tall: 'spacious',
53
- extra_tall: 'spacious',
54
- };
55
- return map[rowHeight];
131
+ if (!Object.prototype.hasOwnProperty.call(ROW_HEIGHT_TO_DENSITY, rowHeight)) {
132
+ return undefined;
133
+ }
134
+ return ROW_HEIGHT_TO_DENSITY[rowHeight];
135
+ }
136
+ /**
137
+ * The spec's own parse-time lift, applied where `parse` cannot reach
138
+ * (objectui#4585).
139
+ *
140
+ * `@objectstack/spec` accepts BOTH spellings of `view.exportOptions` and lifts
141
+ * the legacy bare format array to the object form at parse (objectstack#8010,
142
+ * `ui/view.zod.ts`):
143
+ *
144
+ * z.array(ListViewExportFormatSchema).transform((formats) => ({ formats }))
145
+ *
146
+ * That transform never runs on this path. The bridge's input is a TypeScript
147
+ * type, not a parsed value — there is no `parse`/`safeParse` anywhere under
148
+ * `spec-bridge/` — so a host that parses first hands over the object form while
149
+ * a host that forwards raw stored metadata hands over whatever was authored,
150
+ * and nothing here can tell them apart. The legacy array therefore used to
151
+ * reach the `object-grid` node verbatim, where `ObjectGrid` reads
152
+ * `exportOptions.formats` and only that: `.formats` on an array is `undefined`,
153
+ * the renderer's `['csv', 'json']` default won, and the view's declared formats
154
+ * were dropped with no error and no console line — the export button still
155
+ * showed, because a non-empty array is truthy.
156
+ *
157
+ * This mirrors that transform and NOTHING more: the same contract applied one
158
+ * layer out, not a second de-facto one (AGENTS.md #0.1 — the consumer-side
159
+ * `Array.isArray` fallback in the renderer is what that rule forbids). Hence:
160
+ *
161
+ * - the object form passes through by reference, unread and unrewritten;
162
+ * - an empty array lifts to `{ formats: [] }`, exactly as the spec's transform
163
+ * does — its `z.array()` carries no `.min(1)`, so `[]` is a legal input that
164
+ * wraps rather than defaults. ObjectGrid then offers no format and hides the
165
+ * export button: "declared zero formats", read literally;
166
+ * - a `'pdf'` stored before its retirement is carried, not filtered. The spec
167
+ * REFUSES `'pdf'` at parse with a migration prescription (objectstack#8010;
168
+ * PDF export itself was declined as objectstack#1301 NOT_PLANNED) — it does
169
+ * not silently drop the value, so neither may this. Such a format dies
170
+ * downstream in ObjectGrid's format-AGNOSTIC menu filter, kept deliberately
171
+ * at objectui#4535 for metadata predating `os migrate meta --from 16`.
172
+ */
173
+ function liftExportOptions(exportOptions) {
174
+ if (!Array.isArray(exportOptions))
175
+ return exportOptions;
176
+ // The pinned `@objectstack/spec@17.0.0-rc.6` still admits `'pdf'` as an array
177
+ // member (the enum narrowing arrives with the pin bump), so the element type
178
+ // here is wider than the renderer-side `ListViewExportFormat`. Narrowing it by
179
+ // filtering would invent precisely the silent drop the spec declines to do.
180
+ return { formats: exportOptions };
56
181
  }
57
182
  /** Transforms a ListView spec into a DataTable SchemaNode */
58
183
  export const bridgeListView = (spec, _context) => {
@@ -86,10 +211,6 @@ export const bridgeListView = (spec, _context) => {
86
211
  node.filterableFields = spec.filterableFields;
87
212
  if (spec.resizable != null)
88
213
  node.resizable = spec.resizable;
89
- if (spec.striped != null)
90
- node.striped = spec.striped;
91
- if (spec.bordered != null)
92
- node.bordered = spec.bordered;
93
214
  if (spec.navigation)
94
215
  node.navigation = spec.navigation;
95
216
  if (spec.kanban)
@@ -109,14 +230,12 @@ export const bridgeListView = (spec, _context) => {
109
230
  node.bulkActions = spec.bulkActions;
110
231
  if (spec.bulkActionDefs)
111
232
  node.bulkActionDefs = spec.bulkActionDefs;
112
- if (spec.virtualScroll != null)
113
- node.virtualScroll = spec.virtualScroll;
114
233
  if (spec.conditionalFormatting)
115
234
  node.conditionalFormatting = spec.conditionalFormatting;
116
235
  if (spec.inlineEdit != null)
117
236
  node.inlineEdit = spec.inlineEdit;
118
237
  if (spec.exportOptions)
119
- node.exportOptions = spec.exportOptions;
238
+ node.exportOptions = liftExportOptions(spec.exportOptions);
120
239
  if (spec.emptyState)
121
240
  node.emptyState = spec.emptyState;
122
241
  if (spec.userActions)
@@ -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
- export { SpecBridge } from './SpecBridge';
9
- export type { BridgeContext, BridgeFn, ObjectDefLite } from './types';
10
- export { bridgeListView } from './bridges/list-view';
11
- export { bridgeFormView } from './bridges/form-view';
8
+ export { SpecBridge } from './SpecBridge.js';
9
+ export type { BridgeContext, BridgeFn, ObjectDefLite } from './types.js';
10
+ export { bridgeListView } from './bridges/list-view.js';
11
+ export { bridgeFormView } from './bridges/form-view.js';
@@ -5,6 +5,6 @@
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 { SpecBridge } from './SpecBridge';
9
- export { bridgeListView } from './bridges/list-view';
10
- export { bridgeFormView } from './bridges/form-view';
8
+ export { SpecBridge } from './SpecBridge.js';
9
+ export { bridgeListView } from './bridges/list-view.js';
10
+ export { bridgeFormView } from './bridges/form-view.js';
@@ -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 { SchemaNode } from '@object-ui/core';
8
+ import type { BaseSchema } from '@object-ui/types';
9
9
  /** Context passed to all bridge functions */
10
10
  export interface BridgeContext {
11
11
  /** Current user context for permission checks */
@@ -30,5 +30,26 @@ export interface ObjectDefLite {
30
30
  }>;
31
31
  }>;
32
32
  }
33
- /** A bridge function transforms a spec schema into a SchemaNode tree */
34
- export type BridgeFn<T = any> = (spec: T, context: BridgeContext) => SchemaNode;
33
+ /**
34
+ * A bridge function transforms a spec schema into a schema node tree.
35
+ *
36
+ * Returns **`BaseSchema`**, not `SchemaNode` (objectui#4580, ruling Q4-B).
37
+ * Once core's `SchemaNode` became `@object-ui/types`' union
38
+ * (`BaseSchema | string | number | boolean | null | undefined`), declaring the
39
+ * return as `SchemaNode` made every caller re-ask a question no bridge has ever
40
+ * answered "yes" to: *is this a bare string / null?* Both shipped bridges end
41
+ * in a single `return node` on an object literal — `bridges/list-view.ts:226`
42
+ * and `bridges/form-view.ts:195` — so the wider declaration described nothing
43
+ * real while forcing a narrowing at every read.
44
+ *
45
+ * That cost was measured, not assumed: round 1 of this card recorded **272**
46
+ * mechanical errors across five spec-bridge suites, all of them TS18049
47
+ * (`'node' is possibly 'null' or 'undefined'`) and TS2339
48
+ * (`Property 'formType' does not exist on type
49
+ * 'string | number | boolean | BaseSchema'`) — the union being destructured by
50
+ * tests reading properties off a node the bridge always produces.
51
+ *
52
+ * This is a type ANNOTATION only: no runtime behaviour changes, and the emitted
53
+ * JavaScript is byte-identical (proven by bundle sha256 on this PR).
54
+ */
55
+ export type BridgeFn<T = any> = (spec: T, context: BridgeContext) => BaseSchema;