Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4e93107
feat(spec)!: retire the list view's own `tabs` key (WIP: schema, form…
claude Sep 27, 2026
919f5e9
test(spec): pin the list-view tabs retirement at every door; move sur…
claude Sep 27, 2026
92e98c8
chore(i18n): regenerate metadata-form bundles after the list-view tab…
claude Sep 27, 2026
71037ab
Merge remote-tracking branch 'origin/main' into claude/issue-20301-li…
claude Sep 27, 2026
2fcf214
chore(spec): drop the stale undrilled-container row for list.tabs; ru…
claude Sep 27, 2026
caa51ec
test(spec): spell the checkTabs anchor as a full repo path
claude Sep 27, 2026
13325ce
test(spec): drop an unused type import from the tabs retirement pin
claude Sep 27, 2026
74f8ab9
docs(spec): regenerate the reference pages for the list-view tabs tom…
claude Sep 27, 2026
baa3830
Merge remote-tracking branch 'origin/main' into claude/issue-20301-li…
claude Sep 28, 2026
bfa765c
Merge remote-tracking branch 'origin/main' into claude/issue-20301-li…
claude Sep 28, 2026
c13e610
docs(spec): regenerate the reference pages on the merged tree (discha…
claude Sep 28, 2026
a3f8054
test: move four consumer pins of the retired list-view `tabs` to the …
claude Sep 28, 2026
d0003a1
Merge remote-tracking branch 'origin/main' into claude/issue-20301-li…
claude Sep 28, 2026
8845cb5
docs(spec): regenerate the object reference page on the merged tree (…
claude Sep 28, 2026
c9f81fa
docs(spec): say what is true about the readers of a list view's tabs
claude Sep 28, 2026
606046e
Merge remote-tracking branch 'origin/main' into claude/issue-20301-li…
claude Sep 28, 2026
ec79ac0
Merge remote-tracking branch 'origin/main' into claude/issue-20301-li…
claude Sep 28, 2026
b751fc7
chore(spec): regenerate the authorable surface and object reference p…
claude Sep 28, 2026
b4c624a
Merge remote-tracking branch 'origin/main' into claude/issue-20301-li…
claude Sep 28, 2026
8ca08e0
chore(spec): regenerate the three reference pages on the merged tree …
claude Sep 28, 2026
c1c3ec9
Merge remote-tracking branch 'origin/main' into claude/issue-20301-li…
claude Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .changeset/list-view-tabs-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
'@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: 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

| 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)

<!-- adr-0087: registered view-list-tabs-removed, list-view-tabs-retired -->
8 changes: 4 additions & 4 deletions content/docs/protocol/objectui/layout-dsl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -563,10 +563,10 @@ is a **parse failure** — a loud rejection, not a silent no-op.
</Callout>

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
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1699,7 +1699,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<string, string>; 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 |
Expand Down Expand Up @@ -1784,7 +1784,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<string, string>; 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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,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<string, string>; 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 |
Expand Down
Loading
Loading