From a75d602d6b236bce6b8c040142fead721b857259 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 05:53:51 +0000 Subject: [PATCH 1/3] feat(spec): retire the element:filter element at element grain (#9220, ADR-0049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No renderer or reader for element:filter exists in any repo (objectui registers none; Studio's palette lists it as a no-renderer exclusion; the 2026-06 audit recorded it rendering 'Unknown component type'). All six authorable keys are retiredKey() tombstones; the ComponentPropsMap row stays so the #5068 props gate keeps refusing loudly; the PageComponentType enum entry is removed (de-advertisement — the open string arm makes it parse-neutral). D2 conversion element-filter-removed (protocol 18) strips the keys from old sources; the #9198 conversion's negative-control fixture moves to an open-union custom type (pre-registered flip). Pin tests, regenerated baselines/docs, minor changeset per the post-17.0.0 launch-window convention. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --- .changeset/element-filter-retired.md | 72 ++++++++ content/docs/references/ui/component.mdx | 12 +- content/docs/references/ui/page.mdx | 5 +- content/docs/ui/pages.mdx | 2 +- packages/spec/authorable-defaults/ui.json | 2 - packages/spec/authorable-surface/ui.json | 12 +- packages/spec/scripts/format-type.test.ts | 2 +- packages/spec/src/conversions/registry.ts | 171 +++++++++++++++++- .../18.ui__ElementFilterProps__aria.ts | 22 +++ .../18.ui__ElementFilterProps__fields.ts | 22 +++ .../18.ui__ElementFilterProps__layout.ts | 22 +++ .../18.ui__ElementFilterProps__object.ts | 22 +++ .../18.ui__ElementFilterProps__showSearch.ts | 22 +++ ....ui__ElementFilterProps__targetVariable.ts | 22 +++ packages/spec/src/migrations/registry.ts | 133 +++++++++++++- packages/spec/src/ui/component.test.ts | 77 ++++---- packages/spec/src/ui/component.zod.ts | 51 +++++- packages/spec/src/ui/page.zod.ts | 7 +- 18 files changed, 612 insertions(+), 66 deletions(-) create mode 100644 .changeset/element-filter-retired.md create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__aria.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__fields.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__layout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__object.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__showSearch.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__targetVariable.ts diff --git a/.changeset/element-filter-retired.md b/.changeset/element-filter-retired.md new file mode 100644 index 0000000000..af18de7e65 --- /dev/null +++ b/.changeset/element-filter-retired.md @@ -0,0 +1,72 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): retire the `element:filter` element at element grain (#9220, ADR-0049) + +**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep +launch-window convention ships it as `minor`; the migration prescription is +registered under protocol major 18, where `os migrate meta` users will look). + +`element:filter` never had a renderer or reader anywhere. Measured at +retirement (objectstack `2f65b1b42`, objectui `5ffcc14`; cloud per the origin +card's recorded sweep): objectui registers no renderer for it — its +`renderers/basic/elements.tsx` header deferred the element to "owning plugins" +that never materialized — Studio's designer palette carries it as a no-renderer +`PALETTE_EXCLUSIONS` entry ("list surfaces own filtering (userFilters / filter +builder)"), and the 2026-06 page-liveness audit recorded it rendering "Unknown +component type". Every one of its six authorable keys — `object`, `fields`, +`targetVariable`, `layout`, `showSearch`, `aria` — was a capability claim +nothing kept: an author (human or AI) who configured a filter element got a +success receipt for a component that renders nothing (the ADR-0078 shape). +\#9198 retired `targetVariable` per-key on the two input elements and recorded +this wider finding; per-key retirement would have been the wrong grain here, so +the whole element retires at once. + +**What is refused:** any authored key on `element:filter` `properties`. All six +keys are `retiredKey()` tombstones — refused at `tsc` (typed `never`) and at +the parse, message carrying the element-grain prescription. The +`ComponentPropsMap` row deliberately STAYS so the #5068 props gate keeps +dispatching on the type and refusing loudly — deleting the row would demote the +type to an unregistered custom string the gate deliberately skips. + +**What stays accepted:** a bare `element:filter` node with empty `properties` +(the migrated shape) still parses at the node level — `PageComponentSchema.type` +is an open union, so a node-level refusal is not expressible; the node was +always inert and stays inert. `element:filter` is removed from the +`PageComponentType` enum (de-advertisement — docs, palette derivations, and the +authorable vocabulary), which changes no parse outcome. Filtering on list +surfaces is unchanged and was never this element's: use a view's `userFilters` +quick-filter bar or the list toolbar's filter builder. + +The retirement kit: + +- tombstones at the schema (`packages/spec/src/ui/component.zod.ts`), enum + removal at `packages/spec/src/ui/page.zod.ts` +- ADR-0087 registration: retired-key entries `ui/ElementFilterProps:object` / + `:fields` / `:targetVariable` / `:layout` / `:showSearch` / `:aria` and the + D2 conversion `element-filter-removed` (protocol 18), wired into the step-18 + chain — `os migrate meta --from 17` strips the keys from old sources (pure + lossless deletes; none ever had an effect to lose) and leaves the bare node +- the #9198 conversion's negative-control fixture moves from `element:filter` + to an open-union custom type (same assertion — the strip dispatches on the + component type, not the key name) +- pin tests (`component.test.ts` — refusal carries the prescription; the + bare migrated node parses clean and materializes nothing) +- generated baselines/docs follow the schema (`authorable-surface/`, + `json-schema.manifest/`, spec-changes, upgrade guide, reference docs) + +## FROM → TO + +```ts +// before — parsed green; nothing anywhere rendered it +{ + type: 'element:filter', + properties: { object: 'order', fields: ['status'], layout: 'sidebar' }, +} + +// after — delete the component; filtering belongs to the list surface +// (view.userFilters / the list toolbar's filter builder) +``` + + diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 6f0a12ecf4..e524e693a0 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -61,12 +61,12 @@ const result = AIChatWindowProps.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object to filter | -| **fields** | `string[]` | ✅ | Filterable field names | -| **targetVariable** | `string` | optional | Page variable to store filter state | -| **layout** | `Enum<'inline' \| 'dropdown' \| 'sidebar'>` | optional (default: `"inline"`) | Filter display layout | -| **showSearch** | `boolean` | optional (default: `true`) | Show search input | -| **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +| **object** | `never` | optional | [REMOVED] `element:filter` property `object` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. | +| **fields** | `never` | optional | [REMOVED] `element:filter` property `fields` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. | +| **targetVariable** | `never` | optional | [REMOVED] `element:filter` property `targetVariable` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. | +| **layout** | `never` | optional | [REMOVED] `element:filter` property `layout` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. | +| **showSearch** | `never` | optional | [REMOVED] `element:filter` property `showSearch` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. | +| **aria** | `never` | optional | [REMOVED] `element:filter` property `aria` was removed in @objectstack/spec 17 (#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ever shipped in objectui, framework or cloud (Studio's designer palette lists it as a no-renderer exclusion), so every key on this element was a capability claim nothing kept. Delete the `element:filter` component; list surfaces own their filtering — use a view's `userFilters` quick-filter bar or the list toolbar's filter builder. Run `os migrate meta --from 17` to rewrite existing sources automatically. | --- diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 8274b98450..36af2a20a9 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -105,7 +105,7 @@ Interface-level page configuration (Airtable parity) | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type (Standard enum or custom string) | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type (Standard enum or custom string) | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | @@ -157,7 +157,6 @@ Interface-level page configuration (Airtable parity) * `element:image` * `element:divider` * `element:button` -* `element:filter` * `element:form` * `element:record_picker` * `element:text_input` @@ -173,7 +172,7 @@ Interface-level page configuration (Airtable parity) | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Region name (e.g. "sidebar", "main", "header") | | **width** | `Enum<'small' \| 'medium' \| 'large' \| 'full'>` | optional | | -| **components** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| … +30 more> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | ✅ | Components in this region | +| **components** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| … +29 more> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | ✅ | Components in this region | --- diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 0d9ddb09f3..30d5a16faa 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -148,7 +148,7 @@ The `type` field is a union of the standard `PageComponentType` enum and any cus - **Navigation:** `app:launcher`, `nav:menu`, `nav:breadcrumb` - **Utility:** `global:search`, `global:notifications`, `user:profile` - **AI:** `ai:chat_window`, `ai:suggestion` -- **Elements:** `element:text`, `element:number`, `element:image`, `element:divider`, `element:button`, `element:filter`, `element:form`, `element:record_picker`, `element:text_input` +- **Elements:** `element:text`, `element:number`, `element:image`, `element:divider`, `element:button`, `element:form`, `element:record_picker`, `element:text_input` (`element:filter` was retired in v17.x — no renderer ever shipped for it; list surfaces own their filtering via a view's `userFilters` quick-filter bar or the list toolbar's filter builder) Components may also carry `dataSource` (per-element object binding for multi-object pages), `responsive`, and `aria` configuration. Custom string types are also accepted for project-specific widgets. diff --git a/packages/spec/authorable-defaults/ui.json b/packages/spec/authorable-defaults/ui.json index 028419bbdf..3945f9b364 100644 --- a/packages/spec/authorable-defaults/ui.json +++ b/packages/spec/authorable-defaults/ui.json @@ -34,8 +34,6 @@ "ui/ElementButtonProps:iconPosition = \"left\"", "ui/ElementButtonProps:size = \"medium\"", "ui/ElementButtonProps:variant = \"primary\"", - "ui/ElementFilterProps:layout = \"inline\"", - "ui/ElementFilterProps:showSearch = true", "ui/ElementFormProps:mode = \"create\"", "ui/ElementImageProps:fit = \"cover\"", "ui/ElementMetadataViewerProps:detail = \"business\"", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 99cb6c991a..b1dd72589f 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -379,12 +379,12 @@ "ui/ElementDataSource:object", "ui/ElementDataSource:sort", "ui/ElementDataSource:view", - "ui/ElementFilterProps:aria", - "ui/ElementFilterProps:fields", - "ui/ElementFilterProps:layout", - "ui/ElementFilterProps:object", - "ui/ElementFilterProps:showSearch", - "ui/ElementFilterProps:targetVariable", + "ui/ElementFilterProps:aria [RETIRED]", + "ui/ElementFilterProps:fields [RETIRED]", + "ui/ElementFilterProps:layout [RETIRED]", + "ui/ElementFilterProps:object [RETIRED]", + "ui/ElementFilterProps:showSearch [RETIRED]", + "ui/ElementFilterProps:targetVariable [RETIRED]", "ui/ElementFormProps:aria", "ui/ElementFormProps:fields", "ui/ElementFormProps:mode", diff --git a/packages/spec/scripts/format-type.test.ts b/packages/spec/scripts/format-type.test.ts index e9309c4769..e48d2d2578 100644 --- a/packages/spec/scripts/format-type.test.ts +++ b/packages/spec/scripts/format-type.test.ts @@ -1284,7 +1284,7 @@ const PAGE_COMPONENT = { 'app:launcher', 'nav:menu', 'nav:breadcrumb', 'global:search', 'global:notifications', 'user:profile', 'ai:chat_window', 'ai:suggestion', 'element:text', 'element:number', 'element:image', 'element:divider', - 'element:button', 'element:filter', 'element:form', 'element:record_picker', + 'element:button', 'element:form', 'element:record_picker', 'element:text_input', ], }, diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 7118f545db..be44181b79 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -6793,9 +6793,16 @@ const elementInputTargetVariableRemoved: MetadataConversion = { properties: { inputType: 'email', targetVariable: 'contact_email' }, }, { type: 'element:record_picker', properties: { object: 'showcase_project', targetVariable: 'selected_id' } }, - // `targetVariable` on `element:filter` is a DIFFERENT surface, - // outside #9198's disposition — untouched here. - { type: 'element:filter', properties: { object: 'a', fields: ['status'], targetVariable: 'active_filter' } }, + // Negative control: a `targetVariable` on a type OUTSIDE this + // conversion's dispatch proves the strip is scoped by the + // component TYPE, not by the key name. It was originally an + // `element:filter` row ("a DIFFERENT surface, outside #9198's + // disposition") — #9220 then retired that element at element + // grain and its conversion strips every `element:filter` key, + // so the control moved to an open-union custom type, which no + // conversion may ever touch (the #5068 gate's deliberate-skip + // class). Same assertion, one hop further out. + { type: 'custom:legacy_input', properties: { targetVariable: 'active_filter' } }, // Nested one container down (#6775) — the walk descends. { type: 'page:card', @@ -6840,7 +6847,7 @@ const elementInputTargetVariableRemoved: MetadataConversion = { properties: { inputType: 'email' }, }, { type: 'element:record_picker', properties: { object: 'showcase_project' } }, - { type: 'element:filter', properties: { object: 'a', fields: ['status'], targetVariable: 'active_filter' } }, + { type: 'custom:legacy_input', properties: { targetVariable: 'active_filter' } }, { type: 'page:card', properties: { @@ -6866,11 +6873,158 @@ const elementInputTargetVariableRemoved: MetadataConversion = { }, ], }, - // One per stripped key — the filter element keeps its (out-of-scope) key. + // One per stripped key — the custom-typed control keeps its key. expectedNotices: 4, }, }; +/** + * `element:filter` — the whole element retired (protocol 18, #9220, ADR-0049 + * enforce-or-remove at ELEMENT grain). + * + * #9198 retired `targetVariable` per-key on the two input elements and left + * this element's copy alone because the finding was wider than one key: + * measured at retirement time (objectstack @2f65b1b42, objectui @5ffcc14; cloud + * per the card's recorded sweep), NO renderer or behavior reader of + * `element:filter` exists anywhere. objectui registers no renderer (its + * `renderers/basic/elements.tsx` header deferred the element to "owning + * plugins" that never materialized), Studio's designer palette carries it as a + * no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering + * (userFilters / filter builder)"), and the 2026-06 page-liveness audit + * recorded it rendering "Unknown component type". Every key was a capability + * claim nothing kept — per-key retirement would have been the wrong grain, so + * all six authorable keys are tombstoned together and this conversion strips + * them together. + * + * Pure lossless deletes — no key ever had an effect to lose. The component + * node itself is NOT removed: the open `type` union tolerates a bare inert + * node (nothing rendered it before either), and deleting authored page nodes + * is a layout decision a mechanical conversion must not make. The + * prescription tells the author to delete the component and use the list + * surface's own filtering instead. + */ +const elementFilterRemoved: MetadataConversion = { + id: 'element-filter-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: + 'page.component.element:filter.object / page.component.element:filter.fields / ' + + 'page.component.element:filter.targetVariable / page.component.element:filter.layout / ' + + 'page.component.element:filter.showSearch / page.component.element:filter.aria', + summary: + "the whole 'element:filter' element retired (#9220 — no renderer for it ever shipped in " + + 'any repo, so every key was a capability claim nothing kept; list surfaces own their ' + + "filtering via a view's userFilters / the list filter builder). All six props are " + + 'stripped; the bare node stays, inert as it always was', + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'element:filter') return component; + const properties = component.properties; + if (!isDict(properties)) return component; + const stripped = stripKeys( + properties, + ['object', 'fields', 'targetVariable', 'layout', 'showSearch', 'aria'], + emit, + `${path}.properties`, + ); + if (stripped === properties) return component; + return { ...component, properties: stripped }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'order_board', + regions: [ + { + name: 'main', + components: [ + { + type: 'element:filter', + properties: { + object: 'order', + fields: ['status', 'priority'], + targetVariable: 'active_filter', + layout: 'sidebar', + showSearch: true, + }, + }, + // Key overlap on a DIFFERENT type rides through untouched — + // `object` is also an `element:form` prop, and the strip + // dispatches on the component type, not the key name. + { type: 'element:form', properties: { object: 'order' } }, + // Nested one container down (#6775) — the walk descends. + { + type: 'page:card', + properties: { + title: 'Orders', + children: [ + { type: 'element:filter', properties: { object: 'order', fields: ['status'] } }, + ], + }, + }, + ], + }, + ], + }, + // The named-slot shape, on a slotted record page. + { + name: 'order_detail', + kind: 'slotted', + regions: [], + slots: { + side: [ + { + type: 'element:filter', + properties: { object: 'order', fields: ['status'], aria: { label: 'Order filter' } }, + }, + ], + }, + }, + ], + }, + after: { + pages: [ + { + name: 'order_board', + regions: [ + { + name: 'main', + components: [ + { type: 'element:filter', properties: {} }, + { type: 'element:form', properties: { object: 'order' } }, + { + type: 'page:card', + properties: { + title: 'Orders', + children: [ + { type: 'element:filter', properties: {} }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'order_detail', + kind: 'slotted', + regions: [], + slots: { + side: [ + { type: 'element:filter', properties: {} }, + ], + }, + }, + ], + }, + // One per stripped key: 5 on the top-level node, 2 on the nested one, + // 3 on the slotted one. The `element:form` neighbor is untouched. + expectedNotices: 10, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -6943,7 +7097,12 @@ export const CONVERSIONS_BY_MAJOR: Readonly> // The prescription reaches authors through the tombstone (`tsc` + the parse) // and the D3 semantic entry `metadata-plugin-additional-types-retired`. 'kernel/MetadataPluginConfig:additionalTypes', + // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never + // had a renderer or reader anywhere: objectui registers none (its + // renderers/basic/elements.tsx header deferred the element to "owning plugins" + // that never materialized), Studio's designer palette carries it as a + // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering + // (userFilters / filter builder)"), and the 2026-06 page-liveness audit + // recorded it rendering "Unknown component type". Measured at retirement time + // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded + // sweep): zero production readers of any `element:filter` key — so every key, + // this one included, was a capability claim nothing kept. Per-key retirement + // would have been the wrong grain (the #9198 lesson, one element over); all + // six authorable keys are tombstoned together. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstones ship on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). + // Sources are rewritten by the D2 conversion `element-filter-removed`, which + // strips all six keys and leaves the bare node — inert as it always was. + 'ui/ElementFilterProps:aria', + // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never + // had a renderer or reader anywhere: objectui registers none (its + // renderers/basic/elements.tsx header deferred the element to "owning plugins" + // that never materialized), Studio's designer palette carries it as a + // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering + // (userFilters / filter builder)"), and the 2026-06 page-liveness audit + // recorded it rendering "Unknown component type". Measured at retirement time + // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded + // sweep): zero production readers of any `element:filter` key — so every key, + // this one included, was a capability claim nothing kept. Per-key retirement + // would have been the wrong grain (the #9198 lesson, one element over); all + // six authorable keys are tombstoned together. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstones ship on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). + // Sources are rewritten by the D2 conversion `element-filter-removed`, which + // strips all six keys and leaves the bare node — inert as it always was. + 'ui/ElementFilterProps:fields', + // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never + // had a renderer or reader anywhere: objectui registers none (its + // renderers/basic/elements.tsx header deferred the element to "owning plugins" + // that never materialized), Studio's designer palette carries it as a + // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering + // (userFilters / filter builder)"), and the 2026-06 page-liveness audit + // recorded it rendering "Unknown component type". Measured at retirement time + // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded + // sweep): zero production readers of any `element:filter` key — so every key, + // this one included, was a capability claim nothing kept. Per-key retirement + // would have been the wrong grain (the #9198 lesson, one element over); all + // six authorable keys are tombstoned together. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstones ship on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). + // Sources are rewritten by the D2 conversion `element-filter-removed`, which + // strips all six keys and leaves the bare node — inert as it always was. + 'ui/ElementFilterProps:layout', + // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never + // had a renderer or reader anywhere: objectui registers none (its + // renderers/basic/elements.tsx header deferred the element to "owning plugins" + // that never materialized), Studio's designer palette carries it as a + // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering + // (userFilters / filter builder)"), and the 2026-06 page-liveness audit + // recorded it rendering "Unknown component type". Measured at retirement time + // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded + // sweep): zero production readers of any `element:filter` key — so every key, + // this one included, was a capability claim nothing kept. Per-key retirement + // would have been the wrong grain (the #9198 lesson, one element over); all + // six authorable keys are tombstoned together. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstones ship on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). + // Sources are rewritten by the D2 conversion `element-filter-removed`, which + // strips all six keys and leaves the bare node — inert as it always was. + 'ui/ElementFilterProps:object', + // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never + // had a renderer or reader anywhere: objectui registers none (its + // renderers/basic/elements.tsx header deferred the element to "owning plugins" + // that never materialized), Studio's designer palette carries it as a + // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering + // (userFilters / filter builder)"), and the 2026-06 page-liveness audit + // recorded it rendering "Unknown component type". Measured at retirement time + // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded + // sweep): zero production readers of any `element:filter` key — so every key, + // this one included, was a capability claim nothing kept. Per-key retirement + // would have been the wrong grain (the #9198 lesson, one element over); all + // six authorable keys are tombstoned together. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstones ship on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). + // Sources are rewritten by the D2 conversion `element-filter-removed`, which + // strips all six keys and leaves the bare node — inert as it always was. + 'ui/ElementFilterProps:showSearch', + // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never + // had a renderer or reader anywhere: objectui registers none (its + // renderers/basic/elements.tsx header deferred the element to "owning plugins" + // that never materialized), Studio's designer palette carries it as a + // no-renderer PALETTE_EXCLUSIONS entry ("list surfaces own filtering + // (userFilters / filter builder)"), and the 2026-06 page-liveness audit + // recorded it rendering "Unknown component type". Measured at retirement time + // (objectstack @2f65b1b42, objectui @5ffcc14; cloud per the card's recorded + // sweep): zero production readers of any `element:filter` key — so every key, + // this one included, was a capability claim nothing kept. Per-key retirement + // would have been the wrong grain (the #9198 lesson, one element over); all + // six authorable keys are tombstoned together. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstones ship on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). + // Sources are rewritten by the D2 conversion `element-filter-removed`, which + // strips all six keys and leaves the bare node — inert as it always was. + 'ui/ElementFilterProps:targetVariable', // #9198 — ADR-0049 enforce-or-remove. `targetVariable` on // `element:record_picker` was a declarative hint with zero readers: the picker // writes the selected record id through the reverse binding — the page diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index d78fdb9627..0759a89d0c 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -880,40 +880,53 @@ describe('Interactive Elements — element:button', () => { }); // --------------------------------------------------------------------------- -// Interactive Elements — element:filter +// Interactive Elements — element:filter (RETIRED at element grain, #9220) // --------------------------------------------------------------------------- -describe('Interactive Elements — element:filter', () => { - it('should accept element:filter component', () => { +describe('Interactive Elements — element:filter (retired, #9220)', () => { + // The node-level parse never judged `properties` (that is the #5068 props + // gate's job), and `type` is an open union — so a stored, not-yet-migrated + // node still parses at THIS level. Pinned so the element retirement is not + // misread as a node-level refusal. + it('still parses at the node level — the refusal lives at the props dispatch', () => { expect(() => PageComponentSchema.parse({ type: 'element:filter', properties: { object: 'order', fields: ['status'] }, })).not.toThrow(); }); - it('should parse filter props with defaults', () => { - const props = ElementFilterPropsSchema.parse({ - object: 'order', - fields: ['status', 'priority'], - }); - expect(props.object).toBe('order'); - expect(props.layout).toBe('inline'); - expect(props.showSearch).toBe(true); - }); - - it('should accept filter with targetVariable', () => { - const props = ElementFilterPropsSchema.parse({ - object: 'task', - fields: ['status'], - targetVariable: 'active_filter', - layout: 'sidebar', - }); - expect(props.targetVariable).toBe('active_filter'); - expect(props.layout).toBe('sidebar'); - }); - - it('should reject filter without required fields', () => { - expect(() => ElementFilterPropsSchema.parse({})).toThrow(); - expect(() => ElementFilterPropsSchema.parse({ object: 'order' })).toThrow(); + // #9220 tombstones — ADR-0049 enforce-or-remove at ELEMENT grain: no + // renderer for `element:filter` ever shipped anywhere, so every authorable + // key refuses with the element-retirement prescription. The former accept + // shape (`{ object, fields }`) is the exact input that must now refuse. + it('rejects every former accept shape with the element-retirement prescription', () => { + expect(() => ElementFilterPropsSchema.parse({ object: 'order', fields: ['status'] })) + .toThrow(/`element:filter` property `object`.*removed.*`element:filter` element is retired.*Delete the `element:filter` component/s); + expect(() => ElementFilterPropsSchema.parse({ layout: 'sidebar' })) + .toThrow(/`element:filter` property `layout`.*removed.*Delete the `element:filter` component/s); + expect(() => ElementFilterPropsSchema.parse({ showSearch: true })) + .toThrow(/`element:filter` property `showSearch`.*removed/s); + expect(() => ElementFilterPropsSchema.parse({ aria: { label: 'Filter' } })) + .toThrow(/`element:filter` property `aria`.*removed/s); + }); + + // Flip of "should accept filter with targetVariable" — #9198 deliberately + // left this element's `targetVariable` untouched as out-of-scope; #9220 + // retires it with its element, and the prescription carries the migrate + // sentence (the D2 conversion `element-filter-removed` strips it). + it('rejects the retired `targetVariable` with its prescription', () => { + expect(() => ElementFilterPropsSchema.parse({ targetVariable: 'active_filter' })) + .toThrow(/`element:filter` property `targetVariable`.*removed.*Run `os migrate meta --from 17` to rewrite existing sources automatically/s); + }); + + // The migrated shape — `element-filter-removed` strips all six keys and + // leaves the bare node — parses clean and materializes nothing. (The + // pre-retirement schema REQUIRED `object` + `fields`, so `{}` used to + // throw; the requiredness died with the element.) + it('parses a bare (migrated) node clean and materializes nothing', () => { + const props = ElementFilterPropsSchema.parse({}); + for (const key of ['object', 'fields', 'targetVariable', 'layout', 'showSearch', 'aria']) { + expect(props).not.toHaveProperty(key); + } }); }); @@ -1211,12 +1224,14 @@ describe('ComponentPropsMap interactive elements', () => { expect(result.label).toBe('Click Me'); }); - it('should parse element:filter props', () => { - const result = ComponentPropsMap['element:filter'].parse({ + // Flip of "should parse element:filter props" (#9220): the row STAYS so the + // #5068 props gate keeps dispatching on the type — and what it dispatches to + // now refuses with the element-retirement prescription. + it('refuses element:filter props through the kept map row (retired, #9220)', () => { + expect(() => ComponentPropsMap['element:filter'].parse({ object: 'order', fields: ['status'], - }); - expect(result.object).toBe('order'); + })).toThrow(/`element:filter` element is retired/s); }); it('should parse element:form props', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index d48cd3f043..49a1881842 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1625,19 +1625,50 @@ export const ElementButtonPropsSchema = lazySchema(() => strictObject({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); +/** + * One prescription for every retired `element:filter` key — the retirement is + * ELEMENT-grain (#9220), so all six keys carry the same story and differ only + * in the fully-qualified key that heads the string (house style, rule 1). + */ +const elementFilterRetired = (key: string): string => + '`element:filter` property `' + key + '` was removed in @objectstack/spec 17 ' + + '(#9220, ADR-0049) — the whole `element:filter` element is retired: no renderer for it ' + + 'ever shipped in objectui, framework or cloud (Studio\'s designer palette lists it as a ' + + 'no-renderer exclusion), so every key on this element was a capability claim nothing ' + + 'kept. Delete the `element:filter` component; list surfaces own their filtering — use a ' + + "view's `userFilters` quick-filter bar or the list toolbar's filter builder. " + + 'Run `os migrate meta --from 17` to rewrite existing sources automatically.'; + +/** + * RETIRED at element grain (#9220, ADR-0049 enforce-or-remove). `element:filter` + * never had a renderer anywhere: objectui registers none (its + * `renderers/basic/elements.tsx` header deferred it to "owning plugins" that + * never materialized), Studio's designer palette lists it as a no-renderer + * exclusion ("list surfaces own filtering"), and the 2026-06 page-liveness + * audit already recorded it rendering "Unknown component type". Every key was + * therefore a capability claim nothing kept — the same declared-but-unread + * shape #9198 retired per-key on the two input elements, one grain wider. + * + * The schema (and its `ComponentPropsMap` row) stays exported so the #5068 + * props gate keeps DISPATCHING on `type: 'element:filter'` and refusing every + * authored key with the prescription — deleting the row would demote the type + * to an unregistered custom string the gate deliberately skips, turning a loud + * retirement back into a silent no-op. A bare node with empty `properties` + * parses clean (the open `type` union accepts any string, so a node-level + * refusal is not expressible here); the migration strips the keys and leaves + * exactly that bare, inert node. + */ export const ElementFilterPropsSchema = lazySchema(() => strictObject({ surface: 'this `element:filter`', history: PROPS_HISTORY, guidanceSets: COMPONENT_LEVEL_GUIDANCE, }, { - object: z.string().describe('Object to filter'), - fields: z.array(z.string()).describe('Filterable field names'), - targetVariable: z.string().optional().describe('Page variable to store filter state'), - layout: z.enum(['inline', 'dropdown', 'sidebar']) - .optional().default('inline').describe('Filter display layout'), - showSearch: z.boolean().optional().default(true).describe('Show search input'), - /** ARIA accessibility */ - aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), + object: retiredKey(elementFilterRetired('object')), + fields: retiredKey(elementFilterRetired('fields')), + targetVariable: retiredKey(elementFilterRetired('targetVariable')), + layout: retiredKey(elementFilterRetired('layout')), + showSearch: retiredKey(elementFilterRetired('showSearch')), + aria: retiredKey(elementFilterRetired('aria')), })); export const ElementFormPropsSchema = lazySchema(() => strictObject({ @@ -2307,6 +2338,10 @@ export const ComponentPropsMap = { // Interactive Elements 'element:button': ElementButtonPropsSchema, + // RETIRED at element grain (#9220) — the row STAYS so the #5068 props gate + // keeps dispatching on the type and refusing every authored key with the + // prescription; deleting it would demote `element:filter` to an unregistered + // custom string the gate deliberately skips. See the schema's own header. 'element:filter': ElementFilterPropsSchema, 'element:form': ElementFormPropsSchema, 'element:record_picker': ElementRecordPickerPropsSchema, diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index b70f41eebf..3b3a9b8a40 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -70,7 +70,12 @@ export const PageComponentType = z.enum([ // Content Elements (Airtable Interface parity) 'element:text', 'element:number', 'element:image', 'element:divider', // Interactive Elements (Phase B — Element Library) - 'element:button', 'element:filter', 'element:form', 'element:record_picker', 'element:text_input' + // `element:filter` REMOVED (#9220, ADR-0049): retired at element grain — no + // renderer ever shipped anywhere. Dropping the enum value is de-advertisement + // only (the `type` union's open string arm still accepts any string); the + // LOUD half of the retirement is `ElementFilterPropsSchema`'s retiredKey + // tombstones, dispatched through the kept `ComponentPropsMap` row. + 'element:button', 'element:form', 'element:record_picker', 'element:text_input' ]); /** From bd4495c0ddc740ad3c5a9c2b44df9bdcb12d7097 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 06:26:06 +0000 Subject: [PATCH 2/3] fix(lint): drop the element:filter COMPONENT_FIELD_SPECS residue entry (#9220) The package's own component-field-specs-liveness gate (#6629) refuses entries naming retired keys; the element-grain tombstones turned it red with the exact prescription to drop the entry. Patch changeset for @objectstack/lint. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --- .changeset/element-filter-lint-residue.md | 15 +++++++++++++++ packages/lint/src/validate-page-field-bindings.ts | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 .changeset/element-filter-lint-residue.md diff --git a/.changeset/element-filter-lint-residue.md b/.changeset/element-filter-lint-residue.md new file mode 100644 index 0000000000..136103907f --- /dev/null +++ b/.changeset/element-filter-lint-residue.md @@ -0,0 +1,15 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): drop the `element:filter` entry from `COMPONENT_FIELD_SPECS` (#9220) + +The whole `element:filter` element retired at element grain (ADR-0049 — no +renderer ever shipped for it), so every `ElementFilterProps` key is a +`retiredKey()` tombstone and no spec-conformant page carries `fields` on it. +The field-binding rule's job (resolve a field NAME against the object) is not +the question a retired key raises: an authored key is already reported by name +with the element-retirement prescription through the #5068 props gate, and the +binding entry would only add a second finding about a key that no longer +exists — the #5775/#6629 residue class the package's own +`component-field-specs-liveness` gate refuses. diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index 212d7c46e7..4357734b1a 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -191,7 +191,12 @@ export const COMPONENT_FIELD_SPECS: Readonly> 'record:details': { props: ['fields', 'hideFields'], nestedSections: ['sections'] }, 'record:path': { props: ['statusField'] }, 'element:number': { props: ['field'] }, - 'element:filter': { props: ['fields'] }, + // `element:filter` had a `{ props: ['fields'] }` entry until #9220 retired the + // whole element at element grain (ADR-0049 — no renderer ever shipped for it). + // Every `ElementFilterProps` key is a `retiredKey()` tombstone now, so no + // spec-conformant page carries `fields` on it, and the #5068 props gate + // reports an authored one by name with the element-retirement prescription — + // the same #5775/#6629 residue class as the record-picker entries below. 'element:form': { props: ['fields'] }, // `labelField` is the one field-bearing prop this element declares. Its former // companions `displayField` (renamed to `labelField`, ADR-0087 D2) and From 9ffa49994d0e9e55808444ab7adea0b0b079ea93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 06:44:03 +0000 Subject: [PATCH 3/3] chore(spec): regenerate artifacts on the merged tree (os-regen deferral clear) Merge of origin/main (2abb66bd4) into the #9220 branch; regenerated gen:schema + gen:docs + gen:openapi from the merged tree per scripts/pm/os-regen-merge.sh step 4. Survival asserted: step-18 block, six ElementFilterProps retired-keys entries, element-filter-removed conversion, fixture control, and the reference pages under the merged (#9239) generator. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --- content/docs/references/ui/component.mdx | 4 ++-- content/docs/references/ui/page.mdx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index e524e693a0..d48a00b762 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -525,7 +525,7 @@ const result = AIChatWindowProps.parse(data); | **width** | `string \| number` | optional | Panel width (e.g., "350px", "30%") — side positions (`right`/`left`) only. | | **collapsible** | `boolean` | optional | Whether the panel can be collapsed (renderer default: off). | | **defaultCollapsed** | `boolean` | optional | Whether the panel starts collapsed (renderer default: off; only meaningful with `collapsible`). | -| **feed** | `{ types?: Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| … +6 more>[]; filterMode: Enum<'all' \| 'comments_only' \| 'changes_only' \| 'tasks_only'>; showFilterToggle: boolean; limit: integer; … }` | optional | Embedded activity feed configuration | +| **feed** | `{ types?: Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| …>[]; filterMode: Enum<'all' \| 'comments_only' \| 'changes_only' \| 'tasks_only'>; showFilterToggle: boolean; limit: integer; … }` | optional | Embedded activity feed configuration | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -660,7 +660,7 @@ Type: `string` | **columns** | `string[]` | optional | Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list. | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records | | **limit** | `integer` | optional (default: `5`) | Number of records to display initially | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records | | **title** | `string \| Record` | optional | Custom title for the related list | | **showViewAll** | `boolean` | optional (default: `true`) | Show "View All" link to see all related records | | **actions** | `string[]` | optional | Action IDs available for related records | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 36af2a20a9..248674269c 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -50,7 +50,7 @@ Interface-level page configuration (Airtable parity) | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| … +15 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | @@ -172,7 +172,7 @@ Interface-level page configuration (Airtable parity) | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Region name (e.g. "sidebar", "main", "header") | | **width** | `Enum<'small' \| 'medium' \| 'large' \| 'full'>` | optional | | -| **components** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| … +29 more> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | ✅ | Components in this region | +| **components** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | ✅ | Components in this region | ---