From 4e9310781fb670f08c34c23e39c1a2ffb29fed40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 21:31:21 +0000 Subject: [PATCH 01/13] feat(spec)!: retire the list view's own `tabs` key (WIP: schema, form, D2/D3, ledger) A list view's `tabs` parsed and was stored at every list-view door and drew nothing: the one component that reads a ViewTab[] has no production mount, and the tab strip above an object's records is the saved-view switcher, which renders one tab per `listViews` entry. ADR-0049 enforce-or-remove. - `tabs` becomes a retiredKey() tombstone on the list-view shape, with a prescription naming how to move each tab to a `listViews` entry. - The metadata form's `tabs` repeater leaves with the key. - D2 `view-list-tabs-removed` (protocol 18) strips it from list payloads in stack.views[]; D3 `list-view-tabs-retired`; RETIRED_KEYS_BY_MAJOR[18] gains ui/ListView:tabs and ui/ObjectListView:tabs. - The ledger row stays dead with a REMOVED note. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- content/docs/protocol/objectui/layout-dsl.mdx | 8 +- packages/spec/liveness/view.json | 4 +- packages/spec/src/conversions/registry.ts | 106 ++++++++++++++++++ .../retired-keys/18.ui__ListView__tabs.ts | 12 ++ .../18.ui__ObjectListView__tabs.ts | 12 ++ .../semantic/18.list-view-tabs-retired.ts | 37 ++++++ packages/spec/src/migrations/registry.ts | 66 ++++++++++- packages/spec/src/ui/view.form.ts | 25 +---- packages/spec/src/ui/view.zod.ts | 35 +++++- skills/objectstack-ui/rules/list-views.md | 13 +-- 10 files changed, 279 insertions(+), 39 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ListView__tabs.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectListView__tabs.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.list-view-tabs-retired.ts diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 1b8a13bc6ae..af814985396 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -550,10 +550,10 @@ is a **parse failure** — a loud rejection, not a silent no-op. Looking for tabs that carry their own `name`, `icon`, `filter`, `order`, -`pinned` and `isDefault`? That surface exists, but it belongs to **list** views, -not forms: `ui/ViewTab` declares exactly nine keys (`filter`, `icon`, -`isDefault`, `label`, `name`, `order`, `pinned`, `view`, `visible`), and each -tab points at a named list view. See +`pinned` and `isDefault`? That surface belongs to a **page list's** preset bar +(`userFilters.tabs`), not to forms: `ui/ViewTab` declares exactly nine keys (`filter`, +`icon`, `isDefault`, `label`, `name`, `order`, `pinned`, `view`, `visible`). A list +view has no `tabs` key — an object's tab strip lists its named `listViews`. See [View Reference → ViewTab](/docs/references/ui/view#viewtab). ## Responsive Layout Modifiers diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 21c15486454..900e7810b11 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -209,9 +209,9 @@ }, "tabs": { "status": "dead", - "verifiedAt": "2026-09-07", + "verifiedAt": "2026-09-27", "evidenceScope": "cross-repo", - "note": "RE-DERIVED 2026-09-07 (#16362, decision batch #60 option A — the ledger audit that precedes the #16094 authorWarn flip). VERDICT FLIPPED live to dead, and the note this replaces was wrong in BOTH directions at once, which is why the row was worth re-opening. It read: \"objectui: TabBar.tsx — icon/visible/pinned/filter wired (audit L15). Sub-key tabs[].order is NOT used for sorting (audit L20) — dead sub-surface.\" (a) The OVERSTATEMENT, and the reason the row moves: TabBar reads those sub-keys, but NOTHING MOUNTS TabBar. Measured at objectui @a472b071: every ` (a.order ?? 0) - (b.order ?? 0))`. The old note called it a dead sub-surface. Both halves of one sentence were false in opposite directions, which is the two-direction rot the README warns a ledger entry accumulates. CORROBORATION, independent of this measurement: this repo already says so in code — packages/cli/src/utils/i18n-extract.ts scopes its tab-label extractor to a page's `interfaceConfig.userFilters.tabs` and states \"`ListViewSchema.tabs` has no reader in either repo\", deliberately emitting no scaffolding keys for this one. THE hotcrm PREMISE IS UPHELD, NOT CONTRADICTED: hotcrm#1307's `test/view-tab-label-inert.test.ts` asserts the object-view switcher never reads `list.tabs`, and the switcher is plugin-view/src/ViewTabBar.tsx, which takes a `views: ViewTabItem[]` prop (saved views) and reads no `tabs` key at all. So nothing needs filing against that test. (That hotcrm reading is INHERITED from the card and #16094, not re-measured — hotcrm is out of this seat's repo scope; what IS measured here is the objectui switcher it describes.) WHY NOT `live` ON THE TWO AUTHOR-TIME READERS: packages/lint/src/validate-list-view-field-refs.ts#checkTabs and packages/metadata-protocol/src/metadata-diagnostics.ts both walk `tabs[].filter[].field` for reference integrity, and metadata-protocol normalizes filter operators inside it. None of them delivers the key's declared effect (\"Tab definitions for multi-tab view interface\"). That is the route_generation precedent verbatim — an enum validated at the door and then ignored is accept/reject, a different question from liveness — and it is the opposite of dashboard.widgets[].suppressWarnings, whose declared effect IS the lint read. The spec's own ObjectUserFiltersSchema already refuses `tabs` on object views with the prescription \"an object view's tab bar is its saved-view switcher (ViewTabBar)\"; this row now says the same thing about the list slot. ENFORCE-OR-REMOVE (ADR-0049) is a follow-up decision, not this card: the enforce route mounts TabBar and threads `list.tabs` into it, the remove route retires the key and its ViewTabSchema carrier on this slot. Not authorWarn-ed here — the authorWarn field is #16094's." + "note": "REMOVED 2026-09-27 (#20301, ADR-0049 enforce-or-remove; triage verdict RETIRE under the maintainer's #18900 criterion — mainstream named-view switching is already delivered here, by `listViews` rendered in ViewTabBar) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error at every list-view door built from the shape: ListViewSchema, ObjectListViewSchema and the flattened overlay arm) and stripped from stored rows and sources by the protocol-18 view-list-tabs-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); to give end users one tab per preset, declare each tab as a named entry under the object's `listViews` — every entry renders as a tab in the saved-view switcher. RE-MEASURED 2026-09-27 before removal, with lit controls: objectui at the pinned sha f8a9d0fb — every ` + kind === 'list' ? stripKeys(payload, ['tabs'], emit, path) : payload); + }, + fixture: { + before: { + views: [ + // A container: the default `list` and one named entry carry the key, + // the other named entry does not and rides through by reference. + { + object: 'crm_ticket', + list: { + type: 'grid', + columns: ['subject'], + tabs: [{ name: 'mine', label: 'Mine', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }], + }, + listViews: { + triage: { type: 'grid', columns: ['subject'], tabs: [{ name: 'urgent', label: 'Urgent' }] }, + all: { type: 'grid', columns: ['subject'] }, + }, + }, + // A ViewItem record: the payload hangs off `config`. + { + name: 'crm_ticket.queue', + object: 'crm_ticket', + viewKind: 'list', + config: { type: 'grid', columns: ['subject'], tabs: [] }, + }, + // A form payload has no `tabs` key to strip — untouched. + { name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }, + ], + }, + after: { + views: [ + { + object: 'crm_ticket', + list: { type: 'grid', columns: ['subject'] }, + listViews: { + triage: { type: 'grid', columns: ['subject'] }, + all: { type: 'grid', columns: ['subject'] }, + }, + }, + { + name: 'crm_ticket.queue', + object: 'crm_ticket', + viewKind: 'list', + config: { type: 'grid', columns: ['subject'] }, + }, + { name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }, + ], + }, + // One notice per key removed: `list`, `listViews.triage`, the record's `config`. + expectedNotices: 3, + }, +}; + /** * The page-component types whose `properties.filter` is a converged rule-array * door: every `ComponentPropsMap` row whose `filter` answers the record form @@ -11314,6 +11419,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly> // which is a different key on a different surface and has always rendered. D2: // `view-page-mount-removed`. 'ui/ListView:pageName', + // #20301 (ADR-0049 enforce-or-remove; triage verdict RETIRE under the + // maintainer's #18900 criterion). `ListView.tabs` declared tab definitions for + // a multi-tab view interface, and no renderer ever drew them: the one + // component that reads a `ViewTab[]` (objectui's `TabBar`) has no production + // mount, and the tab strip above an object's records is the saved-view + // switcher (`ViewTabBar`), which renders one tab per `listViews` entry and + // reads no `tabs` key. Tombstoned with `retiredKey()` beside the `pageName` + // tombstone already on this shape; `ViewTabSchema` stays, reused by the + // page-only `userFilters.tabs` preset bar. D2: `view-list-tabs-removed`. + 'ui/ListView:tabs', // #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 @@ -19752,6 +19806,16 @@ 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/ObjectListView:pageName', + // #20301 (ADR-0049 enforce-or-remove; triage verdict RETIRE under the + // maintainer's #18900 criterion). `ObjectListView.tabs` declared tab definitions for + // a multi-tab view interface, and no renderer ever drew them: the one + // component that reads a `ViewTab[]` (objectui's `TabBar`) has no production + // mount, and the tab strip above an object's records is the saved-view + // switcher (`ViewTabBar`), which renders one tab per `listViews` entry and + // reads no `tabs` key. Tombstoned with `retiredKey()` beside the `pageName` + // tombstone already on this shape; `ViewTabSchema` stays, reused by the + // page-only `userFilters.tabs` preset bar. D2: `view-list-tabs-removed`. + 'ui/ObjectListView:tabs', // ADR-0090 D2 (no Profile concept) + ADR-0049 enforce-or-remove; maintainer // ruling 2026-09-12, decision batch #121 item 2, verbatim 「同意」. // `Page.assignedProfiles` was an authorable key named for the concept ADR-0090 D2 diff --git a/packages/spec/src/ui/view.form.ts b/packages/spec/src/ui/view.form.ts index 7dd048b82a0..aa84fb4c7de 100644 --- a/packages/spec/src/ui/view.form.ts +++ b/packages/spec/src/ui/view.form.ts @@ -166,26 +166,11 @@ export const viewForm = defineForm({ collapsed: true, fields: [ { field: 'userFilters', type: 'composite', helpText: 'Quick-filter bar: element style (dropdown / tabs / toggle) + exposed fields or tab presets' }, - { - field: 'tabs', - type: 'repeater', - helpText: 'In-view filter tabs — each tab applies its own filter rules', - // Row-property names (#17508): every authorable row property, `label` - // equal to the item schema's `.meta({ title })`, so `os i18n extract` - // emits a catalog key per column and the panel keeps its schema-derived - // widgets (no `type` here). - fields: [ - { field: 'name', label: 'Name' }, - { field: 'label', label: 'Label' }, - { field: 'icon', label: 'Icon' }, - { field: 'view', label: 'List View' }, - { field: 'filter', label: 'Filter' }, - { field: 'order', label: 'Display Order' }, - { field: 'pinned', label: 'Pinned' }, - { field: 'isDefault', label: 'Default Tab' }, - { field: 'visible', label: 'Visible' }, - ], - }, + // [#20301] The `tabs` repeater was REMOVED with the key it wrote — now a + // `retiredKey()` tombstone on `ListViewSchema` (no renderer ever mounted a + // tab bar for it). A form input for an unwritable key is the + // false-compliant UI half of a retirement; named presets are `listViews` + // entries, each a tab in the saved-view switcher. { field: 'appearance', type: 'composite', helpText: 'allowedVisualizations: which renderers users may switch between' }, { field: 'userActions', type: 'composite', helpText: 'Toolbar toggles: sort / search / filter / row height' }, { field: 'addRecord', type: 'composite' }, diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 25d7ad851ed..b64e9996f3f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2769,8 +2769,39 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** Appearance (Airtable Interface parity) */ appearance: AppearanceConfigSchema.optional().describe('Appearance and visualization configuration'), - /** Tabs (Airtable Interface parity) */ - tabs: z.array(ViewTabSchema).optional().describe('Tab definitions for multi-tab view interface'), + /** + * [#20301] REMOVED — ADR-0049 enforce-or-remove, triage verdict RETIRE by + * the maintainer's #18900 criterion (mainstream named-view switching is + * already delivered here, by `listViews`). The key parsed, was stored, and + * drew nothing: objectui's `TabBar` — the one component that reads a + * `ViewTab[]` — has zero production mounts, and the tab strip above an + * object's records is the saved-view switcher (`ViewTabBar`), which renders + * one tab per named list view and reads no `tabs` key. Measured with lit + * controls and recorded on the ledger row (`liveness/view.json`, + * `/props/list/children/tabs`). + * + * Tombstoned rather than deleted so the removal is audible in both channels + * an upgrading author hits — `tsc` (the input type is `never`) and the parse + * (the prescription, not a bare unrecognized-key report) — the `pageName` + * precedent above on this same strict shape. The tombstone reaches every + * list-view door built from this shape: `ListViewSchema`, + * `ObjectListViewSchema` (a container's `list` / `listViews`, an object's + * `listViews`) and the flattened overlay arm. + * + * ⛔ `ViewTabSchema` itself is NOT retired: `UserFiltersSchema.tabs` — the + * page-only preset bar — reuses it and renders. D2: `view-list-tabs-removed`. + */ + tabs: retiredKey( + '`view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no ' + + 'renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an ' + + "object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list " + + 'view and never read this key. Delete the key, and move each tab you want to a named list view ' + + "under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` " + + "the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the " + + "view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every " + + '`listViews` entry renders as a tab in the switcher. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), /** Add Record (Airtable Interface parity) */ addRecord: AddRecordConfigSchema.optional().describe('Add record entry point configuration'), diff --git a/skills/objectstack-ui/rules/list-views.md b/skills/objectstack-ui/rules/list-views.md index 4866ced2bd8..542c0a04a03 100644 --- a/skills/objectstack-ui/rules/list-views.md +++ b/skills/objectstack-ui/rules/list-views.md @@ -123,12 +123,6 @@ userFilters: { ], }, -// In-view filter tabs (presets on top of the base filter): -tabs: [ - { name: 'all', label: 'All', isDefault: true }, - { name: 'urgent', label: 'Urgent', filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }] }, -], - // Runtime visualization whitelist (Airtable "Appearance → Visualizations"): appearance: { allowedVisualizations: ['grid', 'kanban', 'gallery'] }, ``` @@ -136,10 +130,9 @@ appearance: { allowedVisualizations: ['grid', 'kanban', 'gallery'] }, Rules: - Every `field` MUST exist on the source object — reference diagnostics (`_diagnostics`) flag unknown fields; treat `valid: false` as a failed write. -- **Tabs XOR dropdowns — never both on one view.** The toolbar renders ONE - filter element style (Airtable's Elements choice). If a view configures - both `tabs` and `userFilters`, tabs win and the dropdowns never render. - Want both demos? Put them on different views. +- **A list view has no `tabs` key** — it was removed (a parse error that + names the fix): nothing ever drew it. Each named preset is a `listViews` + entry, rendered as a tab in the object's saved-view switcher. - **On an object list view (`*.view.ts` `list` / `listViews`), only `element: 'dropdown'` (value chips) is allowed — `tabs` is page-only** (ADR-0047 amendment). An object view's saved-view `ViewTabBar` already owns From 919f5e97e877879f71e59d93ca1e70360026d5a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 21:55:59 +0000 Subject: [PATCH 02/13] test(spec): pin the list-view tabs retirement at every door; move surviving tab-carrier fixtures to userFilters.tabs Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- packages/spec/authorable-surface/ui.json | 4 +- .../src/ui/view-authoring-wire-split.test.ts | 9 +- .../src/ui/view-filter-rule-wire-id.test.ts | 6 +- .../src/ui/view-list-tabs-retirement.test.ts | 564 ++++++++++++++++++ packages/spec/src/ui/view.test.ts | 22 +- 5 files changed, 582 insertions(+), 23 deletions(-) create mode 100644 packages/spec/src/ui/view-list-tabs-retirement.test.ts diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 5228c0ba78d..103dc45f313 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -693,7 +693,7 @@ "ui/ListView:showRecordCount", "ui/ListView:sort", "ui/ListView:striped [RETIRED]", - "ui/ListView:tabs", + "ui/ListView:tabs [RETIRED]", "ui/ListView:timeline", "ui/ListView:tree", "ui/ListView:type", @@ -876,7 +876,7 @@ "ui/ObjectListView:showRecordCount", "ui/ObjectListView:sort", "ui/ObjectListView:striped [RETIRED]", - "ui/ObjectListView:tabs", + "ui/ObjectListView:tabs [RETIRED]", "ui/ObjectListView:timeline", "ui/ObjectListView:tree", "ui/ObjectListView:type", diff --git a/packages/spec/src/ui/view-authoring-wire-split.test.ts b/packages/spec/src/ui/view-authoring-wire-split.test.ts index 8cb59b25f03..4cb989ed9a7 100644 --- a/packages/spec/src/ui/view-authoring-wire-split.test.ts +++ b/packages/spec/src/ui/view-authoring-wire-split.test.ts @@ -244,7 +244,8 @@ describe('#5074 — the wire door accepts what the platform itself writes', () = it.each([ ['flattened overlay — `sort[]`', { ...OVERLAY_BASE, sort: [CONSOLE_SORT_ROW] }], ['flattened overlay — `filter[]`', { ...OVERLAY_BASE, filter: [CONSOLE_FILTER_ROW] }], - ['flattened overlay — `tabs[].filter[]`', { ...OVERLAY_BASE, tabs: [{ name: 'won', label: 'Won', filter: [CONSOLE_FILTER_ROW] }] }], + // [#20301] The `tabs[].filter[]` row left with the list view's own `tabs` + // (retired); the surviving `ViewTabSchema` carrier is the row below. ['flattened overlay — `userFilters.tabs[].filter[]`', { ...OVERLAY_BASE, userFilters: { element: 'tabs', tabs: [{ name: 'won', label: 'Won', filter: [CONSOLE_FILTER_ROW] }] }, @@ -316,11 +317,13 @@ describe('#5074 — `stripViewConsoleDecorations`, the write-path mirror of `str ...OVERLAY_BASE, sort: [CONSOLE_SORT_ROW], filter: [CONSOLE_FILTER_ROW], - tabs: [{ name: 'won', filter: [CONSOLE_FILTER_ROW] }], + // [#20301] The tab carrier is `userFilters.tabs` — the list view's own + // `tabs` is retired. + userFilters: { element: 'tabs', tabs: [{ name: 'won', filter: [CONSOLE_FILTER_ROW] }] }, }) as Record; expect(out.sort[0]).toEqual({ field: 'estimate_hours', order: 'desc' }); expect(out.filter[0]).toEqual({ field: 'stage', operator: 'equals', value: 'won' }); - expect(out.tabs[0].filter[0]).not.toHaveProperty('id'); + expect(out.userFilters.tabs[0].filter[0]).not.toHaveProperty('id'); }); it('leaves a TOP-LEVEL `id` alone — only builder ROWS are decorated', () => { diff --git a/packages/spec/src/ui/view-filter-rule-wire-id.test.ts b/packages/spec/src/ui/view-filter-rule-wire-id.test.ts index d45fce9b04c..67eed23fa5f 100644 --- a/packages/spec/src/ui/view-filter-rule-wire-id.test.ts +++ b/packages/spec/src/ui/view-filter-rule-wire-id.test.ts @@ -133,11 +133,13 @@ describe('#5114 — a console-written filter row, judged per door (#5074)', () = // The second carrier of `ViewFilterRuleSchema` in this file — the per-tab // filter Studio writes through the SAME builder widget, so it stamps the // same `id`. Probed at its own path because strictness does not recurse in - // either direction. + // either direction. [#20301] Carried by the `userFilters.tabs` preset bar + // since the list view's own `tabs` was retired; `ViewTabSchema` is the + // same item schema on both. expect( ViewMetadataSchema.safeParse({ ...CONSOLE_PUT_BODY, - tabs: [{ name: 'won', label: 'Won', filter: [CONSOLE_FILTER_ROW] }], + userFilters: { element: 'tabs', tabs: [{ name: 'won', label: 'Won', filter: [CONSOLE_FILTER_ROW] }] }, }).success, ).toBe(true); }); diff --git a/packages/spec/src/ui/view-list-tabs-retirement.test.ts b/packages/spec/src/ui/view-list-tabs-retirement.test.ts new file mode 100644 index 00000000000..12d17ca018c --- /dev/null +++ b/packages/spec/src/ui/view-list-tabs-retirement.test.ts @@ -0,0 +1,564 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The list view's own `tabs` RETIRED (#20301) — ADR-0049 enforce-or-remove; + * triage verdict RETIRE under the maintainer's #18900 criterion (mainstream + * named-view switching is already delivered here, by `listViews`). + * + * `ListViewSchema.tabs` parsed at every list-view door, was stored, and drew + * nothing. Measured before removal, with lit controls, and recorded on the + * ledger row (`liveness/view.json`, `/props/list/children/tabs`): objectui's + * `TabBar` — the one component that reads a `ViewTab[]` — has zero production + * mounts at the pinned sha, while `ViewTabBar`, the saved-view switcher, + * mounts in the object view and is fed from `listViews`. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on the SHAPE every list-view door is built + * from — `ListViewSchema`, `ObjectListViewSchema` (a container's `list` / + * `listViews`, an object's `listViews`) and the flattened overlay arm — so + * each refuses with the prescription, and `tsc` refuses at the call site. + * 2. `ViewTabSchema` is NOT retired: the page-only `userFilters.tabs` preset + * bar reuses it and renders. Pinned as a BOUNDARY. + * 3. D2 conversion `view-list-tabs-removed` (step 18) over `stack.views[]` + * in all three persisted spellings; `objects[].listViews.*` is reached by + * no conversion — pinned as a declared boundary, not discovered later. + * 4. `RETIRED_KEYS_BY_MAJOR[18]` carries `ui/ListView:tabs` and + * `ui/ObjectListView:tabs`; the D3 entry `list-view-tabs-retired` rides + * beside the D2 (ruling B on #17152), with no tracker number in any + * author-shown field. + * 5. The metadata form's `tabs` repeater left with the key. + * + * On the assertion set (the #13823 precedent): a schema refusal raises a + * `ZodError` whose issues carry `code` and `path` but no ADR-0112 `status` — + * that envelope belongs to the API error surface. So these pins assert the + * strongest set this surface has: refusal, the issue `code`, the `path` naming + * the key, and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; + +import { collectConversionNotices } from '../conversions/apply'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { ObjectSchema } from '../data/object.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { viewForm } from './view.form'; +import { + ListViewSchema, + ObjectListViewSchema, + ViewItemSchema, + ViewMetadataSchema, + ViewSchema, + ViewTabSchema, + defineView, +} from './view.zod'; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself below. +const PRESCRIPTION = + /`view\.list\.tabs` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049 enforce-or-remove\).*Delete the key, and move each tab.*`listViews`.*`os migrate meta --from 17`/s; + +const TABS = [{ name: 'mine', label: 'Mine', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }]; +const LIST = { type: 'grid', columns: ['subject'] } as const; + +type Issue = { code: string; path: PropertyKey[]; message: string; expected?: string; errors?: Issue[][] }; + +/** Walk `invalid_union` wrappers and return every issue, nested arms included. */ +const flatten = (issues: readonly Issue[]): Issue[] => + issues.flatMap((i) => + i.code === 'invalid_union' && Array.isArray(i.errors) ? [i, ...flatten(i.errors.flat())] : [i]); + +/** + * Every door a list-view payload is judged at, with the path its `tabs` key + * sits at there. Each builds the body around the SAME list payload, so a door + * that silently stopped carrying the tombstone is the only way a row can go + * green without a refusal. + */ +const DOORS: ReadonlyArray) => { success: boolean; error?: { issues: readonly unknown[] } }, string]> = [ + ['ListViewSchema', (list) => ListViewSchema.safeParse(list), 'tabs'], + ['ObjectListViewSchema', (list) => ObjectListViewSchema.safeParse(list), 'tabs'], + ['ViewSchema (defineView) — the default `list`', (list) => ViewSchema.safeParse({ list }), 'list.tabs'], + ['ViewSchema (defineView) — a named `listViews` entry', (list) => ViewSchema.safeParse({ list: LIST, listViews: { triage: list } }), 'listViews.triage.tabs'], + [ + 'ViewItemSchema — a record\'s `config`', + (list) => ViewItemSchema.safeParse({ name: 'crm_ticket.queue', object: 'crm_ticket', viewKind: 'list', config: list }), + 'config.tabs', + ], + [ + 'the `view` write door — a flattened list overlay (PUT /api/v1/meta/view)', + (list) => ViewMetadataSchema.safeParse({ name: 'crm_ticket.queue', object: 'crm_ticket', viewKind: 'list', ...list }), + 'tabs', + ], + [ + 'ObjectSchema — an object\'s own `listViews`', + (list) => ObjectSchema.safeParse({ name: 'crm_ticket', fields: { subject: { type: 'text' } }, listViews: { triage: list } }), + 'listViews.triage.tabs', + ], +]; + +describe('list-view tabs retirement — the tombstone, at every list-view door', () => { + it.each(DOORS)('%s refuses `tabs` at its path, with the prescription', (_label, parse, at) => { + const r = parse({ ...LIST, tabs: TABS }); + expect(r.success).toBe(false); + // Select the TOMBSTONE issue by the shape `retiredKey()` raises, not by its + // text: the union doors also lift the text onto a path-less wrapper. + const issue = flatten((r.error?.issues ?? []) as Issue[]).find((i) => i.expected === 'never'); + expect(issue, JSON.stringify(r.error?.issues)).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path.join('.')).toBe(at); + expect(issue!.message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue!.message.startsWith('`view.list.tabs` was removed')).toBe(true); + }); + + it.each(DOORS)('CONTROL: %s accepts the same body without `tabs`, and grows no `tabs`', (_label, parse) => { + const r = parse({ ...LIST }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); + + it('an empty `tabs: []` is refused too — the key is gone, not merely emptied', () => { + const r = ListViewSchema.safeParse({ ...LIST, tabs: [] }); + expect(r.success).toBe(false); + }); + + it('the `view` registry binding is the union these pins exercise', () => { + // A rebinding of `saveMetaItem`'s schema to some third shape would pass + // the door pins above and still accept the key in production. + expect(getMetadataTypeSchema('view')).toBe(ViewMetadataSchema); + }); + + it('the prescription names the one-line move to `listViews`, and carries no tracker number', () => { + const r = ListViewSchema.safeParse({ ...LIST, tabs: TABS }); + const message = (r.error?.issues ?? []).map((i) => i.message).join('\n'); + expect(message).toContain("the tab's `name` becomes the entry's key"); + expect(message).toContain('Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'); + expect(message).not.toMatch(/#\d/); + }); + + it('fails tsc at the authoring site: the input type of `tabs` is `never`', () => { + const attempt = () => + defineView({ + list: { + type: 'grid', + columns: ['subject'], + // @ts-expect-error — `tabs` is a retiredKey() tombstone: its input type is `never`. + tabs: [{ name: 'mine', label: 'Mine' }], + }, + }); + // The parse channel agrees with the type channel on the same literal. + expect(attempt).toThrow(PRESCRIPTION); + }); +}); + +describe('list-view tabs retirement — the BOUNDARY: `ViewTabSchema` stays, on the page-only preset bar', () => { + it('a page list\'s `userFilters.tabs` preset bar still parses — a different key that renders', () => { + const r = ListViewSchema.safeParse({ ...LIST, userFilters: { element: 'tabs', tabs: TABS } }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + expect(ViewTabSchema.safeParse(TABS[0]).success).toBe(true); + }); + + it('the metadata form offers no `tabs` input on a list view any more', () => { + const paths: string[] = []; + const walk = (fields: unknown, prefix: string) => { + for (const f of (Array.isArray(fields) ? fields : []) as Array<{ field?: string; fields?: unknown }>) { + if (!f?.field) continue; + const p = prefix ? `${prefix}.${f.field}` : f.field; + paths.push(p); + walk(f.fields, p); + } + }; + for (const section of (viewForm as unknown as { sections: Array<{ fields: unknown }> }).sections) { + walk(section.fields, ''); + } + // Anti-vacuity: the walk reached the neighbours of the removed input. + expect(paths).toContain('userFilters'); + expect(paths).toContain('appearance'); + expect(paths).not.toContain('tabs'); + }); +}); + +describe('list-view tabs retirement — the D2 conversion', () => { + it('a STORED view container carrying the key replays clean through the rehydration seam', () => { + // `database-loader.ts` replays the chain over every stored row it loads; + // the seam wraps a `view` row as `{ views: [row] }`. + const stored = { + name: 'crm_ticket', + object: 'crm_ticket', + list: { ...LIST, tabs: TABS }, + listViews: { triage: { ...LIST, tabs: [] }, all: { ...LIST } }, + }; + const notices: { conversionId?: string; path?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('view', stored, { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string }), + }) as Record; + + expect(notices.map((n) => n.conversionId)).toEqual(['view-list-tabs-removed', 'view-list-tabs-removed']); + expect(notices.map((n) => n.path)).toEqual(['views[0].list.tabs', 'views[0].listViews.triage.tabs']); + expect(rehydrated.list).not.toHaveProperty('tabs'); + expect(rehydrated.listViews.triage).not.toHaveProperty('tabs'); + // CONTROL: the live keys on the same payloads survive byte-for-byte. + expect(rehydrated.list).toEqual(LIST); + expect(rehydrated.listViews.all).toEqual(LIST); + // And the rehydrated row is exactly what the write door accepts now. + expect(ViewMetadataSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('reaches a ViewItem record\'s `config` and a flattened overlay, never a form payload', () => { + const { stack, notices } = collectConversionNotices( + { + views: [ + { name: 'crm_ticket.queue', object: 'crm_ticket', viewKind: 'list', config: { ...LIST, tabs: TABS } }, + { name: 'crm_ticket.board', object: 'crm_ticket', viewKind: 'list', ...LIST, tabs: TABS }, + { name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }, + ], + }, + { includeRetired: true }, + ); + const own = notices.filter((n) => n.conversionId === 'view-list-tabs-removed'); + expect(own.map((n) => n.path)).toEqual(['views[0].config.tabs', 'views[1].tabs']); + const views = stack.views as Record[]; + expect(views[0]!.config).toEqual(LIST); + expect(views[1]).not.toHaveProperty('tabs'); + expect(views[2]).toEqual({ name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }); + + // Idempotence, measured: a second replay converts nothing and hands the + // input back by reference (copy-on-write). + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices.filter((n) => n.conversionId === 'view-list-tabs-removed')).toHaveLength(0); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const { stack, notices } = collectConversionNotices({ views: [{ object: 'crm_ticket', list: { ...LIST, tabs: TABS } }] }); + expect(notices.filter((n) => n.conversionId === 'view-list-tabs-removed')).toHaveLength(0); + expect((stack.views as Record[])[0]!.list.tabs).toEqual(TABS); + }); + + it('BOUNDARY: an object\'s own `listViews` is reached by no conversion — refused at its door instead', () => { + const notices: { conversionId?: string }[] = []; + const row = { name: 'crm_ticket', fields: { subject: { type: 'text' } }, listViews: { triage: { ...LIST, tabs: TABS } } }; + const out = applyConversionsToStoredItem('object', row, { + onNotice: (n) => notices.push(n as { conversionId?: string }), + }) as Record; + expect(notices.filter((n) => n.conversionId === 'view-list-tabs-removed')).toHaveLength(0); + expect(out.listViews.triage.tabs).toEqual(TABS); + expect(ObjectSchema.safeParse(out).success).toBe(false); + }); +}); + +describe('list-view tabs retirement — ADR-0087 registration', () => { + it('declares the key on both list-view defs under major 18, with the D2 conversion in the step-18 chain', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ui/ListView:tabs'); + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ui/ObjectListView:tabs'); + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain('view-list-tabs-removed'); + }); + + it('carries its D3 entry beside the D2 (ruling B), with no tracker number in any author-shown field', () => { + const entry = MIGRATIONS_BY_MAJOR[18]!.semantic.find((s) => s.id === 'list-view-tabs-retired'); + expect(entry, 'the family owes one D3 entry even though its D2 is lossless').toBeDefined(); + for (const field of ['surface', 'replacement', 'reason', 'acceptanceCriteria'] as const) { + expect(entry![field], field).not.toMatch(/#\d/); + } + expect(entry!.replacement).toContain('`listViews`'); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types the key `never` on every +// list-view input, so every typed authoring site fails to compile. The residue +// is what `tsc` never judges: JSON, YAML, MD/MDX code fences, untyped `.js`, +// and TS literals typed `unknown` (a test body handed to a raw walker). This +// walk covers that residue across the five roots already declared for +// `@objectstack/spec#test` in `scripts/cross-package-test-inputs.mjs` and +// mirrored in `turbo.json` — the same roots and extensions the view-item +// owner/hidden pin walks. +// +// ⭐ `tabs` is a common key (page slots, the `userFilters` preset bar, studio +// preview config …), so a textual matcher would be all noise. The matcher is +// STRUCTURAL: an offender is one object literal (or one YAML mapping) whose +// OWN keys include `tabs` AND a key only a list-view payload carries. The +// `userFilters` object itself (`element` / `fields` / `tabs`) carries none of +// them, so the surviving preset bar is not matched. +// +// The bound, stated: a payload assembled by SPREAD (`{ ...list, tabs }`) or +// computed keys is invisible to a text walk; `docs/**`, `.claude/**`, +// `.github/**` and the repo-root files are outside the radius. +describe('tree-scoped absence: no list-view payload inside the declared radius still carries `tabs`', () => { + 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('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` only the non-code extensions are scanned AND declared. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** Keys only a list-view PAYLOAD carries beside its own `tabs`. */ + const LIST_VIEW_SIBLINGS = new Set([ + 'columns', 'filter', 'sort', 'data', 'kanban', 'calendar', 'gantt', 'gallery', 'timeline', 'grouping', + 'rowColor', 'hiddenFields', 'fieldOrder', 'filterableFields', 'searchableFields', 'userFilters', + 'appearance', 'userActions', 'addRecord', 'pagination', 'selection', 'rowActions', 'bulkActions', + 'emptyState', 'showRecordCount', 'allowPrinting', 'conditionalFormatting', 'inlineEdit', 'exportOptions', + 'rowHeight', + ]); + + /** + * 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 key on a list-view payload. + */ + const EXCLUDED = new Set([ + // The tombstone itself: the list-view SHAPE literal declares `tabs` beside + // every sibling above. Schema source, not an authoring. + 'packages/spec/src/ui/view.zod.ts', + // This pin authors the key at every door to assert the refusal. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement payload on purpose. + 'packages/spec/src/conversions/', + // GITIGNORED build output reached only because this is a FILESYSTEM walk: + // `retiredKey()` emits the tombstone into the generated JSON Schema's + // `properties` beside the siblings. Its source, `view.zod.ts`, is excluded + // above for the same reason. + 'packages/spec/json-schema/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // The liveness ledger keys its rows by the SCHEMA's key names, so the list + // slot's `children` object spells `tabs` beside `columns` / `filter` — and + // the tombstone discipline requires that row to STAY (`dead`, REMOVED note). + 'packages/spec/liveness/', + ]; + /** + * RESIDUE, declared and self-expiring — not exempted. Test fixtures of the + * two author-time reference walks that still read a list view's `tabs` off + * RAW input (`validate-list-view-field-refs.ts#checkTabs` in `@objectstack/lint`, + * `computeViewReferenceDiagnostics` in `@objectstack/metadata-protocol`) and + * the CLI's negative i18n pin. Removing those walks is outside this + * retirement's file surface and is reported as its follow-up; each entry here + * is asserted to STILL hold an offender, so the day the follow-up deletes a + * fixture this set goes red and the entry leaves with it. + */ + const RESIDUE = new Set([ + 'packages/cli/test/i18n-tab-coverage.test.ts', + 'packages/lint/src/validate-list-view-field-refs.test.ts', + 'packages/objectql/src/metadata-diagnostics.test.ts', + ]); + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const isOffendingKeySet = (keys: Set): boolean => + keys.has('tabs') && [...keys].some((k) => LIST_VIEW_SIBLINGS.has(k)); + + /** + * One pass over JS/TS/JSON text: a stack of bracket frames, each `{` frame + * collecting its OWN keys — an identifier or quoted string in key position + * (after `{` or `,`) followed by `:`. Strings and comments are skipped; a + * single- or double-quoted string never spans a line, so a mis-lexed quote + * (a regex literal) costs at most that line. Returns the 1-based line of each + * closing brace whose frame is an offender. (The view-item pin's lexer.) + */ + const lexOffenders = (text: string): number[] => { + const out: number[] = []; + const stack: { kind: string; keys: Set }[] = []; + let lastSig = ''; + let line = 1; + let i = 0; + const n = text.length; + while (i < n) { + const c = text[i]!; + if (c === '\n') { line += 1; i += 1; continue; } + if (c === '/' && text[i + 1] === '/') { while (i < n && text[i] !== '\n') i += 1; continue; } + if (c === '/' && text[i + 1] === '*') { + i += 2; + while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') line += 1; i += 1; } + i += 2; + continue; + } + if (c === '"' || c === "'" || c === '`') { + const start = i; + i += 1; + while (i < n && text[i] !== c) { + if (text[i] === '\\') i += 1; + else if (text[i] === '\n') { if (c !== '`') break; line += 1; } + i += 1; + } + const token = text.slice(start + 1, i); + i += 1; + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + const top = stack[stack.length - 1]; + if (c !== '`' && text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ',')) top.keys.add(token); + lastSig = 'str'; + continue; + } + if (/[A-Za-z_$]/.test(c)) { + const start = i; + while (i < n && /[\w$]/.test(text[i]!)) i += 1; + const token = text.slice(start, i); + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + const top = stack[stack.length - 1]; + if (text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ',')) top.keys.add(token); + lastSig = 'id'; + continue; + } + if (c === '{' || c === '[' || c === '(') stack.push({ kind: c, keys: new Set() }); + else if (c === '}' || c === ']' || c === ')') { + const frame = stack.pop(); + if (frame?.kind === '{' && c === '}' && isOffendingKeySet(frame.keys)) out.push(line); + } + if (!/\s/.test(c)) lastSig = c; + i += 1; + } + return out; + }; + + /** + * YAML: a mapping's OWN keys are the key lines at one column, bounded by a + * line at a smaller column or by a new list item at that column. Returns the + * 1-based line of each `tabs` key whose mapping is an offender. + */ + const yamlOffenders = (text: string): number[] => { + const rows = text.split('\n').map((raw, idx) => { + const m = /^(\s*)(-\s+)?([A-Za-z_][\w]*)\s*:/.exec(raw); + return m ? { idx, col: m[1]!.length + (m[2]?.length ?? 0), key: m[3]!, item: Boolean(m[2]) } : null; + }); + const out: number[] = []; + rows.forEach((row, at) => { + if (!row || row.key !== 'tabs') return; + const keys = new Set([row.key]); + if (!row.item) { + for (let k = at - 1; k >= 0; k -= 1) { + const r = rows[k]; + if (!r) continue; + if (r.col < row.col) break; + if (r.col === row.col) { keys.add(r.key); if (r.item) break; } + } + } + for (let k = at + 1; k < rows.length; k += 1) { + const r = rows[k]; + if (!r) continue; + if (r.col < row.col || (r.col === row.col && r.item)) break; + if (r.col === row.col) keys.add(r.key); + } + if (isOffendingKeySet(keys)) out.push(row.idx + 1); + }); + return out; + }; + + /** MD/MDX: only fenced code is judged — prose mentions are not authorings. */ + const markdownOffenders = (text: string): number[] => { + const out: number[] = []; + const fence = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + for (let m = fence.exec(text); m; m = fence.exec(text)) { + const lang = m[1]!.toLowerCase(); + const body = m[2]!; + const offset = text.slice(0, m.index).split('\n').length; + const hits = lang === 'yaml' || lang === 'yml' ? yamlOffenders(body) : lexOffenders(body); + for (const h of hits) out.push(offset + h); + } + return out; + }; + + const offendersIn = (ext: string, text: string): number[] => { + if (!/\btabs\b/.test(text)) return []; + if (ext === '.yaml' || ext === '.yml') return yamlOffenders(text); + if (ext === '.md' || ext === '.mdx') return markdownOffenders(text); + return lexOffenders(text); + }; + + const vanished: string[] = []; + /** Tolerates ONLY a path's disappearance mid-walk; every other fault is re-raised. */ + const readIfPresent = (full: string, rel: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + vanished.push(rel); + return undefined; + } + }; + + it('the matcher finds a list-view authoring and ignores every neighbouring shape (anti-vacuity)', () => { + // Offenders — the list-view payload spelling, in each syntax the walk reads. + expect(offendersIn('.ts', "defineView({ list: { type: 'grid', columns: ['a'], tabs: [{ name: 'x' }] } })")).toEqual([1]); + expect(offendersIn('.ts', "const v = {\n columns: ['a'],\n tabs: [],\n};")).toEqual([4]); + expect(offendersIn('.json', '{ "list": { "columns": ["a"], "tabs": [ { "name": "x" } ] } }')).toEqual([1]); + expect(offendersIn('.yaml', 'list:\n type: grid\n columns: [a]\n tabs:\n - name: x\n')).toEqual([4]); + expect(offendersIn('.md', "Prose.\n\n```ts\nlist: { filter: [], tabs: [] }\n```\n")).toEqual([4]); + // Neighbours that must NOT match. + // The surviving page-only preset bar: the `userFilters` object's own keys. + expect(offendersIn('.ts', "({ columns: ['a'], userFilters: { element: 'tabs', fields: [], tabs: [{ name: 'x' }] } })")).toEqual([]); + // A record page's slots: `tabs` beside other slots. + expect(offendersIn('.ts', "({ slots: { highlights: {}, tabs: { type: 'page:tabs', properties: { items: [] } } } })")).toEqual([]); + // Studio's object preview config. + expect(offendersIn('.ts', "({ objectPreview: { tabs: [], defaultTab: 'fields', showHeader: true } })")).toEqual([]); + // A spread-assembled payload — the stated bound. + expect(offendersIn('.ts', "({ ...list, tabs: [] })")).toEqual([]); + // Prose and quoted strings are not authorings. + expect(offendersIn('.md', 'A list view once took `columns` and `tabs: []`.')).toEqual([]); + expect(offendersIn('.ts', "const s = \"{ columns: [], tabs: [] }\";")).toEqual([]); + }); + + it('a path that VANISHES mid-walk is not a finding, and every other read fault still is', () => { + const before = vanished.length; + const gone = path.join(REPO_ROOT, 'packages/spec/does-not-exist.bundled_probe.mjs'); + expect(fs.existsSync(gone)).toBe(false); + expect(readIfPresent(gone, 'probe/gone')).toBeUndefined(); + expect(vanished.slice(before)).toEqual(['probe/gone']); + expect(readIfPresent(fileURLToPath(import.meta.url), THIS_FILE)).toContain('tree-scoped absence'); + expect(() => readIfPresent(path.join(REPO_ROOT, 'packages/spec'), 'probe/dir')).toThrow(); + expect(vanished.length).toBe(before + 1); + }); + + it('no list-view payload carrying `tabs` survives inside the declared radius', () => { + const offenders: string[] = []; + const residueHits = new Map(); + let visited = 0; + let tabsBearing = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (rel.startsWith('.changeset/')) continue; // the changeset names the key it retires + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + const text = readIfPresent(full, rel); + if (text === undefined) continue; + if (/\btabs\b/.test(text)) tabsBearing += 1; + const hits = offendersIn(ext, text); + if (RESIDUE.has(rel)) { residueHits.set(rel, hits.length); continue; } + for (const lineNo of hits) offenders.push(`${rel}:${lineNo}`); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk covered the tree, and the files that CAN hold the + // key were really judged. + expect(visited).toBeGreaterThan(1000); + expect(tabsBearing).toBeGreaterThan(50); + expect(offenders, 'a list-view payload carrying `tabs` means the retirement is being undone').toEqual([]); + // The residue is self-expiring: every declared file still holds an + // offender, or its entry is stale and leaves. + for (const rel of RESIDUE) { + expect(residueHits.get(rel) ?? 0, `${rel} no longer authors the key — delete its RESIDUE entry`).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index fd7632991ec..28b77df9b37 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -3646,17 +3646,12 @@ describe('ListViewSchema — Airtable Interface parity fields', () => { expect(listView.appearance?.allowedVisualizations).toHaveLength(3); }); - it('should accept list view with tabs', () => { - const listView = ListViewSchema.parse({ - columns: ['name', 'status'], - tabs: [ - { name: 'my_customers', label: 'My Customers', isDefault: true }, - { name: 'all_records', label: 'All Records' }, - ], - }); - expect(listView.tabs).toHaveLength(2); - expect(listView.tabs![0].isDefault).toBe(true); - }); + // [#20301] "should accept list view with tabs" was REMOVED: the list view's + // own `tabs` is retired (no renderer ever drew it — the tab strip above an + // object's records lists its `listViews`). The refusal at every list-view + // door, the D2 conversion and the tree-scoped absence pin live in + // `view-list-tabs-retirement.test.ts`, the one place that authors the key on + // purpose. it('should accept list view with addRecord', () => { const listView = ListViewSchema.parse({ @@ -3714,10 +3709,6 @@ describe('ListViewSchema — Airtable Interface parity fields', () => { showDescription: true, allowedVisualizations: ['grid', 'gallery', 'kanban'], }, - tabs: [ - { name: 'my_customers', label: 'my customers', isDefault: true, pinned: true }, - { name: 'all_records', label: 'All records' }, - ], addRecord: { enabled: true, position: 'bottom', @@ -3729,7 +3720,6 @@ describe('ListViewSchema — Airtable Interface parity fields', () => { expect(listView.name).toBe('customer_list'); expect(listView.userActions?.sort).toBe(true); expect(listView.appearance?.allowedVisualizations).toHaveLength(3); - expect(listView.tabs).toHaveLength(2); expect(listView.showRecordCount).toBe(true); expect(listView.allowPrinting).toBe(true); }); From 92e98c8392d3be09c547369da296b7be7f8633ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:22:49 +0000 Subject: [PATCH 03/13] chore(i18n): regenerate metadata-form bundles after the list-view tabs retirement; add the changeset The view form's `tabs` repeater left with the key it wrote, so the extracted form labels drop with it across en/es-ES/ja-JP/zh-CN. Pure deletion, regenerated by check-i18n-bundles.mjs --write. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- .changeset/list-view-tabs-retired.md | 96 +++++++++++++++++++ .../en.metadata-forms.generated.ts | 31 ------ .../es-ES.metadata-forms.generated.ts | 31 ------ .../ja-JP.metadata-forms.generated.ts | 31 ------ .../zh-CN.metadata-forms.generated.ts | 31 ------ 5 files changed, 96 insertions(+), 124 deletions(-) create mode 100644 .changeset/list-view-tabs-retired.md diff --git a/.changeset/list-view-tabs-retired.md b/.changeset/list-view-tabs-retired.md new file mode 100644 index 00000000000..9ea5492f323 --- /dev/null +++ b/.changeset/list-view-tabs-retired.md @@ -0,0 +1,96 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: retire the list view's own `tabs` key — parsed, stored, and drawn by nothing; named presets are `listViews` entries + +**BREAKING** — `tabs` is removed from the list view (`ListViewSchema`, +`ObjectListViewSchema` — a `defineView` container's `list` / `listViews`, an +object's `listViews` — a view item record's list `config`, and the flattened +list overlay the `PUT /api/v1/meta/view` door accepts). ADR-0049 +enforce-or-remove; triage verdict RETIRE, on the rule that a capability the +mainstream has and this platform already delivers keeps ONE spelling. + +The key parsed at every list-view door and was stored, and no renderer ever +drew it. Measured before removal, each reading beside a lit control: the one +component that reads a `ViewTab[]` (objectui's `TabBar`) has zero production +mounts at the objectui commit this repo pins — every occurrence is in its own +two test files — while the saved-view switcher (`ViewTabBar`) mounts in the +object view and is fed from the object's `listViews`. That switcher IS the tab +strip above an object's records: one tab per named list view. Zero list views +in this repo's examples, skills or platform sources authored the key. + +### FROM → TO + +| removed | what to write instead | +| --- | --- | +| a list view's `tabs: [{ name, label, filter, … }]` | one named list view per tab, under the object's `listViews`: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too). A tab whose `view` already named a list view needs nothing more. | +| the tab keys `icon`, `order`, `pinned`, `isDefault`, `visible` | nothing — none of them ever had an effect. | + +**The one-line fix: delete `tabs:` from every list view, and add a `listViews` +entry for each tab you want users to switch to.** `os migrate meta --from 17` +lists the mechanical edits for existing sources; apply them by hand. + +```ts +// before — parsed clean, drew no tab bar +defineView({ + object: 'crm_ticket', + list: { + type: 'grid', columns: ['subject', 'status'], + tabs: [{ name: 'open', label: 'Open', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }], + }, +}); +// after — the switcher above the records shows "Open" beside the default view +defineView({ + object: 'crm_ticket', + list: { type: 'grid', columns: ['subject', 'status'] }, + listViews: { + open: { + type: 'grid', label: 'Open', columns: ['subject', 'status'], + filter: [{ field: 'status', operator: 'equals', value: 'open' }], + }, + }, +}); +``` + +⛔ **Untouched: the page-only preset bar.** `userFilters: { element: 'tabs', +tabs: [...] }` on a page list is a different key, it renders, and +`ViewTabSchema` stays for it. + +### The retirement kit + +- **A `retiredKey()` tombstone on the list-view shape**, beside the `pageName` + tombstone on the same strict shape. Every door built from it refuses: `tsc` + types the key `never`, and the parse raises the prescription (which names the + move to `listViews`) instead of a bare unknown-key report. +- **D2 conversion `view-list-tabs-removed`** (protocol 18, retired from the load + path): strips `tabs` from every list payload in `stack.views[]`, in all three + persisted spellings, as a lossless delete — nothing ever drew the tabs — so a + stored `view` row replays clean through the rehydration seam. An object's own + `listViews` is reached by no conversion, so such an object is refused at its + door until edited by hand. +- **D3 entry `list-view-tabs-retired`** beside it, carrying the part no + conversion can decide: which tabs deserve a `listViews` entry. +- **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ListView:tabs`, `ui/ObjectListView:tabs`; + both `authorable-surface/ui.json` rows become `[RETIRED]`. +- **The metadata form's `tabs` repeater** leaves with the key, and the + extracted form-label bundles are regenerated. +- **The liveness row stays `dead`**, re-verified, with a REMOVED note — the + tombstone keeps the key in the walked shape. +- **The published `objectstack-ui` skill** no longer teaches the key: its + list-view rules example and the "tabs win over dropdowns" rule (which + described a tab bar that never rendered) are replaced by the `listViews` + pointer. +- **Pins** (`ui/view-list-tabs-retirement.test.ts`): the refusal, its issue + code, path and prescription at seven doors, each with a lit control; the tsc + channel; the `userFilters.tabs` boundary; the conversion's reach, boundary and + idempotence; the D2/D3 registration; and a tree-scoped absence walk over the + declared radius. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` +is published, so this is breaking for consumers no telemetry was consulted for. + +Clause-②: no (narrowing) + + diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index f867b693420..c97d541fa48 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -891,37 +891,6 @@ export const enMetadataForms: NonNullable = { label: "User Filters", helpText: "Quick-filter bar: element style (dropdown / tabs / toggle) + exposed fields or tab presets" }, - tabs: { - label: "Tabs", - helpText: "In-view filter tabs — each tab applies its own filter rules" - }, - "tabs.name": { - label: "Name" - }, - "tabs.label": { - label: "Label" - }, - "tabs.icon": { - label: "Icon" - }, - "tabs.view": { - label: "List View" - }, - "tabs.filter": { - label: "Filter" - }, - "tabs.order": { - label: "Display Order" - }, - "tabs.pinned": { - label: "Pinned" - }, - "tabs.isDefault": { - label: "Default Tab" - }, - "tabs.visible": { - label: "Visible" - }, appearance: { label: "Appearance", helpText: "allowedVisualizations: which renderers users may switch between" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 2cea4709f35..4f5f0f86d14 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -891,37 +891,6 @@ export const esESMetadataForms: NonNullable = label: "Filtros de usuario", helpText: "Barra de filtros rápidos: estilo de elemento (dropdown / tabs / toggle) + campos expuestos o preajustes de pestañas" }, - tabs: { - label: "Pestañas", - helpText: "Pestañas de filtro en la vista: cada pestaña aplica sus propias reglas de filtro" - }, - "tabs.name": { - label: "Nombre" - }, - "tabs.label": { - label: "Etiqueta" - }, - "tabs.icon": { - label: "Icono" - }, - "tabs.view": { - label: "Vista de lista" - }, - "tabs.filter": { - label: "Filtro" - }, - "tabs.order": { - label: "Orden de visualización" - }, - "tabs.pinned": { - label: "Fijada" - }, - "tabs.isDefault": { - label: "Pestaña predeterminada" - }, - "tabs.visible": { - label: "Visibilidad" - }, appearance: { label: "Apariencia", helpText: "allowedVisualizations: qué renderizadores pueden alternar los usuarios" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index a5538f580d4..18fa41bc118 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -891,37 +891,6 @@ export const jaJPMetadataForms: NonNullable = label: "ユーザーフィルター", helpText: "クイックフィルターバー:要素スタイル(dropdown / tabs / toggle)+ 公開フィールドまたはタブプリセット" }, - tabs: { - label: "タブ", - helpText: "ビュー内フィルタータブ——各タブが独自のフィルタールールを適用" - }, - "tabs.name": { - label: "名前" - }, - "tabs.label": { - label: "表示名" - }, - "tabs.icon": { - label: "アイコン" - }, - "tabs.view": { - label: "リストビュー" - }, - "tabs.filter": { - label: "フィルター" - }, - "tabs.order": { - label: "表示順" - }, - "tabs.pinned": { - label: "固定" - }, - "tabs.isDefault": { - label: "既定のタブ" - }, - "tabs.visible": { - label: "表示" - }, appearance: { label: "外観", helpText: "allowedVisualizations:ユーザーが切り替え可能なレンダラー" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index d244098baf3..4da9f946480 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -891,37 +891,6 @@ export const zhCNMetadataForms: NonNullable = label: "用户筛选器", helpText: "快速筛选栏:控件样式(dropdown / tabs / toggle)+ 暴露的字段或标签页预设" }, - tabs: { - label: "标签页", - helpText: "视图内筛选标签页——每个标签页应用各自的筛选规则" - }, - "tabs.name": { - label: "名称" - }, - "tabs.label": { - label: "显示名称" - }, - "tabs.icon": { - label: "图标" - }, - "tabs.view": { - label: "列表视图" - }, - "tabs.filter": { - label: "筛选" - }, - "tabs.order": { - label: "显示顺序" - }, - "tabs.pinned": { - label: "固定" - }, - "tabs.isDefault": { - label: "默认标签页" - }, - "tabs.visible": { - label: "可见" - }, appearance: { label: "外观", helpText: "allowedVisualizations:允许用户切换的渲染器" From 2fcf214c3d0878310790f64fb38ae5a475cd5ec4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:35:12 +0000 Subject: [PATCH 04/13] chore(spec): drop the stale undrilled-container row for list.tabs; run the retirement pin in the repo project The tombstone is no longer a container, so check:liveness reports the view/list.tabs undrilled row as a closed gap; the tree-scoped absence walk reads outside the package, so it joins vitest.repo-tests.json. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- .../spec/scripts/liveness/undrilled-containers.baseline.json | 1 - packages/spec/vitest.repo-tests.json | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index d7ff9e15b8e..78ca5f650bb 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -161,7 +161,6 @@ "view/list.rowColor", "view/list.selection", "view/list.sharing", - "view/list.tabs", "view/list.timeline", "view/list.tree", "view/list.userActions", diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 23a523dc6b0..3943ec829db 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -31,5 +31,6 @@ "src/system/constants/platform-object-names.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", - "src/ui/view-item-owner-hidden-retirement.test.ts" + "src/ui/view-item-owner-hidden-retirement.test.ts", + "src/ui/view-list-tabs-retirement.test.ts" ] From caa51ec48f5357dd53131ed34538ad1b9a509538 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:07:22 +0000 Subject: [PATCH 05/13] test(spec): spell the checkTabs anchor as a full repo path Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- packages/spec/src/ui/view-list-tabs-retirement.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/view-list-tabs-retirement.test.ts b/packages/spec/src/ui/view-list-tabs-retirement.test.ts index 12d17ca018c..b94d26b1dc3 100644 --- a/packages/spec/src/ui/view-list-tabs-retirement.test.ts +++ b/packages/spec/src/ui/view-list-tabs-retirement.test.ts @@ -339,8 +339,8 @@ describe('tree-scoped absence: no list-view payload inside the declared radius s /** * RESIDUE, declared and self-expiring — not exempted. Test fixtures of the * two author-time reference walks that still read a list view's `tabs` off - * RAW input (`validate-list-view-field-refs.ts#checkTabs` in `@objectstack/lint`, - * `computeViewReferenceDiagnostics` in `@objectstack/metadata-protocol`) and + * RAW input (`packages/lint/src/validate-list-view-field-refs.ts#checkTabs`, + * and `computeViewReferenceDiagnostics` in `@objectstack/metadata-protocol`) and * the CLI's negative i18n pin. Removing those walks is outside this * retirement's file surface and is reported as its follow-up; each entry here * is asserted to STILL hold an offender, so the day the follow-up deletes a From 13325ce9ceaf403aaff7259b484816890078f630 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:33:28 +0000 Subject: [PATCH 06/13] test(spec): drop an unused type import from the tabs retirement pin Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- packages/spec/src/ui/view-list-tabs-retirement.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/spec/src/ui/view-list-tabs-retirement.test.ts b/packages/spec/src/ui/view-list-tabs-retirement.test.ts index b94d26b1dc3..e923842bcb6 100644 --- a/packages/spec/src/ui/view-list-tabs-retirement.test.ts +++ b/packages/spec/src/ui/view-list-tabs-retirement.test.ts @@ -40,7 +40,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import type { z } from 'zod'; import { collectConversionNotices } from '../conversions/apply'; import { applyConversionsToStoredItem } from '../conversions/stored'; From 74f8ab9727f25f8126847048c205b3e66f1a705a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:46:55 +0000 Subject: [PATCH 07/13] docs(spec): regenerate the reference pages for the list-view tabs tombstone Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- content/docs/references/api/protocol.mdx | 4 +-- content/docs/references/data/object.mdx | 2 +- content/docs/references/ui/view.mdx | 44 ++++-------------------- 3 files changed, 9 insertions(+), 41 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 759668c85ea..6ee4bd67119 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1698,7 +1698,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -1783,7 +1783,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index dff789d10e8..145856283ba 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -397,7 +397,7 @@ const result = ApiMethod.parse(data); | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 868ea46178a..a7da9f96186 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -825,7 +825,7 @@ Map view configuration | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -1114,22 +1114,6 @@ View filter rule | **showDescription** | `boolean` | optional (default: `true`) | Show the view description text | | **allowedVisualizations** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[]` | optional | Whitelist of visualization types users can switch between (e.g. ["grid", "gallery", "kanban"]) | -### Nested Shape: `ListView.tabs[number]` - -Tab configuration for multi-tab view interface - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Tab identifier (snake_case) | -| **label** | `string \| Record` | optional | Display label | -| **icon** | `string` | optional | Tab icon name | -| **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | -| **order** | `integer` | optional | Tab display order | -| **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | -| **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | -| **visible** | `boolean` | optional (default: `true`) | Tab visibility | - ### Nested Shape: `ListView.addRecord` | Property | Type | Required | Description | @@ -1233,7 +1217,7 @@ Tab configuration for multi-tab view interface | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -1513,22 +1497,6 @@ View filter rule | **showDescription** | `boolean` | optional (default: `true`) | Show the view description text | | **allowedVisualizations** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[]` | optional | Whitelist of visualization types users can switch between (e.g. ["grid", "gallery", "kanban"]) | -### Nested Shape: `ObjectListView.tabs[number]` - -Tab configuration for multi-tab view interface - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Tab identifier (snake_case) | -| **label** | `string \| Record` | optional | Display label | -| **icon** | `string` | optional | Tab icon name | -| **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | -| **order** | `integer` | optional | Tab display order | -| **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | -| **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | -| **visible** | `boolean` | optional (default: `true`) | Tab visibility | - ### Nested Shape: `ObjectListView.addRecord` | Property | Type | Required | Description | @@ -1832,7 +1800,7 @@ Tab configuration for multi-tab view interface | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -1917,7 +1885,7 @@ Tab configuration for multi-tab view interface | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -2158,7 +2126,7 @@ This schema accepts one of the following structures: | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | @@ -2334,7 +2302,7 @@ This schema accepts one of the following structures: | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | -| **tabs** | `{ name: string; label?: string \| Record; icon?: string; view?: string; … }[]` | optional | Tab definitions for multi-tab view interface | +| **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | From c13e610ab5e4e4a9f6422e86f5e67e4c78d33ec4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:41:46 +0000 Subject: [PATCH 08/13] docs(spec): regenerate the reference pages on the merged tree (discharges the os-regen deferral) Main's side of the three pages (the form layout narrowing) taken by os-regen-merge.sh step 2, then gen:docs re-derived the list-view tabs tombstone rows on top. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- content/docs/references/api/protocol.mdx | 10 +++++----- content/docs/references/data/object.mdx | 2 +- content/docs/references/ui/view.mdx | 20 ++++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 6ee4bd67119..2345214b652 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1,6 +1,6 @@ --- title: Protocol -description: Protocol protocol schemas +description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 133 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -1644,9 +1644,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **label** | `string \| Record` | optional | Human-readable label shown in metadata lists. | | **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. | | **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | optional | | -| **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | | +| **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }` | optional | | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | -| **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | +| **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -1716,7 +1716,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional (default: `"simple"`) | | -| **layout** | `Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>` | optional | Field layout direction | +| **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | **columns** | `integer` | optional | Number of columns for the form body | | **title** | `string` | optional | Form title | | **description** | `string` | optional | Form description | @@ -1801,7 +1801,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional (default: `"simple"`) | | -| **layout** | `Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>` | optional | Field layout direction | +| **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | **columns** | `integer` | optional | Number of columns for the form body | | **title** | `string` | optional | Form title | | **description** | `string` | optional | Form description | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 145856283ba..e5c3eb68549 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -1,6 +1,6 @@ --- title: Object -description: Object protocol schemas +description: "Object schemas of the ObjectStack Data Protocol: ApiMethod, ApiOperation, Index and 13 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index a7da9f96186..c790c216f54 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -1,6 +1,6 @@ --- title: View -description: View protocol schemas +description: "View protocol schemas — the view metadata type and its three persisted body spellings." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -401,7 +401,7 @@ Form-view select option — the object-field option shape minus the per-option ` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional (default: `"simple"`) | | -| **layout** | `Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>` | optional | Field layout direction | +| **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | **columns** | `integer` | optional | Number of columns for the form body | | **title** | `string` | optional | Form title | | **description** | `string` | optional | Form description | @@ -1746,9 +1746,9 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Human-readable label shown in metadata lists. | | **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. | | **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | optional | | -| **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | | +| **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }` | optional | | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | -| **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | +| **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -1818,7 +1818,7 @@ Tab configuration for multi-tab view interface | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional (default: `"simple"`) | | -| **layout** | `Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>` | optional | Field layout direction | +| **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | **columns** | `integer` | optional | Number of columns for the form body | | **title** | `string` | optional | Form title | | **description** | `string` | optional | Form description | @@ -1903,7 +1903,7 @@ Tab configuration for multi-tab view interface | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional (default: `"simple"`) | | -| **layout** | `Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>` | optional | Field layout direction | +| **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | **columns** | `integer` | optional | Number of columns for the form body | | **title** | `string` | optional | Form title | | **description** | `string` | optional | Form description | @@ -2155,7 +2155,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **viewKind** | `'form'` | ✅ | | -| **config** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | ✅ | Form view configuration. | +| **config** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }` | ✅ | Form view configuration. | | **name** | `string` | ✅ | Globally-unique view id, `.`. | | **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | | **label** | `string \| Record` | optional | Display label (supports i18n). | @@ -2178,7 +2178,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional (default: `"simple"`) | | -| **layout** | `Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>` | optional | Field layout direction | +| **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | **columns** | `integer` | optional | Number of columns for the form body | | **title** | `string` | optional | Form title | | **description** | `string` | optional | Form description | @@ -2338,7 +2338,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **viewKind** | `'form'` | ✅ | | -| **config** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | ✅ | Form view configuration. | +| **config** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal'>; columns?: integer; title?: string; … }` | ✅ | Form view configuration. | | **name** | `string` | ✅ | Globally-unique view id, `.`. | | **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | | **label** | `string \| Record` | optional | Display label (supports i18n). | @@ -2364,7 +2364,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional (default: `"simple"`) | | -| **layout** | `Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>` | optional | Field layout direction | +| **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` | | **columns** | `integer` | optional | Number of columns for the form body | | **title** | `string` | optional | Form title | | **description** | `string` | optional | Form description | From a3f8054dccc3ff3814363139c34b0d50ef88ddb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:41:57 +0000 Subject: [PATCH 09/13] test: move four consumer pins of the retired list-view `tabs` to the post-retirement truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - platform-objects repeater survey: `view:tabs` is asserted ABSENT (dark leg) beside the two surviving view repeaters, instead of present. - platform-objects zh/ja/es positive control: 608 to 598 — the retired tabs repeater took its own label and nine row labels out of the catalog. - metadata-protocol graft: the nested ViewTab.filter leg rides the surviving userFilters.tabs carrier; the view-filter leg is unchanged. - metadata-protocol findReferencesToMeta control: the consulted source types are read from the derived reference-site index, and `object` is pinned absent — its one view-reference site was listViews.*.tabs[].view. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- ...rotocol.graft-normalized-operators.test.ts | 17 +++++++++--- ...otocol.read-seam-empty-accumulator.test.ts | 26 ++++++++++++++----- ...ect-lifecycle-panel-echo-decisions.test.ts | 9 +++++-- .../repeater-row-properties.test.ts | 14 +++++++--- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts b/packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts index 6912c988d01..b2a445ec8fc 100644 --- a/packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts +++ b/packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts @@ -97,17 +97,28 @@ describe('graftNormalizedOperators — through the real view metadata schema', ( expect(graftThroughSchema(authored)).toBe(authored); }); + // [#20301] The nested carrier is the page-list preset bar, `userFilters.tabs[]`: + // the list view's own `tabs` is a retired key the parse now refuses, and + // `ViewTabSchema` — whose `filter` this leg exercises — survives only there. it('normalizes a tab filter, not just the view filter', () => { const authored = view( [{ field: 'status', operator: 'eq', value: 'open' }], - { tabs: [{ name: 'mine', label: 'Mine', filter: [{ field: 'owner', operator: 'isNotNull' }] }] }, + { + userFilters: { + element: 'tabs', + tabs: [{ name: 'mine', label: 'Mine', filter: [{ field: 'owner', operator: 'isNotNull' }] }], + }, + }, ); const out = graftThroughSchema(authored) as { filter: Array<{ operator: string }>; - tabs: Array<{ filter: Array<{ operator: string }> }>; + userFilters: { element: string; tabs: Array<{ name: string; filter: Array<{ operator: string }> }> }; }; expect(out.filter[0].operator).toBe('equals'); - expect(out.tabs[0].filter[0].operator).toBe('is_not_null'); + expect(out.userFilters.tabs[0].filter[0].operator).toBe('is_not_null'); + // Only the operator moved: the preset bar's own keys ride through as authored. + expect(out.userFilters.element).toBe('tabs'); + expect(out.userFilters.tabs[0].name).toBe('mine'); }); }); diff --git a/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts b/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts index 2ffe326c067..c970a51893d 100644 --- a/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts +++ b/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts @@ -41,6 +41,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ErrorCode } from '@objectstack/spec/api'; import { ObjectStackProtocolImplementation } from './protocol.js'; +import { REFERENCE_SITES } from './reference-sites.js'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; interface FixtureObject { @@ -318,11 +319,11 @@ describe('[#11754] searchAll — a registry that cannot ENUMERATE is not a regis describe('[#8896] findReferencesToMeta — a source type that could not be READ is not a source type with no references', () => { /** - * `view` is reachable from four source types (`app`, `object`, `page`, - * `view`), so a single failing source type leaves the others answering — - * which is exactly the pre-fix trap: a SHORT list that looks complete. - * `page` carries a real reference to `my_view`, so the healthy half is - * observable. + * `view` is reachable from several source types (`app`, `page`, `view` — + * `object` left the set with #20301, below), so a single failing source type + * leaves the others answering — which is exactly the pre-fix trap: a SHORT + * list that looks complete. `page` carries a real reference to `my_view`, so + * the healthy half is observable. * * [#9190] The fixture used to spell that reference `page.viewName`, which * `PageSchema` does not declare — it agreed with the hand-curated path @@ -367,10 +368,21 @@ describe('[#8896] findReferencesToMeta — a source type that could not be READ }, ]); // Every source type that can name a view was really consulted — this is - // what makes "one of them failed" a meaningful condition below. + // what makes "one of them failed" a meaningful condition below. The + // population is the DERIVED index's, not a remembered list, so a source + // type that gains or loses a view-reference site moves this control with it. expect(typeReads).toContain('app'); - expect(typeReads).toContain('object'); expect(typeReads).toContain('page'); + const viewSources = new Set((REFERENCE_SITES.byTarget.get('view') ?? []).map((s) => s.fromType)); + expect(viewSources.size, 'the derived index names no source type for `view`: the walk is broken').toBeGreaterThan(1); + for (const source of viewSources) expect(typeReads, `${source} can name a view`).toContain(source); + // [#20301] `object` is no longer one of them. Its one view-reference site + // was `listViews.*.tabs[].view` — a `ViewTab` naming a list view — and the + // list view's own `tabs` is now a retired key (`retiredKey()`, its input + // type `never`), so the walk finds no `view`-spelled property under + // `object` and the scan correctly stops reading `object` rows for a view. + expect(viewSources.has('object'), 'an object names no view since list-view `tabs` retired').toBe(false); + expect(typeReads).not.toContain('object'); }); it('a source type whose read FAILS fails the whole scan, envelope intact', async () => { diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 49f5018b1b6..915fcbdb666 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1115,8 +1115,13 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // form's `columns` / `sort` / `tabs` repeaters, authored in all three // locales. 608 since #20161: the report form's joined-block `chart` row // left with its key (nothing ever drew a block chart), taking its label - // — authored in all three locales — out of the catalog. - expect(translated.length, `${locale} positive control`).toBe(608); + // — authored in all three locales — out of the catalog. 598 since #20301: + // the list view's own `tabs` is a retired key, and its form repeater left + // `view.form.ts` with it — the repeater's own label and its nine row labels + // (`name`, `label`, `icon`, `view`, `filter`, `order`, `pinned`, + // `isDefault`, `visible`), ten leaves authored in all three locales, out of + // the catalog. + expect(translated.length, `${locale} positive control`).toBe(598); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts b/packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts index 6f03cde6c60..c4c07023e45 100644 --- a/packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts +++ b/packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts @@ -30,6 +30,8 @@ // enumerated tomorrow is red on the day it lands rather than a month later. // `view.columns` / `view.sort` / `view.tabs` joined it with #19955: #17507 // titled their item schemas, and `view.form.ts` now enumerates their children. +// `view.tabs` left it again with #20301: the list view's own `tabs` is a +// retired key, and its repeater left `view.form.ts` with it. import { describe, it, expect } from 'vitest'; @@ -139,12 +141,18 @@ describe('#17508 — the enumerated-repeater survey itself (controls before verd for (const type of ['action', 'app', 'dashboard', 'dataset', 'field', 'flow', 'page', 'report', 'skill', 'view']) { expect(carrying.has(type), `${type} enumerates a repeater's row properties`).toBe(true); } - // Lit — the three `view` repeaters are inside the population (#19955). - // `view.columns` is the union case: a string-array arm beside the + // Lit — the two surviving `view` repeaters are inside the population + // (#19955). `view.columns` is the union case: a string-array arm beside the // object-array arm whose properties these are. - for (const carrier of ['view:columns', 'view:sort', 'view:tabs']) { + for (const carrier of ['view:columns', 'view:sort']) { expect(CARRIERS, `${carrier} enumerates its row properties`).toContain(carrier); } + // Dark — `view:tabs` was the third, and it is GONE: the list view's own + // `tabs` is a `retiredKey()` tombstone (#20301), so a form row for it would + // be an input for a key the parse refuses. Its reappearance is a red here, + // not a silent regrowth of the catalog. + expect(CARRIERS, 'the retired `view.tabs` repeater must not come back').not.toContain('view:tabs'); + expect(ROW_PROPERTIES.some((p) => p.id.startsWith('view:tabs.')), 'no `view:tabs.*` row property').toBe(false); // Dark — forms that enumerate none contribute none. A walk that matched // everything, or nothing, cannot pass both halves. for (const type of ['agent', 'tool', 'hook', 'position']) { From 8845cb564ed7029b85ae87df9ed77d352b91daa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:09:08 +0000 Subject: [PATCH 10/13] docs(spec): regenerate the object reference page on the merged tree (discharges the os-regen deferral) Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- content/docs/references/data/object.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e5c3eb68549..a7401ee45ce 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -260,7 +260,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ precision?: integer; currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -592,7 +592,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ precision?: integer; currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Configuration for currency field type | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | From c9f81fa1328835c1d609ac37998d1e3f9a27debc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:09:11 +0000 Subject: [PATCH 11/13] docs(spec): say what is true about the readers of a list view's tabs A list view's own `tabs` has no reader, and objectui's TabBar, the one component that would draw it, has zero production mounts. The earlier wording said the one component that reads a ViewTab[] has none, which is false: `userFilters.tabs` is a ViewTab[] too, and TabFilters reads and renders it. The population sentence now says examples or platform sources, and that the one published skill example that taught the key is corrected in this change. Corrected in the changeset, the tombstone docblock, both retired-key entries, the conversion docblock, the step-18 rationale, the ledger note and the pin's header; the migration registry regenerated. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- .changeset/list-view-tabs-retired.md | 17 ++++--- packages/spec/liveness/view.json | 2 +- packages/spec/src/conversions/registry.ts | 27 +++++++----- .../retired-keys/18.ui__ListView__tabs.ts | 16 ++++--- .../18.ui__ObjectListView__tabs.ts | 18 ++++---- packages/spec/src/migrations/registry.ts | 44 +++++++++++-------- .../src/ui/view-list-tabs-retirement.test.ts | 11 +++-- packages/spec/src/ui/view.zod.ts | 14 +++--- 8 files changed, 85 insertions(+), 64 deletions(-) diff --git a/.changeset/list-view-tabs-retired.md b/.changeset/list-view-tabs-retired.md index 9ea5492f323..17f5f95d2a3 100644 --- a/.changeset/list-view-tabs-retired.md +++ b/.changeset/list-view-tabs-retired.md @@ -12,13 +12,16 @@ enforce-or-remove; triage verdict RETIRE, on the rule that a capability the mainstream has and this platform already delivers keeps ONE spelling. The key parsed at every list-view door and was stored, and no renderer ever -drew it. Measured before removal, each reading beside a lit control: the one -component that reads a `ViewTab[]` (objectui's `TabBar`) has zero production -mounts at the objectui commit this repo pins — every occurrence is in its own -two test files — while the saved-view switcher (`ViewTabBar`) mounts in the -object view and is fed from the object's `listViews`. That switcher IS the tab -strip above an object's records: one tab per named list view. Zero list views -in this repo's examples, skills or platform sources authored the key. +drew it. Measured before removal, each reading beside a lit control: a list +view's own `tabs` has no reader, and objectui's `TabBar` — the one component +that would draw it — has zero production mounts at the objectui commit this +repo pins (every occurrence is in its own two test files), while the saved-view +switcher (`ViewTabBar`) mounts in the object view and is fed from the object's +`listViews`. That switcher IS the tab strip above an object's records: one tab +per named list view. `userFilters.tabs` is a different key with the same +element type: it is read and rendered as a page list's preset bar, and it +stays. Zero list views in this repo's examples or platform sources authored the +key; the one published skill example that taught it is corrected here. ### FROM → TO diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 5e2b656284e..b65e0730e0b 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -211,7 +211,7 @@ "status": "dead", "verifiedAt": "2026-09-27", "evidenceScope": "cross-repo", - "note": "REMOVED 2026-09-27 (#20301, ADR-0049 enforce-or-remove; triage verdict RETIRE under the maintainer's #18900 criterion — mainstream named-view switching is already delivered here, by `listViews` rendered in ViewTabBar) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error at every list-view door built from the shape: ListViewSchema, ObjectListViewSchema and the flattened overlay arm) and stripped from stored rows and sources by the protocol-18 view-list-tabs-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); to give end users one tab per preset, declare each tab as a named entry under the object's `listViews` — every entry renders as a tab in the saved-view switcher. RE-MEASURED 2026-09-27 before removal, with lit controls: objectui at the pinned sha f8a9d0fb — every `> 'ui/ListView:pageName', // #20301 (ADR-0049 enforce-or-remove; triage verdict RETIRE under the // maintainer's #18900 criterion). `ListView.tabs` declared tab definitions for - // a multi-tab view interface, and no renderer ever drew them: the one - // component that reads a `ViewTab[]` (objectui's `TabBar`) has no production - // mount, and the tab strip above an object's records is the saved-view - // switcher (`ViewTabBar`), which renders one tab per `listViews` entry and - // reads no `tabs` key. Tombstoned with `retiredKey()` beside the `pageName` - // tombstone already on this shape; `ViewTabSchema` stays, reused by the - // page-only `userFilters.tabs` preset bar. D2: `view-list-tabs-removed`. + // a multi-tab view interface, and no renderer ever drew them: a list view's own + // `tabs` has no reader, and objectui's `TabBar`, the one component that would + // draw it, has no production mount. The tab strip above an object's records is + // the saved-view switcher (`ViewTabBar`), which renders one tab per `listViews` + // entry and reads no `tabs` key. `userFilters.tabs`, a different key of the + // same element type, is read and rendered, and stays. Tombstoned with + // `retiredKey()` beside the `pageName` tombstone already on this shape; + // `ViewTabSchema` stays, reused by the page-only `userFilters.tabs` preset bar. + // D2: `view-list-tabs-removed`. 'ui/ListView:tabs', // #16885 — the list view's `navigation.view` binding, retired under ADR-0049 // enforce-or-remove by maintainer ruling 2026-09-13 (director decision batch @@ -20368,14 +20372,16 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `view-page-mount-removed`. 'ui/ObjectListView:pageName', // #20301 (ADR-0049 enforce-or-remove; triage verdict RETIRE under the - // maintainer's #18900 criterion). `ObjectListView.tabs` declared tab definitions for - // a multi-tab view interface, and no renderer ever drew them: the one - // component that reads a `ViewTab[]` (objectui's `TabBar`) has no production - // mount, and the tab strip above an object's records is the saved-view - // switcher (`ViewTabBar`), which renders one tab per `listViews` entry and - // reads no `tabs` key. Tombstoned with `retiredKey()` beside the `pageName` - // tombstone already on this shape; `ViewTabSchema` stays, reused by the - // page-only `userFilters.tabs` preset bar. D2: `view-list-tabs-removed`. + // maintainer's #18900 criterion). `ObjectListView.tabs` declared tab + // definitions for a multi-tab view interface, and no renderer ever drew them: a + // list view's own `tabs` has no reader, and objectui's `TabBar`, the one + // component that would draw it, has no production mount. The tab strip above an + // object's records is the saved-view switcher (`ViewTabBar`), which renders one + // tab per `listViews` entry and reads no `tabs` key. `userFilters.tabs`, a + // different key of the same element type, is read and rendered, and stays. + // Tombstoned with `retiredKey()` beside the `pageName` tombstone already on + // this shape; `ViewTabSchema` stays, reused by the page-only `userFilters.tabs` + // preset bar. D2: `view-list-tabs-removed`. 'ui/ObjectListView:tabs', // ADR-0090 D2 (no Profile concept) + ADR-0049 enforce-or-remove; maintainer // ruling 2026-09-12, decision batch #121 item 2, verbatim 「同意」. diff --git a/packages/spec/src/ui/view-list-tabs-retirement.test.ts b/packages/spec/src/ui/view-list-tabs-retirement.test.ts index e923842bcb6..b325384bd60 100644 --- a/packages/spec/src/ui/view-list-tabs-retirement.test.ts +++ b/packages/spec/src/ui/view-list-tabs-retirement.test.ts @@ -7,10 +7,13 @@ * * `ListViewSchema.tabs` parsed at every list-view door, was stored, and drew * nothing. Measured before removal, with lit controls, and recorded on the - * ledger row (`liveness/view.json`, `/props/list/children/tabs`): objectui's - * `TabBar` — the one component that reads a `ViewTab[]` — has zero production - * mounts at the pinned sha, while `ViewTabBar`, the saved-view switcher, - * mounts in the object view and is fed from `listViews`. + * ledger row (`liveness/view.json`, `/props/list/children/tabs`): a list + * view's own `tabs` has no reader, and objectui's `TabBar` — the one component + * that would draw it — has zero production mounts at the pinned sha, while + * `ViewTabBar`, the saved-view switcher, mounts in the object view and is fed + * from `listViews`. `userFilters.tabs`, a different key of the same element + * type, is read and rendered (the page preset bar) and stays — the BOUNDARY + * pinned below. * * Bookkeeping shapes, pinned below: * 1. A `retiredKey()` tombstone on the SHAPE every list-view door is built diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 3393f2a4f33..1766a51f70f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2773,12 +2773,14 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ * [#20301] REMOVED — ADR-0049 enforce-or-remove, triage verdict RETIRE by * the maintainer's #18900 criterion (mainstream named-view switching is * already delivered here, by `listViews`). The key parsed, was stored, and - * drew nothing: objectui's `TabBar` — the one component that reads a - * `ViewTab[]` — has zero production mounts, and the tab strip above an - * object's records is the saved-view switcher (`ViewTabBar`), which renders - * one tab per named list view and reads no `tabs` key. Measured with lit - * controls and recorded on the ledger row (`liveness/view.json`, - * `/props/list/children/tabs`). + * drew nothing: a list view's own `tabs` has no reader, and objectui's + * `TabBar` — the one component that would draw it — has zero production + * mounts. The tab strip above an object's records is the saved-view + * switcher (`ViewTabBar`), which renders one tab per named list view and + * reads no `tabs` key. (`userFilters.tabs`, a different key of the same + * element type, IS read and rendered — the page preset bar — and stays.) + * Measured with lit controls and recorded on the ledger row + * (`liveness/view.json`, `/props/list/children/tabs`). * * Tombstoned rather than deleted so the removal is audible in both channels * an upgrading author hits — `tsc` (the input type is `never`) and the parse From b751fc7842bf87a05c08726a9b73713dbc72fa4d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:06:38 +0000 Subject: [PATCH 12/13] chore(spec): regenerate the authorable surface and object reference page on the merged tree (discharges the os-regen deferral) Main's side taken by os-regen-merge.sh step 2, then the spec build and gen:docs re-derived this branch's two list-view tabs rows on top. Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV Co-Authored-By: Claude --- content/docs/references/data/object.mdx | 2 +- packages/spec/authorable-surface/ui.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index a7401ee45ce..25a97eeaef1 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -478,7 +478,7 @@ const result = ApiMethod.parse(data); | **opensInNewTab** | `boolean` | optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | | **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | -| **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +| **aria** | `never` | optional | [REMOVED] `action.aria` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's `label` and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required `label` — the visible button or menu-item text, and the `aria-label` of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author `ariaLabel` / `ariaDescribedBy` / `role` in the `aria` block of the placing node: `page.components[].aria` (the component that renders the actions) or the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 103dc45f313..593727e61dc 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -14,7 +14,7 @@ "ui/Action:_packageVersion", "ui/Action:_provenance", "ui/Action:ai", - "ui/Action:aria", + "ui/Action:aria [RETIRED]", "ui/Action:body", "ui/Action:bodyExtra", "ui/Action:bodyShape", From 8ca08e044140a10535891fe0e55f46e3908df6f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:17:45 +0000 Subject: [PATCH 13/13] chore(spec): regenerate the three reference pages on the merged tree (discharges the os-regen deferral) Main's side taken by os-regen-merge.sh step 2, then the spec build and gen:docs re-derived the pages: main's docs title rule (title + navTitle) comes back, and this branch's list-view tabs rows are unchanged. gen:migration-registry is byte-identical on the merged tree. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 3 ++- content/docs/references/data/object.mdx | 3 ++- content/docs/references/ui/view.mdx | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 2345214b652..48fa1fb1dbc 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1,5 +1,6 @@ --- -title: Protocol +title: Protocol schema — API Protocol reference +navTitle: Protocol description: "Protocol schemas of the ObjectStack API Protocol: AiAgentCapabilities and 133 more — each property with its type, default and a TypeScript example." --- diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 25a97eeaef1..9140697862b 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -1,5 +1,6 @@ --- -title: Object +title: Object schema — Data Protocol reference +navTitle: Object description: "Object schemas of the ObjectStack Data Protocol: ApiMethod, ApiOperation, Index and 13 more — each property with its type, default and a TypeScript example." --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index c790c216f54..53bfd0e0dec 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -1,5 +1,6 @@ --- -title: View +title: View schema — UI Protocol property reference +navTitle: View description: "View protocol schemas — the view metadata type and its three persisted body spellings." ---