From cb0cca0854c92d83f5ae0e6c26c617e52bf1919a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:16:03 +0000 Subject: [PATCH 1/3] fix(types): settle FilterField.operators, Container.maxWidth and HeaderBar.variant between mirror and declaration Three keys from objectui#7759 groups C and D, each decided by that card's ruling: - FilterField.operators: both faces now state the spec's VIEW_FILTER_OPERATORS (TS by type reference; mirror spelled out and compared to the spec at runtime). - ContainerSchema.maxWidth: the mirror's z.boolean() arm narrows to z.literal(false); the renderer draws nothing for `true`. - HeaderBarSchema.variant: retired on both faces; the renderer reads no variant. The ledger rows leave WiderThanDeclared, WIDER_ARMS and KnownDrift, the header figures follow, and mirror-groups-cd-10286.test.ts pins each change. FormSchema.mode is left ledgered. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S --- .changeset/10286-mirror-groups-cd-settled.md | 30 ++++ .../__tests__/mirror-groups-cd-10286.test.ts | 141 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 77 ++++++---- packages/types/src/complex.ts | 23 ++- packages/types/src/navigation.ts | 15 +- packages/types/src/zod/complex.zod.ts | 26 +++- packages/types/src/zod/layout.zod.ts | 8 +- packages/types/src/zod/navigation.zod.ts | 8 +- 8 files changed, 290 insertions(+), 38 deletions(-) create mode 100644 .changeset/10286-mirror-groups-cd-settled.md create mode 100644 packages/types/src/__tests__/mirror-groups-cd-10286.test.ts diff --git a/.changeset/10286-mirror-groups-cd-settled.md b/.changeset/10286-mirror-groups-cd-settled.md new file mode 100644 index 0000000000..fb91b77ea0 --- /dev/null +++ b/.changeset/10286-mirror-groups-cd-settled.md @@ -0,0 +1,30 @@ +--- +'@object-ui/types': minor +--- + +fix(types): settle three 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`. + +Breaking, but only for documents the renderer ignored anyway: a `container` with +`maxWidth: true`, or a `header-bar` with any `variant`, now fails validation. Delete +the key. `FormSchema.mode` is not changed here. 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..d32e223021 --- /dev/null +++ b/packages/types/src/__tests__/mirror-groups-cd-10286.test.ts @@ -0,0 +1,141 @@ +/** + * 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. + * + * `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 type { FilterField } from '../complex.js'; +import type { ContainerSchema as ContainerSchemaType } from '../layout.js'; +import type { HeaderBarSchema as HeaderBarSchemaType } from '../navigation.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'); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 11e4c55a5b..8e3e3906c8 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,7 +154,16 @@ * 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. - * - **46 entries** in `KnownDrift`, **85 keys** across them — 46 / 83 until + * - **44 entries** in `KnownDrift`, **82 keys** across them — 46 / 85 until + * objectui#10286 settled three 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 one entry SHRANK (`complex.zod.ts#FilterBuilderSchema` lost + * `fields` and keeps `onChange`), so the entry count fell by two and the key + * total by three. ⚠️ `operators` and `fields` left because both faces now state + * the spec's filter vocabulary; `variant` left because both faces RETIRED it — + * read the ledger notes where those entries stood before counting either as the + * same kind of move. It was 46 / 83 until * objectui#9447 DECLARED `onNavigate` and `onAddComment` on * `views.zod.ts#DetailViewSchema`, an EXISTING entry (it already held `onBack`), * so the key total moved by two and the entry count did not. ⭐ A pure TRANSFER, @@ -395,9 +404,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. - * - **19 entries** in `WiderThanDeclared`, **29 keys** across them, and **36 arms** - * under those keys — split **5** SCHEMA-NODE, **24** CONCRETE, **0** MIXED, **7** unions. - * It read 20 / 30 / 37 — 5 / 25 / 0 / 7 — until objectui#8572 RETIRED + * - **16 entries** in `WiderThanDeclared`, **25 keys** across them, and **31 arms** + * under those keys — split **5** SCHEMA-NODE, **20** CONCRETE, **0** MIXED, **6** unions. + * It read 19 / 29 / 36 — 5 / 24 / 0 / 7 — until objectui#10286 closed four 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) and `navigation.zod.ts#HeaderBarSchema::variant` + * (retired on both faces). Three entries left whole; `HeaderBarSchema` stays on + * `logo`. ⚠️ Four keys, five arms: the Container key is why `arms` fell by one more + * than CONCRETE did. It read 20 / 30 / 37 — 5 / 25 / 0 / 7 — until objectui#8572 RETIRED * `complex.zod.ts#ChatbotSchema::body`, that entry's whole content, so the entry, its * one key and its one arm left together. ⚠️ Compare the objectui#8338 move below: the * same DEPARTURE shape, but that key carried TWO arms, so CONCRETE and `arms` fell by @@ -568,7 +585,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 46 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 44 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 @@ -1866,13 +1883,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 @@ -2042,8 +2060,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'; /** @@ -3045,10 +3067,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. /** * CONCRETE. `columns` compares an inline element shape against the named * `TableColumn`; `renderCellEditor` is the FUNCTION-SLOT class — zod 4 gives @@ -3088,11 +3111,11 @@ interface WiderThanDeclared { * said would outlive it, on a pair nothing had looked at. */ 'form.zod.ts#SliderSchema': 'defaultValue' | 'value'; - /** - * 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 @@ -3110,8 +3133,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 @@ -3120,7 +3143,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'; /** * CONCRETE. ENTERED under objectui#7760, unmeasurable before it: the mirror is * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` and the declaration states @@ -3254,8 +3277,6 @@ 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'], 'data-display.zod.ts#DataTableSchema::columns': ['CONCRETE'], 'data-display.zod.ts#DataTableSchema::renderCellEditor': ['CONCRETE'], 'data-display.zod.ts#TableColumnSchema::cell': ['CONCRETE'], @@ -3270,10 +3291,8 @@ const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = { 'form.zod.ts#FormSchema::mode': ['CONCRETE'], 'form.zod.ts#SliderSchema::defaultValue': ['CONCRETE', 'CONCRETE'], 'form.zod.ts#SliderSchema::value': ['CONCRETE', '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'], 'overlay.zod.ts#TooltipSchema::content': ['CONCRETE', 'CONCRETE'], 'views.zod.ts#DetailViewFieldSchema::options': ['CONCRETE'], 'views.zod.ts#DetailViewSchema::fields': ['SCHEMA-NODE'], diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index e3787c6aa6..bcf00a2a50 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -19,6 +19,7 @@ import type { DashboardWidget as SpecDashboardWidget, DateRangeDefaultRange as SpecDateRangeDefaultRange, GlobalFilter as SpecGlobalFilter, + ViewFilterOperator as SpecViewFilterOperator, } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; // `GroupingConfig`, `KanbanConditionalFormattingRule` and `ViewNavigationConfig` @@ -715,9 +716,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/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 430ab45b47..4e10cdcc22 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/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 47b0ec6578..1a70cf5f28 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 ' From f31d5c20455fa4ca8c3dc6f12c74e64e1d029b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:39:35 +0000 Subject: [PATCH 2/3] fix(types): retire FormSchema.mode on both faces (objectui#7759 D1-(ii)) The plain `form` node's `mode` is not in @objectstack/spec (its create/edit/view belongs to object-form), and nothing reads it on a `form` node. TS `mode?: never`; the mirror refuses it by name with a retirementTombstone that points at object-form and `disabled`. Ledger rows leave KnownDrift, WiderThanDeclared and WIDER_ARMS; header figures follow; pins in mirror-groups-cd-10286.test.ts; docs and changeset updated. Claude-Session: https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S --- .changeset/10286-mirror-groups-cd-settled.md | 19 ++++++-- content/docs/api/schema-reference.md | 2 +- packages/plugin-form/README.md | 6 +-- .../__tests__/mirror-groups-cd-10286.test.ts | 46 +++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 35 ++++++++------ packages/types/src/form.ts | 21 +++++++-- packages/types/src/zod/form.zod.ts | 8 +++- 7 files changed, 112 insertions(+), 25 deletions(-) diff --git a/.changeset/10286-mirror-groups-cd-settled.md b/.changeset/10286-mirror-groups-cd-settled.md index fb91b77ea0..6cb9e1dacb 100644 --- a/.changeset/10286-mirror-groups-cd-settled.md +++ b/.changeset/10286-mirror-groups-cd-settled.md @@ -2,7 +2,7 @@ '@object-ui/types': minor --- -fix(types): settle three mirror-vs-declaration disagreements from objectui#7759 groups C and D +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 @@ -25,6 +25,19 @@ where it does not, the renderer's read site decides. `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`, or a `header-bar` with any `variant`, now fails validation. Delete -the key. `FormSchema.mode` is not changed here. +`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 e493fd9dfa..9aab9eaa20 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 c13dd7f6a1..7cf1113f0f 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 index d32e223021..1f8a04b4f3 100644 --- a/packages/types/src/__tests__/mirror-groups-cd-10286.test.ts +++ b/packages/types/src/__tests__/mirror-groups-cd-10286.test.ts @@ -21,6 +21,10 @@ * 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 @@ -33,9 +37,13 @@ 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'; @@ -139,3 +147,41 @@ describe('HeaderBarSchema.variant is retired on both faces (objectui#10286)', () 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 8e3e3906c8..ef8c5503a9 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,8 +154,11 @@ * 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. - * - **44 entries** in `KnownDrift`, **82 keys** across them — 46 / 85 until - * objectui#10286 settled three keys of objectui#7759's groups C and D: two + * - **44 entries** in `KnownDrift`, **81 keys** across them — 44 / 82 until + * objectui#10286's second step RETIRED `form.zod.ts#FormSchema::mode` on both faces + * (objectui#7759 ruling D1-(ii)); the entry survives on `fields` and its three runtime + * slots, so the key total moved by one and the entry count did not. It was 46 / 85 + * until objectui#10286 settled three 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 one entry SHRANK (`complex.zod.ts#FilterBuilderSchema` lost @@ -404,9 +407,11 @@ * 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. - * - **16 entries** in `WiderThanDeclared`, **25 keys** across them, and **31 arms** - * under those keys — split **5** SCHEMA-NODE, **20** CONCRETE, **0** MIXED, **6** unions. - * It read 19 / 29 / 36 — 5 / 24 / 0 / 7 — until objectui#10286 closed four CONCRETE + * - **16 entries** in `WiderThanDeclared`, **24 keys** across them, and **30 arms** + * under those keys — split **5** SCHEMA-NODE, **19** CONCRETE, **0** MIXED, **6** unions. + * It read 16 / 25 / 31 — 5 / 20 / 0 / 6 — until objectui#10286's second step retired + * `form.zod.ts#FormSchema::mode`, a one-arm CONCRETE key; the entry stays on `layout` + * and `fields`. It read 19 / 29 / 36 — 5 / 24 / 0 / 7 — until objectui#10286 closed four 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 @@ -2035,8 +2040,10 @@ interface KnownDrift { /** RUNTIME SLOT (objectui#6124): the `file-upload` renderer calls `props.onChange(files)` after `SchemaRenderer`'s spread. */ 'form.zod.ts#FileUploadSchema': 'onChange'; /** - * `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.) @@ -2045,7 +2052,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. */ @@ -2552,8 +2559,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': @@ -3098,10 +3105,11 @@ 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. `fields` - * and `mode` are the disjoint pair objectui#5927 left in `KnownDrift` — measured - * here from the other side. + * is the drift objectui#5927 left in `KnownDrift` — measured here from the other + * side. (`mode`, its disjoint partner, LEFT under objectui#10286: retired on both + * faces.) */ - 'form.zod.ts#FormSchema': 'layout' | 'fields' | 'mode'; + 'form.zod.ts#FormSchema': 'layout' | 'fields'; /** * CONCRETE, and the class objectui#7069 was filed for, ALIVE: the mirror is * `z.union([z.number(), z.array(z.number())])` and the declaration states the @@ -3288,7 +3296,6 @@ const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = { 'form.zod.ts#FormFieldSchema::condition': ['CONCRETE'], 'form.zod.ts#FormSchema::layout': ['CONCRETE'], 'form.zod.ts#FormSchema::fields': ['CONCRETE'], - 'form.zod.ts#FormSchema::mode': ['CONCRETE'], 'form.zod.ts#SliderSchema::defaultValue': ['CONCRETE', 'CONCRETE'], 'form.zod.ts#SliderSchema::value': ['CONCRETE', 'CONCRETE'], 'layout.zod.ts#PageNodeSchema::slots': ['SCHEMA-NODE'], diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index a2b17b6fd6..7e599816c2 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1830,10 +1830,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/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 2a664196b8..911da5943f 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -895,7 +895,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'), From 965e324e09facc8dccecc8e20e6680d5d1c48da9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:00:34 +0000 Subject: [PATCH 3/3] fix(plugin-designer): NAV_TYPE_META has an entry for the spec's `doc` navigation item type objectstack#19789 widened the spec's navigation item union with `doc`, and `NAV_TYPE_META` is a Record keyed by that spec-derived union, so objectui stopped compiling against @objectstack/spec built from objectstack main (the Spec Main Shape Gate, TS2741 on NavigationDesigner). - `doc` entry: `appDesigner.navTypeDoc`, `bg-blue-100 text-blue-700`, `BookOpen`. - The map is typed `Record`: the pinned spec 17.4.0 has no `doc`, so a plain `doc:` key is an excess property there. The `| 'doc'` goes at the pin bump that ships `doc`. - `appDesigner.navTypeDoc` fallback in useDesignerTranslation and all ten locale packs. - Guard test: every discriminant of the installed spec's NavigationItemSchema renders a row with a resolved type badge (spec subset of map, not equality). - `doc` is deliberately NOT in QUICK_ADD_TYPES: an empty doc item fails the spec's book-or-doc requirement; authoring it is objectui#10188. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC (cherry picked from commit 6f970da0588a6671432a9cdee71ac759aa67be21) --- .../10287-navigation-designer-doc-type.md | 21 +++++ packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + .../src/NavigationDesigner.tsx | 13 ++- .../NavigationDesigner.specNavTypes.test.tsx | 93 +++++++++++++++++++ .../src/hooks/useDesignerTranslation.ts | 1 + 14 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 .changeset/10287-navigation-designer-doc-type.md create mode 100644 packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx diff --git a/.changeset/10287-navigation-designer-doc-type.md b/.changeset/10287-navigation-designer-doc-type.md new file mode 100644 index 0000000000..457e6b739a --- /dev/null +++ b/.changeset/10287-navigation-designer-doc-type.md @@ -0,0 +1,21 @@ +--- +'@object-ui/plugin-designer': patch +'@object-ui/i18n': patch +--- + +fix(plugin-designer): the Navigation Designer has an entry for the spec's `doc` navigation item type + +objectstack#19789 added a `doc` member to the spec's navigation item union (an +item that targets a book and/or a doc). `NAV_TYPE_META` in `NavigationDesigner` +is keyed by that spec-derived union, so objectui stopped compiling against +`@objectstack/spec` built from objectstack `main`, and every row reads its badge, +colour and icon from that map. The map now has a `doc` entry (a `BookOpen` icon, +its own colour and the `appDesigner.navTypeDoc` label key). The key has an +English fallback in the designer's defaults and a translation in all ten locale +packs. + +The map is typed `Record` so it compiles both +against the pinned `@objectstack/spec`, which predates `doc`, and against +objectstack `main`. The `| 'doc'` goes away at the pin bump that ships `doc`. +`doc` is not added to the quick-add buttons: an empty `doc` item fails the spec's +book-or-doc requirement, and authoring one is objectui#10188. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index c419304a7e..62350bdddf 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1405,6 +1405,7 @@ const ar = { navTypeSeparator: "فاصل", navTypeAction: "إجراء", navTypeComponent: "مكوّن", + navTypeDoc: "مستند", navEditIcon: "تعديل الأيقونة", navToggleVisible: "تبديل الرؤية", navHidden: "مخفي", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index e5baca0494..f2ae6e0d7f 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1398,6 +1398,7 @@ const de = { navTypeSeparator: "Trenner", navTypeAction: "Aktion", navTypeComponent: "Komponente", + navTypeDoc: "Dokument", navEditIcon: "Symbol bearbeiten", navToggleVisible: "Sichtbarkeit umschalten", navHidden: "Ausgeblendet", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index b0772d1c52..df400a3bad 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1647,6 +1647,7 @@ const en = { navTypeSeparator: 'Separator', navTypeAction: 'Action', navTypeComponent: 'Component', + navTypeDoc: 'Doc', navEditIcon: 'Edit icon', navToggleVisible: 'Toggle visibility', navHidden: 'Hidden', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index abe8f946b7..408c88ecb6 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1402,6 +1402,7 @@ const es = { navTypeSeparator: "Separador", navTypeAction: "Acción", navTypeComponent: "Componente", + navTypeDoc: "Documento", navEditIcon: "Editar icono", navToggleVisible: "Alternar visibilidad", navHidden: "Oculto", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 21949ab87c..e71ca35695 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1400,6 +1400,7 @@ const fr = { navTypeSeparator: "Séparateur", navTypeAction: "Action", navTypeComponent: "Composant", + navTypeDoc: "Document", navEditIcon: "Modifier l'icône", navToggleVisible: "Basculer la visibilité", navHidden: "Masqué", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 6706238a5b..3dcbb9d28c 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1398,6 +1398,7 @@ const ja = { navTypeSeparator: "区切り", navTypeAction: "アクション", navTypeComponent: "コンポーネント", + navTypeDoc: "ドキュメント", navEditIcon: "アイコンを編集", navToggleVisible: "表示を切り替え", navHidden: "非表示", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 645e690ca5..019f9f10ea 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1398,6 +1398,7 @@ const ko = { navTypeSeparator: "구분선", navTypeAction: "작업", navTypeComponent: "컴포넌트", + navTypeDoc: "문서", navEditIcon: "아이콘 편집", navToggleVisible: "가시성 토글", navHidden: "숨김", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 92d0bee665..b6b50b8b5f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1397,6 +1397,7 @@ const pt = { navTypeSeparator: "Separador", navTypeAction: "Ação", navTypeComponent: "Componente", + navTypeDoc: "Documento", navEditIcon: "Editar ícone", navToggleVisible: "Alternar visibilidade", navHidden: "Oculto", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 682ec698dd..f30ac8280b 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1408,6 +1408,7 @@ const ru = { navTypeSeparator: "Разделитель", navTypeAction: "Действие", navTypeComponent: "Компонент", + navTypeDoc: "Документ", navEditIcon: "Редактировать значок", navToggleVisible: "Переключить видимость", navHidden: "Скрыто", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 6285b0ea38..703ed32126 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1463,6 +1463,7 @@ const zh = { navTypeSeparator: '分隔线', navTypeAction: '操作', navTypeComponent: '组件', + navTypeDoc: '文档', navEditIcon: '编辑图标', navToggleVisible: '切换可见性', navHidden: '已隐藏', diff --git a/packages/plugin-designer/src/NavigationDesigner.tsx b/packages/plugin-designer/src/NavigationDesigner.tsx index 677ae3f20f..dc6cc9b5f0 100644 --- a/packages/plugin-designer/src/NavigationDesigner.tsx +++ b/packages/plugin-designer/src/NavigationDesigner.tsx @@ -17,6 +17,7 @@ import React, { useState, useCallback, useRef } from 'react'; import type { NavigationItem, NavigationItemType } from '@object-ui/types'; import { + BookOpen, ChevronDown, ChevronRight, ChevronUp, @@ -79,7 +80,16 @@ function createId(prefix: string): string { return `${prefix}_${Date.now()}_${ndCounter}`; } -const NAV_TYPE_META: Record }> = { +// Keyed by the spec-derived union, so a nav type the spec adds stops this file +// compiling until it has an entry -- keep it a `Record`, never `Partial` or +// `Record`. +// +// `| 'doc'` is the published pin lagging the spec: objectstack#19789 added the +// `doc` nav item, and the pinned `@objectstack/spec` predates it, so without +// the extra key a `doc:` entry is an excess property against the pin while its +// absence fails the compile against objectstack `main` (Spec Main Shape Gate). +// Drop `| 'doc'` at the pin bump that ships `doc`; the entry itself stays. +const NAV_TYPE_META: Record }> = { object: { labelKey: 'appDesigner.navTypeObject', color: 'bg-green-100 text-green-700', Icon: Database }, dashboard: { labelKey: 'appDesigner.navTypeDashboard', color: 'bg-amber-100 text-amber-700', Icon: LayoutDashboard }, page: { labelKey: 'appDesigner.navTypePage', color: 'bg-teal-100 text-teal-700', Icon: FileText }, @@ -89,6 +99,7 @@ const NAV_TYPE_META: Record = [ diff --git a/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx b/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx new file mode 100644 index 0000000000..6b747efac7 --- /dev/null +++ b/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx @@ -0,0 +1,93 @@ +/** + * 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#10287 — every navigation item type the spec declares gets a row + * the Navigation Designer can draw. + * + * `NAV_TYPE_META` is a `Record` keyed by the spec-derived `NavigationItemType`, + * so the compiler already refuses a missing entry. This pin is the runtime + * half, and it reads the vocabulary from the INSTALLED `@objectstack/spec`'s + * `NavigationItemSchema` discriminants rather than from a list written here, + * so it follows whatever spec it is run against: + * + * - against the pinned release it covers that release's members; + * - against a spec built from objectstack `main` (the Spec Main Shape Gate's + * injected install) it also covers members the pin does not have yet — + * objectstack#19789's `doc` was the one that went unhandled. + * + * The map may carry MORE keys than the spec it is run against (`doc` ahead of + * the pin bump), so this asserts spec ⊆ map, never equality. + * + * A missing entry fails as a render error: every row reads `meta.Icon` from + * the map. An entry whose label key has no English fallback fails the + * raw-key assertion. + */ + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { NavigationItemSchema } from '@objectstack/spec/ui'; +import type { NavigationItem } from '@object-ui/types'; +import { NavigationDesigner } from '../NavigationDesigner'; + +/** + * Walk `.unwrap()` (the spec wraps its schemas lazily) until the node carries + * `key`. Bounded, and answers `undefined` rather than looping on a shape it + * does not recognise. + */ +function unwrapUntil(node: unknown, key: string): Record | undefined { + let current = node as Record | undefined; + for (let depth = 0; depth < 8 && current; depth += 1) { + if (key in current) return current; + const unwrap = current.unwrap; + if (typeof unwrap !== 'function') return undefined; + current = unwrap.call(current) as Record | undefined; + } + return current && key in current ? current : undefined; +} + +/** The discriminant values of the spec's nav-item union. Throws when unreadable. */ +function specNavItemTypes(): string[] { + const union = unwrapUntil(NavigationItemSchema, 'options'); + const options = union?.options; + if (!Array.isArray(options) || options.length === 0) { + throw new Error('could not read NavigationItemSchema options from @objectstack/spec'); + } + return options.flatMap((option, index) => { + const shape = unwrapUntil(option, 'shape')?.shape as Record | undefined; + const literal = shape?.type as { values?: unknown } | undefined; + if (!(literal?.values instanceof Set) || literal.values.size === 0) { + throw new Error(`could not read the \`type\` literal of NavigationItemSchema option ${index}`); + } + return [...literal.values].map(String); + }); +} + +const SPEC_NAV_ITEM_TYPES = specNavItemTypes(); + +describe('NavigationDesigner — a row for every spec navigation item type (#10287)', () => { + it('reads a non-empty vocabulary from the installed spec', () => { + // Non-vacuity: an empty or mis-read list would make every case below pass. + expect(SPEC_NAV_ITEM_TYPES).toContain('object'); + expect(SPEC_NAV_ITEM_TYPES).toContain('group'); + expect(new Set(SPEC_NAV_ITEM_TYPES).size).toBe(SPEC_NAV_ITEM_TYPES.length); + }); + + it.each(SPEC_NAV_ITEM_TYPES)('draws the `%s` type in the tree and the live preview', (type) => { + // Cast at the fixture boundary only: the type comes from the installed + // spec at runtime, which can name a member the pinned types do not have. + const item = { id: `probe_${type}`, type, label: 'Probe item' } as unknown as NavigationItem; + + render( {}} showPreview />); + + const row = screen.getByTestId(`nav-designer-item-probe_${type}`); + // The type badge resolves to a label, not to its raw translation key. + expect(row.textContent ?? '').not.toMatch(/appDesigner\./); + }); +}); diff --git a/packages/plugin-designer/src/hooks/useDesignerTranslation.ts b/packages/plugin-designer/src/hooks/useDesignerTranslation.ts index f540bc504e..3cecda4251 100644 --- a/packages/plugin-designer/src/hooks/useDesignerTranslation.ts +++ b/packages/plugin-designer/src/hooks/useDesignerTranslation.ts @@ -104,6 +104,7 @@ export const DESIGNER_DEFAULT_TRANSLATIONS: Record = { 'appDesigner.navTypeSeparator': 'Separator', 'appDesigner.navTypeAction': 'Action', 'appDesigner.navTypeComponent': 'Component', + 'appDesigner.navTypeDoc': 'Doc', 'appDesigner.navEditIcon': 'Edit icon', 'appDesigner.navToggleVisible': 'Toggle visibility', 'appDesigner.navHidden': 'Hidden',