From 5894688d04086376b82d9ae77a749b1097e7d7da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:30:34 +0000 Subject: [PATCH 1/3] feat(spec)!: retire ListViewSchema.navigation.view (#16885) WIP: tombstone + ADR-0087 registration. Generated artifacts follow. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- .../18.ui__NavigationConfig__view.ts | 25 +++++++ .../18.list-view-navigation-view-retired.ts | 47 +++++++++++++ packages/spec/src/migrations/registry.ts | 66 +++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 43 ++++++++++-- 4 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__NavigationConfig__view.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.list-view-navigation-view-retired.ts diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__NavigationConfig__view.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__NavigationConfig__view.ts new file mode 100644 index 00000000000..621b7e439df --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__NavigationConfig__view.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16885 — the list view's `navigation.view` binding, retired under ADR-0049 +// enforce-or-remove by maintainer ruling 2026-09-13 (director decision batch +// #126 item 4, verbatim 「同意」, option B). The key's describe promised "the +// form view to use for details" and no layer from spec to console ever +// resolved a view BY NAME: its one read in the shipped console passed the +// value into the SECOND argument of `onNavigate` — the slot that otherwise +// carries the navigation-mode token — so an authored name substituted for the +// mode rather than selecting a view, and a consumer reading that argument +// against its closed `edit`/`view` vocabulary matched neither branch. Zero +// authored instances in this repo; the one external author deleted its +// occurrence. ONE key and one entry: `NavigationConfigSchema` is reused BY +// REFERENCE (`ListViewSchema.navigation` is its only referent), so the walked +// shape has a single `ui/NavigationConfig` def and the baseline marks one line +// `[RETIRED]`. +// +// Registered here but NOT in `src/conversions/registry.ts`, and deliberately: +// the ruling's disposition is a D3 SEMANTIC entry +// (`list-view-navigation-view-retired`). A mechanical strip would delete the +// key without telling anyone which list view lost it, and an author who wrote +// it wanted a named detail layout — a want that page assignment serves and a +// stripped key does not record. So the prescription reaches consumers as that +// semantic TODO plus this tombstone. +export const entry = 'ui/NavigationConfig:view'; diff --git a/packages/spec/src/migrations/entries/semantic/18.list-view-navigation-view-retired.ts b/packages/spec/src/migrations/entries/semantic/18.list-view-navigation-view-retired.ts new file mode 100644 index 00000000000..14f3a1de9c1 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.list-view-navigation-view-retired.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'list-view-navigation-view-retired', + surface: 'view.list.navigation.view', + replacement: + 'page assignment — assign a `record` page to the object and let `isDefault` pick the one ' + + 'that opens. That is the machinery that resolves a detail layout by name; a list view\'s ' + + '`navigation` block decides only HOW the detail is surfaced (`mode`, `size`, ' + + '`preventNavigation`, `openNewTab`, `width`), and every one of those keys is unchanged', + reason: + 'DECLARED, CONSUMED, AND WRONG — which is why this is a semantic TODO rather than a ' + + 'mechanical strip. The key\'s describe promised "the form view to use for details" and ' + + 'no layer from spec to console ever resolved a view by name. Its only read in the ' + + 'shipped console put the value in the SECOND argument of `onNavigate`, the slot that ' + + 'otherwise carries the navigation-MODE token: an authored `view` did not select a view, ' + + 'it SUBSTITUTED for the mode. At least one consumer in the same bundle reads that ' + + 'argument against a closed two-value vocabulary (`edit` / `view`), so any other authored ' + + 'value matched neither branch — invisible on grids whose handler takes one argument, a ' + + 'dead row click on the ones that do not. The enumeration behind the removal was ' + + 'exhaustive rather than sampled: every `.view` property read in the bundle (three) and ' + + 'every `formViews` read, and NO read anywhere is keyed by an authored view name, so ' + + 'there is no path by which this key or any sibling could have resolved one. ' + + 'ADR-0049 enforce-or-remove; zero authored instances in this repo and the one external ' + + 'author removed its occurrence, so the pull that would justify ENFORCE is zero. ' + + 'A mechanical D2 strip was weighed and declined with the direction: deleting the key ' + + 'silently discards the author\'s actual intent — "open the detail in THIS layout" — and ' + + 'leaves no record of which list view carried it, which is exactly the judgement a ' + + 'semantic TODO exists to hand back. Should "open the detail in a chosen view" ever be ' + + 'pulled, it belongs to the page-assignment machinery (`record` pages, `isDefault`), not ' + + 'to a string on a list view.', + acceptanceCriteria: + 'For EACH list view that declared `navigation.view` — the TODO names the surface, you name ' + + 'the view: delete the key from that view\'s `navigation` block, then decide whether the ' + + 'detail layout it asked for was ever actually delivered. It was not, so nothing regresses ' + + 'by deleting it: confirm the record detail opens exactly as it did before (the surviving ' + + '`mode` and `size` decide that, and both are untouched). If the named layout is one you ' + + 'still want, publish it as a `record` page on that object and mark the one that should ' + + 'open `isDefault`. Done when no `navigation` block in your sources carries `view`; a block ' + + 'that still does fails to parse with the removal prescription, at `ListViewSchema`, at ' + + '`ObjectListViewSchema` and at the `PUT /api/v1/meta/view` overlay door, and authoring it ' + + 'is a `tsc` error at the call site. ⚠️ Nothing else in the block moves — a `navigation` ' + + 'that carries only live keys (`{ mode: \'drawer\', size: \'lg\' }`) parses byte-for-byte ' + + 'as it did before.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 76b9e2ad8ff..3d54818d5d9 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9084,6 +9084,49 @@ const step18: MigrationStep = { + 'PluginStartupResult interface — a different type, carrying startTime rather than any ' + 'duration key — which is not a reader of this schema and is unchanged.', }, + { + id: 'list-view-navigation-view-retired', + surface: 'view.list.navigation.view', + replacement: + 'page assignment — assign a `record` page to the object and let `isDefault` pick the one ' + + 'that opens. That is the machinery that resolves a detail layout by name; a list view\'s ' + + '`navigation` block decides only HOW the detail is surfaced (`mode`, `size`, ' + + '`preventNavigation`, `openNewTab`, `width`), and every one of those keys is unchanged', + reason: + 'DECLARED, CONSUMED, AND WRONG — which is why this is a semantic TODO rather than a ' + + 'mechanical strip. The key\'s describe promised "the form view to use for details" and ' + + 'no layer from spec to console ever resolved a view by name. Its only read in the ' + + 'shipped console put the value in the SECOND argument of `onNavigate`, the slot that ' + + 'otherwise carries the navigation-MODE token: an authored `view` did not select a view, ' + + 'it SUBSTITUTED for the mode. At least one consumer in the same bundle reads that ' + + 'argument against a closed two-value vocabulary (`edit` / `view`), so any other authored ' + + 'value matched neither branch — invisible on grids whose handler takes one argument, a ' + + 'dead row click on the ones that do not. The enumeration behind the removal was ' + + 'exhaustive rather than sampled: every `.view` property read in the bundle (three) and ' + + 'every `formViews` read, and NO read anywhere is keyed by an authored view name, so ' + + 'there is no path by which this key or any sibling could have resolved one. ' + + 'ADR-0049 enforce-or-remove; zero authored instances in this repo and the one external ' + + 'author removed its occurrence, so the pull that would justify ENFORCE is zero. ' + + 'A mechanical D2 strip was weighed and declined with the direction: deleting the key ' + + 'silently discards the author\'s actual intent — "open the detail in THIS layout" — and ' + + 'leaves no record of which list view carried it, which is exactly the judgement a ' + + 'semantic TODO exists to hand back. Should "open the detail in a chosen view" ever be ' + + 'pulled, it belongs to the page-assignment machinery (`record` pages, `isDefault`), not ' + + 'to a string on a list view.', + acceptanceCriteria: + 'For EACH list view that declared `navigation.view` — the TODO names the surface, you name ' + + 'the view: delete the key from that view\'s `navigation` block, then decide whether the ' + + 'detail layout it asked for was ever actually delivered. It was not, so nothing regresses ' + + 'by deleting it: confirm the record detail opens exactly as it did before (the surviving ' + + '`mode` and `size` decide that, and both are untouched). If the named layout is one you ' + + 'still want, publish it as a `record` page on that object and mark the one that should ' + + 'open `isDefault`. Done when no `navigation` block in your sources carries `view`; a block ' + + 'that still does fails to parse with the removal prescription, at `ListViewSchema`, at ' + + '`ObjectListViewSchema` and at the `PUT /api/v1/meta/view` overlay door, and authoring it ' + + 'is a `tsc` error at the call site. ⚠️ Nothing else in the block moves — a `navigation` ' + + 'that carries only live keys (`{ mode: \'drawer\', size: \'lg\' }`) parses byte-for-byte ' + + 'as it did before.', + }, { id: 'logging-durations-unit-in-key', surface: 'HttpDestinationConfig `batch.flushInterval` / `retry.initialDelay` / `timeout` and ' @@ -14973,6 +15016,29 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // which is a different key on a different surface and has always rendered. D2: // `view-page-mount-removed`. 'ui/ListView:pageName', + // #16885 — the list view's `navigation.view` binding, retired under ADR-0049 + // enforce-or-remove by maintainer ruling 2026-09-13 (director decision batch + // #126 item 4, verbatim 「同意」, option B). The key's describe promised "the + // form view to use for details" and no layer from spec to console ever + // resolved a view BY NAME: its one read in the shipped console passed the + // value into the SECOND argument of `onNavigate` — the slot that otherwise + // carries the navigation-mode token — so an authored name substituted for the + // mode rather than selecting a view, and a consumer reading that argument + // against its closed `edit`/`view` vocabulary matched neither branch. Zero + // authored instances in this repo; the one external author deleted its + // occurrence. ONE key and one entry: `NavigationConfigSchema` is reused BY + // REFERENCE (`ListViewSchema.navigation` is its only referent), so the walked + // shape has a single `ui/NavigationConfig` def and the baseline marks one line + // `[RETIRED]`. + // + // Registered here but NOT in `src/conversions/registry.ts`, and deliberately: + // the ruling's disposition is a D3 SEMANTIC entry + // (`list-view-navigation-view-retired`). A mechanical strip would delete the + // key without telling anyone which list view lost it, and an author who wrote + // it wanted a named detail layout — a want that page assignment serves and a + // stripped key does not record. So the prescription reaches consumers as that + // semantic TODO plus this tombstone. + 'ui/NavigationConfig:view', // #11805 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-25, // decision-inbox batch 4: 「#11805 退役 defaultSort,不需要major」; the producer // half of objectui#5861, under the objectui#4869 「接受所有」 direction). diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 176e70a61fd..ef16a872c88 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1674,6 +1674,36 @@ export const NavigationModeSchema = lazySchema(() => z.enum([ 'none' // No navigation (read-only list) ])); +// [#16885] `navigation.view` retirement prescription — director decision batch +// #126 item 4 (maintainer ruling 2026-09-13, option B: retire). Declared with +// `//` on purpose: build-docs takes a file's first JSDoc per exported symbol, +// and this constant needs no doc page (the `LIST_VIEW_EXPORT_PDF_RETIRED` +// placement note in this same file). +// +// The key promised "the form view to use for details" and nothing from spec to +// console ever resolved a view BY NAME. Its one read in the shipped console put +// the value in the second argument of `onNavigate` — the slot that otherwise +// carries the navigation-MODE token — so an authored name did not select a +// view, it SUBSTITUTED for the mode, and a consumer reading that argument +// against its closed `edit`/`view` vocabulary matched neither branch. Declared, +// consumed, and wrong: the one state that teaches an author something false. +// +// ⛔ No `os migrate meta` sentence: the house sentence is owed only where an +// ADR-0087 conversion covers the surface (`shared/retired-key.ts` module +// docblock). This retirement's ADR-0087 disposition is a D3 SEMANTIC entry +// (`list-view-navigation-view-retired`) — an author who wrote the key wanted a +// named detail layout, and stripping it mechanically would drop that intent +// without telling anyone which list view lost it. +const NAVIGATION_VIEW_RETIRED = + '`view.list.navigation.view` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) ' + + '— it named the form view to open for a record detail, and no layer resolved a view by that ' + + 'name: the value was passed straight into the navigation-MODE argument of the console\'s ' + + '`onNavigate`, where anything other than `edit` or `view` matched no branch, so the key ' + + 'selected nothing and could silently deaden the row click. Delete the key; to choose what ' + + 'opens for a record, assign a `record` page to the object and let `isDefault` pick the one ' + + 'that opens — page assignment is the machinery that resolves a detail layout, and a list ' + + 'view\'s navigation block only decides HOW the detail is surfaced (`mode`, `size`).'; + /** * Navigation Configuration Schema */ @@ -1682,10 +1712,15 @@ export const NavigationConfigSchema = lazySchema(() => strictObject({ history: VIEW_HISTORY, }, { mode: NavigationModeSchema.default('page'), - - /** Target View Config */ - view: z.string().optional().describe('Name of the form view to use for details (e.g. "summary_view", "edit_form")'), - + + /** + * [#16885] RETIRED — see {@link NAVIGATION_VIEW_RETIRED} above the schema. + * The tombstone stays in the shape on purpose: `tsc` types the key `never` + * and a value reaching a parse raises the prescription instead of a bare + * unrecognized-key report. + */ + view: retiredKey(NAVIGATION_VIEW_RETIRED), + /** Interaction Triggers */ preventNavigation: z.boolean().default(false).describe('Disable standard navigation entirely'), openNewTab: z.boolean().default(false).describe('Force open in new tab (applies to page mode)'), From 0a968bb06d02f4a13b389815393f84052778bab4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:37:35 +0000 Subject: [PATCH 2/3] feat(spec)!: retire ListViewSchema.navigation.view (#16885) Tombstone, ADR-0087 registration, regenerated artifacts and the pins. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 4 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/ui/view.mdx | 18 ++--- packages/spec/authorable-surface/ui.json | 2 +- packages/spec/src/ui/view.test.ts | 95 ++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 13 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index f34ecca6796..e0aa4ac3f76 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1638,7 +1638,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | @@ -1723,7 +1723,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 24152b29e25..b0ae75ec582 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -372,7 +372,7 @@ const result = ApiMethod.parse(data); | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 714835c268e..58dd1b7ccf0 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -798,7 +798,7 @@ Map view configuration | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | @@ -916,7 +916,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **mode** | `Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>` | optional (default: `"page"`) | | -| **view** | `string` | optional | Name of the form view to use for details (e.g. "summary_view", "edit_form") | +| **view** | `never` | optional | [REMOVED] `view.list.navigation.view` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's `onNavigate`, where anything other than `edit` or `view` matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a `record` page to the object and let `isDefault` pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (`mode`, `size`). | | **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely | | **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) | | **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. | @@ -1163,7 +1163,7 @@ Tab configuration for multi-tab view interface | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **mode** | `Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>` | optional (default: `"page"`) | | -| **view** | `string` | optional | Name of the form view to use for details (e.g. "summary_view", "edit_form") | +| **view** | `never` | optional | [REMOVED] `view.list.navigation.view` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's `onNavigate`, where anything other than `edit` or `view` matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a `record` page to the object and let `isDefault` pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (`mode`, `size`). | | **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely | | **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) | | **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. | @@ -1205,7 +1205,7 @@ Tab configuration for multi-tab view interface | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | @@ -1314,7 +1314,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **mode** | `Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>` | optional (default: `"page"`) | | -| **view** | `string` | optional | Name of the form view to use for details (e.g. "summary_view", "edit_form") | +| **view** | `never` | optional | [REMOVED] `view.list.navigation.view` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's `onNavigate`, where anything other than `edit` or `view` matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a `record` page to the object and let `isDefault` pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (`mode`, `size`). | | **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely | | **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) | | **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. | @@ -1803,7 +1803,7 @@ Tab configuration for multi-tab view interface | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | @@ -1888,7 +1888,7 @@ Tab configuration for multi-tab view interface | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | @@ -2129,7 +2129,7 @@ This schema accepts one of the following structures: | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | @@ -2305,7 +2305,7 @@ This schema accepts one of the following structures: | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 89a82028b71..e98e4224165 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -681,7 +681,7 @@ "ui/NavigationConfig:openNewTab", "ui/NavigationConfig:preventNavigation", "ui/NavigationConfig:size", - "ui/NavigationConfig:view", + "ui/NavigationConfig:view [RETIRED]", "ui/NavigationConfig:width", "ui/NavigationContribution:app", "ui/NavigationContribution:group", diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 05386b001bd..1cd0eb99a74 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -4155,6 +4155,101 @@ const flattenUnionIssues = (issues: z.ZodIssue[]): z.ZodIssue[] => : [i]; }); +// ============================================================================ +// [#16885] The RETIRED `navigation.view` binding — declared, consumed, wrong +// ============================================================================ + +/** + * `navigation.view` promised "the form view to use for details", and nothing + * from spec to console ever resolved a view BY NAME: its one read in the + * shipped console put the value in the SECOND argument of `onNavigate` — the + * slot that otherwise carries the navigation-MODE token — so an authored name + * substituted for the mode instead of selecting a view. Retired under ADR-0049 + * enforce-or-remove by maintainer ruling 2026-09-13 (director decision batch + * #126 item 4, option B). + * + * The refusal is pinned at all three doors, and the five surviving keys of the + * same block are pinned ACCEPTING beside it — separately and together. That + * second half is not ceremony: a tombstone that also broke its live siblings + * would satisfy every refusal assertion above while being a different and + * larger bug, and `navigation` is one `strictObject`, so the blast radius of a + * mistake here is the whole block. + */ +describe('ListViewSchema — the RETIRED `navigation.view` binding (#16885)', () => { + describe.each(viewDoorsCarryingObjectLevelChecks)('%s', (_label, parse) => { + it('REFUSES `navigation.view` with the tombstone prescription, not a bare unknown-key report', () => { + const r = parse({ type: 'grid', columns: ['name'], navigation: { view: 'summary_view' } }); + expect(r.success).toBe(false); + // Select the TOMBSTONE issue by the SHAPE `retiredKey()` raises rather + // than by its text — the overlay door also carries that text on the union + // WRAPPER (path `[]`), so a text-only find is satisfied by either, and + // this pin's subject is that the refusal is raised AT THE KEY the author + // wrote, which needs the issue that has a path. + const issue = flattenUnionIssues((r as { error: z.ZodError }).error.issues) + .find((i) => (i as { expected?: string }).expected === 'never'); + expect(issue, JSON.stringify((r as { error: z.ZodError }).error.issues)).toBeDefined(); + expect(issue!.message).toContain('`view.list.navigation.view` was removed'); + // The prescription must name the ROUTE, not merely the removal: a bare + // "this key is gone" leaves the author with the want that made them write + // it. Page assignment is where a chosen detail layout belongs. + expect(issue!.message).toContain('`record` page'); + expect(issue!.message).toContain('`isDefault`'); + expect(issue!.path.join('.')).toBe('navigation.view'); + }); + + it("still ACCEPTS `navigation: { mode: 'page' }` — the lit control for the refusal above", () => { + expect(parse({ type: 'grid', columns: ['name'], navigation: { mode: 'page' } }).success).toBe(true); + }); + + it('still ACCEPTS every surviving key of the same block, one at a time and all together', () => { + const survivors = { + mode: 'drawer', + preventNavigation: false, + openNewTab: false, + size: 'lg', + width: '600px', + } as const; + for (const [key, value] of Object.entries(survivors)) { + const r = parse({ type: 'grid', columns: ['name'], navigation: { [key]: value } }); + expect(r.success, `${key}: ${JSON.stringify(r.success ? null : (r as { error: z.ZodError }).error.issues)}`) + .toBe(true); + } + expect(parse({ type: 'grid', columns: ['name'], navigation: { ...survivors } }).success).toBe(true); + }); + }); + + // The tombstone must not become the answer for every navigation typo. An + // unrelated unknown key keeps the ordinary closed-shape report, which is what + // tells the author it is unrecognised rather than retired — the failure mode + // `acceptsNothing()` exists for, one message over. + it('does not hand the removal prescription to an unrelated unknown navigation key', () => { + const r = ListViewSchema.safeParse({ type: 'grid', columns: ['name'], navigation: { placement: 'right' } }); + expect(r.success).toBe(false); + const messages = flattenUnionIssues((r as { error: z.ZodError }).error.issues) + .map((i) => i.message).join('\n'); + expect(messages).not.toContain('`view.list.navigation.view` was removed'); + // ...and it must not SUGGEST the tombstoned key either: `view` is still in + // `Object.keys(shape)` and is 4 edits from `placement`, so the guard that + // keeps a dead key out of the suggester is load-bearing here. + expect(messages).not.toMatch(/Did you mean[^\n]*`view`/); + }); + + // A `navigation` block that carries only live keys parses byte-for-byte as it + // did before the retirement — the property the semantic TODO promises. + it('leaves a live-only navigation block byte-identical after parse', () => { + const r = ListViewSchema.safeParse({ + type: 'grid', + columns: ['name'], + navigation: { mode: 'drawer', size: 'lg' }, + }); + expect(r.success).toBe(true); + expect((r as { data: { navigation?: Record } }).data.navigation) + .toMatchObject({ mode: 'drawer', size: 'lg' }); + expect((r as { data: { navigation?: Record } }).data.navigation) + .not.toHaveProperty('view'); + }); +}); + describe("ListViewSchema — calendar in `appearance.allowedVisualizations` requires the `calendar:` block (#13817)", () => { // The same three doors, and since #17063 this is the ONLY object-level check // they carry — it is attached at three separate points for the zod-4 reason, From 650c7938a962900518041f8aac7279e056166367 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 08:42:29 +0000 Subject: [PATCH 3/3] chore(spec): changeset for the navigation.view retirement (#16885) Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- .changeset/16885-retire-navigation-view.md | 73 ++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .changeset/16885-retire-navigation-view.md diff --git a/.changeset/16885-retire-navigation-view.md b/.changeset/16885-retire-navigation-view.md new file mode 100644 index 00000000000..3e3160f22cf --- /dev/null +++ b/.changeset/16885-retire-navigation-view.md @@ -0,0 +1,73 @@ +--- +'@objectstack/spec': minor +--- + +**BREAKING** — retire `ListViewSchema.navigation.view`, the detail-view binding nothing +ever resolved. + +`navigation.view` was an unconstrained string whose describe promised *"the form view to +use for details"*. No layer from spec to console ever resolved a view by that name. Its +one read in the shipped console passed the value into the **second argument of +`onNavigate`** — the slot that otherwise carries the navigation-MODE token — so an +authored name did not select a view, it **substituted for the mode**. A consumer in the +same bundle reads that argument against a closed two-value vocabulary (`edit` / `view`), +so any other authored value matched neither branch: invisible on grids whose handler +takes one argument, a dead row click on the ones that do not. + +The enumeration behind the removal was exhaustive rather than sampled — every `.view` +property read in the bundle (exactly three) and every `formViews` read — and **no read +anywhere is keyed by an authored view name**. There was no path by which the key could +resolve one. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-13 (director decision +batch #126 item 4, option B). Zero authored instances in this repository; the one +external author removed its occurrence. + +## FROM → TO + +| you wrote (17.4 and earlier) | write instead | +| --- | --- | +| `navigation: { view: 'summary_view' }` on a list view | `navigation: { }` — delete the key. Then publish the layout you wanted as a `record` page on that object and mark the one that should open `isDefault` | +| `navigation: { mode: 'drawer', view: 'edit_form' }` | `navigation: { mode: 'drawer' }` — the mode, size and every other key of the block are **unchanged** | + +**The one-line fix:** delete `view` from the list view's `navigation` block; to choose +what opens for a record, assign a `record` page to the object and let `isDefault` pick +the one that opens. + +Nothing regresses by deleting it: the key never selected anything. What decides how the +detail is surfaced is `mode` and `size`, and both are untouched. + +## The retirement kit + +- **`navigation.view`** — a `retiredKey()` tombstone on `NavigationConfigSchema`. `tsc` + types the key `never`, so writing it fails at the authoring site; a value reaching a + parse raises the prescription rather than a bare unrecognized-key report. Refused at + all three doors — `ListViewSchema`, `ObjectListViewSchema` and the flattened + `PUT /api/v1/meta/view` overlay — and pinned at each. +- **ADR-0087 disposition: a D3 SEMANTIC entry**, `list-view-navigation-view-retired`, not + a D2 conversion. A mechanical strip would delete the key without recording which list + view lost it, and an author who wrote it wanted a named detail layout — a want page + assignment serves and a stripped key does not record. So the TODO names the surface and + hands the judgement back, which is what a semantic entry is for. The tombstone + prescription therefore carries **no** `os migrate meta` sentence: that sentence is owed + only where a conversion covers the surface. +- **The five surviving keys of the block** — `mode`, `preventNavigation`, `openNewTab`, + `size`, `width` — are unchanged, and pinned accepting beside the refusal. A tombstone + that broke its live siblings would satisfy every refusal assertion while being a larger + bug; `navigation` is one closed shape, so that blast radius is the whole block. +- **`ui/NavigationConfig:view`** is registered in `RETIRED_KEYS_BY_MAJOR[18]`, which is + also what starts its aging clock. + +## What is deliberately NOT in this change + +`view/list/navigation`'s six children are unclassified in the liveness ledger because +`check-liveness` drills one level. That is #17424's subject and is cited here, not fixed: +the ledger row for `navigation` itself is untouched, and no row exists for `view` to +update. + +The sibling `objectui` contract twin — `ViewNavigationConfig`, a re-export of this very +type — is in the other repository and is left to it. Its parity pin authors +`{ view: 'summary_view' }` as a legal value, so it needs the tombstone pin before that +repo picks up a spec carrying this retirement. + +Clause-②: no + +