From e09627afb27594b617e60ce6da59cdaf8918653c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:38:31 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(spec)!:=20retire=20FormView=20drawerSi?= =?UTF-8?q?de=20/=20drawerWidth=20=E2=80=94=20no=20form=20view=20opens=20i?= =?UTF-8?q?n=20a=20drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tombstone both keys on FormViewSchema (ADR-0049 enforce-or-remove), move them to the React ObjectForm block's overlay (the drawer that reads them), add the form-view-drawer-keys-removed D2 conversion, its D3 entry, the RETIRED_KEYS_BY_MAJOR entries, the step-18 rationale fragment, the liveness rows and the pins. Generated artifacts follow in the next commit. Claude-Session: https://claude.ai/code/session_01ADHkmSVA1CYQ5wh8VMGZ5r Co-authored-by: Claude --- packages/spec/liveness/view.json | 12 +- packages/spec/src/conversions/registry.ts | 143 ++++++- .../18.ui__FormView__drawerSide.ts | 9 + .../18.ui__FormView__drawerWidth.ts | 9 + .../18.ui-form-view-drawer-keys-retired.ts | 32 ++ .../spec/src/migrations/registry.ts.template | 12 + .../retired-key-removal-version.pin.test.ts | 5 + .../form-view-drawer-keys-retirement.test.ts | 391 ++++++++++++++++++ .../form-view-type-layout-retirement.test.ts | 22 +- packages/spec/src/ui/react-blocks.ts | 19 +- .../spec/src/ui/view-metadata-schema.test.ts | 9 +- packages/spec/src/ui/view.zod.ts | 40 +- packages/spec/vitest.repo-tests.json | 1 + 13 files changed, 666 insertions(+), 38 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerSide.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerWidth.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-form-view-drawer-keys-retired.ts create mode 100644 packages/spec/src/ui/form-view-drawer-keys-retirement.test.ts diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index caceb08cfe7..824b9507c0d 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -320,16 +320,16 @@ "note": "REMOVED 2026-10-28 (#27155, ADR-0050 Accepted by ruling 6464210455 on #27032, letter A′) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error at every form-view door built from the shape: FormViewSchema, the container's form / formViews, both ViewItem form arms and the flattened form overlay) and stripped from stored rows and sources by the protocol-18 form-view-presentation-types-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). It made the split form's panes resizable. Its only reader was ui/packages/plugin-form/src/SplitForm.tsx, reached through ObjectForm's `split` arm and RecordFormPage's split hand-off, both behind `type: 'split'`, which left the enum in the same change; nothing to write instead — a `tabbed` form gives each section its own tab." }, "drawerSide": { - "status": "live", - "verifiedAt": "2026-10-28", + "status": "dead", + "verifiedAt": "2026-11-02", "evidenceScope": "in-repo", - "note": "objectui: DrawerForm.tsx:185. INERT ON A FORM VIEW since 2026-10-28 (#27155): its form-view reader sat behind `type: 'drawer'`, which left the enum. The row stays live because the React `ObjectForm` block takes its contract from FormViewSchema (packages/spec/src/ui/react-blocks.ts dataProps), and that tier's `formType='drawer'` still reads it: since #27159 the block renderer is the caller that opens the form in a drawer (ui/packages/plugin-form/src/ObjectFormContainer.tsx hands it to DrawerForm.tsx), so the key keeps a live reader after ObjectForm dropped its drawer arm. Its enforce-or-remove question for form views stays on #27153." + "note": "REMOVED 2026-11-02 (#29012, ADR-0049 enforce-or-remove, triage ruling 6571544001: retire) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error at every form-view door built from the shape: FormViewSchema, the container's form / formViews, both ViewItem form arms and the flattened form overlay) and stripped from stored rows and sources by the protocol-18 form-view-drawer-keys-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). Its form-view reader sat behind `type: 'drawer'`, which left the enum on #27155; measured at origin/main 104cd1a5, no metadata form-view surface reads it (ui/packages/plugin-form/src/ObjectFormContainer.tsx is the only caller that hands it to DrawerForm.tsx, and it serves the `object-form` block's `formType='drawer'`, which a form view's `type` cannot reach). The React `ObjectForm` block keeps the prop in its own overlay (packages/spec/src/ui/react-blocks.ts), and the `object-form` page block declares it in ObjectFormPropsSchema; nothing to write on a form view instead — a list opens a clicked record in its drawer (`navigation.mode`)." }, "drawerWidth": { - "status": "live", - "verifiedAt": "2026-10-28", + "status": "dead", + "verifiedAt": "2026-11-02", "evidenceScope": "in-repo", - "note": "objectui: DrawerForm.tsx:377-382 still reads it. DEPRECATED in spec (#2578 → size buckets; a pixel width can't be authored blind) — live-but-deprecated, remove the read when the deprecation completes. INERT ON A FORM VIEW since 2026-10-28 (#27155): its form-view reader sat behind `type: 'drawer'`, which left the enum; it stays for the React `ObjectForm` block contract, as drawerSide does: the block's drawer container (ObjectFormContainer.tsx → DrawerForm.tsx, #27159) still reads it (#27153)." + "note": "REMOVED 2026-11-02 (#29012, ADR-0049 enforce-or-remove, triage ruling 6571544001: retire) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error at every form-view door built from the shape) and stripped from stored rows and sources by the protocol-18 form-view-drawer-keys-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). It was already DEPRECATED (#2578 → size buckets) with no size-bucket conversion of its own, and inert on a form view since `type: 'drawer'` left the enum on #27155: the only reader, DrawerForm.tsx, is reached only through the `object-form` block's `formType='drawer'` (ObjectFormContainer.tsx). The React `ObjectForm` block keeps the prop in its own overlay (packages/spec/src/ui/react-blocks.ts); nothing to write on a form view instead — a list's record drawer takes its starting width from `navigation.size`." }, "modalSize": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 36f5e353794..46dc0b14d60 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8491,6 +8491,133 @@ const formViewButtonsResetRemoved: MetadataConversion = { }, }; +/** + * `drawerSide` and `drawerWidth` leave the form view (protocol 18, #29012 — + * ADR-0049 enforce-or-remove, triage ruling: retire). `type: 'drawer'` left the + * form view in #27155 ({@link formViewPresentationTypesRemoved}), so no form + * view opens in a drawer, and no metadata form-view surface reads either key: + * a stored `drawerSide: 'left'` saved, served and changed nothing. + * + * Authoring refuses both keys by their `retiredKey()` tombstones + * (`ui/view.zod.ts`). Deleting is the whole edit, and it changes nothing a + * reader sees. The React `ObjectForm` block keeps its own `drawerSide` / + * `drawerWidth` props (`react-blocks.ts` overlay) and the `object-form` PAGE + * BLOCK keeps them in `ObjectFormPropsSchema`: its `formType: 'drawer'` + * container reads them, so this entry never walks page components. + * + * **Reach: every FORM payload**, as {@link formViewButtonsResetRemoved}: + * {@link mapViewPayloads} (`form`, `formViews.*`, a form view item's `config`, + * a flattened form overlay) and the assembled-manifest `viewItems` channel + * ({@link ASSEMBLED_VIEW_ITEMS_KEY}). A list payload is never judged. + * + * The schema accepted both keys through 18.2.0 and `PUT /meta/view` persists + * form views, so a stored `view` row CAN carry them. **Retired from the load + * path from day one**: a live author meets the parse's prescription, and this + * entry serves stored-row rehydration, assembled artifacts and + * `os migrate meta`. Idempotent by construction (`stripKeys`). + * + * Its D3 entry is `ui-form-view-drawer-keys-retired`. + */ +const formViewDrawerKeysRemoved: MetadataConversion = { + id: 'form-view-drawer-keys-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.2.0', + surface: 'view.form.drawerSide / view.form.drawerWidth / view.formViews..drawerSide / view.formViews..drawerWidth', + summary: + 'form view `drawerSide` and `drawerWidth` removed (no form view opens in a drawer since `type: \'drawer\'` ' + + 'retired, and no form-view surface read either key). Both keys are deleted, which changes nothing a reader ' + + 'sees; the React `ObjectForm` block and the `object-form` page block keep their own `drawerSide` / ' + + '`drawerWidth` for the drawer they open', + apply(stack, emit) { + const convertForm = (form: Dict, path: string): Dict => stripKeys(form, ['drawerSide', 'drawerWidth'], emit, path); + const withViews = mapViewPayloads(stack, (payload, kind, path) => + kind === 'form' ? convertForm(payload, path) : payload); + return mapCollection(withViews, ASSEMBLED_VIEW_ITEMS_KEY, (item, path) => { + if (item.viewKind !== 'form') return item; + if (isDict(item.config)) { + const config = convertForm(item.config, `${path}.config`); + return config === item.config ? item : { ...item, config }; + } + // A flattened form overlay — the body IS the payload. A present but + // malformed `config` is neither shape and is left for the parse. + return item.config === undefined ? convertForm(item, path) : item; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'fvd_task_desk', + regions: [ + { + name: 'main', + components: [ + // The `object-form` page block keeps both keys: its drawer reads them. + { type: 'object-form', properties: { objectName: 'fvd_task', formType: 'drawer', drawerSide: 'left', drawerWidth: '480px' } }, + ], + }, + ], + }, + ], + views: [ + { + object: 'fvd_task', + // Both keys go; the rest of the form rides through. + form: { columns: 2, drawerSide: 'left', drawerWidth: '480px', sections: [{ label: 'Main', fields: ['title'] }] }, + formViews: { + // `modalSize` is live (the console's record dialog reads it) and stays. + quick: { modalSize: 'lg', drawerWidth: '50%' }, + // A form without the keys rides through by reference. + plain: { columns: 1 }, + }, + }, + // A form view item record: the payload is `config`. + { name: 'fvd_task.peek', object: 'fvd_task', viewKind: 'form', config: { drawerSide: 'right' } }, + // A list payload is never judged. + { name: 'fvd_task.all', object: 'fvd_task', viewKind: 'list', config: { type: 'grid', columns: ['title'] } }, + ], + viewItems: [ + // An assembled flattened form overlay — the body IS the payload. + { name: 'fvd_task.side', object: 'fvd_task', viewKind: 'form', drawerSide: 'top' }, + ], + }, + after: { + pages: [ + { + name: 'fvd_task_desk', + regions: [ + { + name: 'main', + components: [ + { type: 'object-form', properties: { objectName: 'fvd_task', formType: 'drawer', drawerSide: 'left', drawerWidth: '480px' } }, + ], + }, + ], + }, + ], + views: [ + { + object: 'fvd_task', + form: { columns: 2, sections: [{ label: 'Main', fields: ['title'] }] }, + formViews: { + quick: { modalSize: 'lg' }, + plain: { columns: 1 }, + }, + }, + { name: 'fvd_task.peek', object: 'fvd_task', viewKind: 'form', config: {} }, + { name: 'fvd_task.all', object: 'fvd_task', viewKind: 'list', config: { type: 'grid', columns: ['title'] } }, + ], + viewItems: [ + { name: 'fvd_task.side', object: 'fvd_task', viewKind: 'form' }, + ], + }, + // `form`: both keys (2). `quick`, the item record and the assembled + // overlay: one each (3). The page block, `plain` and the list emit none. + expectedNotices: 5, + }, +}; + /** * [commit c459da6bc] The per-option `default` key leaves the FORM-VIEW options * vocabulary (protocol 18; maintainer-ruled narrowing 2026-08-28 on the @@ -8998,10 +9125,10 @@ const connectorConnectionTimeoutMsRemoved: MetadataConversion = { * tombstone. Deleting is the whole edit: a form whose `type` was retired lands * on the `simple` default, the conversion invents no layout. The keys go on * every form, whatever its `type`: each is refused on every form now, and a - * `pane` on a non-split form was already a parse error. `drawerSide`, - * `drawerWidth` and `modalSize` stay — the first two for the React - * `ObjectForm` block contract, whose drawer container reads them, `modalSize` - * because the console's record dialog reads it on every layout. + * `pane` on a non-split form was already a parse error. `modalSize` stays, + * because the console's record dialog reads it on every layout. `drawerSide` + * and `drawerWidth` stayed here and left the form view later in the same + * release ([#29012] {@link formViewDrawerKeysRemoved}). * * **[#27159] The renderer half, absorbed here** (the skill's same-major rule: * 18.3.0 is unreleased — `@objectstack/spec` is 18.2.0 on `main` — and one @@ -9177,8 +9304,9 @@ const formViewPresentationTypesRemoved: MetadataConversion = { { label: 'Schedule', pane: 'secondary', fields: ['due_date'] }, ], }, - // A drawer form: the member goes, its other keys stay. - quick: { type: 'drawer', drawerSide: 'left', sections: [{ label: 'Quick', fields: ['status'] }] }, + // A drawer form: the member goes, its other keys stay. ([#29012] Not + // `drawerSide`, which `form-view-drawer-keys-removed` now strips.) + quick: { type: 'drawer', title: 'Quick', sections: [{ label: 'Quick', fields: ['status'] }] }, // A modal form whose sections ride the legacy `groups` alias. dialog: { type: 'modal', modalSize: 'lg', groups: [{ label: 'Edit', fields: ['title'] }] }, }, @@ -9233,7 +9361,7 @@ const formViewPresentationTypesRemoved: MetadataConversion = { { label: 'Schedule', fields: ['due_date'] }, ], }, - quick: { drawerSide: 'left', sections: [{ label: 'Quick', fields: ['status'] }] }, + quick: { title: 'Quick', sections: [{ label: 'Quick', fields: ['status'] }] }, dialog: { modalSize: 'lg', groups: [{ label: 'Edit', fields: ['title'] }] }, }, }, @@ -15411,6 +15539,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: formFieldPublicPickerRemoved, order: 53 }, { conversion: formLayoutInlineGridToVertical, order: 40 }, { conversion: formViewButtonsResetRemoved, order: 79 }, + { conversion: formViewDrawerKeysRemoved, order: 81 }, { conversion: formViewOptionDefaultRemoved, order: 17 }, { conversion: formViewPresentationTypesRemoved, order: 78 }, { conversion: formViewSubformColumnsCanonicalized, order: 51 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerSide.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerSide.ts new file mode 100644 index 00000000000..13bf48e7db5 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerSide.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #29012 — ADR-0049 enforce-or-remove on the form view's drawer keys (triage +// ruling: retire). `type: 'drawer'` left the form view in #27155, so no form +// view opens in a drawer and no form-view surface read the key. Tombstoned +// with `retiredKey()`; the React `ObjectForm` block keeps its own `drawerSide` +// overlay prop. D2: `form-view-drawer-keys-removed`; D3: +// `ui-form-view-drawer-keys-retired`. +export const entry = 'ui/FormView:drawerSide'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerWidth.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerWidth.ts new file mode 100644 index 00000000000..d7361659d32 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__FormView__drawerWidth.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #29012 — the second drawer key, retired with `drawerSide` on the same +// evidence (no form view opens in a drawer since #27155). It was already +// `[DEPRECATED → size buckets]` and had no size-bucket conversion of its own: +// the D2 entry `form-view-drawer-keys-removed` deletes it, and a list's record +// drawer takes its starting width from `navigation.size`. D3: +// `ui-form-view-drawer-keys-retired`. +export const entry = 'ui/FormView:drawerWidth'; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-form-view-drawer-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.ui-form-view-drawer-keys-retired.ts new file mode 100644 index 00000000000..d92b00ec364 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-form-view-drawer-keys-retired.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #29012 (ADR-0049 enforce-or-remove, triage ruling: retire) — the D3 entry of +// the `form-view-drawer-keys-removed` family. The keys are registered in +// RETIRED_KEYS_BY_MAJOR as `ui/FormView:drawerSide` and `ui/FormView:drawerWidth`. +export const entry: SemanticMigration = { + id: 'ui-form-view-drawer-keys-retired', + surface: + 'view.form.drawerSide / view.form.drawerWidth and view.formViews..drawerSide / ' + + 'view.formViews..drawerWidth — removed from the form view (the React `ObjectForm` block and ' + + 'the `object-form` page block keep their own)', + replacement: + 'nothing on the form view: no form view opens in a drawer. Delete the keys. A list opens a clicked ' + + 'record in its drawer (`navigation.mode`) sized by the `navigation.size` bucket, and a React page ' + + 'that opens a form in a drawer writes the props on the block: `ObjectForm` with `formType=\'drawer\'` ' + + 'and its own `drawerSide` / `drawerWidth`', + reason: + 'ADR-0049: `type: \'drawer\'` left the form view in the same release, so no form view opens in a drawer, ' + + 'and no form-view surface (the console form page, its New and Edit dialogs, the record form page, ' + + 'Studio) read either key — a stored value saved, served and changed nothing. The D2 conversion ' + + '`form-view-drawer-keys-removed` makes each stored or existing source parse by deleting the keys, ' + + 'which changes nothing a reader sees. What it cannot decide is whether a form view that carried them ' + + 'was meant to open in a drawer somewhere: an author who wanted that opens it from a React page through ' + + 'the `ObjectForm` block\'s drawer, or lets a list open the record in its drawer', + acceptanceCriteria: + 'No form view declares `drawerSide` or `drawerWidth`; each is refused at parse with its prescription, ' + + 'and TypeScript rejects it at a `FormView` position. The React `ObjectForm` block still publishes both ' + + 'props, and the `object-form` page block still accepts them with `formType: \'drawer\'`.', + conversionIds: ['form-view-drawer-keys-removed'], +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index e8732b60f0c..e3d474bcb1b 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -3021,6 +3021,18 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'stops clearing; an author who wants that declares `submitBehavior: { kind: \'continue\' }`. ' + 'Its D3 record is the semantic entry `ui-form-view-buttons-reset-retired`.', }, + { + id: 'ui-form-view-drawer-keys-retired', + order: 116, + text: + 'It also retires the form view\'s `drawerSide` and `drawerWidth` (ADR-0049): once `type: ' + + '\'drawer\'` left the form view, no form view opened in a drawer and no form-view surface read ' + + 'either key. Both are refused at parse with their prescription; the D2 conversion ' + + '`form-view-drawer-keys-removed` strips them from existing sources and stored rows, which changes ' + + 'nothing a reader sees. The React `ObjectForm` block keeps both as props of its own for the drawer ' + + 'it opens with `formType=\'drawer\'`, as does the `object-form` page block. Its D3 record is the ' + + 'semantic entry `ui-form-view-drawer-keys-retired`.', + }, { id: 'ui-form-view-type-layout-only', order: 112, diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 569e74795d0..12f610a65ee 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -415,6 +415,11 @@ const JOIN: Readonly> = { 'form-view-buttons-reset-removed': [ tombstone((FormViewSchema.shape.buttons as unknown as { unwrap: () => unknown }).unwrap(), 'view.form.buttons', 'reset'), ], + // #29012: the drawer keys, tombstoned on the form view. + 'form-view-drawer-keys-removed': [ + tombstone(FormViewSchema, 'view.form', 'drawerSide'), + tombstone(FormViewSchema, 'view.form', 'drawerWidth'), + ], // #27155: the form view `type` narrowed to the form's layout — each retired // member refused by the enum's own error map, plus the three split-only keys // tombstoned beside it. diff --git a/packages/spec/src/ui/form-view-drawer-keys-retirement.test.ts b/packages/spec/src/ui/form-view-drawer-keys-retirement.test.ts new file mode 100644 index 00000000000..95a8fb9b4b6 --- /dev/null +++ b/packages/spec/src/ui/form-view-drawer-keys-retirement.test.ts @@ -0,0 +1,391 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `FormView.drawerSide` / `drawerWidth` are retired (#29012, ADR-0049 + * enforce-or-remove, triage ruling: retire). `type: 'drawer'` left the form + * view in #27155, so no form view opens in a drawer, and no metadata form-view + * surface reads either key: a stored value saved, served and changed nothing. + * + * Bookkeeping shapes, pinned below: + * 1. Both keys are `retiredKey()` tombstones, refused with their + * prescription at every form-view door (the schema, a container's `form` + * / `formViews.*`, a ViewItem form record, a flattened form overlay, and + * `defineStack`). + * 2. The drawer that reads them keeps them: the React `ObjectForm` block + * declares both in its overlay (`react-blocks.ts`) with the meaning it + * always published, and the `object-form` page block + * (`ObjectFormPropsSchema`) still accepts them. + * 3. One D2 conversion, retired from the load path: + * `form-view-drawer-keys-removed` strips both keys from stored rows and + * existing sources; its D3 entry is `ui-form-view-drawer-keys-retired`. + * 4. Tree-scoped absence: no form view inside the declared radius still + * authors either key. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` (that envelope belongs to the + * doors), so each schema refusal is pinned by the `path` and the + * prescription's first sentence; the `defineStack` door adds the envelope's + * `code` + `status`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { listRepoWalkFiles } from '../../scripts/lib/repo-walk-testkit'; +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice } from '../conversions/types'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack } from '../stack.zod'; +import { ObjectFormPropsSchema } from './component.zod'; +import { REACT_BLOCKS, REACT_OVERLAY_SHADOWS } from './react-blocks'; +import { FormViewSchema, VIEW_METADATA_MEMBERS, ViewItemSchema, ViewSchema } from './view.zod'; + +const CONVERSION_ID = 'form-view-drawer-keys-removed'; +const D3_ID = 'ui-form-view-drawer-keys-retired'; + +const FIRST_SENTENCE = { + drawerSide: + '`view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a ' + + 'drawer, so no form view ever read it.', + drawerWidth: + '`view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a ' + + 'drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client ' + + 'viewport.', +} as const; +const MIGRATE_SENTENCE = 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources;'; +const KEYS = ['drawerSide', 'drawerWidth'] as const; +const VALUES = { drawerSide: ['left', 'right', 'top', 'bottom'], drawerWidth: ['480px', '50%'] } as const; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Parse = { success: boolean; data?: unknown; error?: { issues: Issue[] } }; +type Door = { name: string; parse: (form: Record) => Parse; at: PropertyKey[] }; + +const SECTIONS = [{ label: 'Main', fields: ['name'] }]; + +/** Every door a form view payload is parsed through, with the path the payload sits at. */ +const DOORS: readonly Door[] = [ + { name: 'FormViewSchema', parse: (f) => FormViewSchema.safeParse(f), at: [] }, + { name: 'view.form', parse: (f) => ViewSchema.safeParse({ form: f }), at: ['form'] }, + { name: 'view.formViews.*', parse: (f) => ViewSchema.safeParse({ formViews: { quick: f } }), at: ['formViews', 'quick'] }, + { + name: 'ViewItem form record', + parse: (f) => ViewItemSchema.safeParse({ name: 'crm_lead.quick', object: 'crm_lead', viewKind: 'form', config: f }), + at: ['config'], + }, + { + name: 'flattened form overlay', + parse: (f) => VIEW_METADATA_MEMBERS.formOverlay.safeParse({ ...f, object: 'crm_lead', viewKind: 'form' }), + at: [], + }, +]; + +/** The issues a door reports under the payload — a wrapper may report others of its own. */ +const payloadIssues = (r: Parse, door: Door) => + (r.error?.issues ?? []).filter((i) => door.at.every((seg, k) => i.path[k] === seg)); + +describe('FormView.drawerSide / drawerWidth — tombstones at every form-view door', () => { + for (const key of KEYS) { + it.each(DOORS.map((d) => [d.name, d] as const))(`%s refuses \`${key}\`, whatever it carries, with its prescription`, (_, door) => { + for (const value of VALUES[key]) { + const r = door.parse({ type: 'simple', sections: SECTIONS, [key]: value }); + expect(r.success, String(value)).toBe(false); + const issues = payloadIssues(r, door); + expect(issues, JSON.stringify(issues)).toHaveLength(1); + expect(issues[0]!.path).toEqual([...door.at, key]); + expect(issues[0]!.message.startsWith(FIRST_SENTENCE[key]), issues[0]!.message).toBe(true); + expect(issues[0]!.message).toContain(MIGRATE_SENTENCE); + } + }); + } + + it.each(DOORS.map((d) => [d.name, d] as const))('CONTROL: %s still accepts the same form without the keys, `modalSize` included', (_, door) => { + const r = door.parse({ type: 'simple', sections: SECTIONS, modalSize: 'lg' }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); + + it('defineStack refuses them with the ADR-0112 envelope and the prescription', () => { + let refusal: unknown; + try { + defineStack({ + views: [{ object: 'crm_lead', formViews: { quick: { sections: SECTIONS, drawerSide: 'left', drawerWidth: '480px' } } }], + } as never); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = (refusal as { issues: { path: PropertyKey[]; message: string }[] }).issues; + expect(issues.map((i) => i.path.join('.')).sort()).toEqual([ + 'views.0.formViews.quick.drawerSide', + 'views.0.formViews.quick.drawerWidth', + ]); + for (const issue of issues) { + const key = issue.path[issue.path.length - 1] as (typeof KEYS)[number]; + expect(issue.message.startsWith(FIRST_SENTENCE[key]), issue.message).toBe(true); + } + expect(() => defineStack({ + views: [{ object: 'crm_lead', formViews: { quick: { sections: SECTIONS, modalSize: 'lg' } } }], + } as never)).not.toThrow(); + }); + + it('is registered in RETIRED_KEYS_BY_MAJOR[18] under the exact def keys', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toEqual(expect.arrayContaining(['ui/FormView:drawerSide', 'ui/FormView:drawerWidth'])); + }); + + it('fails tsc at the authoring site: the tombstones admit only absence', () => { + const form: z.input = { + sections: SECTIONS, + // @ts-expect-error — `drawerSide` is a `retiredKey()` tombstone, typed `never`. + drawerSide: 'left', + }; + const wide: z.input = { + sections: SECTIONS, + // @ts-expect-error — `drawerWidth` is a `retiredKey()` tombstone, typed `never`. + drawerWidth: '480px', + }; + expect(FormViewSchema.safeParse(form).success).toBe(false); + expect(FormViewSchema.safeParse(wide).success).toBe(false); + }); +}); + +describe('the drawer that reads them keeps them', () => { + const objectForm = REACT_BLOCKS.find((b) => b.tag === 'ObjectForm')!; + + it(' publishes both from its overlay, no longer from the form view schema', () => { + for (const key of KEYS) { + const prop = objectForm.interactions.find((i) => i.name === key); + expect(prop, key).toBeDefined(); + expect(prop!.kind).toBe('binding'); + expect(prop!.description).toContain("formType 'drawer'"); + expect(objectForm.dataProps).not.toContain(key); + } + expect(objectForm.interactions.find((i) => i.name === 'drawerSide')!.type).toBe("'top' | 'bottom' | 'left' | 'right'"); + // The collision with the tombstone's name is ledgered, not accidental. + expect(REACT_OVERLAY_SHADOWS.ObjectForm).toEqual(expect.arrayContaining([...KEYS])); + }); + + it('the `object-form` page block still accepts both with its drawer container', () => { + const r = ObjectFormPropsSchema.safeParse({ objectName: 'crm_contact', formType: 'drawer', drawerSide: 'left', drawerWidth: '480px' }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); +}); + +type Notice = Pick; +const brief = (n: ConversionNotice): Notice => ({ conversionId: n.conversionId, path: n.path, from: n.from, to: n.to }); +const ours = (n: ConversionNotice) => n.conversionId === CONVERSION_ID; + +describe(`the D2 conversion ${CONVERSION_ID}`, () => { + it('a STORED view container replays to a body the strict door accepts', () => { + const row = { + object: 'crm_lead', + form: { sections: SECTIONS, columns: 2 }, + formViews: { + quick: { sections: SECTIONS, modalSize: 'lg', drawerSide: 'left', drawerWidth: '480px' }, + }, + }; + expect(ViewSchema.safeParse(row).success).toBe(false); + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('view', row, { onNotice: (n) => notices.push(n) }) as typeof row; + expect(notices.filter(ours).map(brief)).toEqual([ + { conversionId: CONVERSION_ID, path: 'views[0].formViews.quick.drawerSide', from: 'drawerSide', to: '(removed)' }, + { conversionId: CONVERSION_ID, path: 'views[0].formViews.quick.drawerWidth', from: 'drawerWidth', to: '(removed)' }, + ]); + const parsed = ViewSchema.parse(out); + expect(parsed.formViews?.quick).not.toHaveProperty('drawerSide'); + expect(parsed.formViews?.quick).not.toHaveProperty('drawerWidth'); + expect(parsed.formViews?.quick?.modalSize).toBe('lg'); + // A form without the keys is the author's and is never touched. + expect(out.form).toBe(row.form); + // Copy-on-write: the stored row is not mutated. + expect(row.formViews.quick).toHaveProperty('drawerSide', 'left'); + }); + + it('a STORED form view item record converts its `config`', () => { + const row = { name: 'crm_lead.peek', object: 'crm_lead', viewKind: 'form', config: { sections: SECTIONS, drawerWidth: '50%' } }; + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('view', row, { onNotice: (n) => notices.push(n) }) as typeof row; + expect(notices.filter(ours).map((n) => n.path)).toEqual(['views[0].config.drawerWidth']); + expect(out.config).toEqual({ sections: SECTIONS }); + expect(ViewItemSchema.safeParse(out).success).toBe(true); + }); + + it('never touches the `object-form` page block, whose drawer reads the keys', () => { + const input = { + pages: [{ + name: 'desk', + regions: [{ name: 'main', components: [{ type: 'object-form', properties: { objectName: 'o', formType: 'drawer', drawerSide: 'left' } }] }], + }], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + expect(notices.filter(ours)).toEqual([]); + expect(stack).toEqual(input); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const { stack } = collectConversionNotices( + { views: [{ object: 'o', form: { sections: SECTIONS, drawerSide: 'right', drawerWidth: '480px' } }] }, + { includeRetired: true }, + ); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices.filter(ours)).toEqual([]); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — the authoring funnel rewrites no live source', () => { + const input = { views: [{ object: 'o', form: { sections: SECTIONS, drawerSide: 'left' } }] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter(ours)).toEqual([]); + expect(stack).toEqual(input); + }); + + it('is wired into the step-18 chain as a retired entry stamped after 18.2.0', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion).toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toBe('18.2.0'); + }); + + it(`carries the D3 entry ${D3_ID}, naming its D2 conversion`, () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries).toHaveLength(1); + expect(entries[0]!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entries[0]!.conversionIds).toEqual([CONVERSION_ID]); + expect(entries[0]!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps the TYPED authoring sites; this walk covers every text file +// under the five repo roots `scripts/cross-package-test-inputs.mjs` declares +// for `@objectstack/spec#test` (mirrored in `turbo.json`), with the example +// apps' own `src/` trees. It judges the two places a form view is written as +// an object literal: a `form: { … }` literal whose own top-level keys include +// a drawer key, and a `formViews: { … }` literal one of whose entries does. The +// bound, stated: the keys stay legal on the React `ObjectForm` block (JSX +// attributes) and the `object-form` page block (`properties: { … }`), which +// neither shape matches; a form assembled by spread or a computed key, a +// ViewItem form record's `config`, YAML, and the console under `ui/` (outside +// this radius) are outside what this walk sees. +describe('tree-scoped absence: no form view inside the declared radius still authors a drawer key', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx']); + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + const DRAWER_KEY = /(?:^|[\s,{])["']?(?:drawerSide|drawerWidth)["']?\s*:/; + + /** The text of each `{ … }` literal at `depth` inside the literal opened at `start`. */ + const topLevelText = (text: string, start: number, depth: number): string[] => { + const out: string[] = []; + let d = 0; + let cur = ''; + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '{') { + d += 1; + if (d === depth) cur = ''; + } else if (ch === '}') { + if (d === depth) out.push(cur); + d -= 1; + if (d === 0) break; + } else if (d === depth) cur += ch; + } + return out; + }; + + /** Whether some form view literal holds a drawer key among its own top-level keys. */ + const judge = (raw: string): boolean => { + // Inline code spans are prose; newline-bounded, so a fenced example is still judged. + const text = raw.replace(/`[^`\n]*`/g, ''); + const opener = /["']?\b(form|formViews)["']?\s*:\s*\{/g; + for (let m = opener.exec(text); m; m = opener.exec(text)) { + const brace = m.index + m[0].length - 1; + const depth = m[1] === 'form' ? 1 : 2; + if (topLevelText(text, brace, depth).some((t) => DRAWER_KEY.test(t))) return true; + } + return false; + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired keys. + */ + const EXCLUDED = new Set([ + // This pin authors the keys to assert their refusal and their conversion. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement form on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + ]; + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores its neighbours (anti-vacuity)', () => { + expect(judge("form: { type: 'simple', drawerSide: 'left', sections: [] },")).toBe(true); + expect(judge("formViews: { quick: { modalSize: 'lg', drawerWidth: '480px' } },")).toBe(true); + expect(judge('{ "formViews": {\n "quick": { "drawerSide": "left" }\n} }')).toBe(true); + expect(judge("Prose.\n\n```ts\nform: { drawerWidth: '50%' }\n```\n")).toBe(true); + // Neighbours that must NOT match: the blocks that keep the keys, and deeper nesting. + expect(judge("properties: { objectName: 'task', formType: 'drawer', drawerSide: 'right' },")).toBe(false); + expect(judge('')).toBe(false); + expect(judge("form: { modalSize: 'lg', sections: [{ label: 'A', fields: [] }] },")).toBe(false); + expect(judge("formViews: { quick: { modalSize: 'lg' } },")).toBe(false); + expect(judge("form: { sections: [{ props: { drawerSide: 'left' } }] }")).toBe(false); + expect(judge('a form once wrote `form: { drawerSide: "left" }`.')).toBe(false); + }); + + it('no form view authoring `drawerSide` or `drawerWidth` survives inside the declared radius', () => { + const offenders: string[] = []; + let visited = 0; + let mentioning = 0; + const skipDir = (name: string) => SKIPPED_DIRS.has(name) || name.startsWith('.'); + for (const root of WALK_ROOTS) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + const name = path.basename(full); + const ext = path.extname(name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(name)) continue; + visited += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + if (/["']?\bformViews["']?\s*:\s*\{/.test(text)) mentioning += 1; + if (judge(text)) offenders.push(rel); + } + } + // Anti-vacuity: the walk covered the tree, and files that DO author a + // `formViews` block were really judged. + expect(visited).toBeGreaterThan(1000); + expect(mentioning).toBeGreaterThan(0); + expect(offenders, 'a form view authoring a drawer key means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/form-view-type-layout-retirement.test.ts b/packages/spec/src/ui/form-view-type-layout-retirement.test.ts index c41150d5a7b..d95af823f44 100644 --- a/packages/spec/src/ui/form-view-type-layout-retirement.test.ts +++ b/packages/spec/src/ui/form-view-type-layout-retirement.test.ts @@ -16,8 +16,10 @@ * 2. The split-only keys `sections[].pane` (and the legacy `groups[].pane`), * `splitSize`, `splitResizable` and — since the renderer half, #27159 — * `splitDirection` are `retiredKey()` tombstones, refused on every - * layout. `drawerSide`, `drawerWidth` and `modalSize` stay declared — a - * stated boundary, pinned as one. + * layout. `modalSize` stays declared — a stated boundary, pinned as one. + * ([#29012] `drawerSide` / `drawerWidth` stayed here and were retired + * later in the same release: `form-view-drawer-keys-removed`, pinned in + * `form-view-drawer-keys-retirement.test.ts`.) * 3. The `object-form` page block (#27159): `formType` drops `split` and * keeps `drawer` / `modal` (the block renderer is the caller that opens * those containers); its `splitDirection`, `splitSize`, `splitResizable` @@ -226,16 +228,13 @@ describe('the split-only keys — `pane`, `splitSize`, `splitResizable`, `splitD ])); }); - it('STATED BOUNDARY: `drawerSide`, `drawerWidth` and `modalSize` stay declared', () => { - // `modalSize` is read by the console's record dialog on every layout; the - // other two feed the React `ObjectForm` block contract (`react-blocks.ts`), - // whose `formType='drawer'` container still reads them (#27153 keeps their - // enforce-or-remove question for form views). + it('STATED BOUNDARY: `modalSize` stays declared', () => { + // `modalSize` is read by the console's record dialog on every layout. + // [#29012] `drawerSide` / `drawerWidth` left this boundary: they are + // tombstones now (`form-view-drawer-keys-retirement.test.ts`). const r = FormViewSchema.safeParse({ type: 'simple', sections: SECTIONS, - drawerSide: 'left', - drawerWidth: '480px', modalSize: 'lg', }); expect(r.success).toBe(true); @@ -415,7 +414,8 @@ describe(`the D2 conversion ${CONVERSION_ID}`, () => { splitDirection: 'vertical', sections: [{ label: 'A', pane: 'primary', fields: ['name'] }, { label: 'B', pane: 'secondary', fields: ['email'] }], }, - quick: { type: 'drawer', drawerSide: 'left', sections: [{ label: 'Q', fields: ['status'] }] }, + // [#29012] The bystander is `modalSize`: `drawerSide` is stripped by its own conversion now. + quick: { type: 'drawer', modalSize: 'lg', sections: [{ label: 'Q', fields: ['status'] }] }, }, }; const notices: ConversionNotice[] = []; @@ -432,7 +432,7 @@ describe(`the D2 conversion ${CONVERSION_ID}`, () => { expect(parsed.formViews?.beside?.type).toBe('simple'); expect(parsed.formViews?.beside).not.toHaveProperty('splitDirection'); expect(parsed.formViews?.quick?.type).toBe('simple'); - expect(parsed.formViews?.quick?.drawerSide).toBe('left'); + expect(parsed.formViews?.quick?.modalSize).toBe('lg'); // A live layout is the author's and is never touched. expect(out.form).toBe(row.form); // Copy-on-write: the stored row is not mutated. diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index f359bad97c4..82bc0c8a61a 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -87,6 +87,15 @@ export const REACT_OVERLAY_SHADOWS: Readonly> // REQUIRED to write on this tier — the binding `objectName` carried until // its retirement, now spelled `data={{ provider: 'object', object }}` only. ListView: ['data', 'navigation'], + // `drawerSide` / `drawerWidth` — [#29012] the schema member is a `retiredKey()` + // tombstone, not a live prop: a form VIEW never opens in a drawer, so + // `FormViewSchema` refuses both, while this block's `formType='drawer'` + // container still reads them. The overlay is now the block's only + // declaration of the two props, with the meaning the block always published + // (the #9392 rule: a prop only the react/registry tier reads lives here, not + // in `dataProps`). The collision is the tombstone's name, never a second + // meaning. + ObjectForm: ['drawerSide', 'drawerWidth'], }; /** @@ -239,7 +248,9 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ summary: "Server-connected create/edit/view form for one object. Config props come from the spec FormView schema; bind + wire it with the React props below.", schema: FormViewSchema, // [#27159] `splitDirection` left with the split form renderer (ADR-0050). - dataProps: ['layout', 'columns', 'tabPosition', 'drawerSide', 'drawerWidth', 'modalSize', 'sections', 'subforms', 'submitBehavior'], + // [#29012] `drawerSide` / `drawerWidth` left the form view (tombstones) and + // moved to the overlay below: the block's drawer container still reads them. + dataProps: ['layout', 'columns', 'tabPosition', 'modalSize', 'sections', 'subforms', 'submitBehavior'], interactions: [ OBJECT_NAME, { name: 'mode', type: "'create' | 'edit' | 'view'", kind: 'controlled', description: 'Create a new record, or edit/view an existing one — drive from React state.' }, @@ -263,6 +274,12 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ // navigation family ruling — see the baseline note on #9392). { name: 'customFields', type: 'any[]', kind: 'binding', description: 'Field definitions merged over the set generated from object metadata. With inline definitions and no data source, this becomes the only field source.' }, { name: 'readOnly', type: 'boolean', kind: 'binding', description: 'Render every field read-only, whatever `mode` says.' }, + // [#29012] Retired from FormViewSchema (a form view never opens in a + // drawer) and declared here, with the description the block published + // from the schema: only this block's `formType='drawer'` container + // (objectui `ObjectFormContainer` → `DrawerForm`) reads them. + { name: 'drawerSide', type: "'top' | 'bottom' | 'left' | 'right'", kind: 'binding', description: "Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer'" }, + { name: 'drawerWidth', type: 'string', kind: 'binding', description: '[DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType \'drawer\', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it.' }, { name: 'modalCloseButton', type: 'boolean', kind: 'binding', description: "Show the close button of the block's modal container (formType 'modal')." }, { name: 'contentLayout', type: "'simple' | 'tabbed'", kind: 'binding', description: "How the modal presentation lays out sections; 'tabbed' needs more than one section to differ from 'simple'." }, { name: 'confirmOnDiscard', type: 'boolean', kind: 'binding', description: 'Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set false to close immediately.' }, diff --git a/packages/spec/src/ui/view-metadata-schema.test.ts b/packages/spec/src/ui/view-metadata-schema.test.ts index e7791b6c7cf..812ef2cff51 100644 --- a/packages/spec/src/ui/view-metadata-schema.test.ts +++ b/packages/spec/src/ui/view-metadata-schema.test.ts @@ -380,10 +380,11 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha it('derives its vocabulary from the members, so a new arm key is admitted automatically', () => { // Not a hand-written list: every top-level key any member declares counts. - // `drawerSide` is a FormView key nobody would think to allow-list by - // hand. ([#7741] full acceptance additionally needs the binding pair; the - // vocabulary question this test asks is unchanged.) - expect(ViewMetadataSchema.safeParse({ drawerSide: 'left', object: 'crm_lead', viewKind: 'form' }).success).toBe(true); + // `showStepIndicator` is a FormView key nobody would think to allow-list + // by hand. ([#7741] full acceptance additionally needs the binding pair; + // the vocabulary question this test asks is unchanged. [#29012] It was + // `drawerSide` until that key became a tombstone.) + expect(ViewMetadataSchema.safeParse({ showStepIndicator: true, object: 'crm_lead', viewKind: 'form' }).success).toBe(true); // …and a key that exists only NESTED (inside `config`) is not top-level // vocabulary, so it cannot smuggle a garbage body through. expect(ViewMetadataSchema.safeParse({ groupByField: 'stage' }).success).toBe(false); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 53cd12faac2..e89e25aad27 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5015,6 +5015,31 @@ const FORM_VIEW_BUTTONS_RESET_RETIRED = + 'longer clears after a save, and a form that should declares `submitBehavior: { kind: \'continue\' }`. ' + FORM_VIEW_TYPE_RETIRED_FIX; +// [#29012] The drawer keys (ADR-0049 enforce-or-remove, triage ruling: retire). +// `type: 'drawer'` left the form view in #27155, so no form view opens in a +// drawer, and no metadata form-view surface (the console's form page, its New +// / Edit dialogs, the record form page, Studio) reads either key: a stored +// `drawerSide: 'left'` saved, served and changed nothing. The one drawer that +// reads them is the container the React `ObjectForm` block opens with +// `formType='drawer'`, and that block keeps both as props of its own +// (`react-blocks.ts` overlay; the `object-form` page block's +// `ObjectFormPropsSchema` declares them too). The +// `form-view-drawer-keys-removed` conversion (ADR-0087 D2) strips them from +// stored rows and sources. +const FORM_VIEW_DRAWER_SIDE_RETIRED = + '`view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a ' + + 'drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer ' + + '(`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it ' + + 'opens with `formType=\'drawer\'`. ' + + FORM_VIEW_TYPE_RETIRED_FIX; + +const FORM_VIEW_DRAWER_WIDTH_RETIRED = + '`view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a ' + + 'drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client ' + + 'viewport. Delete the key: a list\'s record drawer takes its starting width from the `navigation.size` ' + + 'bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. ' + + FORM_VIEW_TYPE_RETIRED_FIX; + // Form-view `layout` retired-value prescriptions (#20221, ADR-0049 // enforce-or-remove). Declared with `//` on purpose — the // `LIST_VIEW_EXPORT_PDF_RETIRED` placement note applies here too. An @@ -5163,16 +5188,13 @@ export const FormViewSchema = lazySchema(() => strictObject({ /** [#27155] RETIRED with `type: 'split'` — {@link FORM_VIEW_SPLIT_RESIZABLE_RETIRED}. */ splitResizable: retiredKey(FORM_VIEW_SPLIT_RESIZABLE_RETIRED), /** - * Drawer side and width. [#27155] Inert on a form view since `type: 'drawer'` - * was retired. [#27159] Both keep a live reader: the React `ObjectForm` block - * takes its contract from this schema (`react-blocks.ts`), and a React page's - * `formType='drawer'` opens the drawer container the block supplies, which - * reads them. The drawer half of their enforce-or-remove question stays open - * on #27153. + * [#29012] RETIRED — {@link FORM_VIEW_DRAWER_SIDE_RETIRED}. Inert on a form + * view since `type: 'drawer'` left it (#27155); the React `ObjectForm` block + * declares its own `drawerSide` in its overlay (`react-blocks.ts`). */ - drawerSide: z.enum(['top', 'bottom', 'left', 'right']).optional().describe("Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer'"), - /** @deprecated [#2578 → `modalSize` / size buckets] A pixel width can't be authored blind (unknown client viewport); the renderer derives width from content + viewport. */ - drawerWidth: z.string().optional().describe('[DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType \'drawer\', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it.'), + drawerSide: retiredKey(FORM_VIEW_DRAWER_SIDE_RETIRED), + /** [#29012] RETIRED — {@link FORM_VIEW_DRAWER_WIDTH_RETIRED}. */ + drawerWidth: retiredKey(FORM_VIEW_DRAWER_WIDTH_RETIRED), /** * Size bucket of the dialog a caller opens this form in. Live on every * layout: the console's New / Edit dialog reads it off the form view it diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 6dbf4e9f628..e6cb09a24da 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -74,6 +74,7 @@ "src/ui/element-text-variant-heading-retirement.test.ts", "src/ui/form-field-public-picker-retirement.test.ts", "src/ui/form-view-buttons-reset-retirement.test.ts", + "src/ui/form-view-drawer-keys-retirement.test.ts", "src/ui/form-view-type-layout-retirement.test.ts", "src/ui/master-detail-detail-sort-field-retirement.test.ts", "src/ui/navigation-mode-retirement.test.ts", From 686739bedc2c1980b200423ce8594bb37ef975d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:42:39 +0000 Subject: [PATCH 2/6] chore(spec): regenerate artifacts for the FormView drawer-key retirement authorable-surface (two [RETIRED] rows), reference docs, the react-blocks contract (drawerSide / drawerWidth now published from the ObjectForm overlay) and the liveness counts. Claude-Session: https://claude.ai/code/session_01ADHkmSVA1CYQ5wh8VMGZ5r Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 8 ++++---- content/docs/references/ui/view.mdx | 20 +++++++++---------- packages/spec/authorable-surface/ui.json | 4 ++-- packages/spec/liveness/state-counts/view.md | 2 +- .../objectstack-ui/references/react-blocks.md | 4 ++-- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index feda4f59e89..2d1c10ea9de 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1916,8 +1916,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -1995,8 +1995,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 9ae054c37e6..f5e5689d813 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -400,8 +400,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -1809,8 +1809,8 @@ Tab configuration for multi-tab view interface | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -1888,8 +1888,8 @@ Tab configuration for multi-tab view interface | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -2227,8 +2227,8 @@ This schema accepts one of the following structures: | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -2427,8 +2427,8 @@ This schema accepts one of the following structures: | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index be773fe3492..03f58c6218a 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -574,8 +574,8 @@ "ui/FormView:defaultTab", "ui/FormView:defaults", "ui/FormView:description", - "ui/FormView:drawerSide", - "ui/FormView:drawerWidth", + "ui/FormView:drawerSide [RETIRED]", + "ui/FormView:drawerWidth [RETIRED]", "ui/FormView:groups", "ui/FormView:layout", "ui/FormView:modalSize", diff --git a/packages/spec/liveness/state-counts/view.md b/packages/spec/liveness/state-counts/view.md index 260f861304e..a040c094b70 100644 --- a/packages/spec/liveness/state-counts/view.md +++ b/packages/spec/liveness/state-counts/view.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `view` | 75 | 0 | 0 | 8 | 0 | 83 | +| `view` | 73 | 0 | 0 | 10 | 0 | 83 | diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 5e5d0513044..8caecbf7371 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -23,6 +23,8 @@ Server-connected create/edit/view form for one object. Config props come from th | `initialValues` | `Record` | binding | | Prefill values in create mode. | | `customFields` | `any[]` | binding | | Field definitions merged over the set generated from object metadata. With inline definitions and no data source, this becomes the only field source. | | `readOnly` | `boolean` | binding | | Render every field read-only, whatever `mode` says. | +| `drawerSide` | `'top' \| 'bottom' \| 'left' \| 'right'` | binding | | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | +| `drawerWidth` | `string` | binding | | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | | `modalCloseButton` | `boolean` | binding | | Show the close button of the block's modal container (formType 'modal'). | | `contentLayout` | `'simple' \| 'tabbed'` | binding | | How the modal presentation lays out sections; 'tabbed' needs more than one section to differ from 'simple'. | | `confirmOnDiscard` | `boolean` | binding | | Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set false to close immediately. | @@ -44,8 +46,6 @@ Server-connected create/edit/view form for one object. Config props come from th | `layout` | `'vertical' \| 'horizontal'` | data | | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | `columns` | `integer` | data | | Number of columns for the form body | | `tabPosition` | `'top' \| 'bottom' \| 'left' \| 'right'` | data | | Tab strip position (tabbed forms) | -| `drawerSide` | `'top' \| 'bottom' \| 'left' \| 'right'` | data | | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| `drawerWidth` | `string` | data | | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chos… | | `modalSize` | `'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'` | data | | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal… | | `sections` | `object[]` | data | | | | `subforms` | `object[]` | data | | Inline master-detail child collections | From 0dc8fcc3b75064b809588313ea1c1e10dd6bf0fa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:43:45 +0000 Subject: [PATCH 3/6] test(spec): drop the metadata-form omit row for the retired FormView drawerWidth The reconciliation ledger excuses a tombstoned key automatically and refuses an entry for a key that is no longer authorable. Claude-Session: https://claude.ai/code/session_01ADHkmSVA1CYQ5wh8VMGZ5r Co-authored-by: Claude --- .../src/system/metadata-form-zod-reconciliation.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 10220b34c84..fd5adc838bd 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -445,13 +445,6 @@ const LEDGER: ReadonlyArray = [ key: 'titleFormat', why: "`[DEPRECATED → nameField (ADR-0079)]` render-only title template the server cannot return or query, deliberately not offered to new authors; its own describe prescribes the migration — a single-field title to `nameField`, a composite title that import, sort or search must match to a stored computed field (a non-formula field with an `expression`) designated as `nameField`, a text formula staying legal for display only — and every target is authorable", }, - { - kind: 'omit', - type: 'view', - path: ROOT_PATH, - key: 'drawerWidth', - why: '`[DEPRECATED → size buckets]` pixel drawer width, deliberately not offered to new authors: a pixel width cannot be chosen without knowing the client viewport, so the renderer derives it from the size bucket', - }, { kind: 'omit', type: 'view', From 8c7bfc303c46d669e401ac727afde0e3e832d35e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:44:49 +0000 Subject: [PATCH 4/6] chore(changeset): FormView drawerSide / drawerWidth retirement (BREAKING, minor on spec) Claude-Session: https://claude.ai/code/session_01ADHkmSVA1CYQ5wh8VMGZ5r Co-authored-by: Claude --- .../29012-form-view-drawer-keys-retired.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/29012-form-view-drawer-keys-retired.md diff --git a/.changeset/29012-form-view-drawer-keys-retired.md b/.changeset/29012-form-view-drawer-keys-retired.md new file mode 100644 index 00000000000..4493a9c5a0f --- /dev/null +++ b/.changeset/29012-form-view-drawer-keys-retired.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: a form view no longer declares `drawerSide` / `drawerWidth`; no form view opens in a drawer, so neither key was ever read there + +Clause-②: no (narrowing) + +Metadata-protocol: narrowed + +Skills-docs: regenerated here — `skills/objectstack-ui/references/react-blocks.md` (`gen:react-blocks`) lists ``'s `drawerSide` / `drawerWidth` as binding props from the block's overlay instead of data props from the form view schema, with the same types and descriptions; no hand-written file under `skills/**` teaches either key on a form view (a `git grep` of `skills/` names only that contract and `rules/record-page.md`'s list-drawer width on `navigation`, which this does not touch). + + + +**BREAKING** accept-set narrowing on the form view surface (`@objectstack/spec`), ADR-0049 enforce-or-remove. + +`type: 'drawer'` left the form view earlier in this release, so no form view opens in a drawer. No form-view surface read the two keys either: the console's form page, its New and Edit dialogs, the record form page and Studio all ignored them, so a form view saved with `drawerSide: 'left', drawerWidth: '480px'` was accepted, served and drawn exactly as one without them. + +- **FROM → TO.** `form: { …, drawerSide, drawerWidth }` (and `formViews.`, a form view item's `config`) → `form: { … }`. Nothing on the form view replaces them. A list opens a clicked record in its drawer (`navigation.mode`) and sizes it with the `navigation.size` bucket; a React page that opens a form in a drawer writes the props on the block, `ObjectForm` with `formType='drawer'`. +- **The one-line fix.** Delete `drawerSide` and `drawerWidth` from the form view. `os migrate meta --from 17` lists the edits for existing sources, and `--write` applies them. A stored `sys_metadata` row is converted when it is read (the `form-view-drawer-keys-removed` conversion), so a form view saved with the keys keeps loading, and nothing a reader sees changes. +- **What the parse says.** "`view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. …" (and the same for `drawerWidth`), at every form view door (a view's `form` and `formViews.*`, a form view item record, a flattened form overlay) and at `defineStack` as `STACK_SCHEMA_INVALID` / 422. TypeScript rejects the keys at the authoring site. +- **Unchanged.** The React `ObjectForm` block still takes `drawerSide` / `drawerWidth` for the drawer it opens with `formType='drawer'`: the react-tier contract now declares them on the block itself, with the same types and descriptions. The `object-form` page block keeps both in its own props. `modalSize` stays on the form view; the console's record dialog reads it. From 8a8c90a767b0b890e7752864a1aabf6f46b1c2af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:05:15 +0000 Subject: [PATCH 5/6] chore(changeset): correct 27155's Unchanged bullet for the drawer-key retirement The pending 27155 note said drawerSide / drawerWidth stay declared on the form view; this branch retires both in the same 18.3.0 release. Drop them from the sentence and say they retire in the same release. splitDirection and modalSize are left as written. Deliberate correction: check-empty-changeset exits 1 on a modified pending changeset by design. Claude-Session: https://claude.ai/code/session_01ADHkmSVA1CYQ5wh8VMGZ5r Co-authored-by: Claude --- .changeset/27155-form-view-type-layout-only.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/27155-form-view-type-layout-only.md b/.changeset/27155-form-view-type-layout-only.md index e7e508f7734..3a181f51686 100644 --- a/.changeset/27155-form-view-type-layout-only.md +++ b/.changeset/27155-form-view-type-layout-only.md @@ -18,6 +18,6 @@ The model: `type` says how the form lays out its sections. Where a form opens is - **FROM → TO.** `type: 'split'` → `type: 'tabbed'` (one tab per section) or delete the value for `simple`; delete each section's `pane` and the form's `splitSize` / `splitResizable`; declare `subforms` to edit child records beside the parent. `type: 'drawer'` or `type: 'modal'` → delete the value for `simple`, or write the layout you want. A list opens a clicked record as its `navigation.mode` says (the drawer by default), and the console's New and Edit entries open the record form themselves, sized by the form's `modalSize`. - **The one-line fix.** Delete the retired value and the three split-only keys. `os migrate meta --from 17` lists the edits for existing sources, and `--write` applies them. A stored `sys_metadata` row is converted when it is read (the `form-view-presentation-types-removed` conversion), so a form view saved with one of the three values keeps loading as a `simple` form. - **What the parse says.** Each retired value is refused by name with its prescription, for example "'modal' was removed from the form view `type` enum in @objectstack/spec 18.3.0 (ADR-0050) — …", at every form view door (a view's `form` and `formViews.*`, a form view item record, a flattened form overlay) and at `defineStack` as `STACK_SCHEMA_INVALID` / 422. `pane`, `splitSize` and `splitResizable` are refused on every layout. TypeScript rejects each at the authoring site. A misspelt value keeps zod's own message. -- **Unchanged.** `splitDirection`, `drawerSide`, `drawerWidth` and `modalSize` stay declared: the console reads `modalSize` for its record dialog, and the React `ObjectForm` block contract still takes the other three from this schema until its renderer follows. +- **Unchanged.** `splitDirection` and `modalSize` stay declared: the console reads `modalSize` for its record dialog, and the React `ObjectForm` block contract still takes `splitDirection` from this schema until its renderer follows. `drawerSide` and `drawerWidth` retire from the form view in the same release (#29012); the React `ObjectForm` block keeps them as its own props. The console (`@object-ui/app-shell`): Studio's New View form offers the three form layouts only. It offered all six, and a form view created with `split`, `drawer` or `modal` would now be refused when saved. From ffc3fe89618b871f7bd587b607da9e42cad6244f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:52:43 +0000 Subject: [PATCH 6/6] fix(spec): finish the 27155 correction, drop the block drawerWidth's size-bucket tag, bump skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 27155's Unchanged bullet: only modalSize stays declared; splitDirection (#27159) and drawerSide / drawerWidth retire from the form view in the same release, and the React ObjectForm block keeps the drawer pair as its own props. Deliberate correction of a pending note: check-empty-changeset exits 1 by design. - The ObjectForm overlay drawerWidth no longer carries the form view's [DEPRECATED → size buckets] tag: the block's drawer has no size bucket. Its description now says what DrawerForm does with the value. react-blocks.md regenerated. - The drawer-keys changeset bumps @objectstack/skills (patch): the regenerated react-blocks.md ships in that package. Claude-Session: https://claude.ai/code/session_01ADHkmSVA1CYQ5wh8VMGZ5r Co-authored-by: Claude --- .changeset/27155-form-view-type-layout-only.md | 2 +- .changeset/29012-form-view-drawer-keys-retired.md | 3 ++- packages/spec/src/ui/react-blocks.ts | 2 +- skills/objectstack-ui/references/react-blocks.md | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/27155-form-view-type-layout-only.md b/.changeset/27155-form-view-type-layout-only.md index 3a181f51686..34aebfcfa6d 100644 --- a/.changeset/27155-form-view-type-layout-only.md +++ b/.changeset/27155-form-view-type-layout-only.md @@ -18,6 +18,6 @@ The model: `type` says how the form lays out its sections. Where a form opens is - **FROM → TO.** `type: 'split'` → `type: 'tabbed'` (one tab per section) or delete the value for `simple`; delete each section's `pane` and the form's `splitSize` / `splitResizable`; declare `subforms` to edit child records beside the parent. `type: 'drawer'` or `type: 'modal'` → delete the value for `simple`, or write the layout you want. A list opens a clicked record as its `navigation.mode` says (the drawer by default), and the console's New and Edit entries open the record form themselves, sized by the form's `modalSize`. - **The one-line fix.** Delete the retired value and the three split-only keys. `os migrate meta --from 17` lists the edits for existing sources, and `--write` applies them. A stored `sys_metadata` row is converted when it is read (the `form-view-presentation-types-removed` conversion), so a form view saved with one of the three values keeps loading as a `simple` form. - **What the parse says.** Each retired value is refused by name with its prescription, for example "'modal' was removed from the form view `type` enum in @objectstack/spec 18.3.0 (ADR-0050) — …", at every form view door (a view's `form` and `formViews.*`, a form view item record, a flattened form overlay) and at `defineStack` as `STACK_SCHEMA_INVALID` / 422. `pane`, `splitSize` and `splitResizable` are refused on every layout. TypeScript rejects each at the authoring site. A misspelt value keeps zod's own message. -- **Unchanged.** `splitDirection` and `modalSize` stay declared: the console reads `modalSize` for its record dialog, and the React `ObjectForm` block contract still takes `splitDirection` from this schema until its renderer follows. `drawerSide` and `drawerWidth` retire from the form view in the same release (#29012); the React `ObjectForm` block keeps them as its own props. +- **Unchanged.** `modalSize` stays declared: the console reads it for its record dialog. `splitDirection` (#27159) and `drawerSide` / `drawerWidth` (#29012) retire from the form view in the same release; the React `ObjectForm` block keeps the drawer pair as its own props. The console (`@object-ui/app-shell`): Studio's New View form offers the three form layouts only. It offered all six, and a form view created with `split`, `drawer` or `modal` would now be refused when saved. diff --git a/.changeset/29012-form-view-drawer-keys-retired.md b/.changeset/29012-form-view-drawer-keys-retired.md index 4493a9c5a0f..0e036d2542f 100644 --- a/.changeset/29012-form-view-drawer-keys-retired.md +++ b/.changeset/29012-form-view-drawer-keys-retired.md @@ -1,5 +1,6 @@ --- '@objectstack/spec': minor +'@objectstack/skills': patch --- feat(spec)!: a form view no longer declares `drawerSide` / `drawerWidth`; no form view opens in a drawer, so neither key was ever read there @@ -8,7 +9,7 @@ Clause-②: no (narrowing) Metadata-protocol: narrowed -Skills-docs: regenerated here — `skills/objectstack-ui/references/react-blocks.md` (`gen:react-blocks`) lists ``'s `drawerSide` / `drawerWidth` as binding props from the block's overlay instead of data props from the form view schema, with the same types and descriptions; no hand-written file under `skills/**` teaches either key on a form view (a `git grep` of `skills/` names only that contract and `rules/record-page.md`'s list-drawer width on `navigation`, which this does not touch). +Skills-docs: regenerated here — `skills/objectstack-ui/references/react-blocks.md` (`gen:react-blocks`) lists ``'s `drawerSide` / `drawerWidth` as binding props from the block's overlay instead of data props from the form view schema, with the same types; `drawerWidth`'s description drops the form view's `[DEPRECATED → size buckets]` tag (the block's drawer has no size bucket) and says the value is the drawer's width, or its height from the top or bottom edge; no hand-written file under `skills/**` teaches either key on a form view (a `git grep` of `skills/` names only that contract and `rules/record-page.md`'s list-drawer width on `navigation`, which this does not touch). diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index 82bc0c8a61a..7915dcacfe0 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -279,7 +279,7 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ // from the schema: only this block's `formType='drawer'` container // (objectui `ObjectFormContainer` → `DrawerForm`) reads them. { name: 'drawerSide', type: "'top' | 'bottom' | 'left' | 'right'", kind: 'binding', description: "Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer'" }, - { name: 'drawerWidth', type: 'string', kind: 'binding', description: '[DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType \'drawer\', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it.' }, + { name: 'drawerWidth', type: 'string', kind: 'binding', description: 'Size of the drawer container the React `ObjectForm` block opens with formType \'drawer\', as a CSS length such as "480px": its width when drawerSide is left or right, its height when top or bottom. Unset, the drawer keeps its default size.' }, { name: 'modalCloseButton', type: 'boolean', kind: 'binding', description: "Show the close button of the block's modal container (formType 'modal')." }, { name: 'contentLayout', type: "'simple' | 'tabbed'", kind: 'binding', description: "How the modal presentation lays out sections; 'tabbed' needs more than one section to differ from 'simple'." }, { name: 'confirmOnDiscard', type: 'boolean', kind: 'binding', description: 'Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set false to close immediately.' }, diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 8caecbf7371..9ec87202440 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -24,7 +24,7 @@ Server-connected create/edit/view form for one object. Config props come from th | `customFields` | `any[]` | binding | | Field definitions merged over the set generated from object metadata. With inline definitions and no data source, this becomes the only field source. | | `readOnly` | `boolean` | binding | | Render every field read-only, whatever `mode` says. | | `drawerSide` | `'top' \| 'bottom' \| 'left' \| 'right'` | binding | | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| `drawerWidth` | `string` | binding | | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| `drawerWidth` | `string` | binding | | Size of the drawer container the React `ObjectForm` block opens with formType 'drawer', as a CSS length such as "480px": its width when drawerSide is left or right, its height when top or bottom. Unset, the drawer keeps its default size. | | `modalCloseButton` | `boolean` | binding | | Show the close button of the block's modal container (formType 'modal'). | | `contentLayout` | `'simple' \| 'tabbed'` | binding | | How the modal presentation lays out sections; 'tabbed' needs more than one section to differ from 'simple'. | | `confirmOnDiscard` | `boolean` | binding | | Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set false to close immediately. |