diff --git a/.changeset/10286-mirror-groups-cd-settled.md b/.changeset/10286-mirror-groups-cd-settled.md new file mode 100644 index 0000000000..6cb9e1dacb --- /dev/null +++ b/.changeset/10286-mirror-groups-cd-settled.md @@ -0,0 +1,43 @@ +--- +'@object-ui/types': minor +--- + +fix(types): settle four mirror-vs-declaration disagreements from objectui#7759 groups C and D + +Each of these keys had a zod mirror that accepted something its TypeScript +declaration refused, or the other way round. Each is now settled by the +objectui#7759 ruling: where the spec declares a key, both faces follow the spec; +where it does not, the renderer's read site decides. + +- `FilterField.operators` (inside `FilterBuilderSchema.fields`) now states the + spec's canonical filter vocabulary on both faces: `VIEW_FILTER_OPERATORS` from + `@objectstack/spec/ui`, twenty members. The declaration used to offer + `is_empty` / `is_not_empty` and the mirror `is_null` / `is_not_null`, so each + face refused a spelling the other accepted. Both faces now accept all four, + plus `icontains`, `before`, `after` and `between`. +- `ContainerSchema.maxWidth` no longer parses `true`. The key is not in the spec, + and the `container` renderer draws no max-width class at all for `true` (not the + default `max-w-xl`, and not the `max-w-none` that `false` gives). The + declaration never admitted it. +- `HeaderBarSchema.variant` is retired on both faces (ADR-0049). The key is not in + the spec, and the `header-bar` renderer reads no variant: every spelling + rendered the same header. The declaration offered `floating` and the mirror + `transparent`. The mirror now refuses the key by name, and the declaration types + it `never`. + +- `FormSchema.mode` (the plain `form` node) is retired on both faces, under the + objectui#7759 ruling's D1-(ii). The spec declares no `form` node; its + `create` / `edit` / `view` belongs to `object-form`. Nothing read the key on a + `form` node: every spelling rendered the same form. The declaration offered + `edit` / `read` / `disabled`, the mirror `create` / `edit` / `view`, and + neither was honoured. The mirror now refuses the key by name and points at + `object-form`, whose `mode` (`ObjectFormSchema.mode`) is unchanged and live. + For a non-editable basic form, set `disabled`. The renderer is unchanged: a + document that skips validation and still carries `mode` gets the same stray + `mode` attribute on the rendered `form` element as before. A validated + document can no longer carry the key, so it no longer reaches the DOM that way. + +Breaking, but only for documents the renderer ignored anyway: a `container` with +`maxWidth: true`, a `header-bar` with any `variant`, or a `form` with any `mode` +now fails validation. Delete the key. For a form, use `object-form` or `disabled` +if the mode was meant to do something. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index a68144073d..f588f637a5 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -313,7 +313,7 @@ A complete form with fields, validation, layout, and actions. | `showCancel` | `boolean` | Whether to show a cancel button. | | `showActions` | `boolean` | Whether to show the action buttons row. | | `resetOnSubmit` | `boolean` | Reset form after successful submit. | -| `mode` | `"edit" \| "read" \| "disabled"` | Form interaction mode. | +| `disabled` | `boolean` | Disable every input and the submit button. (`mode` is retired on this node and fails validation; for a create / edit / view form use [ObjectFormSchema](#objectformschema).) | | `actions` | `SchemaNode[]` | Custom action buttons to replace defaults. | **Related:** [InputSchema](#inputschema), [SelectSchema](#selectschema), [ObjectFormSchema](#objectformschema) diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 7f669282f7..dcb4565aac 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -183,7 +183,7 @@ unknown-component placeholder. | `columns` | `number` | grid width (1–4) | | `validationMode` | `'onSubmit' \| 'onBlur' \| 'onChange' \| 'onTouched' \| 'all'` | when the rules run | | `resetOnSubmit` / `disabled` | `boolean` | | -| `mode` | `'edit' \| 'read' \| 'disabled'` | whole-form mode | +| `mode` | — | **retired** (objectui#10286): nothing read it, and validation now refuses it. Use `disabled` for a read-only basic form, or `object-form` for create / edit / view | | `objectName` | `string` | enables metadata field locators `data-testid="field:{objectName}.{field}"` (ADR-0054 C4) | | `previousValues` | `Record` | edit-mode hosts only — the persisted record, as evaluation context for `previous` / `readonlyWhen`. Never sent anywhere | | `fieldContainerClass` | `string` | class for the field grid inside the `
` | @@ -914,8 +914,8 @@ export const App = () => ( The object comes from `objectName`; there is no `resource` key. For `mode: 'edit'` or `'view'`, add the `recordId` of the record being opened. Note that this -`mode` vocabulary is `'create' | 'edit' | 'view'` — the basic form's `mode` is a -different key with a different vocabulary (`'edit' | 'read' | 'disabled'`, see +`mode` vocabulary is `'create' | 'edit' | 'view'` and it lives on `object-form` +only — the basic `form` node has no `mode` (retired by objectui#10286, see [Schema API](#schema-api)). ### The TypeScript route — basic `form` diff --git a/packages/types/src/__tests__/mirror-groups-cd-10286.test.ts b/packages/types/src/__tests__/mirror-groups-cd-10286.test.ts new file mode 100644 index 0000000000..1f8a04b4f3 --- /dev/null +++ b/packages/types/src/__tests__/mirror-groups-cd-10286.test.ts @@ -0,0 +1,187 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10286 — the decidable subset of objectui#7759's groups C and D: three + * pairs where the zod mirror and the TypeScript declaration disagreed about a key, + * each settled by the objectui#7759 ruling (where the spec declares a key both + * faces follow the spec; for an objectui-own key the read site is the truth). + * + * - `FilterField.operators` — both faces now state the spec's canonical filter + * vocabulary (`VIEW_FILTER_OPERATORS`). The TS face takes the spec's type by + * reference; the mirror spells the list out, so the runtime comparison below is + * what reddens if the two lists ever part. + * - `ContainerSchema.maxWidth` — not in the spec. The `container` renderer maps + * `false` to `max-w-none` and `true` to no class at all, so the mirror narrowed + * its `z.boolean()` arm to the `false` the declaration already stated. + * - `HeaderBarSchema.variant` — not in the spec, and the `header-bar` renderer + * reads no `variant`, so both faces retire it. + * - `FormSchema.mode` — the spec declares no `form` node (its create / edit / + * view is the `object-form` block's), and nothing reads the key on a `form` + * node, so both faces retire it (objectui#7759 ruling D1-(ii)). The live mode + * is `ObjectFormSchema.mode`, which this file shows is untouched. + * + * `BaseSchema` is `.passthrough()`: an UNDECLARED key parses green unexamined. So + * every refusal here has a lit control on the same schema and document — an + * unknown key that must stay green — which is what shows the refusal is the + * declared key's own verdict and not a strict object refusing everything. + */ + +import { describe, it, expect } from 'vitest'; +import { VIEW_FILTER_OPERATORS } from '@objectstack/spec/ui'; +import { FilterBuilderSchema, FilterFieldSchema } from '../zod/complex.zod.js'; +import { ContainerSchema } from '../zod/layout.zod.js'; +import { HeaderBarSchema } from '../zod/navigation.zod.js'; +import { FormSchema } from '../zod/form.zod.js'; +import { ObjectFormSchema } from '../zod/objectql.zod.js'; +import type { FilterField } from '../complex.js'; +import type { ContainerSchema as ContainerSchemaType } from '../layout.js'; +import type { HeaderBarSchema as HeaderBarSchemaType } from '../navigation.js'; +import type { FormSchema as FormSchemaType } from '../form.js'; +import type { ObjectFormSchema as ObjectFormSchemaType } from '../objectql.js'; + +/** A control key no surface in this package declares. */ +const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares10286'; + +/** The issue paths of a failed parse, joined, so a refusal can be told apart from a stray one. */ +function refusedPaths(result: { success: boolean; error?: { issues: { path: PropertyKey[] }[] } }): string[] { + return result.success ? [] : (result.error?.issues ?? []).map((i) => i.path.map(String).join('.')); +} + +describe('FilterField.operators states the spec vocabulary on both faces (objectui#10286)', () => { + const field = (operators: unknown) => ({ value: 'status', label: 'Status', operators }); + + it('the mirror accepts exactly the spec list — no member missing, none extra', () => { + for (const op of VIEW_FILTER_OPERATORS) { + expect(FilterFieldSchema.safeParse(field([op])).success, `spec operator \`${op}\` refused`).toBe(true); + } + // The element enum's own options, compared as a SET with the spec's array: a + // member the spec gains, or one this mirror keeps after the spec drops it, + // reddens here. + const element = FilterFieldSchema.shape.operators.unwrap().element; + expect([...element.options].sort()).toEqual([...VIEW_FILTER_OPERATORS].sort()); + }); + + it('the two spellings each face used to refuse are both accepted now', () => { + // The declaration carried `is_empty` / `is_not_empty`; the mirror refused them. + expect(FilterFieldSchema.safeParse(field(['is_empty', 'is_not_empty'])).success).toBe(true); + // The mirror carried `is_null` / `is_not_null`; the declaration refused them. + const typed: FilterField = { value: 'status', label: 'Status', operators: ['is_null', 'is_not_null'] }; + expect(FilterFieldSchema.safeParse(typed).success).toBe(true); + }); + + it('a spelling outside the spec vocabulary is still refused, at the element', () => { + // `isEmpty` is the builder dropdown's camelCase id: an ALIAS in the spec's + // fold table, not a member of the canonical list this key declares. + const result = FilterFieldSchema.safeParse(field(['isEmpty'])); + expect(refusedPaths(result)).toEqual(['operators.0']); + }); + + it('the vocabulary reaches `FilterBuilderSchema.fields` through the element', () => { + const node = { type: 'filter-builder', fields: [field(['icontains', 'between'])] }; + expect(FilterBuilderSchema.safeParse(node).success).toBe(true); + expect(refusedPaths(FilterBuilderSchema.safeParse({ ...node, fields: [field(['isEmpty'])] }))) + .toEqual(['fields.0.operators.0']); + }); + + it('the TS face takes the same set (compile-time: refused by `tsc` when it does not)', () => { + const ok: FilterField = { value: 'a', label: 'A', operators: ['icontains', 'before', 'after', 'between'] }; + // @ts-expect-error — the builder's camelCase id is not a canonical spec operator. + const bad: FilterField = { value: 'a', label: 'A', operators: ['isEmpty'] }; + expect([ok, bad].length).toBe(2); + }); +}); + +describe('ContainerSchema.maxWidth admits `false` and not `true` (objectui#10286)', () => { + const node = (maxWidth: unknown) => ({ type: 'container', maxWidth }); + + it('`true` is refused by the declared key, `false` and a size word parse', () => { + expect(refusedPaths(ContainerSchema.safeParse(node(true)))).toEqual(['maxWidth']); + expect(ContainerSchema.safeParse(node(false)).success).toBe(true); + expect(ContainerSchema.safeParse(node('xl')).success).toBe(true); + }); + + it('lit control: an undeclared key on the same document stays green', () => { + expect(ContainerSchema.safeParse({ ...node(false), [UNKNOWN_KEY]: true }).success).toBe(true); + }); + + it('the declaration refuses `true` too (compile-time)', () => { + // @ts-expect-error — `maxWidth` declares the false literal alone. + const bad: ContainerSchemaType = { type: 'container', maxWidth: true }; + const ok: ContainerSchemaType = { type: 'container', maxWidth: false }; + expect([ok, bad].length).toBe(2); + }); +}); + +describe('HeaderBarSchema.variant is retired on both faces (objectui#10286)', () => { + const NODE = { type: 'header-bar' as const, crumbs: [{ label: 'Home' }] }; + + it('every spelling either face used to offer is refused BY NAME', () => { + for (const variant of ['default', 'bordered', 'floating', 'transparent']) { + const result = HeaderBarSchema.safeParse({ ...NODE, variant }); + expect(refusedPaths(result), `variant \`${variant}\``).toEqual(['variant']); + } + }); + + it('the refusal says why, and names what the renderer reads instead', () => { + const result = HeaderBarSchema.safeParse({ ...NODE, variant: 'floating' }); + expect(result.success).toBe(false); + const message = result.success ? '' : result.error.issues[0].message; + expect(message.startsWith('REFUSED (objectui#10286, ADR-0049)')).toBe(true); + for (const key of ['actions', 'crumbs', 'rightContent', 'search']) expect(message).toContain(`\`${key}\``); + }); + + it('lit control: the same document without `variant`, and with an undeclared key, parses', () => { + expect(HeaderBarSchema.safeParse(NODE).success).toBe(true); + expect(HeaderBarSchema.safeParse({ ...NODE, [UNKNOWN_KEY]: 'x' }).success).toBe(true); + }); + + it('the declaration refuses it too (compile-time)', () => { + // @ts-expect-error — `variant` is `never` on the TS face. + const bad: HeaderBarSchemaType = { type: 'header-bar', variant: 'floating' }; + expect(bad.type).toBe('header-bar'); + }); +}); + +describe('FormSchema.mode is retired on both faces (objectui#10286, objectui#7759 D1-(ii))', () => { + const NODE = { type: 'form' as const, fields: [{ name: 'title', label: 'Title', type: 'text' }] }; + + it('every spelling either face used to offer is refused BY NAME', () => { + for (const mode of ['edit', 'read', 'disabled', 'create', 'view']) { + const result = FormSchema.safeParse({ ...NODE, mode }); + expect(refusedPaths(result), `mode \`${mode}\``).toEqual(['mode']); + } + }); + + it('the refusal points the author at `object-form`, where the mode is live', () => { + const result = FormSchema.safeParse({ ...NODE, mode: 'edit' }); + const message = result.success ? '' : result.error.issues[0].message; + expect(message.startsWith('REFUSED (objectui#10286, ADR-0049')).toBe(true); + expect(message).toContain('`object-form`'); + expect(message).toContain('`disabled`'); + }); + + it('lit control: the same document without `mode`, and with an undeclared key, parses', () => { + expect(FormSchema.safeParse(NODE).success).toBe(true); + expect(FormSchema.safeParse({ ...NODE, [UNKNOWN_KEY]: 'x' }).success).toBe(true); + }); + + it('the object-form mode the refusal names is untouched and still parses', () => { + for (const mode of ['create', 'edit', 'view']) { + const doc = { type: 'object-form', objectName: 'probe', mode }; + expect(ObjectFormSchema.safeParse(doc).success, `object-form mode \`${mode}\``).toBe(true); + } + }); + + it('the declaration refuses it too, and object-form keeps it (compile-time)', () => { + // @ts-expect-error — `mode` is `never` on the `form` node's TS face. + const bad: FormSchemaType = { type: 'form', mode: 'read' }; + const live: Pick = { mode: 'edit' }; + expect([bad.type, live.mode]).toEqual(['form', 'edit']); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 618ce95068..9421e537f6 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,7 +154,17 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **50 entries** in `KnownDrift`, **92 keys** across them — 48 / 88 until + * - **48 entries** in `KnownDrift`, **88 keys** across them — 50 / 92 until + * objectui#10286 settled four keys of objectui#7759's groups C and D: two WHOLE + * entries left (`complex.zod.ts#FilterFieldSchema`, whose one key was `operators`, + * and `navigation.zod.ts#HeaderBarSchema`, whose one key was `variant`) and two + * entries SHRANK (`complex.zod.ts#FilterBuilderSchema` lost `fields` and keeps + * `onChange`; `form.zod.ts#FormSchema` lost `mode` and keeps `fields` and its three + * runtime slots), so the entry count fell by two and the key total by four. + * ⚠️ `operators` and `fields` left because both faces now state the spec's filter + * vocabulary; `variant` and `mode` left because both faces RETIRED them (the + * second under ruling D1-(ii)) — read the ledger notes where those entries stood + * before counting them as the same kind of move. It was 48 / 88 until * objectui#10296 pointed `views.zod.ts#DetailViewFieldSchema`'s `options` at the * spec's AUTHORING `SelectOptionSchema` (ruling F1 on objectui#7759): that pair * and `views.zod.ts#DetailViewSectionSchema` are NEW entries with one key each, @@ -417,8 +427,17 @@ * spelled "six" rots exactly as fast as one spelled `6`, it is just harder to * point a regex at. ⛔ Do not spell a live figure out again, and ⛔ do not * restate one without checking that the pin's spelling still reaches it. - * - **11 entries** in `WiderThanDeclared`, **16 keys** across them, and **18 arms** - * under those keys — split **5** SCHEMA-NODE, **11** CONCRETE, **0** MIXED, **2** unions. + * - **8 entries** in `WiderThanDeclared`, **11 keys** across them, and **12 arms** + * under those keys — split **5** SCHEMA-NODE, **6** CONCRETE, **0** MIXED, **1** unions. + * It read 11 / 16 / 18 — 5 / 11 / 0 / 2 — until objectui#10286 closed five CONCRETE + * keys of objectui#7759's groups C and D: `complex.zod.ts#FilterFieldSchema::operators` + * and `complex.zod.ts#FilterBuilderSchema::fields` (both faces now state the spec's + * filter vocabulary), `layout.zod.ts#ContainerSchema::maxWidth` (the mirror's + * `z.boolean()` arm narrowed to the `false` the declaration states — a TWO-arm key, + * and the one union that left), `navigation.zod.ts#HeaderBarSchema::variant` and + * `form.zod.ts#FormSchema::mode` (both retired on both faces). Three entries left + * whole; `HeaderBarSchema` stays on `logo` and `FormSchema` on `layout`. ⚠️ Five + * keys, six arms: the Container key is why `arms` fell by one more than CONCRETE did. * It read 12 / 18 / 22 — 5 / 13 / 0 / 4 — until objectui#10293 made * `form.zod.ts#CalendarSchema`'s `defaultValue` and `value` declare the ISO string their * mirror accepts: the entry and both of its two-arm keys left together, so `arms` fell @@ -608,7 +627,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 50 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 48 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -1906,13 +1925,14 @@ interface KnownDrift { */ 'complex.zod.ts#DashboardWidgetSchema': 'component' | 'options'; /** - * `fields` — inherited from `FilterFieldSchema.operators` below; the element type is - * the drifted one. `onChange` — RUNTIME SLOT (objectui#6124): the `filter-builder` renderer - * calls it as `props.onChange` after `SchemaRenderer`'s spread. + * `onChange` — RUNTIME SLOT (objectui#6124): the `filter-builder` renderer + * calls it as `props.onChange` after `SchemaRenderer`'s spread. (`fields` LEFT + * under objectui#10286 with `FilterFieldSchema.operators`, whose element drift it + * inherited: both faces now take the spec's `VIEW_FILTER_OPERATORS` by reference. + * `complex.zod.ts#FilterFieldSchema` left this ledger WHOLE on the same card — + * `operators` was its only key.) */ - 'complex.zod.ts#FilterBuilderSchema': 'fields' | 'onChange'; - /** DISJOINT vocabularies: TS declares `is_empty`/`is_not_empty`, the mirror declares `is_null`/`is_not_null`. One of the two is dead; which one is a ruling. */ - 'complex.zod.ts#FilterFieldSchema': 'operators'; + 'complex.zod.ts#FilterBuilderSchema': 'onChange'; /** * ⛔ `complex.zod.ts#KanbanSchema` LEFT this ledger with its pair: objectui#8802 * retired the bare `kanban` node type key (maintainer ruling 2026-09-09) and @@ -2066,8 +2086,10 @@ interface KnownDrift { /** Inherited drift: `validation` is `FieldConstraintsSchema`, whose `validate` is the runtime slot in its own entry. (`condition`'s `custom` is RETIRED on both faces, so it measures clean.) */ 'form.zod.ts#FormFieldSchema': 'validation'; /** - * `mode` — DISJOINT: TS `disabled|read|edit`, mirror `create|edit|view`. `fields` is - * inherited element drift. (`validationMode` was a third drifted key until + * `fields` is inherited element drift. (`mode` LEFT under objectui#10286: it was + * DISJOINT — TS `disabled|read|edit`, mirror `create|edit|view` — and neither was + * read; the key is not in the spec, so both faces retired it under the objectui#7759 + * ruling's D1-(ii).) (`validationMode` was a third drifted key until * objectui#5927 widened it to react-hook-form's full `mode` vocabulary — * `useForm({ mode })` in `renderers/form/form.tsx` forwards it verbatim and RHF * implements `onTouched`/`all` as real branches.) @@ -2076,7 +2098,7 @@ interface KnownDrift { * destructures all three off `schema` and calls them (`await onSubmitProp(formData)`, * the objectui#4259 `onChangeProp` subscription, `onCancelProp()`). */ - 'form.zod.ts#FormSchema': 'fields' | 'mode' | 'onSubmit' | 'onChange' | 'onCancel'; + 'form.zod.ts#FormSchema': 'fields' | 'onSubmit' | 'onChange' | 'onCancel'; /** RUNTIME SLOT (objectui#6124): the `input-otp` renderer calls `props.onChange(val)` after `SchemaRenderer`'s spread. (`onComplete` is NOT here: the `toFormControlDomProps` whitelist drops it — both faces retire it.) */ 'form.zod.ts#InputOTPSchema': 'onChange'; /** RUNTIME SLOT (objectui#6124): the `input` renderer calls `props.onChange(e.target.value)` after `SchemaRenderer`'s spread. */ @@ -2091,8 +2113,12 @@ interface KnownDrift { 'layout.zod.ts#PageNodeSchema': 'slots' | 'pageType'; /** RUNTIME SLOT (objectui#6124): the `tabs` renderer spreads `tabsProps` onto the Radix `Tabs` root AFTER its own `onValueChange`, so the authored function wins. */ 'layout.zod.ts#TabsSchema': 'onValueChange'; - /** DISJOINT: TS declares `floating`, the mirror declares `transparent`. One of the two renders nothing. */ - 'navigation.zod.ts#HeaderBarSchema': 'variant'; + // `navigation.zod.ts#HeaderBarSchema` LEFT this ledger under objectui#10286. Its one + // key was `variant`, DISJOINT (TS `floating`, mirror `transparent`); the card measured + // that NEITHER renders — the renderer reads no `variant` at all, through the real + // `SchemaRenderer` every spelling drew the header byte-identical to its absence — and + // the spec does not declare the key, so both faces retire it (`?: never` beside a + // `retirementTombstone`). /** RUNTIME SLOT (objectui#6124): the `pagination` renderer calls `props.onPageChange(page)` after `SchemaRenderer`'s spread. */ 'navigation.zod.ts#PaginationSchema': 'onPageChange'; /** @@ -2602,8 +2628,8 @@ interface UnmirroredDeclared { /** LOCAL. */ 'form.zod.ts#FormFieldSchema': 'field'; /** - * LOCAL. `fields`/`mode` are in `KnownDrift` above (both mirrored, both drifted in - * TYPE); these eight the mirror does not declare at all. It was nine — + * LOCAL. `fields` is in `KnownDrift` above (mirrored, drifted in TYPE; `mode` sat + * beside it until objectui#10286 retired it on both faces); these eight the mirror does not declare at all. It was nine — * `onDirtyChange` is in `RuntimeOnlyDeclared` below (objectui#6152). */ 'form.zod.ts#FormSchema': @@ -3118,10 +3144,11 @@ interface WiderThanDeclared { * and ⛔ may not be the only place a split is recorded again. */ 'complex.zod.ts#DashboardComponentSchema': 'header' | 'globalFilters' | 'dateRange'; - /** CONCRETE: the element shape differs from the named declaration in both directions; also in `KnownDrift`. */ - 'complex.zod.ts#FilterBuilderSchema': 'fields'; - /** CONCRETE: the mirror's operator enum and the declared operator union are not the same set; also in `KnownDrift`. */ - 'complex.zod.ts#FilterFieldSchema': 'operators'; + // `complex.zod.ts#FilterBuilderSchema` (`fields`) and `complex.zod.ts#FilterFieldSchema` + // (`operators`) LEFT under objectui#10286: the second was the operator vocabulary, each + // face refusing a spelling the other accepted, and the first inherited it through the + // element. Both faces now take the spec's `VIEW_FILTER_OPERATORS` by reference, so the + // mirror accepts nothing the declaration refuses and both entries would be STALE. // ⭐ The FUNCTION-SLOT class (objectui#7759 group E) is GONE from this ledger: // `DataTableSchema.renderCellEditor`, `TableColumnSchema.cell`, // `FieldConstraintsSchema.validate` and `FieldConditionSchema.custom` were bare @@ -3140,12 +3167,13 @@ interface WiderThanDeclared { /** * CONCRETE. `layout` is the clearest single instance in this ledger: the mirror * is `z.enum(['vertical', 'horizontal', 'grid'])` and the declaration states the - * first two, so the third spelling parses green and `tsc` refuses it. `mode` is - * the disjoint key objectui#5927 left in `KnownDrift` — measured here from the - * other side. (`fields` left under objectui#7759 group E: its wider reading was - * the nested `z.function()` arms of `FormFieldSchema`, not the element shape.) + * first two, so the third spelling parses green and `tsc` refuses it. (`fields` + * left under objectui#7759 group E: its wider reading was the nested `z.function()` + * arms of `FormFieldSchema`, not the element shape. `mode`, the disjoint key + * objectui#5927 left in `KnownDrift`, LEFT under objectui#10286: retired on both + * faces under the objectui#7759 ruling's D1-(ii).) */ - 'form.zod.ts#FormSchema': 'layout' | 'mode'; + 'form.zod.ts#FormSchema': 'layout'; // `form.zod.ts#SliderSchema` recorded `defaultValue` and `value` here (CONCRETE, the class // objectui#7069 was filed for: a single-or-list mirror against a list-only declaration). // objectui#10280 (objectui#7759 group B) resolved both by the read site, per the director's @@ -3153,11 +3181,11 @@ interface WiderThanDeclared { // scalar on purpose, so the DECLARATION widened to `number | number[]`; `value` has no read // site, so it was RETIRED on both faces. The entry is GONE, per clause 4 of this ledger's // "when it fires" note. - /** - * CONCRETE: the mirror's arm is `z.boolean()` while the declaration admits the - * false literal alone, so `true` parses green and `tsc` refuses it. - */ - 'layout.zod.ts#ContainerSchema': 'maxWidth'; + // `layout.zod.ts#ContainerSchema` (`maxWidth`) LEFT under objectui#10286. The mirror's + // arm was `z.boolean()` while the declaration admits the false literal alone, so `true` + // parsed green and `tsc` refused it. The key is not in the spec, so the read site + // decides: `container` maps `false` to `max-w-none` and `true` to NO class at all, so + // the mirror narrowed to `z.literal(false)` and now states what the declaration states. /** * SCHEMA-NODE `slots`. (`regions` left under objectui#7760 — its element's content * is a schema-node list, so its reading WAS the annotation. `slots` did not move, so @@ -3175,8 +3203,8 @@ interface WiderThanDeclared { */ 'layout.zod.ts#PageNodeSchema': 'slots'; /** - * CONCRETE. `variant` is DISJOINT — one variant spelling on each side the other - * refuses; also in `KnownDrift`. `logo` ENTERED under objectui#7760: the mirror is + * CONCRETE. (`variant` LEFT under objectui#10286 — retired on both faces, see the + * note where its `KnownDrift` entry stood.) `logo` ENTERED under objectui#7760: the mirror is * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` — the single-or-list * spelling objectui#7069 called systematic — and the declaration states `logo?: * string`. Both arms read now, and both are wider than a bare string, so an author @@ -3185,7 +3213,7 @@ interface WiderThanDeclared { * excluding the key, because the face read `unknown` at the top level and one array * deep. */ - 'navigation.zod.ts#HeaderBarSchema': 'logo' | 'variant'; + 'navigation.zod.ts#HeaderBarSchema': 'logo'; // `overlay.zod.ts#TooltipSchema` recorded `content` here (CONCRETE; ENTERED under // objectui#7760): the mirror spelled it single-or-list and the declaration the single arm. // The renderer places `schema.content` RAW in a React child position, so the list arm parsed @@ -3320,14 +3348,9 @@ const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = { 'complex.zod.ts#DashboardComponentSchema::header': ['CONCRETE'], 'complex.zod.ts#DashboardComponentSchema::globalFilters': ['CONCRETE'], 'complex.zod.ts#DashboardComponentSchema::dateRange': ['CONCRETE'], - 'complex.zod.ts#FilterBuilderSchema::fields': ['CONCRETE'], - 'complex.zod.ts#FilterFieldSchema::operators': ['CONCRETE'], 'form.zod.ts#FormSchema::layout': ['CONCRETE'], - 'form.zod.ts#FormSchema::mode': ['CONCRETE'], - 'layout.zod.ts#ContainerSchema::maxWidth': ['CONCRETE', 'CONCRETE'], 'layout.zod.ts#PageNodeSchema::slots': ['SCHEMA-NODE'], 'navigation.zod.ts#HeaderBarSchema::logo': ['CONCRETE', 'CONCRETE'], - 'navigation.zod.ts#HeaderBarSchema::variant': ['CONCRETE'], 'views.zod.ts#DetailViewFieldSchema::options': ['CONCRETE'], 'views.zod.ts#DetailViewSchema::fields': ['SCHEMA-NODE'], 'views.zod.ts#DetailViewSchema::sections': ['SCHEMA-NODE'], diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 6f478e2fb9..09c80eb97b 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -20,6 +20,7 @@ import type { DateRangeDefaultRange as SpecDateRangeDefaultRange, GlobalFilter as SpecGlobalFilter, Dashboard as SpecDashboard, + ViewFilterOperator as SpecViewFilterOperator, } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; import type { DASHBOARD_SPEC_EXCLUDED } from './zod/complex.zod.js'; @@ -717,9 +718,25 @@ export interface FilterField { | 'select' | 'status' | 'lookup' | 'master_detail' | 'user'; /** - * Available operators for this field - */ - operators?: FilterBuilderOperator[]; + * Available operators for this field. + * + * The spec's canonical filter vocabulary, taken BY REFERENCE + * (`ViewFilterOperator`, the element type of `VIEW_FILTER_OPERATORS` in + * `@objectstack/spec/ui`), so the declaration and its zod mirror state one + * set and cannot drift apart again (objectui#10286, applying the + * objectui#7759 ruling: where the spec declares a vocabulary, both faces + * align to it — and for filter operators the spec's canonical words win). + * Until then this key restated `FilterBuilderOperator` below, which declares + * `is_empty` / `is_not_empty` where the mirror declared `is_null` / + * `is_not_null`, so each face refused a spelling the other accepted. + * + * ⚠️ Nothing renders this key today: the builder draws a field's operator + * dropdown from its `type` alone (`operatorsForFieldType` in + * `packages/components/src/custom/filter-builder.tsx`). The vocabulary is + * settled here; whether the key should be honoured or retired is a separate + * question this declaration does not answer. + */ + operators?: SpecViewFilterOperator[]; /** * Options (for select type) */ diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 5ccd6d2b2a..a6466bca6d 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1856,10 +1856,25 @@ export interface FormSchema extends BaseSchema { */ resetOnSubmit?: boolean; /** - * Form mode - * @default 'edit' + * RETIRED (objectui#10286, ADR-0049) under the objectui#7759 ruling, item + * D1-(ii): both faces dead and no spec declaration, so the key retires. + * + * `@objectstack/spec` declares no `form` node, so this is an objectui-own + * key whose read site is the truth, and it has none: the `form` renderer + * reads no `mode`, and the forms that build a `form` node (`ObjectForm`, + * `ModalForm`, `DrawerForm`) consume their OWN `mode` and never set this + * one. Measured through the real `SchemaRenderer`, every spelling rendered + * the same form as its absence. The two faces had also drifted apart — this + * declaration offered `edit | read | disabled`, the zod mirror `create | + * edit | view` — and neither vocabulary was honoured. + * + * The live create / edit / view mode is `ObjectFormSchema.mode` + * (`../objectql.ts`): author an `object-form` node for that. To make a + * plain form non-editable, use `disabled` (inherited from `BaseSchema`). + * + * @deprecated Nothing renders it; the zod mirror refuses it by name. */ - mode?: 'edit' | 'read' | 'disabled'; + mode?: never; /** * Custom action buttons (replaces default submit/cancel) */ diff --git a/packages/types/src/navigation.ts b/packages/types/src/navigation.ts index 0b745b0ea7..64c0efd3ab 100644 --- a/packages/types/src/navigation.ts +++ b/packages/types/src/navigation.ts @@ -106,10 +106,19 @@ export interface HeaderBarSchema extends BaseSchema { */ height?: string | number; /** - * Header variant - * @default 'default' + * RETIRED (objectui#10286, ADR-0049) — `header-bar` reads no `variant`. + * + * The key is not in `@objectstack/spec`, so the objectui#7759 ruling makes + * the read site the truth, and the read site has none: the renderer's one + * function reads `actions`, `crumbs`, `rightContent` and `search` off + * `schema` and takes no spread props, so every spelling rendered the same + * header. The two faces had also drifted apart on the way there — this + * declaration offered `floating`, the zod mirror `transparent` — and + * neither word had ever reached a class name. + * + * @deprecated Nothing renders it; the zod mirror refuses it by name. */ - variant?: 'default' | 'bordered' | 'floating'; + variant?: never; /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `header-bar` reads NEITHER * content channel: no renderer read consumes `body` or `children` for this diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 6146be2729..70e6d6bf94 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -478,7 +478,31 @@ export const FilterFieldSchema = z.object({ 'select', 'status', 'lookup', 'master_detail', 'user', ]).optional().describe('Field type — the published doc\'s fourteen; `text` when absent'), - operators: z.array(FilterOperatorSchema).optional().describe('Available operators'), + // The spec's canonical filter vocabulary, `VIEW_FILTER_OPERATORS` in + // `@objectstack/spec/ui` (objectui#10286, the objectui#7759 ruling: where the + // spec declares it, both faces align to the spec). This key used to take + // `FilterOperatorSchema` above, which carries `is_null` / `is_not_null` where + // the TS declaration carried `is_empty` / `is_not_empty`, so neither face + // could be satisfied from the other. `FilterOperatorSchema` itself still + // types a CONDITION's `operator` and is not this key's business. + // + // Spelled out rather than imported: a raw spec VALUE read in a mirror must go + // through the objectui#8317 import boundary, which is about schemas and has no + // arm for a bare array. It cannot drift silently: the TS face takes the spec's + // `ViewFilterOperator` BY REFERENCE, so the parity ledger reddens the day the + // two sets differ, and `mirror-groups-cd-10286.test.ts` + // compares this list with the spec's array at runtime. + operators: z.array(z.enum([ + 'equals', 'not_equals', + 'contains', 'not_contains', 'icontains', + 'starts_with', 'ends_with', + 'greater_than', 'less_than', + 'greater_than_or_equal', 'less_than_or_equal', + 'in', 'not_in', + 'is_empty', 'is_not_empty', + 'is_null', 'is_not_null', + 'before', 'after', 'between', + ])).optional().describe('Available operators'), options: z.array(z.object({ label: z.string(), value: z.any(), diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index c8e95e7b5d..c88802cc0f 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -916,7 +916,13 @@ export const FormSchema = BaseSchema.extend({ columns: z.number().optional().describe('Number of columns (for grid layout)'), validationMode: z.enum(['onSubmit', 'onChange', 'onBlur', 'onTouched', 'all']).optional().describe('Validation mode'), resetOnSubmit: z.boolean().optional().describe('Reset form on successful submit'), - mode: z.enum(['create', 'edit', 'view']).optional().describe('Form mode'), + mode: retirementTombstone( + 'REFUSED (objectui#10286, ADR-0049; objectui#7759 ruling D1-(ii)) — the `form` node reads no `mode`: ' + + 'the key is not in `@objectstack/spec`, the `form` renderer never reads it, and every spelling ' + + 'rendered the same form — no error, no warning. The create / edit / view mode belongs to the ' + + '`object-form` node (`ObjectFormSchema.mode`): author `{ "type": "object-form", "objectName": …, ' + + '"mode": "edit", "recordId": … }` for it. To make this form non-editable, set `disabled`.', + ), actions: z.array(z.any()).optional().describe('Custom actions'), onSubmit: handlerKeyRefusal('onSubmit', 'runtime-slot', 'Submit handler'), onChange: handlerKeyRefusal('onChange', 'runtime-slot', 'Change handler'), diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index a21b3fa44f..938f8ad7d1 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -296,9 +296,15 @@ export const SeparatorSchema = BaseSchema.extend({ */ export const ContainerSchema = BaseSchema.extend({ type: z.literal('container'), + // `false` ONLY, not `z.boolean()` (objectui#10286, the objectui#7759 ruling: + // for a key the spec does not declare, the read site is the truth). The + // `container` renderer maps `false` to `max-w-none` and each size word to its + // `max-w-*` class; `true` matches none of those branches, so it parsed green + // here and drew no max-width class at all — neither the default `max-w-xl` + // nor the `max-w-none` that `false` states. The declaration never admitted it. maxWidth: z.union([ z.enum(['sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', 'full', 'screen']), - z.boolean(), + z.literal(false), ]).optional().describe('Max width constraint'), centered: z.boolean().optional().describe('Center the container'), padding: z.number().optional().describe('Padding value'), diff --git a/packages/types/src/zod/navigation.zod.ts b/packages/types/src/zod/navigation.zod.ts index 11a3dfc0e2..787760e537 100644 --- a/packages/types/src/zod/navigation.zod.ts +++ b/packages/types/src/zod/navigation.zod.ts @@ -78,7 +78,13 @@ export const HeaderBarSchema = BaseSchema.extend({ right: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Right content'), sticky: z.boolean().optional().describe('Whether header is sticky'), height: z.union([z.string(), z.number()]).optional().describe('Header height'), - variant: z.enum(['default', 'bordered', 'transparent']).optional().describe('Header variant'), + variant: retirementTombstone( + 'REFUSED (objectui#10286, ADR-0049) — `header-bar` reads no `variant`: the key is not in ' + + '`@objectstack/spec`, and its renderer reads only `actions`, `crumbs`, `rightContent` and ' + + '`search` off the node and takes no spread props, so every variant rendered the same header — ' + + 'no error, no warning, no class. What it renders instead: `actions`, `crumbs`, `rightContent`, ' + + '`search`.', + ), body: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `header-bar` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '