From 330dee3ede3abec898e769485f32b711673b2ee6 Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 9 Sep 2026 16:15:16 +0000 Subject: [PATCH 1/7] feat(types,plugin-kanban,plugin-gantt)!: retire the bare `kanban` / `gantt` node keys and the `kanban-ui` / `kanban-enhanced` registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes three maintainer rulings taken in one batch on 2026-09-09: - objectui#8802 — retire the bare `kanban` node type key - objectui#8257 — retire `kanban-enhanced` and `kanban-ui` - objectui#8008 — retire the bare `gantt` registration Three of the four are registration-only: no schema face ever declared `kanban-ui`, `kanban-enhanced` or `gantt` as a component node type, so unregistering is the whole retirement. The bare `kanban` key had a declared arm on both faces, so it retires as a NAMED REFUSAL — a new `retiredNodeType()` helper keeps an arm claiming the literal in `AnyComponentSchema`'s discriminated union and answers a `{ "type": "kanban" }` document with a message naming `object-kanban`, while the TypeScript half is the absence of the arm from `ComplexSchema` and of the key from `SchemaRegistry`. The STORED `NamedListView.type` values `"kanban"` and `"gantt"` are a different layer and are untouched: `ObjectView` already maps a stored view type onto the `object-*` node type it renders, so zero stored documents move. A compile-time pin guards that layer against a future pattern-matching migration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- ...257-8008-kanban-gantt-family-retirement.md | 73 ++++ README.md | 2 +- apps/console/src/register-plugins.ts | 24 +- content/docs/api/schema-reference.md | 75 +--- content/docs/guide/architecture-overview.md | 10 +- content/docs/guide/troubleshooting.md | 2 +- content/docs/plugins/plugin-kanban.mdx | 59 ++- content/docs/utilities/runner.mdx | 6 +- ...dvanced-kanban-with-badges-and-limits.json | 4 +- .../plugin-kanban/basic-kanban-board.json | 4 +- .../test/catalog-gallery-render.test.tsx | 6 +- .../test/kanban-column-cards-6939.test.tsx | 52 ++- packages/cli/src/utils/known-schema-types.ts | 6 - .../src/renderers/complex/README-KANBAN.md | 22 +- packages/plugin-gantt/README.md | 28 +- packages/plugin-gantt/src/index.tsx | 51 ++- packages/plugin-kanban/README.md | 67 ++-- .../src/KanbanRenderer.uncolumned.test.tsx | 10 +- packages/plugin-kanban/src/ObjectKanban.tsx | 85 ++-- .../filterIsDeclaredInput-7712.test.ts | 11 +- ...-family-registry-keys-retired-8257.test.ts | 145 +++++++ .../kanban-handler-slots-7664.test.tsx | 147 +++++-- ...-plugin-dialect-authoritative-7664.test.ts | 135 ++++--- ...object-kanban-component-props-7322.test.ts | 69 ++-- ...ctFieldsIsAPropNotASchemaKey-7742.test.tsx | 6 +- .../recordsSettledEmptyState-8827.test.tsx | 19 +- ...larKeysAreDeclaredAndHonoured-8201.test.ts | 30 +- ...redKeysAreDeclaredAndHonoured-8313.test.ts | 30 +- packages/plugin-kanban/src/index.test.ts | 119 ++---- packages/plugin-kanban/src/index.tsx | 288 ++++++------- packages/plugin-kanban/src/types.ts | 5 +- packages/types/README.md | 2 +- .../types/examples/zod-validation-example.ts | 41 +- .../bare-kanban-node-key-retired-8802.test.ts | 186 +++++++++ ...ent-docs-retired-handler-keys-7340.test.ts | 34 +- .../handler-keys-json-refusal-6124.test.ts | 73 ++-- .../__tests__/kanban-arm-batch70-7742.test.ts | 168 -------- ...-plugin-dialect-authoritative-7664.test.ts | 343 ---------------- .../schema-registry-chatbot-keys-7704.test.ts | 9 +- .../src/__tests__/zod-mirror-parity.test.ts | 72 ++-- packages/types/src/complex.ts | 378 +++--------------- packages/types/src/index.ts | 4 +- packages/types/src/registry.ts | 35 +- packages/types/src/zod/complex.zod.ts | 129 ++---- packages/types/src/zod/index.zod.ts | 6 +- packages/types/src/zod/tombstone.zod.ts | 53 +++ scripts/check-doc-component-types.mjs | 18 + skills/objectui/guides/page-builder.md | 4 +- 48 files changed, 1463 insertions(+), 1682 deletions(-) create mode 100644 .changeset/8802-8257-8008-kanban-gantt-family-retirement.md create mode 100644 packages/plugin-kanban/src/__tests__/kanban-family-registry-keys-retired-8257.test.ts create mode 100644 packages/types/src/__tests__/bare-kanban-node-key-retired-8802.test.ts delete mode 100644 packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts delete mode 100644 packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts diff --git a/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md b/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md new file mode 100644 index 0000000000..238c17476e --- /dev/null +++ b/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md @@ -0,0 +1,73 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-kanban': minor +'@object-ui/plugin-gantt': minor +'@object-ui/cli': patch +'@object-ui/console': patch +--- + +Four node type keys retire, and the kanban and gantt families converge on their +`object-*` spellings: `kanban` (objectui#8802), `kanban-ui` and `kanban-enhanced` +(objectui#8257), and `gantt` (objectui#8008). All four were ruled by the +maintainer in one batch on 2026-09-09. + +**⛔ No stored document moves.** The strings `kanban` and `gantt` name two +different things at two different layers, and only one of them is retiring: + +| layer | value | who writes it | retired? | +| --- | --- | --- | --- | +| stored `NamedListView.type` | `"kanban"`, `"gantt"` | `CreateViewDialog`, persisted per tenant | **no — untouched** | +| node type key | `kanban`, `gantt` | hand-authored JSON | **yes** | + +`ObjectView`'s `switch (viewType)` maps a stored view type onto the node type it +renders, and it already emitted `object-kanban` and `object-gantt` — as it does +for all twelve stored view types. So every kanban and gantt view any user ever +created through the console already renders through the surviving spelling. +Nothing in a tenant database changes, and ⛔ nothing should be migrated there. + +**What each retirement was, measured.** Three of the four were +registration-only: no schema face in `@object-ui/types` ever declared +`kanban-ui`, `kanban-enhanced` or `gantt` as a component node type, so +unregistering is the whole retirement. The bare `kanban` key was the exception — +it had a declared arm on both faces (`KanbanSchema` in `complex.ts` and its Zod +mirror), and a plain deletion there would have been the objectui#7664 failure: +`BaseSchema` is `.passthrough()`, so a document naming a dropped key validates +green and renders nothing. It therefore retires as a **named refusal**: the Zod +union keeps an arm claiming the literal and answers a `{ "type": "kanban" }` +document with a message naming `object-kanban` as the remedy, while the +TypeScript half is the absence of the arm from `ComplexSchema` and of the key +from `SchemaRegistry`, so `tsc` refuses it at the authoring site. + +**⭐ This closes objectui#8818's `objectFields` hole — for that ENTRY, not for +the class.** `SchemaRenderer` strips a fixed enumerated metadata list and +spreads the rest as React props; `objectFields` is not on that list, and +`KanbanRenderer` — the component the `kanban-ui` key resolved to — declares +`objectFields` as a real prop, so an authored value reached the predicate layer +with no schema face judging it. With the registration gone, no authored node +reaches that component through the registry. ⚠️ The **class** is still open: the +hole returns the moment another registered renderer declares an `objectFields` +prop. objectui#8818's option (a) — stripping at the `SchemaRenderer` boundary — +is what would close the class. + +**⚠️ What the `kanban` arm took with it, stated because it is the cost of this +change.** That arm was the only schema face that ever declared `columns`, +`cardTitle`, `swimlaneField`, `grouping` and `navigation`, the only one that +refused `allowCollapse` / `cardTemplates` / `columnWidths` / `titleField` / +`draggable` / `onColumnAdd` / `onCardAdd` by name, and — through +`columns: KanbanColumn[]` — the only one that judged a lane's `cards` +(objectui#6939). The surviving `ObjectKanbanSchema` face declares none of them. +⛔ Nothing about an `object-kanban` document changes: it was never judged by the +`kanban` arm, so all of those keys have always ridden `BaseSchema`'s index +signature there. What is gone is the `kanban` document that had them. Declaring +them on `ObjectKanbanSchema` would WIDEN a published accept set, which is a +maintainer ruling and not part of this one; every one of these readings is +pinned where it can be seen rather than left to be rediscovered. + +**Migrating.** Replace `"type": "kanban"` with `"type": "object-kanban"` and +`"type": "gantt"` with `"type": "object-gantt"` in hand-authored documents. The +`object-kanban` face requires `groupBy` and one of `bind` / `data` / +`objectName`; a purely static board (lanes carrying their own cards, no record +source) adds `"groupBy"` and `"data": []`. `kanban-ui` and `kanban-enhanced` +have no authored documents anywhere in this repository to migrate. +`KanbanRenderer` and `KanbanEnhanced` are still exported and still importable — +only their registry keys are gone. diff --git a/README.md b/README.md index d086dd5d0e..abccd5766c 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,7 @@ export default App ```json { - "type": "kanban", + "type": "object-kanban", "objectName": "tasks", "groupBy": "status", "titleField": "title", diff --git a/apps/console/src/register-plugins.ts b/apps/console/src/register-plugins.ts index 5f2efd40ff..2e59e86148 100644 --- a/apps/console/src/register-plugins.ts +++ b/apps/console/src/register-plugins.ts @@ -85,10 +85,11 @@ ComponentRegistry.registerLazy('object-gantt', () => import('@object-ui/plugin-g namespace: 'plugin-gantt', category: 'view', }); -ComponentRegistry.registerLazy('gantt', () => import('@object-ui/plugin-gantt'), { - namespace: 'view', - category: 'view', -}); +// ⛔ The bare `gantt` node type key is RETIRED (objectui#8008, maintainer +// ruling 2026-09-09, route 3) — `object-gantt` above is the surviving spelling. +// The STORED `NamedListView.type` value `gantt` is a different layer and is +// untouched: `ObjectView`'s `switch (viewType)` already emits `object-gantt` +// for it. ComponentRegistry.registerLazy('markdown', () => import('@object-ui/plugin-markdown'), { namespace: 'plugin-markdown', @@ -121,16 +122,11 @@ ComponentRegistry.registerLazy('object-kanban', () => import('@object-ui/plugin- namespace: 'plugin-kanban', category: 'view', }); -ComponentRegistry.registerLazy('kanban', () => import('@object-ui/plugin-kanban'), { - namespace: 'view', - category: 'view', -}); -for (const variant of ['kanban-ui', 'kanban-enhanced']) { - ComponentRegistry.registerLazy(variant, () => import('@object-ui/plugin-kanban'), { - namespace: 'plugin-kanban', - category: 'view', - }); -} +// ⛔ The bare `kanban` key (objectui#8802) and the `kanban-ui` / +// `kanban-enhanced` variants (objectui#8257) are RETIRED — maintainer rulings +// 2026-09-09. `object-kanban` above is the surviving spelling. The STORED +// `NamedListView.type` value `kanban` is a different layer and is untouched: +// `ObjectView`'s `switch (viewType)` already emits `object-kanban` for it. ComponentRegistry.registerLazy('report', () => import('@object-ui/plugin-report'), { namespace: 'plugin-report', diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index fbd5518ddb..336d8e5c69 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -910,79 +910,41 @@ A complete object management interface combining grid, form, search, filters, an ## Complex Schemas -### KanbanSchema +### ObjectKanbanSchema -A drag-and-drop Kanban board. The `kanban` type key validates the shape the registered renderer (`@object-ui/plugin-kanban`) reads: bind the board to an object with `objectName` + `groupBy` (the lanes come from the group field's options), or author it statically with `columns`, each carrying its `cards`. +A drag-and-drop Kanban board. The `object-kanban` type key validates the shape the registered renderer (`@object-ui/plugin-kanban`) reads: bind the board to an object with `objectName` + `groupBy` — the lanes come from the group field's options — or hand it rows on `data`. + +> **The bare `kanban` node type key is retired** (objectui#8802, ruled 2026-09-09). It published two faces that disagreed with each other, and `object-kanban` is now the one spelling. A `{ "type": "kanban" }` document is refused by name and told to write `object-kanban`. +> +> ⚠️ The **stored view type** `"kanban"` — what `listViews[].type` and `defaultViewType` hold — is a **different layer and is unchanged**. Do not rewrite it: a saved kanban view already renders through the `object-kanban` node type, because `ObjectView` maps the stored type onto it. ```json { - "type": "kanban", + "type": "object-kanban", "objectName": "tasks", "groupBy": "status", - "cardTitle": "title", + "titleField": "title", "cardFields": ["assignee", "due_date"], "quickAdd": true } ``` -A static board carries its cards inline: - -```json -{ - "type": "kanban", - "columns": [ - { - "id": "todo", - "title": "To Do", - "cards": [ - { - "id": "task-1", - "title": "Design mockups", - "description": "Create wireframes for new feature", - "badges": [{ "label": "High", "variant": "destructive" }] - }, - { "id": "task-2", "title": "Write tests", "description": "Unit tests for auth module" } - ] - }, - { - "id": "in-progress", - "title": "In Progress", - "limit": 3, - "cards": [ - { "id": "task-3", "title": "API integration", "description": "Connect to payment gateway" } - ] - }, - { - "id": "done", - "title": "Done", - "cards": [] - } - ] -} -``` - | Property | Type | Description | |----------|------|-------------| | `objectName` | `string` | Object to fetch records from. | -| `groupBy` | `string` | Field whose values become the lanes (maps to column ids). | -| `swimlaneField` | `string` | Field for swimlane rows (2D grouping). | -| `cardTitle` | `string` | Field used as the card title. | +| `groupBy` | `string` | **Required.** Field whose values become the lanes (maps to column ids). | +| `titleField` | `string` | Field used as the card title. | | `cardFields` | `string[]` | Fields rendered on each card. | -| `data` | `any[]` | Inline records, bucketed into lanes by `groupBy`. | +| `filter` | `any[]` | Query filter, forwarded verbatim as `$filter`. | | `limit` | `number` | Fetch window for the board (default 100). | -| `columns` | `KanbanColumn[]` | Lanes, each with `id`, `title`, `cards`, and optional `limit` / `className` / `collapsed`. A card has `id`, `title`, optional `description` and `badges`. | | `quickAdd` | `boolean` | Show a Quick Add button at the bottom of each column. | | `coverImageField` | `string` | Field whose URL renders as the card cover image. | +| `allowCollapse` | `boolean` | Allow lanes to collapse and expand. | | `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules — native `{ field, operator, value }` or spec `{ condition, style }`. | -| `grouping` | `GroupingConfig` | ListView grouping config; its first field is the swimlane fallback. | -| `navigation` | `ViewNavigationConfig` | Record navigation behaviour when a card is clicked (drawer / dialog / page). Defaults to an inline right-side drawer. | -| `onCardMove` | `function` | Runtime slot supplied by a React host, `(cardId, fromColumnId, toColumnId, newIndex)`; not authorable in JSON. | -| `onCardClick` | `function` | Runtime slot supplied by a React host, `(card, event?)`; not authorable in JSON. On the object-bound board the host's handler runs alongside the record-detail overlay. | -| `onQuickAdd` | `function` | Runtime slot supplied by a React host, `(columnId, title)`; not authorable in JSON. | -> Four spellings the `kanban` arm once accepted are now refused by name (objectui#7742, ADR-0049). `allowCollapse`, `cardTemplates` and `columnWidths` were declared and read by no registered board — collapse a lane with `columns[].collapsed`; card templates and column widths reach the board through a component prop and a hook option, not through the node. `titleField` is the legacy spelling of `cardTitle` and is retired on this arm only: write `cardTitle`. An `object-kanban` node still accepts `titleField`. +> `groupField` is refused by name (objectui#7322): the renderer reads `groupBy`. -> The former `@object-ui/types` kanban dialect — `DeclarativeKanbanSchema`, with a board-level `draggable`, a column `color` and card `labels` / `priority` — was retired in objectui#7664: no registered renderer read it, so a board written that way validated and rendered empty. `draggable` and a column `color` are now refused by name; a static board written with `columns[].cards[]` as above is the same document in both dialects and renders every card. +> The retired `kanban` arm declared `columns`, `cardTitle`, `swimlaneField`, `grouping` and `navigation`; the `object-kanban` face never did, and it is unchanged. The renderer still reads those keys, so a board may carry them — they are simply not judged. The board's React host supplies `onCardMove` / `onCardClick` / `onQuickAdd` as props; none of the three is authorable in JSON. **Related:** [ObjectViewSchema](#objectviewschema), [ObjectGridSchema](#objectgridschema) @@ -1237,8 +1199,9 @@ A toggle control that switches between different view types (list, grid, kanban, "label": "Kanban", "icon": "Kanban", "schema": { - "type": "kanban", - "columns": [] + "type": "object-kanban", + "objectName": "tasks", + "groupBy": "status" } } ] @@ -1256,7 +1219,7 @@ A toggle control that switches between different view types (list, grid, kanban, | `storageKey` | `string` | Storage key for persisting the preference. | | `onViewChange` | `string` | Expression or callback invoked on view change. | -**Related:** [ObjectViewSchema](#objectviewschema), [KanbanSchema](#kanbanschema), [CalendarViewSchema](#calendarviewschema) +**Related:** [ObjectViewSchema](#objectviewschema), [ObjectKanbanSchema](#objectkanbanschema), [CalendarViewSchema](#calendarviewschema) --- @@ -1331,7 +1294,7 @@ import type { ActionSchema, DetailSchema } from '@object-ui/types'; import type { ObjectGridSchema, ObjectFormSchema, ObjectViewSchema } from '@object-ui/types'; // Complex -import type { KanbanSchema, DashboardComponentSchema, CalendarViewSchema } from '@object-ui/types'; +import type { DashboardComponentSchema, CalendarViewSchema } from '@object-ui/types'; // Views import type { DetailViewSchema, ViewSwitcherSchema } from '@object-ui/types'; diff --git a/content/docs/guide/architecture-overview.md b/content/docs/guide/architecture-overview.md index 6b7a65da0d..9f87874b09 100644 --- a/content/docs/guide/architecture-overview.md +++ b/content/docs/guide/architecture-overview.md @@ -132,9 +132,9 @@ Plugins are self-contained packages that register heavy or complex views (grids, PluginScope.registerComponent(type, Component, meta) │ └─ Auto-prefixes with plugin namespace ▼ - ComponentRegistry.register('kanban-ui', KanbanRenderer, { + ComponentRegistry.register('object-kanban', ObjectKanbanRenderer, { namespace: 'plugin-kanban', - category: 'plugin' + category: 'view' }) ``` @@ -164,10 +164,10 @@ const KanbanRenderer: React.FC = ({ schema }) => ( ); // 3. Register in the global registry -ComponentRegistry.register('kanban-ui', KanbanRenderer, { +ComponentRegistry.register('object-kanban', KanbanRenderer, { namespace: 'plugin-kanban', - label: 'Kanban Board', - category: 'plugin', + label: 'Object Kanban', + category: 'view', inputs: [/* schema config */] }); ``` diff --git a/content/docs/guide/troubleshooting.md b/content/docs/guide/troubleshooting.md index fe98cec31f..41923adc28 100644 --- a/content/docs/guide/troubleshooting.md +++ b/content/docs/guide/troubleshooting.md @@ -9,7 +9,7 @@ This guide covers the most common issues you may encounter when working with Obj ## 1. "Component type X not found" -**Symptom:** The `SchemaRenderer` renders nothing or shows a fallback, and the console logs `component type "kanban-ui" not found in registry`. +**Symptom:** The `SchemaRenderer` renders nothing or shows a fallback, and the console logs `component type "object-kanban" not found in registry`. **Cause:** The plugin that provides the component type has not been imported, so it never registered itself with the `ComponentRegistry`. diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index c5329f0768..12f280778e 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -38,11 +38,13 @@ This package publishes a stylesheet. Import it after the base sheets, or the boa ```tsx // Import once in your app entry point import '@object-ui/plugin-kanban' -import type { KanbanSchema } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' // Use in schemas -const schema: KanbanSchema = { - type: 'kanban', +const schema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [ { id: 'todo', @@ -57,7 +59,7 @@ const schema: KanbanSchema = { cards: [] } ], - onCardMove: (cardId, fromCol, toCol, index) => { + onCardMove: (cardId: string, fromCol: string, toCol: string, index: number) => { console.log(`Card ${cardId} moved`) } } @@ -75,7 +77,9 @@ const schema: KanbanSchema = { ```plaintext { - type: 'kanban', + type: 'object-kanban', + groupBy: 'status', + data: [], columns?: KanbanColumn[], onCardMove?: (cardId, fromColumnId, toColumnId, newIndex) => void, className?: string @@ -181,10 +185,12 @@ The `dataSource` binding sets it too — its own `limit`, or the ### Project Task Board ```tsx -import type { KanbanSchema } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' -const taskBoard: KanbanSchema = { - type: 'kanban', +const taskBoard: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [ { id: 'backlog', @@ -227,7 +233,7 @@ const taskBoard: KanbanSchema = { cards: [] } ], - onCardMove: (cardId, fromCol, toCol, index) => { + onCardMove: (cardId: string, fromCol: string, toCol: string, index: number) => { // Update backend/state console.log(`Moved ${cardId} from ${fromCol} to ${toCol}`) } @@ -237,10 +243,12 @@ const taskBoard: KanbanSchema = { ### Support Ticket Board ```tsx -import type { KanbanSchema } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' -const ticketBoard: KanbanSchema = { - type: 'kanban', +const ticketBoard: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [ { id: 'new', @@ -276,10 +284,12 @@ const ticketBoard: KanbanSchema = { ### Sales Pipeline ```tsx -import type { KanbanSchema } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' -const salesPipeline: KanbanSchema = { - type: 'kanban', +const salesPipeline: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [ { id: 'leads', @@ -365,7 +375,8 @@ Handle card movements to update your backend or state: ```tsx import { useState } from 'react' -import type { KanbanColumn, KanbanSchema } from '@object-ui/plugin-kanban' +import type { KanbanColumn } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' declare function updateCardColumn(cardId: string, toColumnId: string, newIndex: number): Promise declare function moveCard(columns: KanbanColumn[], cardId: string, toColumnId: string, newIndex: number): KanbanColumn[] @@ -373,12 +384,14 @@ declare function moveCard(columns: KanbanColumn[], cardId: string, toColumnId: s export function useBoard(initialColumns: KanbanColumn[]) { const [columns, setColumns] = useState(initialColumns) - const schema: KanbanSchema = { - type: 'kanban', + const schema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns, // `onCardMove` returns void, so the callback cannot be awaited by the board: // start the write and update local state without blocking the drop. - onCardMove: (cardId, fromColumnId, toColumnId, newIndex) => { + onCardMove: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => { // Update database void updateCardColumn(cardId, toColumnId, newIndex) @@ -410,7 +423,7 @@ The kanban board includes: ## TypeScript Support ```plaintext -import type { KanbanSchema, KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema, KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban' const column: KanbanColumn = { id: 'todo', @@ -419,8 +432,10 @@ const column: KanbanColumn = { limit: 5 } -const kanbanSchema: KanbanSchema = { - type: 'kanban', +const kanbanSchema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [column] } ``` diff --git a/content/docs/utilities/runner.mdx b/content/docs/utilities/runner.mdx index b5d72ac93e..4e61771b01 100644 --- a/content/docs/utilities/runner.mdx +++ b/content/docs/utilities/runner.mdx @@ -293,7 +293,7 @@ Test how multiple plugins work together: { "type": "div", "children": [ - { "type": "kanban", "..." }, + { "type": "object-kanban", "..." }, { "type": "bar-chart", "..." }, { "type": "data-table", "..." } ] @@ -372,7 +372,9 @@ serve it from your own backend and load it with `?api=`. ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [ { "id": "todo", diff --git a/examples/schema-catalog/src/schemas/plugin-kanban/advanced-kanban-with-badges-and-limits.json b/examples/schema-catalog/src/schemas/plugin-kanban/advanced-kanban-with-badges-and-limits.json index 71ab4eaf21..8b7a6bc3df 100644 --- a/examples/schema-catalog/src/schemas/plugin-kanban/advanced-kanban-with-badges-and-limits.json +++ b/examples/schema-catalog/src/schemas/plugin-kanban/advanced-kanban-with-badges-and-limits.json @@ -1,5 +1,7 @@ { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [ { "id": "backlog", diff --git a/examples/schema-catalog/src/schemas/plugin-kanban/basic-kanban-board.json b/examples/schema-catalog/src/schemas/plugin-kanban/basic-kanban-board.json index bb08ff575e..cb438386b5 100644 --- a/examples/schema-catalog/src/schemas/plugin-kanban/basic-kanban-board.json +++ b/examples/schema-catalog/src/schemas/plugin-kanban/basic-kanban-board.json @@ -1,5 +1,7 @@ { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [ { "id": "todo", diff --git a/examples/schema-catalog/test/catalog-gallery-render.test.tsx b/examples/schema-catalog/test/catalog-gallery-render.test.tsx index ad12fa98a8..ef6e7af483 100644 --- a/examples/schema-catalog/test/catalog-gallery-render.test.tsx +++ b/examples/schema-catalog/test/catalog-gallery-render.test.tsx @@ -623,7 +623,11 @@ describe('objectui#4616 — every catalog entry renders in the docs gallery', () 'chatbot', 'code-editor', 'object-gantt', - 'kanban', + // ⚠️ `'kanban'` until objectui#8802 retired the bare node type key + // (maintainer ruling 2026-09-09); `object-kanban` is the surviving key + // that this package's registration now publishes, and it is what the + // census needs from `@object-ui/plugin-kanban`. + 'object-kanban', 'object-map', 'markdown', 'timeline', diff --git a/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx b/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx index 3906721f59..27ccb1fea2 100644 --- a/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx +++ b/examples/schema-catalog/test/kanban-column-cards-6939.test.tsx @@ -187,25 +187,49 @@ describe('objectui#6939 — the mirror now accepts the spelling every board read expect(reasons(getExample(id).schema)).toEqual([]); }); - it.each(IDS)('%s: the `items` spelling is now REFUSED', (id) => { - // The other half of the accept-set move. This document PASSED before the - // rename; a mirror that merely widened would still accept it. - expect(reasons(toItemsSpelling(getExample(id).schema))).not.toEqual([]); + it.each(IDS)('⚠️ %s: `columns[].items` is ACCEPTED again — objectui#6939\'s judging was ARM-SCOPED and the arm retired', (id) => { + // ⭐ This leg asserted `.not.toEqual([])` — REFUSED — until objectui#8802. + // The reading flipped, and it flipped for a structural reason worth + // spelling out rather than papering over: + // + // - objectui#6939 made `columns[].cards` a JUDGED key by declaring + // `columns: z.array(KanbanColumnSchema)` on the `kanban` arm; + // - objectui#8802 retired that arm with the bare node type key, and the + // surviving `object-kanban` face (`objectql.zod.ts#ObjectKanbanSchema`) + // has NEVER declared `columns`; + // - so `columns` now rides `BaseSchema`'s `.passthrough()` and nothing + // inside it is judged — not the `cards` / `items` spelling, not a + // card's required `title`, not `cards`'s type. + // + // ⛔ NOT repaired here: declaring `columns` on `ObjectKanbanSchema` WIDENS a + // published accept set, which is a ruling and not a repair. Recorded as an + // assertion so it cannot drift back in silence, and reported on the + // retirement PR for the maintainer. + expect(reasons(toItemsSpelling(getExample(id).schema))).toEqual([]); }); - it('`cards` is a real declaration, not a passthrough hole', () => { - // `BaseSchema` is `.passthrough()`, so an unknown key proves nothing here. - // Every probe below uses the DECLARED key. + it('⚠️ `cards` is no longer a judged declaration on the surviving arm — measured, with a firing control', () => { + // The same four probes objectui#6939 wrote, re-read on the surviving face. + // Every one of them is accepted now, INCLUDING the two that are nonsense + // (`cards: 'nope'`, a card with no `title`) and the one objectui#6939 + // required (`cards` present at all). const col = (extra: Record) => ({ - type: 'kanban', + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [{ id: 'todo', title: 'To Do', ...extra }], }); - expect(safeValidateSchema(col({ cards: 'nope' })).success).toBe(false); - expect(safeValidateSchema(col({ cards: [{ id: '1' }] })).success).toBe(false); // card needs `title` - expect(safeValidateSchema(col({})).success).toBe(false); // `cards` is required - // …and the good shape passes, so the three above fail for their own reason. - expect(safeValidateSchema(col({ cards: [] })).success).toBe(true); - expect(safeValidateSchema(col({ cards: [{ id: '1', title: 'Task' }] })).success).toBe(true); + expect({ + cardsIsAString: safeValidateSchema(col({ cards: 'nope' })).success, + cardHasNoTitle: safeValidateSchema(col({ cards: [{ id: '1' }] })).success, + cardsAbsent: safeValidateSchema(col({})).success, + wellFormed: safeValidateSchema(col({ cards: [{ id: '1', title: 'Task' }] })).success, + }).toEqual({ cardsIsAString: true, cardHasNoTitle: true, cardsAbsent: true, wellFormed: true }); + + // FIRING CONTROL on the same call: the surviving arm is not accepting + // everything — its own tombstone still refuses by name, so the four `true`s + // above are readings about `columns` and not about a dead validator. + expect(safeValidateSchema({ ...col({ cards: [] }), groupField: 'status' }).success).toBe(false); }); }); diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts index a3d3c41895..aa42333eed 100644 --- a/packages/cli/src/utils/known-schema-types.ts +++ b/packages/cli/src/utils/known-schema-types.ts @@ -195,7 +195,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'form', 'form-analytics', 'formula', - 'gantt', 'geolocation', 'global:notifications', 'global:search', @@ -220,9 +219,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'input', 'input-otp', 'ins', - 'kanban', - 'kanban-enhanced', - 'kanban-ui', 'kbd', 'label', 'layout:app-schema-renderer', @@ -336,8 +332,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'plugin-gantt:object-gantt', 'plugin-grid:import-wizard', 'plugin-grid:object-grid', - 'plugin-kanban:kanban-enhanced', - 'plugin-kanban:kanban-ui', 'plugin-kanban:object-kanban', 'plugin-list:list-view', 'plugin-list:object-gallery', diff --git a/packages/components/src/renderers/complex/README-KANBAN.md b/packages/components/src/renderers/complex/README-KANBAN.md index e80348e341..a82788d240 100644 --- a/packages/components/src/renderers/complex/README-KANBAN.md +++ b/packages/components/src/renderers/complex/README-KANBAN.md @@ -20,7 +20,9 @@ A fully functional, schema-driven Kanban board component for Object UI with drag ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "className": "w-full h-[600px]", "columns": [ { @@ -56,7 +58,9 @@ A fully functional, schema-driven Kanban board component for Object UI with drag ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [...], "onCardMove": "(event) => { console.log('Card moved:', event); }" } @@ -68,7 +72,7 @@ A fully functional, schema-driven Kanban board component for Object UI with drag | Property | Type | Required | Description | |----------|------|----------|-------------| -| `type` | `"kanban"` | Yes | Component type identifier | +| `type` | `"object-kanban"` | Yes | Component type identifier. ⚠️ The bare `kanban` spelling was RETIRED in objectui#8802 (ruled 2026-09-09) and is refused by name. | | `columns` | `KanbanColumn[]` | Yes | Array of column configurations | | `className` | `string` | No | Custom CSS classes | | `onCardMove` | `function` | No | Callback when a card is moved | @@ -105,7 +109,9 @@ A fully functional, schema-driven Kanban board component for Object UI with drag ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [ { "id": "backlog", @@ -137,7 +143,9 @@ A fully functional, schema-driven Kanban board component for Object UI with drag ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "columns": [ { "id": "new", @@ -175,7 +183,9 @@ The Kanban component uses Tailwind CSS and can be customized using the `classNam ```json { - "type": "kanban", + "type": "object-kanban", + "groupBy": "status", + "data": [], "className": "w-full h-[800px] bg-gray-50 p-4 rounded-lg", "columns": [...] } diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md index 08de99d2f8..d0a3f9cfb4 100644 --- a/packages/plugin-gantt/README.md +++ b/packages/plugin-gantt/README.md @@ -19,7 +19,7 @@ Gantt chart plugin for Object UI - Visualize project timelines and task dependen ### Create / Edit / Delete / View When used through `ObjectGantt` (the wiring the framework uses for the -`gantt` view type) the full CRUD lifecycle is wired automatically: +`gantt` stored view type) the full CRUD lifecycle is wired automatically: - **Create** — click the toolbar "+ New Task" button. A small dialog opens pre-filled with start/end (today → +7 days). On submit the component calls @@ -65,7 +65,7 @@ When used through `ObjectGantt` (the wiring the framework uses for the ### Drag-and-drop rescheduling When the renderer is used through `ObjectGantt` (the standard wiring used by -the framework's `gantt` view type) drag is enabled automatically: each bar +the framework's `gantt` stored view type) drag is enabled automatically: each bar shows a grab cursor; the body drags the entire task, and the two thin edge zones (≈6px) resize start or end. Pointer motion snaps to whole days using the current column width. On release `ObjectGantt` issues an optimistic local @@ -114,7 +114,7 @@ import '@object-ui/plugin-gantt'; // The gantt is RECORD-DRIVEN: it names a data source and the fields to read. // It does not take a task array — see "Schema API" below. const schema = { - type: 'gantt', + type: 'object-gantt', objectName: 'project_tasks', titleField: 'name', startDateField: 'start_date', @@ -134,11 +134,21 @@ claim these schema types: | Schema `type` | Namespaced key | Renderer | | --- | --- | --- | | `object-gantt` | `plugin-gantt:object-gantt` | `ObjectGanttRenderer` | -| `gantt` | `view:gantt` | `ObjectGanttRenderer` | -Both spellings resolve — `register` stores the namespaced key *and* a bare-`type` -fallback. Both keys declare the same two inputs: `objectName` (required) and the -`gantt` configuration object. +Both spellings of the surviving key resolve — `register` stores the namespaced key +*and* a bare-`type` fallback. It declares two inputs: `objectName` (required) and +the `gantt` configuration object. + +> **The bare `gantt` key is retired** (objectui#8008, ruled 2026-09-09). This +> table used to carry a second row, `gantt` / `view:gantt`, on the same renderer. +> The registry accepted both spellings while the published declaration admitted +> only one — `ObjectGanttSchema.type` is the literal `'object-gantt'` — so an +> author who annotated their node could not write the key the registry took. +> `object-gantt` is now the one spelling. +> +> ⚠️ The **stored view type** `"gantt"` — what a saved `listViews[].type` holds — +> is a **different layer and is unchanged**. `ObjectView` maps a stored `gantt` +> view onto the `object-gantt` node type, so no saved view moves. `ObjectGanttRenderer` is a thin wrapper: it pulls `dataSource` off the renderer context and hands the schema to `ObjectGantt`. @@ -150,7 +160,7 @@ The package exports components, helpers and their types — not a registry map: ```typescript import { ObjectGantt, // ObjectQL-integrated gantt: loads records, writes back edits - ObjectGanttRenderer, // the registered renderer for `object-gantt` / `gantt` + ObjectGanttRenderer, // the registered renderer for `object-gantt` GanttView, // the standalone timeline component QuickFilterBar, // the toolbar's quick-filter dropdowns ResourceWorkload, // resource × period workload grid @@ -356,7 +366,7 @@ pulses it — useful in deep or long trees. ```json { - "type": "gantt", + "type": "object-gantt", "objectName": "project_task", "gantt": { "titleField": "name", diff --git a/packages/plugin-gantt/src/index.tsx b/packages/plugin-gantt/src/index.tsx index 122dae1872..7d6e1f321e 100644 --- a/packages/plugin-gantt/src/index.tsx +++ b/packages/plugin-gantt/src/index.tsx @@ -109,12 +109,45 @@ ComponentRegistry.register('object-gantt', ObjectGanttRenderer, { ], }); -ComponentRegistry.register('gantt', ObjectGanttRenderer, { - namespace: 'view', - label: 'Gantt View', - category: 'view', - inputs: [ - { name: 'objectName', type: 'string', required: true }, - { name: 'gantt', type: 'object', description: 'startDateField, endDateField, titleField, progressField, percentageField, colorField, dependenciesField' }, - ], -}); +/** + * ⛔ The bare `gantt` node type key is RETIRED (objectui#8008, maintainer + * ruling 2026-09-09, route 3). `object-gantt` is the one spelling this plugin + * serves. + * + * ## What was here, and why it went + * + * `ComponentRegistry.register('gantt', ObjectGanttRenderer, { namespace: + * 'view', ... })` — a second key on the SAME renderer, which stored both + * `view:gantt` and the bare `gantt` fallback. The declared face admitted only + * one of the two: `ObjectGanttSchema.type` is the literal `'object-gantt'`, so + * an author who annotated their node could not write the key the registry + * accepted (`TS2322`), while an author who left the literal bare got no + * checking at all. Two published faces, opposite verdicts. + * + * ## Why unregistering is the whole retirement here — measured, not assumed + * + * ⚠️ `BaseSchema` closes with `[key: string]: any` and `BaseSchemaCore` ends + * `.passthrough()`, so a dropped MEMBER KEY is KEPT, not refused (the + * objectui#7664 failure). That hazard does not reach a TYPE NAME on this + * surface, and the reason is structural rather than lucky: no schema face in + * `@object-ui/types` ever declared `gantt` as a component node type — measured + * whole-repo, zero declarations, against a firing control of two for + * `object-gantt` (`objectql.ts` + its Zod mirror). There is no arm to convert + * into a named refusal, so unregistering IS the retirement. + * + * ⇒ Registration-only retirement. Contrast the `kanban` sibling in the same + * batch (objectui#8802), which DID have a declared arm and therefore got a + * named refusal rather than a deletion. + * + * ## ⛔ Two layers, and only one of them moved + * + * The string `gantt` also names a STORED `NamedListView.type` — the value + * `CreateViewDialog` writes and every tenant's database holds. That layer is + * untouched: `packages/plugin-view/src/ObjectView.tsx`'s `switch (viewType)` + * maps the stored `gantt` view type onto the node type it emits, and it already + * emits `object-gantt`. ⇒ Every gantt view any user ever created through the + * console already renders through the surviving spelling; this retirement moves + * zero stored documents. + * + * Pinned in `src/__tests__/bare-gantt-node-key-retired-8008.test.ts`. + */ diff --git a/packages/plugin-kanban/README.md b/packages/plugin-kanban/README.md index 74d3d42d63..10e0796a66 100644 --- a/packages/plugin-kanban/README.md +++ b/packages/plugin-kanban/README.md @@ -76,7 +76,8 @@ Object.entries(kanbanComponents).forEach(([type, component]) => { The plugin exports TypeScript types for full type safety: ```typescript -import type { KanbanSchema, KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban'; +import type { KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban'; +import type { ObjectKanbanSchema } from '@object-ui/types'; const card: KanbanCard = { id: 'task-1', @@ -94,8 +95,13 @@ const column: KanbanColumn = { limit: 5 }; -const schema: KanbanSchema = { - type: 'kanban', +// ⚠️ `object-kanban`: the bare `kanban` node type key and its `KanbanSchema` +// arm RETIRED in objectui#8802. `groupBy` and one of `bind` / `data` / +// `objectName` are what the surviving face requires of every board. +const schema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [column] }; ``` @@ -103,27 +109,35 @@ const schema: KanbanSchema = { ## Schema API ```typescript -import type { KanbanSchema } from '@object-ui/plugin-kanban'; +import type { ObjectKanbanSchema } from '@object-ui/types'; declare const columns: KanbanColumn[]; -// The board document. `type` is the only required member — `columns`, -// `onCardMove` and `className` are all optional. The annotation is the type -// this package ships, so each member below is compiled against it rather than -// read as prose: a `columns` array of the wrong shape, or an `onCardMove` -// whose parameters drift from the shipped signature, fails here. An unknown -// key does not — `KanbanSchema` extends `BaseSchema`, whose index signature -// deliberately accepts type-specific extensions, so the compiler is not what -// catches a misspelt board key. -const board: KanbanSchema = { - type: 'kanban', +// The board document. `type` and `groupBy` are required, and so is ONE record +// source — `bind`, `data` or `objectName`. `columns` and `className` are +// optional. +// +// ⚠️ `onCardMove` is NOT a document key: it is a React prop the host supplies +// (JSON has no function value), which is why it is spelled with explicit +// parameter types below rather than inferred from the annotation. The +// annotation is the type this package's renderer consumes; an unknown key does +// not fail it — `ObjectKanbanSchema` extends `BaseSchema`, whose index +// signature deliberately accepts type-specific extensions, so the compiler is +// not what catches a misspelt board key. +const board: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', // required — the field that makes the lanes + data: [], // one record source is required columns, // Array of columns - onCardMove: (cardId, fromColumnId, toColumnId, newIndex) => { - // see "Example with Callbacks" below - }, className: 'h-full', // Tailwind classes }; +// Supplied by the React host, never authored in JSON: +const onCardMove = (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => { + // see "Example with Callbacks" below + void [cardId, fromColumnId, toColumnId, newIndex]; +}; + // Column structure interface KanbanColumn { id: string; @@ -197,17 +211,22 @@ pnpm build ## Example with Callbacks ```typescript -import type { KanbanColumn, KanbanSchema } from '@object-ui/plugin-kanban'; +import type { KanbanColumn } from '@object-ui/plugin-kanban'; +import type { ObjectKanbanSchema } from '@object-ui/types'; declare const columns: KanbanColumn[]; -const schema: KanbanSchema = { - type: 'kanban', +const schema: ObjectKanbanSchema = { + type: 'object-kanban', + groupBy: 'status', + data: [], columns, - onCardMove: (cardId, fromColumnId, toColumnId, newIndex) => { - console.log(`Card ${cardId} moved from ${fromColumnId} to ${toColumnId} at index ${newIndex}`); - // Update your backend or state here - } +}; + +// The host supplies the handler as a React prop — JSON has no function value. +const onCardMove = (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => { + console.log(`Card ${cardId} moved from ${fromColumnId} to ${toColumnId} at index ${newIndex}`); + // Update your backend or state here }; ``` diff --git a/packages/plugin-kanban/src/KanbanRenderer.uncolumned.test.tsx b/packages/plugin-kanban/src/KanbanRenderer.uncolumned.test.tsx index 7e42c98e2d..19b7125bee 100644 --- a/packages/plugin-kanban/src/KanbanRenderer.uncolumned.test.tsx +++ b/packages/plugin-kanban/src/KanbanRenderer.uncolumned.test.tsx @@ -40,7 +40,10 @@ describe('KanbanRenderer — uncategorized lane (#2792)', () => { render( { render( = ({ // CLOSED, not open — do not re-open it as a cleanup. If bucket-vocabulary // unification ever becomes a product direction that is a fresh ruling, // with visual-regression evidence across all four surfaces in one stroke. - // `navigation` is DECLARED on `KanbanSchema` since objectui#7742 (gantt - // precedent objectui#5903), so this read is typed rather than cast. It stayed - // `(schema as any)` for exactly as long as no schema face named the key. + // ⚠️ `navigation` was DECLARED on `KanbanSchema` by objectui#7742 (gantt + // precedent objectui#5903). That arm RETIRED with the bare `kanban` node key + // (objectui#8802), and the surviving `ObjectKanbanSchema` face never declared + // the key — so on an `object-kanban` document this read has ALWAYS ridden + // `BaseSchema`'s `[key: string]: any`, exactly as `filter` does. ⛔ Nothing + // about an `object-kanban` board changed here; what went is the only face + // that ever declared the key, and it only ever judged `kanban` documents. + // The designer face still declares it — `OBJECT_KANBAN_INPUTS` (`index.tsx`). + // Reported on the retirement PR as a follow-up for the `object-kanban` face. const navConfig = schema.navigation ?? { mode: 'drawer' }; // When this kanban is embedded in an ObjectView, the parent provides // `onRowClick`/`onCardClick` and owns the unified record-detail overlay. diff --git a/packages/plugin-kanban/src/__tests__/filterIsDeclaredInput-7712.test.ts b/packages/plugin-kanban/src/__tests__/filterIsDeclaredInput-7712.test.ts index 734126c139..358acec10f 100644 --- a/packages/plugin-kanban/src/__tests__/filterIsDeclaredInput-7712.test.ts +++ b/packages/plugin-kanban/src/__tests__/filterIsDeclaredInput-7712.test.ts @@ -75,10 +75,17 @@ import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; // bounded window). import '../index'; -/** The two tags this one renderer is published under. */ +/** + * The tag this one renderer is published under. + * + * ⚠️ It was a LIST OF TWO — `object-kanban` and `view:kanban` — until + * objectui#8802 retired the bare `kanban` node type key (maintainer ruling + * 2026-09-09). The `it.each` shape is deliberately KEPT over the one survivor: + * the rows below are per-(tag, key), and collapsing them to bare `it`s would + * make re-adding a tag a rewrite rather than a one-line edit. + */ const KANBAN_TAGS = [ { label: 'object-kanban', type: 'object-kanban', namespace: 'plugin-kanban' }, - { label: 'view:kanban', type: 'kanban', namespace: 'view' }, ] as const; /** A filter in the JSON-rules form `ObjectKanban` forwards as `$filter`. */ diff --git a/packages/plugin-kanban/src/__tests__/kanban-family-registry-keys-retired-8257.test.ts b/packages/plugin-kanban/src/__tests__/kanban-family-registry-keys-retired-8257.test.ts new file mode 100644 index 0000000000..f6a9cff7ba --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/kanban-family-registry-keys-retired-8257.test.ts @@ -0,0 +1,145 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The kanban family converged on ONE registered node type key (objectui#8257 + * for `kanban-ui` / `kanban-enhanced`, objectui#8802 for the bare `kanban` — + * both maintainer rulings, 2026-09-09). + * + * ## Why the three retirements are pinned in ONE file + * + * They are one convergence, not three coincidences, and the claim that matters + * is about the SET: exactly one key resolves. A file per key would let the set + * grow back a member without any single file going red. + * + * ## The two mechanisms, kept apart on purpose + * + * ⚠️ `kanban-ui` and `kanban-enhanced` were REGISTRATION-ONLY: measured + * whole-repo, no schema face in `@object-ui/types` ever declared either name as + * a component node type, so unregistering IS the whole retirement. The bare + * `kanban` key DID have a declared arm, so its retirement needed a NAMED + * REFUSAL as well — that half lives in `@object-ui/types` + * (`__tests__/bare-kanban-node-key-retired-8802.test.ts`) and is deliberately + * not restated here. + * + * ⛔ Do not "simplify" this file by asserting the refusal here too: this + * package cannot see the union the CLI applies, and a second copy of the claim + * is a second thing to keep true. + * + * ## ⭐ objectui#8818 — what retiring `kanban-ui` closes, and what it does not + * + * `SchemaRenderer` strips a fixed enumerated metadata list and spreads the rest + * as React props. `objectFields` is not on that list, and `KanbanRenderer` — + * the component `kanban-ui` resolved to — declares `objectFields` as a real + * prop (objectui#7742). An AUTHORED `objectFields` therefore reached the + * predicate layer on that entry with no schema face declaring or judging it. + * With the registration gone no authored node can reach `KanbanRenderer` + * through the registry at all, which closes that path. + * + * ⚠️ It closes the ENTRY, ⛔ NOT the CLASS: `SchemaRenderer` still spreads every + * unstripped key, so the hole returns the moment another registered renderer + * declares an `objectFields` prop. objectui#8818's option (a) — stripping at + * the `SchemaRenderer` boundary — is what would close the class, and it is + * still open. Suite 3 pins the entry half so nobody reads the class as closed. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ComponentRegistry } from '@object-ui/core'; +import { KanbanRenderer, kanbanComponents } from './../index'; +import './../index'; + +const INDEX_TSX = join(dirname(fileURLToPath(import.meta.url)), '..', 'index.tsx'); + +/** The three retired keys, and the one that survives. */ +const RETIRED_KEYS = ['kanban', 'kanban-ui', 'kanban-enhanced'] as const; +const SURVIVOR = 'object-kanban'; + +describe('suite 1 — exactly one kanban node type key resolves', () => { + it.each(RETIRED_KEYS)('`%s` is NOT registered, under its bare spelling', (key) => { + expect(ComponentRegistry.has(key)).toBe(false); + }); + + it('`view:kanban` and `plugin-kanban:kanban-ui` — the namespaced spellings — are gone too', () => { + // `register(key, C, { namespace })` stores BOTH the namespaced key and a + // bare fallback, so a retirement that only removed one spelling would leave + // the other resolving. Measured through `getConfig`, which takes the + // namespace explicitly. + expect(ComponentRegistry.getConfig('kanban', 'view')).toBeFalsy(); + expect(ComponentRegistry.getConfig('kanban-ui', 'plugin-kanban')).toBeFalsy(); + expect(ComponentRegistry.getConfig('kanban-enhanced', 'plugin-kanban')).toBeFalsy(); + }); + + it('FIRING CONTROL — the survivor still resolves under both of its spellings', () => { + // Without this every assertion above would also pass against a registry + // that had failed to load this package at all. + expect(ComponentRegistry.has(SURVIVOR)).toBe(true); + expect(ComponentRegistry.getConfig(SURVIVOR, 'plugin-kanban')).toBeTruthy(); + }); +}); + +describe('suite 2 — the manual-integration map publishes the same one key', () => { + it('`kanbanComponents` names only the survivor', () => { + // A host that mounted the map's keys into its own registry would otherwise + // re-teach the retired spellings under a name this package still publishes. + expect(Object.keys(kanbanComponents)).toEqual([SURVIVOR]); + }); +}); + +describe('suite 3 — objectui#8818: the `objectFields` ENTRY is closed, the CLASS is not', () => { + it('no registry key resolves to `KanbanRenderer` any more — the authored-`objectFields` path is gone', () => { + // `KanbanRenderer` is still exported and still rendered (by `ObjectKanban`, + // which supplies `objectFields` itself as a prop). What is gone is any way + // for an AUTHOR to reach it: nothing in the registry resolves to it. + const src = readFileSync(INDEX_TSX, 'utf8'); + const registeredComponents = [ + ...src.matchAll(/ComponentRegistry\.register\(\s*'([^']+)'\s*,\s*(\w+)/g), + ].map((m) => ({ key: m[1], component: m[2] })); + + // Anti-vacuity for the extraction: it found the registrations it claims to + // read. A regex that matched nothing would make the filter below empty and + // the assertion green forever. + expect(registeredComponents.length).toBeGreaterThan(0); + expect(registeredComponents.map((r) => r.key)).toContain(SURVIVOR); + + expect(registeredComponents.filter((r) => r.component === 'KanbanRenderer')).toEqual([]); + // And through the live registry, not only off disk. + expect(ComponentRegistry.get(SURVIVOR)).not.toBe(KanbanRenderer); + }); + + it('⚠️ the CLASS is still open — this is recorded, not asserted shut', () => { + // `SchemaRenderer`'s stripped-metadata list is in `@object-ui/react` and + // still does not name `objectFields`. This package cannot fix that and does + // not claim to; the assertion here is only that `KanbanRenderer` still + // DECLARES the prop, which is the half that would make the hole return the + // moment any registered renderer takes it again. + const src = readFileSync(INDEX_TSX, 'utf8'); + expect(src).toContain('objectFields?: unknown;'); + }); +}); + +describe('suite 4 — `kanban-enhanced`\'s three keys lost their only authorable surface', () => { + it('none of `onColumnToggle` / `enableVirtualScrolling` / `virtualScrollThreshold` is declared anywhere in this package', () => { + // objectui#8257's own subject, CONFIRMED rather than assumed: the three + // keys were read by the `kanban-enhanced` registration and declared as its + // `inputs`, with no schema arm anywhere. With the registration gone they + // have no authorable surface at all — which resolves the card by removing + // its subject. + const src = readFileSync(INDEX_TSX, 'utf8'); + const declaredInputNames = [...src.matchAll(/name:\s*'([^']+)'/g)].map((m) => m[1]); + + // Anti-vacuity: the extraction reads a real, populated input list. + expect(declaredInputNames).toContain('groupBy'); + + for (const key of ['onColumnToggle', 'enableVirtualScrolling', 'virtualScrollThreshold']) { + expect(declaredInputNames, `\`${key}\` is still a declared input`).not.toContain(key); + } + }); +}); diff --git a/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx index e356e4ab20..29a00ffa9f 100644 --- a/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx +++ b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx @@ -25,34 +25,52 @@ * holds its length constant and no assertion is derived from the read site. * Suite 3 below is that missing derivation. * + * ## ⭐ What the 2026-09-09 family retirement did to this file + * + * Three of the four registrations this file measured RETIRED — the bare + * `kanban` node key (objectui#8802) and the `kanban-ui` / `kanban-enhanced` + * variants (objectui#8257) — and the zod `'kanban'` arm retired with the first + * of them. ⛔ The MECHANISM the file exists to guard did not retire with them, + * so the legs were re-based rather than deleted: + * + * - the per-registration reachability probes collapse to the ONE surviving + * key, `'object-kanban'`; + * - `KanbanRenderer` is still a live component (`ObjectKanban` renders it), + * so its identity-forward probe is kept and driven DIRECTLY instead of + * through a registry key that no longer exists; + * - suite 3's derivation survives intact and its VERDICT flips, which is the + * honest reading: the three forwarded keys had exactly one declaring face, + * the `'kanban'` arm, and with the arm gone they have NONE. Recorded as an + * assertion so the state cannot drift back silently, and reported on the + * retirement PR rather than repaired here — declaring them on + * `ObjectKanbanSchema` would WIDEN a published accept set, which is a + * ruling and not a repair. + * * ## Suite 1 — runtime reachability, per registration * * The two lazy board chunks are replaced by prop recorders; three spies are * authored on the schema; the question is which of them reach the board. * - * - `'kanban-ui'` (`KanbanRenderer`, `../index.tsx`): all three arrive BY + * - `KanbanRenderer` rendered directly (`../index.tsx`): all three arrive BY * IDENTITY. This is the probe whose controls are lit — `onCardMove` and - * `onQuickAdd`, both kept as #6124 runtime slots by this PR, come out live - * on it, and `onCardClick` comes out live beside them off the same - * forward block. - * - `'kanban'` and `'object-kanban'` (both registered to - * `ObjectKanbanRenderer` → `ObjectKanban` → `KanbanRenderer`): - * `onQuickAdd` arrives by identity — the lit control ON THIS KEY, proving - * the schema-spread channel reaches the board here — while `onCardClick` - * AND `onCardMove` are BOTH replaced by `ObjectKanban`'s own functions - * (`ObjectKanban.tsx`, the `` literal). The two keys - * have the SAME reachability on this key, so "`ObjectKanban` overrides it" - * cannot retire one without retiring the other. - * - `'kanban-enhanced'`: `onCardMove` / `onQuickAdd` arrive; `onCardClick` is - * not forwarded there at all. + * `onQuickAdd`, both kept as #6124 runtime slots, come out live on it, and + * `onCardClick` comes out live beside them off the same forward block. + * - `'object-kanban'` (registered to `ObjectKanbanRenderer` → `ObjectKanban` + * → `KanbanRenderer`): `onQuickAdd` arrives by identity — the lit control + * ON THIS KEY, proving the schema-spread channel reaches the board here — + * while `onCardClick` AND `onCardMove` are BOTH replaced by + * `ObjectKanban`'s own functions (`ObjectKanban.tsx`, the + * `` literal). The two keys have the SAME + * reachability here, so "`ObjectKanban` overrides it" cannot retire one + * without retiring the other. * * ## Suite 2 — the prop channel, which only `onCardClick` has * * `SchemaRenderer` spreads every non-metadata schema key as a React prop * (`packages/react/src/SchemaRenderer.tsx`, the `...componentProps` line of its * `createElement` call), and `ObjectKanbanComponentProps` DECLARES - * `onCardClick` — there is no `onCardMove` prop. So on the `'kanban'` key an + * `onCardClick` — there is no `onCardMove` prop. So on the `'object-kanban'` key an * authored `onCardClick` is not merely overridden: `ObjectKanban`'s own * wrapper CALLS it. Suite 2 invokes the function the board was handed and * measures that the authored one runs, with the identity check from suite 1 as @@ -64,10 +82,9 @@ * ## Suite 3 — derived from the read site, so a deletion cannot hide * * The `schema.on*` reads inside `KanbanRenderer`'s body are extracted from - * `../index.tsx` and each is required to be a declared member of the zod - * `'kanban'` arm carrying the RUNTIME SLOT guidance. Nothing here is a list a - * re-key can hold constant: remove a forwarded key from the arm and the - * assertion goes red naming it. + * `../index.tsx`. Nothing here is a list a re-key can hold constant: the read + * site is measured, and each key's declaration status on the surviving + * `object-kanban` face is measured beside it. * * ## Predictions, written before the first run (red-first) * @@ -86,7 +103,8 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; -import { KanbanSchema as KanbanZod } from '@object-ui/types/zod'; +import { ObjectKanbanSchema as ObjectKanbanZod } from '@object-ui/types/zod'; +import { KanbanRenderer } from '../index'; import '../index'; /** Every props object either board implementation was rendered with, in order. */ @@ -144,6 +162,26 @@ async function boardPropsFor(type: string, log: 'impl' | 'enhanced', schemaKeys: return lastBoardProps(log, before, unmount); } +/** + * Render `KanbanRenderer` DIRECTLY with an authored board and return the props + * its board implementation was handed last. + * + * ⚠️ Direct rather than through a registry key, and that is a re-based + * instrument rather than a weakened one: `kanban-ui` — the key this probe used + * to reach the component through — RETIRED (objectui#8257). The component is + * unchanged, still exported, and still rendered by `ObjectKanban`, so the + * forward block being measured is identical; what is gone is one lookup hop. + */ +async function kanbanRendererProps(schemaKeys: Record) { + const before = recorded.impl.length; + const { unmount } = render( + + + , + ); + return lastBoardProps('impl', before, unmount); +} + /** * The production path: author the key on the DOCUMENT and let `SchemaRenderer` * route it. This is the channel that turns an authored `onCardClick` into a @@ -160,9 +198,13 @@ async function boardPropsViaSchemaRenderer(schema: Record) { } describe('which authored handler keys reach a registered kanban board (objectui#7664)', () => { - it("`'kanban-ui'` (KanbanRenderer) forwards onCardClick, onCardMove and onQuickAdd by identity", async () => { + it('KanbanRenderer forwards onCardClick, onCardMove and onQuickAdd by identity', async () => { + // ⚠️ Driven DIRECTLY rather than through a registry key: `kanban-ui` + // retired (objectui#8257). The component is unchanged and still live — + // `ObjectKanban` renders it — so the forward block this leg measures is the + // same one it always measured. const spies = authored(); - const props = await boardPropsFor('kanban-ui', 'impl', spies); + const props = await kanbanRendererProps(spies); expect({ onCardClick: props.onCardClick === spies.onCardClick, onCardMove: props.onCardMove === spies.onCardMove, @@ -170,7 +212,7 @@ describe('which authored handler keys reach a registered kanban board (objectui# }).toEqual({ onCardClick: true, onCardMove: true, onQuickAdd: true }); }); - it.each(['kanban', 'object-kanban'])( + it.each(['object-kanban'])( "`'%s'` (ObjectKanban) passes onQuickAdd through and replaces BOTH onCardClick and onCardMove with its own", async (type) => { const spies = authored(); @@ -191,23 +233,25 @@ describe('which authored handler keys reach a registered kanban board (objectui# }, ); - it("`'kanban-enhanced'` forwards onCardMove and onQuickAdd; onCardClick is not forwarded there", async () => { - const spies = authored(); - const props = await boardPropsFor('kanban-enhanced', 'enhanced', spies); + it('the three RETIRED registrations resolve to nothing, and the survivor still does', () => { + // Replaces the `'kanban-enhanced'` reachability leg, whose subject retired + // (objectui#8257). Stated as a reading with its own firing control rather + // than as three bare `false`s. expect({ - onCardMove: props.onCardMove === spies.onCardMove, - onQuickAdd: props.onQuickAdd === spies.onQuickAdd, - onCardClick: props.onCardClick, - }).toEqual({ onCardMove: true, onQuickAdd: true, onCardClick: undefined }); + kanban: ComponentRegistry.has('kanban'), + kanbanUi: ComponentRegistry.has('kanban-ui'), + kanbanEnhanced: ComponentRegistry.has('kanban-enhanced'), + objectKanban: ComponentRegistry.has('object-kanban'), + }).toEqual({ kanban: false, kanbanUi: false, kanbanEnhanced: false, objectKanban: true }); }); }); describe("ObjectKanban's own onCardClick wrapper CALLS the authored handler (objectui#7664)", () => { - it("on the `'kanban'` key, an onCardClick authored on the DOCUMENT is run by the wrapper", async () => { + it("on the `'object-kanban'` key, an onCardClick authored on the DOCUMENT is run by the wrapper", async () => { const onCardClick = vi.fn(); const card = { id: '1', title: 'One' }; const props = await boardPropsViaSchemaRenderer({ - type: 'kanban', + type: 'object-kanban', columns: STATIC_COLUMNS, onCardClick, }); @@ -221,14 +265,18 @@ describe("ObjectKanban's own onCardClick wrapper CALLS the authored handler (obj }); }); -describe("every handler key KanbanRenderer forwards is declared on the 'kanban' arm (objectui#7664)", () => { +describe('the handler keys KanbanRenderer forwards, and where they are declared (objectui#7664)', () => { const INDEX_TSX = join(dirname(fileURLToPath(import.meta.url)), '..', 'index.tsx'); /** The `schema.on*` reads inside the `KanbanRenderer` component body, read off the source. */ function forwardedByKanbanRenderer(): string[] { const src = readFileSync(INDEX_TSX, 'utf8'); const start = src.indexOf('export const KanbanRenderer'); - const end = src.indexOf("ComponentRegistry.register(\n 'kanban-ui'", start); + // The component body ends at the `kanban-ui` retirement tombstone that + // follows it. The `it` below is this extraction's own anti-vacuity control: + // a marker that stopped matching would make `end` -1 and throw, and a + // marker that matched too early would drop keys from the measured set. + const end = src.indexOf('⛔ The `kanban-ui` node type key is RETIRED', start); if (start === -1 || end === -1) throw new Error('KanbanRenderer body not found in index.tsx'); return [...src.slice(start, end).matchAll(/schema\.(on[A-Z][A-Za-z0-9]*)\b/g)] .map((m) => m[1]) @@ -239,15 +287,26 @@ describe("every handler key KanbanRenderer forwards is declared on the 'kanban' expect(forwardedByKanbanRenderer()).toEqual(['onCardClick', 'onCardMove', 'onQuickAdd']); }); - it('each forwarded key is a declared arm member carrying the RUNTIME SLOT guidance — a bare deletion goes red here', () => { - const shape = KanbanZod.shape as Record; - const readings = forwardedByKanbanRenderer().map((key) => ({ - key, - declared: key in shape, - guidance: shape[key]?.description?.includes('RUNTIME SLOT') ?? false, - })); - expect(readings).toEqual( - ['onCardClick', 'onCardMove', 'onQuickAdd'].map((key) => ({ key, declared: true, guidance: true })), + it('⚠️ none of the three is declared on the surviving `object-kanban` face — the retirement moved this reading, and it is recorded rather than repaired', () => { + // ⭐ This leg USED to assert `declared: true, guidance: true` against the + // zod `'kanban'` arm, which carried all three as objectui#6124 RUNTIME + // SLOTS. objectui#8802 retired that arm with the bare node key, and the + // surviving `ObjectKanbanSchema` never declared them — so the honest + // reading today is the opposite one, and it is pinned so it cannot drift + // back in silence. + // + // ⛔ NOT repaired here: adding the three to `ObjectKanbanSchema` WIDENS a + // published accept set, which is a ruling, not a repair. Reported on the + // retirement PR for the maintainer. + const shape = ObjectKanbanZod.shape as Record; + const forwarded = forwardedByKanbanRenderer(); + expect(forwarded.map((key) => ({ key, declared: key in shape }))).toEqual( + ['onCardClick', 'onCardMove', 'onQuickAdd'].map((key) => ({ key, declared: false })), ); + // Firing control on the SAME instrument: a key this face really does + // declare reads `true`, so the three `false`s above are readings and not a + // shape lookup that answers `false` to everything (an unwrapped + // `.superRefine()` result, say, whose `.shape` is undefined). + expect('groupBy' in shape).toBe(true); }); }); diff --git a/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts b/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts index aa53adc52f..f8beee6578 100644 --- a/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts +++ b/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts @@ -7,22 +7,26 @@ */ /** - * The renderer's face IS the declared `'kanban'` arm (objectui#7664, maintainer - * ruling (a), 2026-09-05). + * The surviving renderer's face IS a declared arm (objectui#7664, maintainer + * ruling (a), 2026-09-05 — re-based on the 2026-09-09 family retirement). * - * ## Why this pin lives here + * ## What objectui#8802 / objectui#8257 did to this file, and why it is a + * rewrite rather than a deletion * - * `@object-ui/types` now declares the plugin dialect and this package imports - * it back (`../types` re-exports `KanbanSchema` and its four companions), so - * the ruling's "the four registered renderers' props still type-check against - * the declared schema" is a claim about THIS package's prop types — and this is - * the only package that can see both sides: the declaration through the - * workspace dependency, the renderers through `../index`. + * objectui#7664's ruling was about FOUR registered kanban keys and one shared + * declaration: `'kanban'`, `'kanban-ui'`, `'kanban-enhanced'`, `'object-kanban'`. + * Three of the four RETIRED on 2026-09-09 (objectui#8802 for the bare `kanban` + * node key, objectui#8257 for the other two), and `KanbanSchema` — the dialect + * this file was written to pin — retired with the first of them. * - * The retired objectui#7645 pin that sat here asserted two dialects still - * existed (`Equal` was `false`). With the - * declarative trio retired that pin has no second operand; what replaces it is - * the stronger claim the ruling makes — ONE declaration. + * ⛔ The claim objectui#7664 established did NOT retire with them: "the + * registered renderer's props type-check against the declared schema, and the + * declaration lives in `@object-ui/types` rather than being re-declared here." + * That claim is now about ONE key, and this file asserts it about that key. The + * legs that named a retired key moved to + * `kanban-family-registry-keys-retired-8257.test.ts`, which owns the retirement + * itself — including the anti-vacuity control that the surviving key is still + * registered. * * ## The instrument * @@ -32,17 +36,22 @@ * package's `type-check` script. That project sets `"paths": {}`, so * `@object-ui/types` resolves through the workspace dependency to * `packages/types/dist/index.d.ts` — BUILD `@object-ui/types` before believing - * either colour this file reports. The one runtime assertion — the four - * registrations exist — is the anti-vacuity control for the prop-type pins: + * either colour this file reports. The one runtime assertion — the surviving + * registration exists — is the anti-vacuity control for the prop-type pins: * a prop type is only worth pinning for a renderer that is registered. */ import { describe, it, expect } from 'vitest'; import { ComponentRegistry } from '@object-ui/core'; -import type { SchemaRegistry, KanbanSchema as DeclaredKanbanSchema, KanbanColumn as DeclaredKanbanColumn, KanbanCard as DeclaredKanbanCard } from '@object-ui/types'; -import type { KanbanSchema, KanbanColumn, KanbanCard } from '../types'; +import type { + SchemaRegistry, + ObjectQLComponentSchema, + ObjectKanbanSchema as DeclaredObjectKanbanSchema, + KanbanColumn as DeclaredKanbanColumn, + KanbanCard as DeclaredKanbanCard, +} from '@object-ui/types'; +import type { KanbanColumn, KanbanCard } from '../types'; import type { ObjectKanbanComponentProps } from '../ObjectKanban'; -import type { KanbanRendererProps } from '../index'; import '../index'; /* -------------------------------------------------------------------------- */ @@ -55,67 +64,65 @@ type IsAny = 0 extends 1 & T ? true : false; // Non-vacuity controls: `any` on either side would satisfy every `extends` // below while checking nothing. -type _PluginFaceIsReal = Assert, false>>; +type _FaceIsReal = Assert, false>>; type _RegistryIsReal = Assert, false>>; -// 1. ONE declaration: what this package exports as `KanbanSchema` / -// `KanbanColumn` / `KanbanCard` is the `@object-ui/types` declaration, not a -// structurally-equal copy. `Equal` is invariant, so a re-declared twin that -// drifted by one member turns this red. -type _SchemaIsTheDeclaredOne = Assert>; +// 1. ONE declaration: what this package exports as `KanbanColumn` / `KanbanCard` +// is the `@object-ui/types` declaration, not a structurally-equal copy. +// `Equal` is invariant, so a re-declared twin that drifted by one member +// turns this red. (`KanbanSchema` was the third member of this row until +// objectui#8802 retired it with the bare `kanban` node key; the two that +// remain are consumed by `KanbanImpl`, `CardTemplates` and `useColumnWidths` +// and are NOT retired.) type _ColumnIsTheDeclaredOne = Assert>; type _CardIsTheDeclaredOne = Assert>; -// 2. The map that calls itself the Single Source of Truth names the same type -// the registered renderer consumes — the objectui#7645 defect, closed by the -// ruling rather than by weakening the entry. -type _RegistryEntryIsThisFace = Assert>; +// 2. The map that calls itself the Single Source of Truth no longer offers the +// RETIRED key. objectui#7645's defect was that `SchemaRegistry['kanban']` +// named a type the registered renderer did not consume; objectui#8802 closed +// it the other way, by retiring the key. +// +// ⚠️ Measured rather than assumed, and NOT repaired here: this map has never +// carried an `'object-kanban'` entry either, so the positive half of this pin +// cannot be spelled against it. The union is where the surviving face is +// reachable, so that is where the positive leg is asserted. +type _RetiredKeyIsGoneFromTheMap = Assert>; +// Non-vacuity: a live sibling key IS still in the map, so the `false` above +// is a reading and not a `keyof` that answers `false` to everything. +type _MapStillHasLiveKeys = Assert>; +// And the surviving face is the one the ObjectQL union selects for the key. +type _UnionSelectsTheDeclaredFace = Assert< + Equal, DeclaredObjectKanbanSchema> +>; -// 3. The renderers' props type-check against the declared schema: -// - `ObjectKanban` (behind `ObjectKanbanRenderer`, registered for `'kanban'` -// AND `'object-kanban'`) ACCEPTS the declared face as its `schema`. +// 3. The renderer's props type-check against the declared schema: `ObjectKanban` +// (behind `ObjectKanbanRenderer`, now registered for `'object-kanban'` ALONE) +// accepts the declared face as its `schema`. // -// ⚠️ This leg read `Equal<…, KanbanSchema>` until objectui#7322 item ②. -// Identity was never what the ruling claimed — the ruling's words are that -// "the four registered renderers' props still type-check against the -// declared schema", i.e. assignability, which is the same form the -// `kanban-ui` leg below already uses and for the same reason. Identity was -// merely the shape the prop happened to have while it named ONE arm, and -// that was itself the defect objectui#7322 item ② filed: the same renderer -// is registered for `'object-kanban'` too, whose declared node type is -// `ObjectKanbanSchema`, and no such node was assignable to the prop. The -// prop is now the union of the two registered keys' declared types, so the -// ruling's claim holds on this arm and now holds on the other one as well. -// The union's own honesty is pinned in -// `object-kanban-component-props-7322.test.ts`, which derives the -// registered key set from `../index` off disk; this leg keeps guarding -// what objectui#7664 asserted — that THIS declaration is the one the -// renderer consumes. -type _ObjectKanbanTakesTheDeclaredFace = Assert; -// - `KanbanRenderer` (`'kanban-ui'`) accepts a declared board — its inline -// prop schema is a looser projection (`columns?: Array`), so the -// claim is assignability, not identity; -type _KanbanUiAcceptsTheDeclaredFace = Assert; -// - `'kanban-enhanced'` is registered as `({ schema }: { schema: any })` and -// accepts anything by construction — nothing to pin, and pinning `any` -// would be the vacuity the controls above exclude. +// ⚠️ Assignability, not identity, and deliberately so — that is the form +// objectui#7322 item ② settled on when the prop served two keys. It stays +// assignability now that it serves one, because the ruling's own words are +// that the props "type-check against the declared schema"; identity was only +// ever the shape the prop happened to have. +type _ObjectKanbanTakesTheDeclaredFace = Assert< + DeclaredObjectKanbanSchema extends ObjectKanbanComponentProps['schema'] ? true : false +>; // 4. The declared face is still a tagged node, and a raw record field on a // card still reads `any` (the open-record index signature survived the move). -type _FaceIsTagged = Assert>; +type _FaceIsTagged = Assert>; type _CardIsAnOpenRecord = Assert, true>>; -describe('the registered kanban renderers consume the declared arm (objectui#7664)', () => { +describe('the registered kanban renderer consumes the declared arm (objectui#7664)', () => { it('is pinned at compile time', () => { expect(true).toBe(true); }); - it('all four registrations the ruling counts exist — the prop-type pins above are about live renderers', () => { - for (const type of ['kanban', 'kanban-ui', 'kanban-enhanced', 'object-kanban']) { - expect(ComponentRegistry.has(type), `\`${type}\` is not registered`).toBe(true); - } - // `'kanban'` and `'object-kanban'` are the SAME renderer, which is why one - // prop-type pin (`ObjectKanbanComponentProps`) covers both keys. - expect(ComponentRegistry.get('kanban')).toBe(ComponentRegistry.get('object-kanban')); + it('the ONE surviving registration exists — the prop-type pins above are about a live renderer', () => { + expect(ComponentRegistry.has('object-kanban'), '`object-kanban` is not registered').toBe(true); + // Firing control on the same instrument: a name nothing registers answers + // `false`, so the `true` above is a reading rather than a matcher that + // agrees with everything. + expect(ComponentRegistry.has('zzz-not-a-type')).toBe(false); }); }); diff --git a/packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts b/packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts index 6718981d4d..e269cc27f8 100644 --- a/packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts +++ b/packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts @@ -12,8 +12,8 @@ * * ## The defect this pins closed * - * `ObjectKanbanRenderer` is registered under two keys — `'object-kanban'` and - * `'kanban'` — and the two keys have different declared node types + * `ObjectKanbanRenderer` used to be registered under two keys — + * `'object-kanban'` and `'kanban'` — with different declared node types * (`ObjectKanbanSchema`, `type: 'object-kanban'`; `KanbanSchema`, * `type: 'kanban'`). The prop named `KanbanSchema` alone, so no * `object-kanban` node was assignable to the component that renders it. The @@ -22,6 +22,17 @@ * `as never`, and the `titleField` read inside `ObjectKanban.tsx` was spelled * `(schema as any).titleField` because the named arm does not declare it. * + * ## ⭐ What objectui#8802 changed, and why this pin got STRONGER rather than weaker + * + * The bare `kanban` node key RETIRED (maintainer ruling 2026-09-09) and + * `KanbanSchema` retired with it, so the component is registered under ONE key + * again and the prop names ONE arm again. ⛔ That is not a revert of item ②: + * the invariant this file pins is "the prop union EQUALS the registered key + * set", and it is that invariant — not the number two — that keeps the two + * halves from drifting. The derivation below is what makes the difference + * legible: it reads the registrations off disk, so a retirement has to move + * BOTH sides or this file names the survivor that was left behind. + * * ## Why the derivation, and not a list of two strings * * A hand-written pair of keys is exactly what the defect survived under: the @@ -48,7 +59,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ComponentRegistry } from '@object-ui/core'; -import type { KanbanSchema, ObjectKanbanSchema, ObjectGridSchema } from '@object-ui/types'; +import type { ObjectKanbanSchema, ObjectGridSchema } from '@object-ui/types'; import type { ObjectKanbanComponentProps } from '../ObjectKanban'; import { ObjectKanbanRenderer } from '../index'; import '../index'; @@ -67,13 +78,9 @@ type Prop = ObjectKanbanComponentProps['schema']; // while checking nothing — and `any` is precisely what this member would have // been widened to by the lazy fix. type _PropIsNotAny = Assert, false>>; -type _KanbanArmIsReal = Assert, false>>; type _ObjectKanbanArmIsReal = Assert, false>>; -// 1. Both registered node types are accepted. The second leg is the one this -// card added; the first is what objectui#7664 already required and must not -// regress. -type _AcceptsTheKanbanNode = Assert; +// 1. The registered node type is accepted. type _AcceptsTheObjectKanbanNode = Assert; // 2. And nothing else. `ObjectGridSchema` is the control: a sibling @@ -83,10 +90,16 @@ type _AcceptsTheObjectKanbanNode = Assert>; -// 3. The discriminant carries exactly the two registered keys. This is the +// 3. The discriminant carries exactly the registered key set. This is the // claim suite 2 checks the other half of: here, what the TYPE says; there, -// what `index.tsx` actually registers. -type _DiscriminantIsTheTwoKeys = Assert>; +// what `index.tsx` actually registers. ⚠️ It read `'kanban' | 'object-kanban'` +// until objectui#8802 retired the bare key; the equality — not the arity — +// is the invariant. +type _DiscriminantIsTheRegisteredKeys = Assert>; +// Non-vacuity for the line above: the RETIRED literal is no longer admitted. +// Without this leg an accidental widening back to `string` would still satisfy +// nothing visible here. +type _RetiredKanbanLiteralIsRefused = Assert>; /* -------------------------------------------------------------------------- */ /* Suite 2 — runtime, derived from the read site. */ @@ -108,13 +121,14 @@ describe('ObjectKanbanComponentProps.schema names every registered node type (ob expect(true).toBe(true); }); - it('index.tsx registers ObjectKanbanRenderer under exactly the two keys the prop union names', () => { - // Red when a third registration is added, or an existing key is renamed, - // without moving `ObjectKanbanComponentProps['schema']` to match. The - // literals here are the union's arms restated: `KanbanSchema['type']` is - // `'kanban'` and `ObjectKanbanSchema['type']` is `'object-kanban'`, which - // suite 1's `_DiscriminantIsTheTwoKeys` holds to the prop. - expect(registeredKeys()).toEqual(['kanban', 'object-kanban']); + it('index.tsx registers ObjectKanbanRenderer under exactly the keys the prop union names', () => { + // Red when a registration is added, or an existing key is renamed, without + // moving `ObjectKanbanComponentProps['schema']` to match. The literal here + // is the union restated: `ObjectKanbanSchema['type']` is `'object-kanban'`, + // which suite 1's `_DiscriminantIsTheRegisteredKeys` holds to the prop. + // ⚠️ It read `['kanban', 'object-kanban']` until objectui#8802 retired the + // bare key — and this line is what forced the prop to follow. + expect(registeredKeys()).toEqual(['object-kanban']); }); it('the extraction is lit — it finds the reader it claims to read, and no other renderer', () => { @@ -122,13 +136,22 @@ describe('ObjectKanbanComponentProps.schema names every registered node type (ob // return `[]` and a same-shaped `toEqual` against `[]` would pass forever. const keys = registeredKeys(); expect(keys.length).toBeGreaterThan(0); - // `'kanban-ui'` and `'kanban-enhanced'` are registered in the same file to - // OTHER renderers — the control that the pattern discriminates on the - // component, not merely on the word `register`. + // ⚠️ The control this leg used to run — `'kanban-ui'` / `'kanban-enhanced'` + // registered in the same file to OTHER renderers — RETIRED with those keys + // (objectui#8257). `KanbanRenderer` is still declared in the same file and + // is still NOT this renderer, so the discrimination the control existed to + // prove is asserted directly instead: the extraction must not pick up a + // `register(` call for a different component, and the retired keys must not + // resolve at all. expect(keys).not.toContain('kanban-ui'); expect(keys).not.toContain('kanban-enhanced'); - expect(ComponentRegistry.has('kanban-ui')).toBe(true); - expect(ComponentRegistry.has('kanban-enhanced')).toBe(true); + expect(ComponentRegistry.has('kanban-ui')).toBe(false); + expect(ComponentRegistry.has('kanban-enhanced')).toBe(false); + expect(ComponentRegistry.has('kanban')).toBe(false); + // Firing control for the three `false`s above — the same call answers + // `true` for the surviving key, so they are readings and not a registry + // that answers `false` to everything. + expect(ComponentRegistry.has('object-kanban')).toBe(true); }); it('every extracted key resolves to that renderer in the live registry', () => { diff --git a/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx b/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx index c5638d9f4b..5f215fffb9 100644 --- a/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx +++ b/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx @@ -84,8 +84,12 @@ const CARD = { id: 'c1', title: 'Painted card', owner: { _id: 'u1', name: 'Ann' /** The catalogue that makes `owner` collapsible. `lookup` is an expandable type. */ const FIELD_CATALOGUE = { owner: { type: 'lookup' } }; + // ⚠️ `object-kanban`, not the bare `kanban`: that node type key RETIRED + // (objectui#8802). The value is inert on this path — `KanbanRendererProps` + // types `schema.type` as `string` and nothing reads it — but a fixture that + // spells a retired key teaches it, and this file is read as an example. const BOARD = { - type: 'kanban', + type: 'object-kanban', columns: [{ id: 'todo', title: 'To Do', cards: [CARD] }], conditionalFormatting: RULE, } as const; diff --git a/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx b/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx index 65bc151f58..5a75f70c92 100644 --- a/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx +++ b/packages/plugin-kanban/src/__tests__/recordsSettledEmptyState-8827.test.tsx @@ -54,6 +54,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, act, cleanup } from '@testing-library/react'; import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; // Registers `object-kanban` and `kanban-ui`. +import { KanbanRenderer } from '../index'; import '../index'; /** @@ -276,18 +277,20 @@ describe('objectui#8827 — reverse: a genuinely empty board STILL announces', ( expect(find).not.toHaveBeenCalled(); }); - it('exit 5 — the schema-only `kanban-ui` entry has NO provider and DEFAULTS to settled', async () => { + it('exit 5 — the schema-only `KanbanRenderer` path has NO provider and DEFAULTS to settled', async () => { // No `ObjectKanban` on this path, so nothing supplies the signal. Its rows // arrive whole from their author and are settled by construction; the // context default is what says so. A default of `false` would leave every // authored board that happens to be empty silent forever. - render( - , - ); + // + // ⚠️ Driven by rendering `KanbanRenderer` DIRECTLY. It used to be reached + // through the `kanban-ui` registry key, which RETIRED (objectui#8257) — + // ⛔ and re-pointing this leg at `object-kanban` would have destroyed it + // rather than moved it: that key resolves to `ObjectKanbanRenderer`, which + // DOES mount `ObjectKanban` and therefore DOES supply the provider, so the + // "no provider" premise would be false and the assertion would pass for the + // wrong reason. The component is unchanged; only the lookup is gone. + render(); await waitFor(() => expect(emptyState()).not.toBeNull()); expect(emptyState()!.textContent).toContain('No cards'); diff --git a/packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts b/packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts index 8fd6d1f979..c8d504841d 100644 --- a/packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts +++ b/packages/plugin-kanban/src/__tests__/scalarKeysAreDeclaredAndHonoured-8201.test.ts @@ -70,10 +70,17 @@ import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; import { bucketCardsIntoColumns } from '../index'; import '../index'; -/** The two tags this one renderer is published under. */ +/** + * The tag this one renderer is published under. + * + * ⚠️ It was a LIST OF TWO — `object-kanban` and `view:kanban` — until + * objectui#8802 retired the bare `kanban` node type key (maintainer ruling + * 2026-09-09). The `it.each` shape is deliberately KEPT over the one survivor: + * the rows below are per-(tag, key), and collapsing them to bare `it`s would + * make re-adding a tag a rewrite rather than a one-line edit. + */ const KANBAN_TAGS = [ { label: 'object-kanban', type: 'object-kanban', namespace: 'plugin-kanban' }, - { label: 'view:kanban', type: 'kanban', namespace: 'view' }, ] as const; /** @@ -155,15 +162,20 @@ describe('objectui#8201 — object-kanban publishes the scalar keys it reads', ( expect(refusedByName({ objectName: 'task', bogusProp: 'x' })).toContain('bogusProp'); }); - it('both tags publish ONE shared list, so a hand-copy cannot drift', () => { - const [a, b] = KANBAN_TAGS.map( - ({ type, namespace }) => (ComponentRegistry.getConfig(type, namespace) as any)?.inputs, - ); - expect(a, 'object-kanban declares no inputs at all').toBeTruthy(); - expect(a.map((i: any) => i.name)).toEqual(b.map((i: any) => i.name)); - expect(a.map((i: any) => i.name)).toEqual( + it('⛔ the RETIRED `view:kanban` tag resolves to nothing, and the survivor still declares the list', () => { + // ⭐ This was "both tags publish ONE shared list, so a hand-copy cannot + // drift" (objectui#8201's row 5). Its second operand RETIRED with the bare + // `kanban` node type key (objectui#8802), so the sharing claim has nothing + // left to compare — and the honest replacement is the retirement itself, + // asserted with the surviving list as its firing control. + const survivor = (ComponentRegistry.getConfig('object-kanban', 'plugin-kanban') as any)?.inputs; + expect(survivor, 'object-kanban declares no inputs at all').toBeTruthy(); + expect(survivor.map((i: any) => i.name)).toEqual( expect.arrayContaining(DECLARED_SCALAR_KEYS.map(({ key }) => key)), ); + // The retired tag: gone from the registry under BOTH spellings it had. + expect(ComponentRegistry.getConfig('kanban', 'view')).toBeFalsy(); + expect(ComponentRegistry.has('kanban')).toBe(false); }); it('honoured — `groupBy` buckets records into the lane its value names', () => { diff --git a/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts b/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts index f09a527081..06c64600b0 100644 --- a/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts +++ b/packages/plugin-kanban/src/__tests__/structuredKeysAreDeclaredAndHonoured-8313.test.ts @@ -76,10 +76,17 @@ import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; // bounded window). import '../index'; -/** The two tags this one renderer is published under. */ +/** + * The tag this one renderer is published under. + * + * ⚠️ It was a LIST OF TWO — `object-kanban` and `view:kanban` — until + * objectui#8802 retired the bare `kanban` node type key (maintainer ruling + * 2026-09-09). The `it.each` shape is deliberately KEPT over the one survivor: + * the rows below are per-(tag, key), and collapsing them to bare `it`s would + * make re-adding a tag a rewrite rather than a one-line edit. + */ const KANBAN_TAGS = [ { label: 'object-kanban', type: 'object-kanban', namespace: 'plugin-kanban' }, - { label: 'view:kanban', type: 'kanban', namespace: 'view' }, ] as const; /** @@ -179,15 +186,20 @@ describe('objectui#8313 — object-kanban publishes the array/object-armed keys expect(refusedByName({ objectName: 'task', bogusProp: 'x' })).toContain('bogusProp'); }); - it('both tags publish ONE shared list, so a hand-copy cannot drift', () => { - const [a, b] = KANBAN_TAGS.map( - ({ type, namespace }) => (ComponentRegistry.getConfig(type, namespace) as any)?.inputs, - ); - expect(a, 'object-kanban declares no inputs at all').toBeTruthy(); - expect(a.map((i: any) => i.name)).toEqual(b.map((i: any) => i.name)); - expect(a.map((i: any) => i.name)).toEqual( + it('⛔ the RETIRED `view:kanban` tag resolves to nothing, and the survivor still declares the list', () => { + // ⭐ This was "both tags publish ONE shared list, so a hand-copy cannot + // drift" (objectui#8201's row 5). Its second operand RETIRED with the bare + // `kanban` node type key (objectui#8802), so the sharing claim has nothing + // left to compare — and the honest replacement is the retirement itself, + // asserted with the surviving list as its firing control. + const survivor = (ComponentRegistry.getConfig('object-kanban', 'plugin-kanban') as any)?.inputs; + expect(survivor, 'object-kanban declares no inputs at all').toBeTruthy(); + expect(survivor.map((i: any) => i.name)).toEqual( expect.arrayContaining(DECLARED_STRUCTURED_KEYS.map(({ key }) => key)), ); + // The retired tag: gone from the registry under BOTH spellings it had. + expect(ComponentRegistry.getConfig('kanban', 'view')).toBeFalsy(); + expect(ComponentRegistry.has('kanban')).toBe(false); }); it('every declared description says what the renderer actually reads', () => { diff --git a/packages/plugin-kanban/src/index.test.ts b/packages/plugin-kanban/src/index.test.ts index 5fbf618588..55b0124f9c 100644 --- a/packages/plugin-kanban/src/index.test.ts +++ b/packages/plugin-kanban/src/index.test.ts @@ -6,6 +6,19 @@ * LICENSE file in the root directory of this source tree. */ +/** + * The plugin's registration surface — what importing this package puts in the + * registry. + * + * ⚠️ Every assertion in this file used to be about the `kanban-ui` entry: that + * it resolved, its label and category, its four declared `inputs`, and the + * `defaultProps` board it shipped. That entry RETIRED (objectui#8257, + * maintainer ruling 2026-09-09), together with `kanban-enhanced` and — from + * objectui#8802 — the bare `kanban` key. `object-kanban` is the one surviving + * registration, so the same questions are asked of it. The retirement itself is + * pinned in `__tests__/kanban-family-registry-keys-retired-8257.test.ts`. + */ + import { describe, it, expect } from 'vitest'; import { ComponentRegistry } from '@object-ui/core'; // Import all renderers to register them. This was a `beforeAll(async () => { @@ -18,97 +31,45 @@ import { ComponentRegistry } from '@object-ui/core'; import './index'; describe('Plugin Kanban', () => { - describe('kanban component', () => { + describe('object-kanban component', () => { it('should be registered in ComponentRegistry', () => { - const kanbanRenderer = ComponentRegistry.get('kanban-ui'); - expect(kanbanRenderer).toBeDefined(); + expect(ComponentRegistry.get('object-kanban')).toBeDefined(); + // Firing control on the same call: a name nothing registers is undefined, + // so the line above is a reading rather than a getter that always answers. + expect(ComponentRegistry.get('zzz-not-a-type')).toBeUndefined(); }); it('should have proper metadata', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); + const config = ComponentRegistry.getConfig('object-kanban'); expect(config).toBeDefined(); - expect(config?.label).toBe('Kanban Board'); - expect(config?.icon).toBe('LayoutDashboard'); - expect(config?.category).toBe('plugin'); - expect(config?.inputs).toBeDefined(); - expect(config?.defaultProps).toBeDefined(); + expect(config?.label).toBe('Object Kanban'); + expect(config?.category).toBe('view'); + expect(config?.namespace).toBe('plugin-kanban'); }); it('should have expected inputs', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const inputNames = config?.inputs?.map((input: any) => input.name) || []; - - expect(inputNames).toContain('columns'); - expect(inputNames).toContain('onCardMove'); - expect(inputNames).toContain('className'); - }); - - it('should have columns as required input', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const columnsInput = config?.inputs?.find((input: any) => input.name === 'columns'); - - expect(columnsInput).toBeDefined(); - expect(columnsInput?.required).toBe(true); - expect(columnsInput?.type).toBe('array'); - expect(columnsInput?.description).toBeDefined(); - }); - - it('should have onCardMove as code input', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const onCardMoveInput = config?.inputs?.find((input: any) => input.name === 'onCardMove'); - - expect(onCardMoveInput).toBeDefined(); - expect(onCardMoveInput?.type).toBe('code'); - expect(onCardMoveInput?.description).toBeDefined(); - }); - - it('should have sensible default props', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const defaults = config?.defaultProps; - - expect(defaults).toBeDefined(); - expect(defaults?.columns).toBeDefined(); - expect(Array.isArray(defaults?.columns)).toBe(true); - expect(defaults?.columns.length).toBeGreaterThan(0); - expect(defaults?.className).toBe('w-full'); + const config = ComponentRegistry.getConfig('object-kanban'); + const names = (config?.inputs ?? []).map((i) => i.name); + expect(names).toEqual( + expect.arrayContaining(['objectName', 'groupBy', 'columns', 'cardTitle', 'conditionalFormatting']), + ); }); - it('should have default columns with proper structure', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const defaults = config?.defaultProps; - const columns = defaults?.columns || []; - - // Verify at least 3 columns exist (todo, in-progress, done) - expect(columns.length).toBeGreaterThanOrEqual(3); - - // Verify each column has required properties - columns.forEach((column: any) => { - expect(column.id).toBeDefined(); - expect(column.title).toBeDefined(); - expect(column.cards).toBeDefined(); - expect(Array.isArray(column.cards)).toBe(true); - }); - - // Verify at least one column has cards - const hasCards = columns.some((column: any) => column.cards.length > 0); - expect(hasCards).toBe(true); + it('should have objectName as required input', () => { + const config = ComponentRegistry.getConfig('object-kanban'); + const objectName = (config?.inputs ?? []).find((i) => i.name === 'objectName'); + expect(objectName).toBeDefined(); + expect(objectName?.required).toBe(true); + // Control: not every declared input is required, so `true` above is a + // reading of this entry and not of a shape that marks everything. + expect((config?.inputs ?? []).find((i) => i.name === 'columns')?.required).toBeFalsy(); }); - it('should have cards with proper structure', () => { - const config = ComponentRegistry.getConfig('kanban-ui'); - const defaults = config?.defaultProps; - const columns = defaults?.columns || []; - - // Find a column with cards - const columnWithCards = columns.find((column: any) => column.cards.length > 0); - expect(columnWithCards).toBeDefined(); - - const card = columnWithCards.cards[0]; - expect(card.id).toBeDefined(); - expect(card.title).toBeDefined(); - expect(card.description).toBeDefined(); - expect(card.badges).toBeDefined(); - expect(Array.isArray(card.badges)).toBe(true); + it('every declared input carries a type', () => { + const config = ComponentRegistry.getConfig('object-kanban'); + const inputs = config?.inputs ?? []; + expect(inputs.length).toBeGreaterThan(0); + expect(inputs.filter((i) => !i.type)).toEqual([]); }); }); }); diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index c33616d641..103249421c 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -108,7 +108,8 @@ export function bucketCardsIntoColumns( } // Export types for external use -export type { KanbanSchema, KanbanCard, KanbanColumn, CardTemplate, ColumnWidthConfig, InlineFieldDefinition } from './types'; +// ⛔ `KanbanSchema` RETIRED with the bare `kanban` node type key (objectui#8802). +export type { KanbanCard, KanbanColumn, CardTemplate, ColumnWidthConfig, InlineFieldDefinition } from './types'; export { ObjectKanban }; export type { ObjectKanbanComponentProps } from './ObjectKanban'; @@ -135,7 +136,6 @@ export type { UseQuickAddReorderOptions, UseQuickAddReorderReturn } from './useQ // 🚀 Lazy load the implementation files const LazyKanban = React.lazy(() => import('./KanbanImpl')); -const LazyKanbanEnhanced = React.lazy(() => import('./KanbanEnhanced')); export interface KanbanRendererProps { schema: { @@ -248,163 +248,94 @@ export const KanbanRenderer: React.FC = ({ schema, objectFi ); }; -// Register the component with the ComponentRegistry -ComponentRegistry.register( - 'kanban-ui', - KanbanRenderer, - { - namespace: 'plugin-kanban', - label: 'Kanban Board', - icon: 'LayoutDashboard', - category: 'plugin', - inputs: [ - { - name: 'columns', - type: 'array', - description: 'Array of { id, title, cards, limit, className }', - required: true - }, - { - name: 'onCardMove', - type: 'code', - description: 'Callback when a card is moved' }, - { - name: 'className', - type: 'string' } - ], - defaultProps: { - columns: [ - { - id: 'todo', - title: 'To Do', - cards: [ - { - id: 'card-1', - title: 'Task 1', - description: 'This is the first task', - badges: [ - { label: 'High Priority', variant: 'destructive' }, - { label: 'Feature', variant: 'default' } - ] - }, - { - id: 'card-2', - title: 'Task 2', - description: 'This is the second task', - badges: [ - { label: 'Bug', variant: 'destructive' } - ] - } - ] - }, - { - id: 'in-progress', - title: 'In Progress', - limit: 3, - cards: [ - { - id: 'card-3', - title: 'Task 3', - description: 'Currently working on this', - badges: [ - { label: 'In Progress', variant: 'default' } - ] - } - ] - }, - { - id: 'done', - title: 'Done', - cards: [ - { - id: 'card-4', - title: 'Task 4', - description: 'This task is completed', - badges: [ - { label: 'Completed', variant: 'outline' } - ] - }, - { - id: 'card-5', - title: 'Task 5', - description: 'Another completed task', - badges: [ - { label: 'Completed', variant: 'outline' } - ] - } - ] - } - ], - className: 'w-full' - } - } -); +/** + * ⛔ The `kanban-ui` node type key is RETIRED (objectui#8257, maintainer ruling + * 2026-09-09), together with `kanban-enhanced` below. `KanbanRenderer` itself + * stays exported and stays in use — `ObjectKanban` renders it — it is only the + * REGISTRY KEY that is gone. + * + * ## The measurement the ruling was taken on + * + * Exact node-type spellings, whole repo: `kanban-ui` was authored 0 times in + * JSON and 0 times in TS/TSX as a registry-resolved node, against a firing + * control of 2 JSON / 128 TS occurrences for the live sibling `object-grid` and + * a silent control (`zzz-not-a-type`, 0). ⇒ a registered type key no document + * in this repository has ever authored. Declaring an arm for it would have + * committed the repo to a validation face for a spelling with no writers — the + * opposite of what ADR-0049 enforce-or-remove asks. + * + * ## Why unregistering is the whole retirement + * + * ⚠️ `BaseSchema` closes with `[key: string]: any` and `BaseSchemaCore` ends + * `.passthrough()`, so a dropped MEMBER KEY is KEPT, not refused (objectui#7664). + * That hazard needs a schema face to arise on, and this key never had one: + * measured whole-repo, `@object-ui/types` declares `kanban-ui` as a component + * node type ZERO times (firing control: `object-kanban`, 2 — `objectql.ts` and + * its Zod mirror). There is no arm to convert into a named refusal. + * ⇒ Registration-only retirement. + * + * ## ⭐ What this closes as a side effect — objectui#8818 + * + * `SchemaRenderer` strips a fixed, enumerated metadata list and spreads the + * REST as React props. `objectFields` is not on that list, and `KanbanRenderer` + * — registered here for `kanban-ui` — declares `objectFields` as a real prop + * (objectui#7742). So an AUTHORED `objectFields` reached the predicate layer + * verbatim on this entry, with no schema face declaring or judging it. + * Retiring this registration closes that path: nothing resolves `kanban-ui` any + * more, so no authored node reaches `KanbanRenderer` through the registry. + * + * ⚠️ This closes the ENTRY, ⛔ not the CLASS. `SchemaRenderer` still spreads + * every unstripped key; if another renderer ever declares an `objectFields` + * prop the hole returns. objectui#8818's option (a) — stripping at the + * `SchemaRenderer` boundary — is the one that would close the class, and it is + * still open. + * + * Pinned in `src/__tests__/kanban-family-registry-keys-retired-8257.test.ts`. + */ -// Standard Export Protocol - for manual integration +/** + * Standard Export Protocol — for manual integration. + * + * ⛔ The `kanban`, `kanban-enhanced` and `kanban-ui` keys are RETIRED + * (objectui#8802 / objectui#8257, maintainer rulings 2026-09-09), so this map + * publishes the one surviving spelling. A host that mounted the retired keys + * from here was re-teaching them under its own registry; `object-kanban` is the + * key to mount. + */ export const kanbanComponents = { - 'kanban': KanbanRenderer, - 'kanban-enhanced': LazyKanbanEnhanced, 'object-kanban': ObjectKanban, }; -// Register enhanced Kanban -ComponentRegistry.register( - 'kanban-enhanced', - ({ schema }: { schema: any }) => { - const processedColumns = React.useMemo(() => { - const { columns = [], data, groupBy } = schema; - if (data && groupBy && Array.isArray(data)) { - const groups = data.reduce((acc, item) => { - const key = item[groupBy]; - if (!acc[key]) acc[key] = []; - acc[key].push(item); - return acc; - }, {} as Record); - return columns.map((col: any) => ({ - ...col, - cards: [...(col.cards || []), ...(groups[col.id] || [])] - })); - } - return columns; - }, [schema]); - - return ( - }> - - - ); - }, - { - namespace: 'plugin-kanban', - label: 'Kanban Board (Enhanced)', - icon: 'LayoutGrid', - category: 'plugin', - inputs: [ - { name: 'columns', type: 'array', required: true }, - { name: 'enableVirtualScrolling', type: 'boolean' }, - { name: 'virtualScrollThreshold', type: 'number' }, - { name: 'onCardMove', type: 'code' }, - { name: 'onColumnToggle', type: 'code' }, - { name: 'className', type: 'string' } - ], - defaultProps: { - columns: [], - enableVirtualScrolling: false, - virtualScrollThreshold: 50, - className: 'w-full' - } - } -); +/** + * ⛔ The `kanban-enhanced` node type key is RETIRED (objectui#8257, maintainer + * ruling 2026-09-09) — the card's own subject. + * + * ## What went, and what went with it + * + * The registration read `onColumnToggle`, `enableVirtualScrolling` and + * `virtualScrollThreshold` off `schema` and declared them as `inputs`, while + * `@object-ui/types` declared no `kanban-enhanced` arm at all: the type was + * dispatched by the registry and validated by nothing but `BaseSchema`'s + * passthrough. ⇒ Re-measured on this branch and CONFIRMED rather than assumed: + * with the registration gone those three keys have NO authorable surface left + * anywhere in the repo — 0 declarations on any schema face, 0 remaining + * `inputs` entries, 0 read sites (firing control on the same instrument: + * `groupBy`, which keeps 1 declaration + read sites on the surviving + * `object-kanban` face). objectui#8257's question is resolved by the removal of + * its subject, not by an answer. + * + * ## The measurement the ruling was taken on + * + * `kanban-enhanced` was authored 0 times in JSON and 0 times in TS/TSX, against + * the same firing control (`object-grid`, 2 JSON / 128 TS) and silent control + * (`zzz-not-a-type`, 0) the `kanban-ui` note above cites. + * + * `KanbanEnhanced` itself is untouched and still importable from + * `@object-ui/plugin-kanban/KanbanEnhanced`; what is gone is the registry key + * and the `React.lazy` wrapper that existed only to serve it. + * + * Pinned in `src/__tests__/kanban-family-registry-keys-retired-8257.test.ts`. + */ /** * What `ObjectKanban` reads for its own query: `objectName`, `filter` and @@ -586,15 +517,40 @@ ComponentRegistry.register( inputs: [...OBJECT_KANBAN_INPUTS], } ); -ComponentRegistry.register( - 'kanban', - ObjectKanbanRenderer, - { - namespace: 'view', - label: 'Kanban Board', - category: 'view', - // Same renderer as `object-kanban`, therefore the same declared surface — - // now SHARED rather than hand-copied (objectui#8201). - inputs: [...OBJECT_KANBAN_INPUTS], - } -); \ No newline at end of file +/** + * ⛔ The bare `kanban` node type key is RETIRED (objectui#8802, maintainer + * ruling 2026-09-09: 「从我们的业务需求角度,我应该只需要 `object-kanban`」). + * `object-kanban` above is the one spelling this plugin serves. + * + * ## What this dissolves rather than patches + * + * The two published faces of this key returned OPPOSITE verdicts on the same + * document: the registry `inputs` above (shared into both registrations by + * objectui#8201) declared `titleField`, while the `kanban` Zod arm refused it + * BY NAME after batch #70. With the key gone there is no arm left to disagree + * with — objectui#8802's four options are all moot. + * + * ## ⚠️ Unlike its `gantt` / `kanban-ui` / `kanban-enhanced` siblings, this one + * had a DECLARED FACE, so unregistering is only half of it + * + * `@object-ui/types` declared `KanbanSchema` with `type: 'kanban'` and mirrored + * it in `zod/complex.zod.ts`. A plain deletion there would have been the + * objectui#7664 failure: `BaseSchema` is `.passthrough()`, so a document naming + * a dropped spelling validates GREEN and renders nothing. The Zod arm is + * therefore a NAMED REFUSAL (`retiredNodeType()`, `zod/tombstone.zod.ts`) + * pointing the author at `object-kanban`, and the TS face leaves `ComplexSchema` + * and `SchemaRegistry` so `tsc` refuses the literal at the authoring site. + * + * ## ⛔ Two layers, and only one of them moved + * + * `kanban` is ALSO a STORED `NamedListView.type` — the value `CreateViewDialog` + * writes and every tenant's database holds. That layer is untouched: + * `packages/plugin-view/src/ObjectView.tsx`'s `switch (viewType)` already emits + * `object-kanban` for a stored `kanban` view, as it emits `object-*` for all + * twelve view types. ⇒ Every kanban view any user ever created through the + * console already renders through the surviving spelling; this retirement moves + * zero stored documents. + * + * Pinned in `src/__tests__/kanban-family-registry-keys-retired-8257.test.ts` + * and `@object-ui/types`' `__tests__/bare-kanban-node-key-retired-8802.test.ts`. + */ \ No newline at end of file diff --git a/packages/plugin-kanban/src/types.ts b/packages/plugin-kanban/src/types.ts index 3ca61eefb7..13b3b85a76 100644 --- a/packages/plugin-kanban/src/types.ts +++ b/packages/plugin-kanban/src/types.ts @@ -32,10 +32,13 @@ * `InlineFieldDefinition` stays local: it is the quick-add FORM's field * definition (`InlineQuickAdd.tsx`), not a member of the authored board. */ +// ⛔ `KanbanSchema` is NOT re-exported any more: it RETIRED with the bare +// `kanban` node type key (objectui#8802, maintainer ruling 2026-09-09). +// `ObjectKanbanSchema` (also from `@object-ui/types`) is the surviving face, +// and `ObjectKanban` takes it directly. export type { KanbanCard, KanbanColumn, - KanbanSchema, CardTemplate, ColumnWidthConfig, } from '@object-ui/types'; diff --git a/packages/types/README.md b/packages/types/README.md index 579faa8925..55d4f12e43 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -233,7 +233,7 @@ Menus and navigation: Advanced composite components: -- `KanbanSchema` - Kanban board (the dialect `@object-ui/plugin-kanban` renders) +- `ObjectKanbanSchema` - Kanban board (`object-kanban`; the bare `kanban` node type key and its `KanbanSchema` arm retired in objectui#8802) - `CalendarViewSchema` - Calendar with events - `FilterBuilderSchema` - Advanced filter builder - `CarouselSchema` - Image/content carousel diff --git a/packages/types/examples/zod-validation-example.ts b/packages/types/examples/zod-validation-example.ts index 69f1c08c3b..790e0dc6d5 100644 --- a/packages/types/examples/zod-validation-example.ts +++ b/packages/types/examples/zod-validation-example.ts @@ -18,7 +18,7 @@ import { FormSchema, CardSchema, DataTableSchema, - KanbanSchema, + ObjectKanbanSchema, } from '../src/zod/index.zod'; // The failure accessor below is `error.issues`. Zod 4 removed the `.errors` @@ -131,42 +131,21 @@ if (!dataTableResult.success) { console.error('DataTable errors:', dataTableResult.error.issues); } -// Example 6: Validate a Kanban component — the dialect `@object-ui/plugin-kanban` -// renders (objectui#7664): an object-bound board, plus static columns whose -// cards carry `badges`. +// Example 6: Validate a Kanban component. +// +// ⚠️ Spelled `object-kanban`, not the bare `kanban` this example used to author: +// the bare node type key RETIRED (objectui#8802, maintainer ruling 2026-09-09) +// and `KanbanSchema` retired with it. `ObjectKanbanSchema` is the surviving +// face, and `groupBy` is REQUIRED on it. const kanbanExample = { - type: 'kanban' as const, + type: 'object-kanban' as const, objectName: 'tasks', groupBy: 'status', - cardTitle: 'title', + titleField: 'title', cardFields: ['assignee', 'due_date'], - columns: [ - { - id: 'todo', - title: 'To Do', - cards: [ - { - id: '1', - title: 'Task 1', - description: 'Do something', - badges: [{ label: 'High', variant: 'destructive' as const }], - }, - ], - }, - { - id: 'in-progress', - title: 'In Progress', - cards: [], - }, - { - id: 'done', - title: 'Done', - cards: [], - }, - ], }; -const kanbanResult = KanbanSchema.safeParse(kanbanExample); +const kanbanResult = ObjectKanbanSchema.safeParse(kanbanExample); console.log('Kanban validation:', kanbanResult.success ? 'PASSED ✓' : 'FAILED ✗'); if (!kanbanResult.success) { console.error('Kanban errors:', kanbanResult.error.issues); diff --git a/packages/types/src/__tests__/bare-kanban-node-key-retired-8802.test.ts b/packages/types/src/__tests__/bare-kanban-node-key-retired-8802.test.ts new file mode 100644 index 0000000000..26f01dfd63 --- /dev/null +++ b/packages/types/src/__tests__/bare-kanban-node-key-retired-8802.test.ts @@ -0,0 +1,186 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The bare `kanban` NODE TYPE key is retired, and a document naming it is + * refused BY NAME (objectui#8802 — maintainer ruling 2026-09-09, verbatim and + * untranslated: 「从我们的业务需求角度,我应该只需要 `object-kanban`」). + * + * ## What it supersedes, and why those files are gone rather than edited + * + * Two pin files had the retired arm as their whole subject and are replaced by + * this one — the third disposition in fixture triage, "the fixture pins exactly + * the branch you deleted", where an edit would have left assertions that keep + * passing because nothing is produced any more: + * + * - `kanban-arm-batch70-7742.test.ts` — batch #70's four arm-scoped refusals + * (`allowCollapse` / `cardTemplates` / `columnWidths` / `titleField`) and + * its one widening (`navigation`). Every one of them was a claim about + * `type: 'kanban'` documents, which no longer exist. + * - `kanban-plugin-dialect-authoritative-7664.test.ts` — that the `'kanban'` + * arm declared the plugin dialect, that `SchemaRegistry['kanban']` named + * it, and a 20-member census of it. + * + * ⭐ The one claim inside them that was NOT about the retired arm is carried + * forward here as suite 3: the SIBLING `object-kanban` arm's verdicts must not + * move. That is what turns this from a deletion into a retirement. + * + * ## ⚠️ The mechanism, which is NOT the passthrough rule + * + * `BaseSchema` is `.passthrough()`, so a dropped MEMBER key is KEPT, not + * refused — the failure objectui#7664's first cut shipped at `onCardClick`. + * ⛔ That rule does not govern a TYPE LITERAL. `AnyComponentSchema` is a + * DISCRIMINATED union (objectui#8498), so it selects one arm from the authored + * literal and an unclaimed literal matches nothing: deleting the arm would + * already refuse. What deleting would NOT do is say why or what to write + * instead — the union answers a missed discriminator with its own remedy-free + * `Invalid input`. So the arm stays, claiming the literal, and refuses through + * `retiredNodeType()`. Suite 1 measures the refusal AND its remedy; suite 4 + * measures that the generic path is still what an unknown literal gets, which + * is the control that separates the two. + * + * ## ⛔ The layer this retirement does NOT touch + * + * `kanban` also names a STORED `NamedListView.type` — the value + * `CreateViewDialog` writes and every tenant's database holds. Suite 5 pins that + * it is still admitted, because a migration that pattern-matched the string + * would have rewritten it and broken every stored kanban view in every + * deployment. `plugin-view`'s `ObjectView` maps a stored `kanban` view onto the + * `object-kanban` NODE type, so this retirement moves zero stored documents. + */ + +import { describe, it, expect } from 'vitest'; +import { safeValidateSchema } from '../zod/index.zod'; +import type { ComplexSchema } from '../complex'; +import type { SchemaRegistry, ComponentType } from '../registry'; +import type { NamedListView, ObjectKanbanSchema } from '../objectql'; + +/* -------------------------------------------------------------------------- */ +/* Compile-time pins — read by tsc -p tsconfig.test.json, not by vitest. */ +/* -------------------------------------------------------------------------- */ + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsNever = [T] extends [never] ? true : false; + +// 1. The `ComplexSchema` union no longer has an arm for the literal, so `tsc` +// refuses it at the authoring site. +type _ComplexHasNoKanbanArm = Assert>, true>>; +// Non-vacuity on the same instrument: a live sibling arm IS still selectable, +// so the `never` above is a reading and not an `Extract` that yields `never` +// for everything (which is what a broken import of `ComplexSchema` gives). +type _ComplexStillHasLiveArms = Assert>, false>>; + +// 2. The published `ComponentType` union no longer offers the retired key. +type _ComponentTypeDropsKanban = Assert>; +type _ComponentTypeKeepsLiveKeys = Assert>; +type _RegistryDropsKanban = Assert>; + +// 3. ⛔ The STORED view type is a DIFFERENT layer and keeps the spelling. This +// pin is the trap-guard: it goes red the moment someone "finishes" the +// retirement by pattern-matching the string. +type _StoredViewTypeKeepsKanban = Assert ? true : false, true>>; +type _StoredViewTypeKeepsGantt = Assert ? true : false, true>>; + +// 4. The surviving node face is untouched and still tagged with its own key. +type _SurvivorIsTagged = Assert>; + +/* -------------------------------------------------------------------------- */ +/* Runtime — through `safeValidateSchema`, the union the CLI applies. */ +/* -------------------------------------------------------------------------- */ + +interface IssueLike { + readonly path?: readonly PropertyKey[]; + readonly message?: string; + readonly code?: string; + readonly errors?: readonly (readonly IssueLike[])[]; +} + +/** Flatten zod's nested union issues into `{ path, message, code }` rows. */ +function flattenIssues(issues: readonly IssueLike[]): Array<{ path: string; message: string; code: string }> { + return issues.flatMap((issue) => [ + { path: (issue.path ?? []).join('.'), message: issue.message ?? '', code: issue.code ?? '' }, + ...(issue.errors ?? []).flatMap((nested) => flattenIssues(nested)), + ]); +} + +function refusals(schema: unknown) { + const result = safeValidateSchema(schema); + return result.success ? [] : flattenIssues(result.error.issues as unknown as IssueLike[]); +} + +/** The board that used to validate under the retired key, spelled unchanged. */ +const RETIRED_BOARD = { + type: 'kanban', + objectName: 'tasks', + groupBy: 'status', + cardTitle: 'title', +} as const; + +describe('suite 1 — a `type: "kanban"` document is REFUSED, by name (objectui#8802)', () => { + it('refuses the document', () => { + expect(refusals(RETIRED_BOARD)).not.toEqual([]); + }); + + it('the refusal names the remedy `object-kanban`, not just a type complaint', () => { + // ⭐ This is the whole reason the arm survives as a refusal instead of being + // deleted. Suite 4 is its control: an unknown literal gets the union's + // generic message, which names no remedy at all. + const text = refusals(RETIRED_BOARD).map((r) => r.message).join('\n'); + expect(text).toContain('object-kanban'); + expect(text).toContain('RETIRED'); + }); + + it('⛔ and it does NOT tell the author to rewrite their stored view type', () => { + // The trap this family's rulings warn about, asserted in the one place an + // author would read: the message must say the stored spelling is a + // different layer, so nobody "finishes the job" in their database. + const text = refusals(RETIRED_BOARD).map((r) => r.message).join('\n'); + expect(text).toContain('STORED'); + }); +}); + +describe('suite 2 — the refusal is not a validator that turned strict', () => { + it('an ACCEPTED document on the same call proves the instrument still says yes', () => { + // Firing control. Without it every assertion in suite 1 would also pass + // under a `safeValidateSchema` that had started refusing everything. + expect(refusals({ type: 'object-kanban', objectName: 'tasks', groupBy: 'status' })).toEqual([]); + }); +}); + +describe('suite 3 — the SIBLING `object-kanban` arm keeps every verdict it had', () => { + // Carried forward from `kanban-arm-batch70-7742.test.ts`, whose `titleField` + // pair is the reason this suite exists: the batch #70 refusals were + // ARM-SCOPED, so retiring the arm must not move the sibling's answers. + it.each([ + ['titleField', 'name'], + ['allowCollapse', true], + ['quickAdd', true], + ['coverImageField', 'cover'], + ])('accepts `%s`, exactly as it did before the retirement', (key, value) => { + expect(refusals({ type: 'object-kanban', objectName: 'tasks', groupBy: 'status', [key]: value })).toEqual([]); + }); + + it('and still REFUSES what it always refused — `groupField`, its own tombstone', () => { + // The other half: suite 3 would be vacuous if the sibling arm accepted + // everything. + const found = refusals({ type: 'object-kanban', objectName: 'tasks', groupBy: 'status', groupField: 'status' }); + expect(found.filter((f) => f.path === 'groupField')).not.toEqual([]); + }); +}); + +describe('suite 4 — control: an unknown literal takes the GENERIC path', () => { + it('a name no arm claims is refused without any remedy text', () => { + // ⭐ The separator. If this document ALSO named `object-kanban` in its + // message, suite 1's remedy assertion would be measuring the union's + // boilerplate rather than this arm's guidance. + const text = refusals({ type: 'zzz-not-a-type' }).map((r) => r.message).join('\n'); + expect(text).not.toEqual(''); + expect(text).not.toContain('object-kanban'); + }); +}); diff --git a/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts b/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts index 716d86d70b..72162ae676 100644 --- a/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts +++ b/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts @@ -253,12 +253,18 @@ const describeRow = (r: DocRow): string => `${r.page}:${r.line} ${r.owner}.${r.k /** Runtime-slot rows measured present on this tree — the blanket-sweep control. */ const CONTROL = [ - // objectui#7664: the page documents the plugin dialect under `KanbanSchema` + // ⛔ objectui#8802 removed the three `KanbanSchema` rows that sat here. The + // bare `kanban` node type key retired (maintainer ruling 2026-09-09), the arm + // retired with it, and `content/docs/api/schema-reference.md`'s + // `### KanbanSchema` section is now `### ObjectKanbanSchema` — which documents + // no handler keys at all, because the surviving face declares none. The three + // are still FORWARDED by `KanbanRenderer`; that they are now declared nowhere + // is measured and recorded in + // `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx`, and + // reported rather than repaired. + // (was: objectui#7664, the page documenting the plugin dialect under `KanbanSchema`) // now, whose three runtime slots are `onCardMove` / `onCardClick` / // `onQuickAdd` — the three `KanbanRenderer` forwards off `schema.*`. - { page: 'api/schema-reference.md', owner: 'KanbanSchema', key: 'onCardMove' }, - { page: 'api/schema-reference.md', owner: 'KanbanSchema', key: 'onCardClick' }, - { page: 'api/schema-reference.md', owner: 'KanbanSchema', key: 'onQuickAdd' }, { page: 'components/basic/pagination.mdx', owner: 'PaginationSchema', key: 'onPageChange' }, { page: 'components/data-display/tree-view.mdx', owner: 'TreeViewSchema', key: 'onNodeClick' }, { page: 'components/form/button.mdx', owner: 'ButtonSchema', key: 'onClick' }, @@ -276,13 +282,22 @@ describe('the retired population is measured off the shipped tree (objectui#7340 // (`ActionCallback`, deleted), the third meaning of `onSuccess` — but the // same `on*?: never` shape this name-shaped census reads, so the move is // recorded here too (maintainer ruling option 1, 2026-09-05). + // + // ⭐ 28 → 26, `complex.ts` 4 → 2: objectui#8802 retired the bare `kanban` + // node type key and its `KanbanSchema` arm, taking that arm's two `?: never` + // handler tombstones (`onColumnAdd`, `onCardAdd`) out of the shipped tree. + // ⛔ A shrink here is normally the failure this census exists to catch — a + // key dropped from a LIVE interface stops being refused and, under + // `.passthrough()`, is KEPT. What separates this one is that the whole ARM + // went: a `{ "type": "kanban" }` document is refused BY NAME before any + // member is examined (`./bare-kanban-node-key-retired-8802.test.ts`). const split: Record = {}; for (const m of RETIRED) split[m.file] = (split[m.file] ?? 0) + 1; expect({ total: RETIRED.length, split }).toEqual({ - total: 28, + total: 26, split: { 'app.ts': 1, - 'complex.ts': 4, + 'complex.ts': 2, 'crud.ts': 3, 'data-display.ts': 4, 'feedback.ts': 1, @@ -303,11 +318,14 @@ describe('the retired population is measured off the shipped tree (objectui#7340 }); it('names the keys no shipped interface declares callable', () => { + // ⛔ `onCardAdd` and `onColumnAdd` left this list with the retired `kanban` + // arm (objectui#8802): no shipped interface declares either name at all any + // more, so the census — which reads NAMES off the shipped tree — has nothing + // to classify. They are not "live again": the document that could have + // carried them is refused at its `type`. expect(UNAMBIGUOUSLY_RETIRED_NAMES).toEqual([ - 'onCardAdd', 'onClose', 'onCollapsedChange', - 'onColumnAdd', 'onConfirm', 'onExpandChange', // objectui#7068: the legacy `ActionSchema.onFailure` callback object — no diff --git a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts index e1d8397635..deeee2e653 100644 --- a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts +++ b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts @@ -87,7 +87,6 @@ import { ChatbotEnhancedSchema as ChatbotEnhancedZod, ChatbotFloatingSchema as ChatbotFloatingZod, FilterBuilderSchema as FilterBuilderZod, - KanbanSchema as KanbanZod, } from '../zod/complex.zod'; import { AlertSchema as AlertZod, @@ -145,7 +144,6 @@ import type { ChatbotEnhancedSchema, ChatbotFloatingSchema, FilterBuilderSchema, - KanbanSchema, } from '../complex'; import type { AlertSchema, DataTableSchema, ListItem, TreeViewSchema } from '../data-display'; import type { AccordionSchema, CollapsibleSchema, ToggleGroupSchema } from '../disclosure'; @@ -224,19 +222,29 @@ const objectOf = (mirror: z.ZodType, key: string): z.ZodObject => * `toFormControlDomProps` whitelist, card's ``). */ const RUNTIME_SLOT: readonly Site[] = [ - // objectui#7664 — the `'kanban'` arm is the plugin dialect now, and - // `KanbanRenderer` forwards all three of these off `schema.*` in one block - // (`plugin-kanban/src/index.tsx`). `onCardClick` is a slot on the retired - // declarative face AND on this one: on the `'kanban'` key `ObjectKanban` - // substitutes its own function, but it substitutes `onCardMove` in the same - // object literal, and its substitute CALLS an authored `onCardClick` through - // the prop `ObjectKanban` declares for it. Measured per registration in - // `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx`; the first - // cut of this card dropped the key instead, which ACCEPTED a document this - // ledger had refused. - ['complex.zod.ts', 'KanbanSchema', 'onCardMove', KanbanZod], - ['complex.zod.ts', 'KanbanSchema', 'onCardClick', KanbanZod], - ['complex.zod.ts', 'KanbanSchema', 'onQuickAdd', KanbanZod], + // ⛔ objectui#7664's three `KanbanSchema` slots — `onCardMove`, `onCardClick`, + // `onQuickAdd` — LEFT this ledger with their arm: objectui#8802 retired the + // bare `kanban` node type key (maintainer ruling 2026-09-09) and + // `KanbanSchema` retired with it, on both faces. + // + // ⚠️ This is a ledger SHRINK, and the distinction matters because shrinking + // one is normally the exact failure this file was written to catch: the first + // cut of objectui#7664 DROPPED `onCardClick` from the arm, which turned a + // refused document into an accepted one while every ratchet stayed green. + // What separates the two is where the refusal went: + // - a key dropped from a LIVE arm stops being judged and the value is KEPT, + // because `BaseSchema` is `.passthrough()`; + // - an arm whose TYPE LITERAL retired stops being reachable at all — a + // `{ "type": "kanban" }` document is now refused BY NAME by + // `RetiredKanbanNodeSchema`, so all three keys are refused a fortiori, + // on a document that never reaches a member check. + // Pinned end to end in `./bare-kanban-node-key-retired-8802.test.ts`. + // + // ⚠️ `KanbanRenderer` still forwards all three off `schema.*`, and the + // SURVIVING `object-kanban` face declares none of them — measured, and + // recorded as a red-flag reading in + // `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx` rather + // than repaired, because declaring them would WIDEN a published accept set. ['complex.zod.ts', 'CalendarViewSchema', 'onViewChange', CalendarViewZod], ['complex.zod.ts', 'FilterBuilderSchema', 'onChange', FilterBuilderZod], ['complex.zod.ts', 'ChatbotSchema', 'onError', ChatbotZod], @@ -296,10 +304,10 @@ const RUNTIME_SLOT: readonly Site[] = [ * from the declared `(value: string) => void`, not a consumer of it. */ const RETIRED: readonly Site[] = [ - // objectui#7664 carried these two tombstones onto the successor arm under the - // same `'kanban'` key, so the spelling keeps refusing by name. - ['complex.zod.ts', 'KanbanSchema', 'onColumnAdd', KanbanZod], - ['complex.zod.ts', 'KanbanSchema', 'onCardAdd', KanbanZod], + // ⛔ objectui#7664's two `KanbanSchema` tombstones — `onColumnAdd`, + // `onCardAdd` — LEFT this ledger with their arm (objectui#8802). Same + // reasoning as the three runtime slots above: the arm's TYPE LITERAL retired, + // so the document never reaches a member check. ['complex.zod.ts', 'CarouselSchema', 'onSlideChange', CarouselZod], ['complex.zod.ts', 'ChatbotSchema', 'onSendMessage', ChatbotZod], ['data-display.zod.ts', 'AlertSchema', 'onDismiss', AlertZod], @@ -384,20 +392,26 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje ]); }); - it('67 sites are ledgered, 45 runtime slots + 22 retired, with no key filed twice', () => { + it('62 sites are ledgered, 42 runtime slots + 20 retired, with no key filed twice', () => { // 58 from objectui#6124; the 59th is `ObjectDataTableSchema.onRowClick`, // minted with its arm by objectui#6576 / #6914; the 60th is // `AlertDialogSchema.onAction`, declared by objectui#7104 for a key the // renderer had been reading undeclared; 61–66 are the six slots the // `ChatbotEnhancedSchema` / `ChatbotFloatingSchema` twins were born with - // (objectui#7655); the 67th is `KanbanSchema.onQuickAdd`, the third - // `KanbanRenderer` forward, ledgered when objectui#7664 re-keyed this arm - // onto the plugin dialect — `onCardMove` and `onCardClick` carried over - // from the retired declarative face under the same `'kanban'` key. - expect(RUNTIME_SLOT).toHaveLength(45); - expect(RETIRED).toHaveLength(22); + // (objectui#7655); the 67th was `KanbanSchema.onQuickAdd`, ledgered when + // objectui#7664 re-keyed the `'kanban'` arm onto the plugin dialect. + // + // ⭐ 67 → 62: objectui#8802 retired the bare `kanban` node type key and its + // arm, taking FIVE `KanbanSchema` sites with it — three runtime slots + // (`onCardMove`, `onCardClick`, `onQuickAdd`) and two tombstones + // (`onColumnAdd`, `onCardAdd`). ⛔ A ledger SHRINK is normally this file's + // own failure mode; the two comment blocks above state why an ARM + // retirement is not that failure, and `./bare-kanban-node-key-retired-8802.test.ts` + // measures the refusal that replaced them. + expect(RUNTIME_SLOT).toHaveLength(42); + expect(RETIRED).toHaveLength(20); const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); - expect(new Set(ids).size).toBe(67); + expect(new Set(ids).size).toBe(62); }); it.each(ALL_SITES)('%s %s.%s is DECLARED on the mirror shape, with the objectui#6124 guidance as its description', (_file, _schema, key, mirror) => { @@ -520,8 +534,6 @@ type KeepsFunction = [Extract, (...args: never[]) => unknown>] : true; export type assertionRetiredKeysAreTombstoned = [ - Expect>, - Expect>, Expect>, Expect>, Expect>, @@ -545,9 +557,6 @@ export type assertionRetiredKeysAreTombstoned = [ ]; export type assertionRuntimeSlotsKeepTheirFunctionType = [ - Expect>, - Expect>, - Expect>, Expect>, Expect>, Expect>, diff --git a/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts b/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts deleted file mode 100644 index 0f5e54e453..0000000000 --- a/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * The `'kanban'` arm's accept set after decision batch #70 (objectui#7742, - * ADR-0049, maintainer 2026-09-07: 「同意」). - * - * ## Why this file exists next to `kanban-plugin-dialect-authoritative-7664` - * - * That file pins the arm's declared BODY — which members exist, live or - * tombstoned — with a TypeScript AST census. This one pins what a DOCUMENT gets - * back, through `safeValidateSchema` itself. The two are not interchangeable: - * `BaseSchema` is `.passthrough()`, so a key can be absent from the shape and - * still parse green, and a key can be present in the shape as a refusal arm and - * make a document fail. Only a parse answers the accept-set question. - * - * ## The unusual thing about this card, stated so a reader checks both halves - * - * The accept set moves in BOTH directions in one change, which is why the PR - * carries `needs:contract-review`: - * - * - NARROWER — `allowCollapse`, `cardTemplates`, `columnWidths` and - * `titleField` were accepted and are now refused BY NAME. - * - WIDER — `navigation` was undeclared (admitted through `BaseSchema`'s - * index signature, never examined) and is now declared and JUDGED. - * - * A file that only checked the narrowing would pass on a change that forgot the - * widening entirely, and the reverse. Both directions are asserted here. - * - * ## Every refusal is paired with a CONTROL that fires the other way - * - * A `.passthrough()` object accepts an unknown key. So "the document failed" - * proves nothing on its own — it has to be shown that an ARBITRARY key does - * NOT fail on the same call, or the refusal could just be strictness that - * arrived some other way. Each refusal assertion below therefore runs against a - * document that also carries a bogus key, and asserts the bogus key draws NO - * issue while the retired key draws one at its own path. - */ - -import { describe, it, expect } from 'vitest'; -import { safeValidateSchema } from '../zod/index.zod'; - -/** Zod 4 nests a failing `z.union` arm's issues under `invalid_union.errors`. */ -type IssueLike = { path: PropertyKey[]; message: string; errors?: IssueLike[][] }; -function flattenIssues(issues: IssueLike[]): Array<{ path: string; message: string }> { - return issues.flatMap((i) => - i.errors - ? i.errors.flat().flatMap((nested) => flattenIssues([nested])) - : [{ path: i.path.join('.'), message: i.message }], - ); -} -function refusals(schema: unknown): Array<{ path: string; message: string }> { - const r = safeValidateSchema(schema); - return r.success ? [] : flattenIssues(r.error.issues as unknown as IssueLike[]); -} - -/** A board that validates today, used as the base every probe is added to. */ -const LIVE_BOARD = { - type: 'kanban', - objectName: 'tasks', - groupBy: 'status', - cardTitle: 'title', -} as const; - -/** - * The key a control uses. It must be a name no arm of the union declares, so a - * `.passthrough()` accept is the only correct answer for it. - */ -const BOGUS = 'thisKeyIsDeclaredByNoArm'; - -/** The four keys batch #70 retired from this arm, with what each points at. */ -const RETIRED = [ - { key: 'allowCollapse', value: true, remedyMentions: 'collapsed' }, - { key: 'cardTemplates', value: [{ id: 't', name: 'Bug', values: {} }], remedyMentions: 'COMPONENT PROP' }, - { key: 'columnWidths', value: { defaultWidth: 280 }, remedyMentions: 'HOOK OPTION' }, - { key: 'titleField', value: 'name', remedyMentions: 'cardTitle' }, -] as const; - -describe('batch #70 NARROWS the `kanban` arm — four keys are refused by name (objectui#7742)', () => { - for (const { key, value, remedyMentions } of RETIRED) { - it(`refuses \`${key}\` at its own path, while an undeclared key on the same call is accepted`, () => { - const found = refusals({ ...LIVE_BOARD, [key]: value, [BOGUS]: 'anything' }); - - // The retired key draws an issue AT ITS OWN PATH — so the author reads - // which key is wrong, not just that the document is. - const atKey = found.filter((f) => f.path === key); - expect(atKey, `no refusal at \`${key}\`: ${JSON.stringify(found)}`).not.toEqual([]); - - // THE CONTROL, on the same parse: an arbitrary undeclared key draws - // nothing. Without this the assertion above would also pass under a - // validator that had simply become strict. - expect( - found.filter((f) => f.path === BOGUS), - 'the control key drew an issue — this arm is not passthrough any more, so the refusal above is not a NAMED one', - ).toEqual([]); - - // The message carries the remedy, not just a type complaint. This is what - // `retirementTombstone` exists for: zod's own text would read "expected - // never, received string" and tell the author nothing. - expect( - atKey.map((f) => f.message).join('\n'), - `\`${key}\`'s refusal does not name its remedy`, - ).toContain(remedyMentions); - }); - } - - it('the same four keys were ACCEPTED before this card — the base board without them still passes', () => { - // The other half of a narrowing claim: the narrowing is the KEY, not the - // board. A board that never named them is untouched. - expect(refusals(LIVE_BOARD)).toEqual([]); - }); - - it('`cardTitle` — the spelling `titleField`\'s refusal points at — still validates', () => { - expect(refusals({ type: 'kanban', objectName: 'tasks', cardTitle: 'name' })).toEqual([]); - }); -}); - -describe('the SIBLING `object-kanban` arm keeps `titleField` (objectui#7322 item ②, PR #8153)', () => { - it('accepts a `titleField` document, so the retirement is arm-scoped and not global', () => { - // ⛔ This is the assertion that makes the `titleField` row a RETIREMENT OF A - // SPELLING rather than a removal of a capability. `ObjectKanban` renders - // both node types and still reads the key; only the `kanban` arm stops - // accepting it. - expect(refusals({ type: 'object-kanban', objectName: 'tasks', groupBy: 'status', titleField: 'name' })).toEqual([]); - }); - - it('and the `kanban` arm refuses the same key on the same call shape — the two answers differ', () => { - // The pair is the point: identical documents but for `type`, opposite - // verdicts. Asserted together so neither can drift without the other. - // (`groupBy` is on both because the `object-kanban` arm REQUIRES it — a - // control that fired on the first cut of this file and is kept here as the - // reason the two probes are spelled the way they are.) - expect(refusals({ type: 'kanban', objectName: 'tasks', groupBy: 'status', titleField: 'name' })).not.toEqual([]); - }); -}); - -describe('batch #70 WIDENS the `kanban` arm — `navigation` is declared (objectui#7742, gantt precedent objectui#5903)', () => { - it('accepts a declared `navigation` config', () => { - expect(refusals({ ...LIVE_BOARD, navigation: { mode: 'drawer' } })).toEqual([]); - expect(refusals({ ...LIVE_BOARD, navigation: { mode: 'page' } })).toEqual([]); - }); - - it('JUDGES the value, not just the key — a bad `mode` is refused inside `navigation`', () => { - // ⭐ The verdict that separates "declared" from "admitted". Before this card - // `navigation` rode `BaseSchema`'s `[key: string]: any`, so ANY value passed - // — including this one. A test that only asserted the good config would - // have passed on `origin/main` too and pinned nothing. - const found = refusals({ ...LIVE_BOARD, navigation: { mode: 'not-a-mode' } }); - expect(found, 'an invalid navigation mode was accepted — the key is admitted, not judged').not.toEqual([]); - expect( - found.some((f) => f.path.startsWith('navigation')), - `no issue under \`navigation\`: ${JSON.stringify(found)}`, - ).toBe(true); - }); - - it('and the member list is the spec\'s — a key the spec does not declare is refused inside `navigation`', () => { - // The vocabulary is `@objectstack/spec`'s `NavigationConfig` BY REFERENCE, - // not restated here, so it cannot fork. `basePath` is the name the package - // README once showed and the spec never declared. - const found = refusals({ ...LIVE_BOARD, navigation: { mode: 'drawer', basePath: '/tasks' } }); - expect(found, 'an undeclared navigation member was accepted').not.toEqual([]); - }); -}); diff --git a/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts b/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts deleted file mode 100644 index 3808710de8..0000000000 --- a/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * The `'kanban'` arm declares the plugin dialect, and the validator applies it - * (objectui#7664 — maintainer ruling (a), 2026-09-05, decision batch #41). - * - * ## The defect this pins shut - * - * For an authored `type: 'kanban'` document two different types were - * authoritative depending on who asked: `safeValidateSchema` (the CLI's - * `validate` / `check`) honoured `DeclarativeKanbanSchema` — `columns` with - * `color`, `draggable`, cards with `labels` / `priority` — while the renderer - * registered for the key (`ObjectKanbanRenderer`, `@object-ui/plugin-kanban`) - * consumed that package's own `KanbanSchema` — `objectName` / `groupBy` / - * `cardTitle` / `cardFields`, cards with `badges`. A board could pass - * validation and render EMPTY (objectui#6086 measured the consequence). The - * ruling: the PLUGIN dialect is authoritative. This package now declares it - * (`complex.ts`, mirrored in `zod/complex.zod.ts`), `SchemaRegistry['kanban']` - * names it, the plugin imports it back, and the declarative trio retired. - * - * ## What is pinned, and in which channel - * - * 1. COMPILE-TIME — `SchemaRegistry['kanban']` IS `KanbanSchema` (the - * objectui#7645 interim value `BaseSchema & { type: 'kanban' }` is gone, - * and the published `ComponentType` union still yields `'kanban'`, the - * `_KeyKept` pin carried forward from the retired 7645 file); the - * `ComplexSchema` arm is the same type; the retired keys read `undefined` - * off the interface (`?: never`); the two runtime slots stay callable. - * Vitest strips types without checking them, so these mean something only - * under `tsc -p packages/types/tsconfig.test.json` (chained off the - * package's `type-check` script). A green vitest run is NOT evidence about - * them. - * 2. RUNTIME — the ruling's three accept-set pins, through - * `safeValidateSchema` itself (the union the CLI applies): an - * `objectName` / `groupBy` board passes; a static `columns[].cards[]` - * board in the plugin dialect passes; a board in the retired dialect is - * REFUSED, and the refusal names the retired shape at the key that - * betrayed it. `z.union` nests a failing arm's issues under - * `invalid_union.errors`, so the reader below walks that tree — the CLI's - * arm-selection reader (`packages/cli/src/utils/union-arm-diagnostics.ts`) - * is the production counterpart. - * 3. CENSUS — the declared body is MEASURED off `complex.ts` with the - * TypeScript parser, not inherited: the ruling quoted "the 18-member - * `KanbanSchema`", an AST census on the same day read 19 (it counts - * `type`), and this file was told to trust neither. Pinned at 20 live - * members plus the 3 tombstones, by name — the plugin dialect's own 19, - * plus `onCardClick`. That twentieth member is the one this arm ADDS to - * the dialect it was modelled on: `plugin-kanban/src/types.ts` never - * declared it (measured on `origin/main`: zero occurrences in that file) - * while `KanbanRenderer` has always forwarded `schema.onCardClick`, so - * copying the dialect member for member reproduced its undeclared read - * and, under `.passthrough()`, ACCEPTED a document the retired arm had - * refused. Its reachability is measured per registration in - * `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx`. - * - * ## Why the refusal keys are `draggable` and a column's `color`, not `columns` - * - * The ruling's pin reads "a `columns` / `cards` board is refused". Read - * literally that contradicts the ruled shape: the plugin dialect DECLARES - * `columns[].cards[]` (a static board — the two catalog entries, pinned in - * `examples/schema-catalog/test/kanban-column-cards-6939.test.tsx`, are exactly - * that and render every card). What distinguishes a board written in the - * RETIRED dialect is the keys it had and this one does not, and every one of - * them was measured inert in the plugin: the board's `draggable` and a - * column's `color` have zero read sites, so both are `?: never` tombstones - * refused by name. The retired CARD keys (`labels`, `assignees`, `dueDate`, - * `priority`, `content`) are deliberately NOT refused: a card is an open record - * (`[key: string]: any` — `bucketCardsIntoColumns` pushes raw records into - * lanes, and a task record legitimately carries `priority` or `dueDate`), so - * refusing those names would refuse real data. A retired-dialect board that - * uses none of the refused keys is, member for member, a valid static board of - * this dialect — and renders. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import ts from 'typescript'; -import type { - SchemaRegistry, - ComponentType, - ComplexSchema, - KanbanSchema, - KanbanColumn, - KanbanCard, -} from '../index'; -import { - BaseSchema as BaseZod, - KanbanSchema as KanbanZod, - KanbanColumnSchema as KanbanColumnZod, - ComplexSchema as ComplexZod, - safeValidateSchema, -} from '../zod/index.zod'; - -/* -------------------------------------------------------------------------- */ -/* Compile-time pins — compiled by tsconfig.test.json, chained off type-check. */ -/* -------------------------------------------------------------------------- */ - -type Assert = T; -type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; -type IsAny = 0 extends 1 & T ? true : false; -/** `?: never` reads as exactly `undefined` off the interface (the 6124 spelling). */ -type RetiredIsNever = Equal; -/** A runtime slot keeps a callable member: some function type survives the `Extract`. */ -type KeepsFunction = [Extract, (...args: never[]) => unknown>] extends [never] - ? false - : true; - -// Non-vacuity controls: `any` on either side satisfies every `extends` below -// while checking nothing, and `Equal` is `false`. -type _RegistryIsReal = Assert, false>>; -type _DeclaredIsReal = Assert, false>>; - -// 1. The published union still yields `'kanban'` — `_KeyKept`, carried forward -// from the retired objectui#7645 pin. `Extract` collapses to `never` if the -// key is ever removed, which fails this loudly. -type _KeyKept = Assert, 'kanban'>>; - -// 2. The value IS the declared arm — not the 7645 interim `BaseSchema & { type }` -// (which `Equal` would reject: it lacks every kanban member), and not the -// retired declarative face (gone from the package). -type _ValueIsTheDeclaredArm = Assert>; - -// 3. `safeValidateSchema`'s type-level counterpart: the `'kanban'` arm of the -// `ComplexSchema` union is the same declaration. -type _ComplexArmIsTheDeclaredArm = Assert, KanbanSchema>>; - -// 4. The retired dialect's own keys are tombstoned on the TypeScript face -// (`Equal`, not `extends`: `BaseSchema`'s index signature makes a DELETED -// member read `any`, which a one-way check would accept). -type _DraggableRetired = Assert>; -type _ColumnColorRetired = Assert>; -type _OnColumnAddStillRetired = Assert>; -type _OnCardAddStillRetired = Assert>; - -// 5. The two runtime slots the board forwards stay callable. -type _OnCardMoveCallable = Assert>; -type _OnQuickAddCallable = Assert>; - -// 6. A card is an open record — the index signature survived the move, so a -// raw record field reads `any` rather than failing. -type _CardIsAnOpenRecord = Assert, true>>; -// …and the helpers can fail (synthetic controls, both directions). -type _RetiredIsNeverCanFail = Assert void) | undefined>, false>>; -type _KeepsFunctionCanFail = Assert, false>>; - -/* -------------------------------------------------------------------------- */ -/* Runtime pins */ -/* -------------------------------------------------------------------------- */ - -/** Zod 4 nests a failing `z.union` arm's issues under `invalid_union.errors`. */ -type IssueLike = { path: PropertyKey[]; message: string; errors?: IssueLike[][] }; -function flattenIssues(issues: IssueLike[]): Array<{ path: string; message: string }> { - return issues.flatMap((i) => - i.errors ? i.errors.flat().flatMap((nested) => flattenIssues([nested])) : [{ path: i.path.join('.'), message: i.message }], - ); -} -function refusals(schema: unknown): Array<{ path: string; message: string }> { - const r = safeValidateSchema(schema); - return r.success ? [] : flattenIssues(r.error.issues as unknown as IssueLike[]); -} - -/** The ruling's first pin: an object-bound board, as `skills/objectui` teaches it. */ -const OBJECT_BOUND_BOARD = { - type: 'kanban', - objectName: 'tasks', - groupBy: 'status', - cardTitle: 'title', - cardFields: ['assignee', 'priority'], - bind: 'tasks', -}; - -/** A static board in the plugin dialect — the catalog entries' shape, badges included. */ -const STATIC_BOARD = { - type: 'kanban', - columns: [ - { - id: 'todo', - title: 'To Do', - cards: [{ id: '1', title: 'Design', description: 'Wireframes', badges: [{ label: 'High', variant: 'destructive' }] }], - }, - { id: 'done', title: 'Done', limit: 3, cards: [] }, - ], -}; - -/** The retired declarative dialect — `schema-reference.md`'s example before this card. */ -const RETIRED_DIALECT_BOARD = { - type: 'kanban', - draggable: true, - columns: [ - { id: 'todo', title: 'To Do', color: '#6366f1', cards: [{ id: 'task-1', title: 'Design mockups' }] }, - ], -}; - -describe("the 'kanban' validator arm accepts what the registered renderer reads (objectui#7664)", () => { - it('the compile-time pins above are read by tsc, not by this run', () => { - expect(true).toBe(true); - }); - - it('an objectName / groupBy board passes safeValidateSchema', () => { - expect(refusals(OBJECT_BOUND_BOARD)).toEqual([]); - }); - - it('a static columns[].cards[] board in the plugin dialect passes safeValidateSchema', () => { - expect(refusals(STATIC_BOARD)).toEqual([]); - }); - - it('the arm the union selects is the declared one, with every ruled member on its shape', () => { - // The declaration pin is `.shape`, not `safeParse`: `BaseSchema` is - // `.passthrough()`, so a DELETED key still parses green. - const declared = Object.keys(KanbanZod.shape); - for (const key of [ - 'objectName', 'groupBy', 'swimlaneField', 'cardTitle', 'cardFields', 'data', 'limit', 'columns', - 'onCardMove', 'className', 'quickAdd', 'onQuickAdd', 'coverImageField', 'allowCollapse', - 'conditionalFormatting', 'cardTemplates', 'columnWidths', 'grouping', - // objectui#7742 (decision batch #70): `navigation` joins as a LIVE member - // and `titleField` joins as a refusal arm. Both are on the shape for the - // same reason the four keys above still are — a REFUSED key is declared, - // and only a DROPPED key would be missing here. - 'navigation', 'titleField', - ]) { - expect(declared, `\`${key}\` missing from the kanban mirror's shape`).toContain(key); - } - expect(KanbanZod.shape.type.value).toBe('kanban'); - // The refusal arms exist as declared keys — a stripped key would not appear here. - expect(KanbanZod.shape.draggable).toBeDefined(); - expect(KanbanColumnZod.shape.color).toBeDefined(); - }); -}); - -describe('a board in the retired declarative dialect is refused, naming the retired shape (objectui#7664)', () => { - it('safeValidateSchema refuses it at `draggable` and at the column `color`', () => { - const found = refusals(RETIRED_DIALECT_BOARD); - expect(found).not.toEqual([]); - const at = (path: string) => found.filter((f) => f.path === path).map((f) => f.message); - for (const path of ['draggable', 'columns.0.color']) { - const messages = at(path); - expect(messages, `no refusal at \`${path}\`: ${JSON.stringify(found)}`).not.toEqual([]); - for (const message of messages) { - expect(message).toContain('DeclarativeKanbanSchema'); - expect(message).toContain('objectui#7664'); - expect(message).toContain('RETIRED'); - } - } - // The message says what to write instead — the named-refusal payload, not - // zod's bare `expected never`. - expect(at('draggable')[0]).toContain('`objectName` + `groupBy`'); - expect(at('columns.0.color')[0]).toContain('`className`'); - }); - - it('the same document is refused by the ComplexSchema arm directly, on the same two keys', () => { - // No union nesting here: the discriminated union selects the arm by `type`. - const r = ComplexZod.safeParse(RETIRED_DIALECT_BOARD); - expect(r.success).toBe(false); - if (r.success) return; - const paths = r.error.issues.map((i) => i.path.join('.')).sort(); - expect(paths).toEqual(['columns.0.color', 'draggable']); - }); - - it('control: the refusal is about those two keys — removing them makes the same board a valid static one', () => { - const { draggable: _d, ...board } = RETIRED_DIALECT_BOARD; - void _d; - const withoutColor = { - ...board, - columns: board.columns.map(({ color: _c, ...col }) => (void _c, col)), - }; - expect(refusals(withoutColor)).toEqual([]); - }); - - it('the retired handler keys carried over from the declarative face are still refused by name', () => { - const found = refusals({ ...OBJECT_BOUND_BOARD, onColumnAdd: { action: 'toast' } }); - expect(found.filter((f) => f.path === 'onColumnAdd').map((f) => f.message).join('\n')).toContain('RETIRED (objectui#6124'); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* Census — the declared body, measured off the source, not quoted */ -/* -------------------------------------------------------------------------- */ - -describe('the declared body is measured, not inherited (objectui#7664)', () => { - const COMPLEX_TS = join(dirname(fileURLToPath(import.meta.url)), '..', 'complex.ts'); - - function membersOf(interfaceName: string): Array<{ name: string; never: boolean }> { - const sf = ts.createSourceFile(COMPLEX_TS, readFileSync(COMPLEX_TS, 'utf8'), ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS); - const decl = sf.statements.find( - (s): s is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(s) && s.name.text === interfaceName, - ); - if (!decl) throw new Error(`no top-level interface ${interfaceName} in ${COMPLEX_TS}`); - return decl.members.filter(ts.isPropertySignature).map((m) => ({ - name: ts.isIdentifier(m.name) || ts.isStringLiteral(m.name) ? m.name.text : m.name.getText(sf), - never: m.type?.kind === ts.SyntaxKind.NeverKeyword, - })); - } - - it('KanbanSchema declares 18 live members and exactly 7 tombstones after the batch #70 ruling', () => { - // objectui#7742 moved this count in BOTH directions in one change. It was - // 20 live / 3 tombstoned: three zero-read members (`allowCollapse`, - // `cardTemplates`, `columnWidths`) and the legacy `titleField` spelling - // retired, and `navigation` — a read this face never named — was declared. - // 20 - 3 + 1 = 18 live; 3 + 4 = 7 tombstones. `titleField` is the one that - // ADDS a member rather than converting one: it was never declared here at - // all, it rode `BaseSchema`'s index signature. - const members = membersOf('KanbanSchema'); - const live = members.filter((m) => !m.never).map((m) => m.name); - const tombstoned = members.filter((m) => m.never).map((m) => m.name); - expect(live).toEqual([ - 'type', 'objectName', 'groupBy', 'swimlaneField', 'cardTitle', 'cardFields', 'data', 'limit', 'columns', - 'onCardMove', 'onCardClick', 'className', 'quickAdd', 'onQuickAdd', 'coverImageField', - 'conditionalFormatting', 'grouping', 'navigation', - ]); - expect(live).toHaveLength(18); - expect(tombstoned).toEqual([ - 'allowCollapse', 'cardTemplates', 'columnWidths', 'titleField', - 'draggable', 'onColumnAdd', 'onCardAdd', - ]); - // Both directions against the mirror, so the number above is the mirror's - // too: every declared member is a key of the shape, and every shape key the - // arm ADDS over `BaseSchema` is a declared member. (The parity ratchet, - // `zod-mirror-parity.test.ts`, holds the TYPES; this holds the key sets.) - const declared = new Set([...live, ...tombstoned]); - for (const key of declared) expect(Object.keys(KanbanZod.shape), `\`${key}\` is declared but not mirrored`).toContain(key); - const baseKeys = new Set(Object.keys(BaseZod.shape)); - const addedByTheArm = Object.keys(KanbanZod.shape).filter((k) => !baseKeys.has(k)).sort(); - expect(addedByTheArm).toEqual([...declared].filter((k) => !baseKeys.has(k)).sort()); - }); - - it('KanbanColumn carries the one retired declarative key as its only tombstone', () => { - const members = membersOf('KanbanColumn'); - expect(members.filter((m) => m.never).map((m) => m.name)).toEqual(['color']); - expect(members.filter((m) => !m.never).map((m) => m.name)).toEqual(['id', 'title', 'cards', 'limit', 'className', 'collapsed']); - }); - - it('the census reader can fail — a name that is not there throws rather than reading empty', () => { - expect(() => membersOf('DeclarativeKanbanSchema')).toThrow(/no top-level interface DeclarativeKanbanSchema/); - }); -}); diff --git a/packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts b/packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts index ff2209d167..c149a64bd9 100644 --- a/packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts +++ b/packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts @@ -147,7 +147,12 @@ describe('SchemaRegistry declares all three chatbot registrations (objectui#7704 // If the parse collapsed, or the interface were renamed, every assertion // below would pass vacuously on an empty list. These are the controls. expect(keys.length).toBeGreaterThan(60); - expect(keys).toContain('kanban'); + // ⚠️ The lit control was `'kanban'` until objectui#8802 retired that key + // from the map with the bare node type. `'carousel'` is its replacement: + // another `// Complex` group entry, declared in the same block, so the + // control still proves the parse reached this group rather than merely + // returning a long list. + expect(keys).toContain('carousel'); expect(keys).toContain('chatbot'); expect(valueOf('chatbot')).toBe('ChatbotSchema'); }); @@ -161,7 +166,7 @@ describe('SchemaRegistry declares all three chatbot registrations (objectui#7704 it('they sit with the family, under the `// Complex` group', () => { const at = keys.indexOf('chatbot'); - expect(at).toBeGreaterThan(keys.indexOf('kanban')); + expect(at).toBeGreaterThan(keys.indexOf('carousel')); expect(keys[at + 1]).toBe('chatbot-enhanced'); expect(keys[at + 2]).toBe('chatbot-floating'); }); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 9427e7c150..f0c7b55c60 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -129,7 +129,14 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **41 entries** in `KnownDrift`, **64 keys** across them — 41 / 63 until + * - **40 entries** in `KnownDrift`, **61 keys** across them — 41 / 64 until + * objectui#8802 RETIRED the bare `kanban` node type key (maintainer ruling + * 2026-09-09) and the `complex.zod.ts#KanbanSchema` pair with it, taking that + * entry's three RUNTIME SLOT keys (`onCardMove` / `onCardClick` / + * `onQuickAdd`) out of the ledger — ⭐ the first entry this ledger has lost by + * retiring a whole ARM rather than a key: the pair's TYPE LITERAL went, so a + * `{ "type": "kanban" }` document is refused before any member is compared. + * It was 41 / 64 until then, and 41 / 63 until * objectui#8344 added `component` to `complex.zod.ts#DashboardWidgetSchema`, an * existing entry (so the entry count did not move). 42 / 64 until * objectui#8338 RETIRED the `feedback.zod.ts#ToastSchema` key on BOTH faces @@ -358,7 +365,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 41 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 40 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -392,7 +399,7 @@ import type { z } from 'zod'; import { AppActionSchema, AppComponentSchema, MenuItemSchema as AppMenuItemSchema, NavigationAreaSchema, NavigationItemSchema } from '../zod/app.zod.js'; import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema, SchemaNodeSchema } from '../zod/base.zod.js'; -import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, KanbanCardSchema, KanbanColumnSchema, KanbanSchema, CardTemplateSchema, ColumnWidthConfigSchema, FilterBuilderConditionSchema, FilterGroupSchema } from '../zod/complex.zod.js'; +import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, KanbanCardSchema, KanbanColumnSchema, CardTemplateSchema, ColumnWidthConfigSchema, FilterBuilderConditionSchema, FilterGroupSchema } from '../zod/complex.zod.js'; import { ActionSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js'; import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineSchema, TreeNodeSchema, TreeViewSchema } from '../zod/data-display.zod.js'; import { AccordionItemSchema, AccordionSchema, CollapsibleSchema, ToggleGroupItemSchema, ToggleGroupSchema } from '../zod/disclosure.zod.js'; @@ -407,7 +414,7 @@ import { DetailViewFieldSchema, DetailViewSchema, DetailViewSectionSchema, Detai import type { AppAction as Ts_AppAction, AppComponentSchema as Ts_AppComponentSchema, NavigationArea as Ts_NavigationArea } from '../app'; import type { BaseSchema as Ts_BaseSchema, ComponentConfig as Ts_ComponentConfig, ComponentInput as Ts_ComponentInput, ComponentMeta as Ts_ComponentMeta, KeyedI18nLabel as Ts_KeyedI18nLabel } from '../base'; -import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatbotEnhancedSchema as Ts_ChatbotEnhancedSchema, ChatbotFloatingSchema as Ts_ChatbotFloatingSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, KanbanCard as Ts_KanbanCard, KanbanColumn as Ts_KanbanColumn, KanbanSchema as Ts_KanbanSchema, CardTemplate as Ts_CardTemplate, ColumnWidthConfig as Ts_ColumnWidthConfig } from '../complex'; +import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatbotEnhancedSchema as Ts_ChatbotEnhancedSchema, ChatbotFloatingSchema as Ts_ChatbotFloatingSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, KanbanCard as Ts_KanbanCard, KanbanColumn as Ts_KanbanColumn, CardTemplate as Ts_CardTemplate, ColumnWidthConfig as Ts_ColumnWidthConfig } from '../complex'; import type { DashboardConfig as Ts_DashboardConfig, DashboardWidgetConfig as Ts_DashboardWidgetConfig } from '../designer'; import type { CRUDDialogSchema as Ts_CRUDDialogSchema, DetailSchema as Ts_DetailSchema } from '../crud'; import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, DrillDownConfig as Ts_DrillDownConfig, HtmlSchema as Ts_HtmlSchema, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; @@ -1086,7 +1093,6 @@ const MIRRORS = { 'complex.zod.ts#FilterFieldSchema': FilterFieldSchema, 'complex.zod.ts#KanbanCardSchema': KanbanCardSchema, 'complex.zod.ts#KanbanColumnSchema': KanbanColumnSchema, - 'complex.zod.ts#KanbanSchema': KanbanSchema, 'complex.zod.ts#CardTemplateSchema': CardTemplateSchema, 'complex.zod.ts#ColumnWidthConfigSchema': ColumnWidthConfigSchema, 'crud.zod.ts#CRUDDialogSchema': CRUDDialogSchema, @@ -1252,7 +1258,6 @@ interface Declared { 'complex.zod.ts#FilterFieldSchema': Ts_FilterField; 'complex.zod.ts#KanbanCardSchema': Ts_KanbanCard; 'complex.zod.ts#KanbanColumnSchema': Ts_KanbanColumn; - 'complex.zod.ts#KanbanSchema': Ts_KanbanSchema; 'complex.zod.ts#CardTemplateSchema': Ts_CardTemplate; 'complex.zod.ts#ColumnWidthConfigSchema': Ts_ColumnWidthConfig; 'crud.zod.ts#CRUDDialogSchema': Ts_CRUDDialogSchema; @@ -1492,32 +1497,20 @@ interface KnownDrift { /** DISJOINT vocabularies: TS declares `is_empty`/`is_not_empty`, the mirror declares `is_null`/`is_not_null`. One of the two is dead; which one is a ruling. */ 'complex.zod.ts#FilterFieldSchema': 'operators'; /** - * RUNTIME SLOT (objectui#6124) ×3: `plugin-kanban`'s `KanbanRenderer` forwards - * `onCardMove`, `onCardClick` and `onQuickAdd` off `schema.*` into the board, - * in one block (`plugin-kanban/src/index.tsx`). Re-keyed by objectui#7664 - * (ruling (a)): the pair was `DeclarativeKanbanSchema` with `onCardMove` / - * `onCardClick` until that face retired, and the plugin dialect this arm now - * declares carries all three. + * ⛔ `complex.zod.ts#KanbanSchema` LEFT this ledger with its pair: objectui#8802 + * retired the bare `kanban` node type key (maintainer ruling 2026-09-09) and + * both faces of the arm retired with it. The three RUNTIME SLOTS it recorded — + * `onCardMove`, `onCardClick`, `onQuickAdd` — are not drifting any more + * because there is no pair left to drift; a `{ "type": "kanban" }` document is + * refused BY NAME by `RetiredKanbanNodeSchema`, pinned in + * `./bare-kanban-node-key-retired-8802.test.ts`. * - * ⚠️ `onCardClick` is here on measurement, not by inheritance. On the - * `'kanban'` / `'object-kanban'` keys `ObjectKanban` substitutes its own - * function for it — but it substitutes `onCardMove` in the SAME object - * literal, and its substitute CALLS an authored `onCardClick` through the prop - * `ObjectKanban` declares for it (`SchemaRenderer` spreads every non-metadata - * schema key as a React prop; there is no `onCardMove` prop). The first cut of - * objectui#7664 read that substitution as "the object-bound board owns the - * click" and dropped the key, which under `.passthrough()` ACCEPTED a document - * this arm had refused. Per-registration readings: - * `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx`. - * - * (`onColumnAdd` / `onCardAdd`, carried over as tombstones, and the retired - * declarative `draggable` are NOT here: `?: never` meets the refusal arm and - * the pair does not drift on them.) The runtime-computed card members — - * `cardFieldCells`, a badge's `colorStyle` — are passed through as `z.any()` - * (the `headerIcon` precedent, objectui#6424), so `KanbanCardSchema` and the - * `columns` key above it do not drift either. + * ⚠️ Recorded rather than repaired: `KanbanRenderer` still forwards all three, + * and the SURVIVING `objectql.zod.ts#ObjectKanbanSchema` pair declares none of + * them, so they are read-but-undeclared on the surviving face. Declaring them + * there would WIDEN a published accept set, which is a ruling and not a + * repair — reported on the retirement PR. */ - 'complex.zod.ts#KanbanSchema': 'onCardMove' | 'onCardClick' | 'onQuickAdd'; /** * RUNTIME SLOT (objectui#7344): `register('detail', DetailView)` — `DetailView`'s * `handleBack` calls `onBack()` when set. The mirror was `z.any()` (wider than @@ -2936,6 +2929,16 @@ export type assertionBaseSchemaKeysResolve = Expect< * in neither map. */ const EXCLUSIONS: Readonly> = { + // objectui#8802 — the RETIRED `kanban` node type key's refusal arm. It is not + // a mirror of anything and cannot drift: `retiredNodeType()` builds an object + // whose only member is the `type` literal it refuses on, so there is no TS + // declaration for it to restate. The arm exists purely so + // `AnyComponentSchema`'s discriminator routes a `{ "type": "kanban" }` + // document to a message naming `object-kanban`, instead of the union's own + // remedy-free `Invalid input`. Pinned in + // `./bare-kanban-node-key-retired-8802.test.ts`. + 'complex.zod.ts#RetiredKanbanNodeSchema': + 'a RETIRED node type refusal arm (objectui#8802), not a mirror — its only member is the `type` literal it refuses on, and the TS half of that retirement is the ABSENCE of an arm in `ComplexSchema`', // Renamed from `StylePropsSchema` by objectui#5928. Under the old name the // like-named `StyleProps` (../base.ts) — the Tailwind-scale vocabulary, sharing // ZERO keys with this `{ className, style }` object — read as its declaration, and @@ -3091,9 +3094,10 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // and are pinned against the header by 'objectui#7279' below. 'complex.zod.ts#DashboardComponentSchema', 'complex.zod.ts#DashboardWidgetSchema', - // objectui#7664: `grouping` is `SpecGroupingConfigSchema` by reference, the - // same way `ObjectGallerySchema` below spells it. - 'complex.zod.ts#KanbanSchema', + // ⛔ `complex.zod.ts#KanbanSchema` removed with the retired `kanban` arm + // (objectui#8802) — it was listed here because `grouping` was + // `SpecGroupingConfigSchema` by reference, the way `ObjectGallerySchema` below + // still spells it. 'form.zod.ts#SelectOptionSchema', 'layout.zod.ts#PageNodeSchema', 'objectql.zod.ts#ObjectGallerySchema', @@ -3127,7 +3131,7 @@ const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); * MINUEND under it had moved. Nothing failed on any of those days, because nothing * compared the registry to a number. objectui#7433 is that absence, not the digits. */ -const EXPECTED_MIRROR_PAIRS = 162; +const EXPECTED_MIRROR_PAIRS = 161; /** * A ledger this file can size from its own AST. `WiderThanDeclared` joined at diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 8c83a05d11..9825086681 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -135,7 +135,9 @@ export interface KanbanColumn { cards: KanbanCard[]; /** * WIP limit — the card count at which the lane warns. Never reaches the - * query; the board's fetch window is {@link KanbanSchema.limit}. + * query; the board's fetch window is `ObjectKanbanSchema.limit` + * (`./objectql.ts`) — the `kanban` arm that used to declare it retired with + * the node key (objectui#8802). */ limit?: number; className?: string; @@ -175,325 +177,59 @@ export interface KanbanColumn { } /** - * Kanban Board component schema — the `'kanban'` arm of {@link ComplexSchema} - * and the face `ObjectKanbanRenderer` (registered for `'kanban'` and - * `'object-kanban'`) consumes; `KanbanRenderer` (`'kanban-ui'`) and the - * `'kanban-enhanced'` registration read the same keys off `schema`. + * ⛔ `KanbanSchema` — the `'kanban'` arm — is RETIRED (objectui#8802, + * maintainer ruling 2026-09-09: 「从我们的业务需求角度,我应该只需要 + * `object-kanban`」, recorded verbatim and not translated). * - * Renders a drag-and-drop kanban board for task management: either bound to - * an object (`objectName` + `groupBy`, lanes materialised from the group - * field's options) or authored statically (`columns` carrying their `cards`). + * ## What this dissolves + * + * The bare `kanban` node type key published TWO faces with OPPOSITE verdicts on + * the same document: `@object-ui/plugin-kanban`'s registry `inputs` declared + * `titleField` while this arm refused it by name after batch #70 + * (objectui#7742). With the key retired there is no arm left to disagree with, + * so objectui#8802's four options are moot rather than chosen between. + * + * ## Where the vocabulary went + * + * ⛔ Nowhere, and that is the point: `object-kanban` has ALWAYS had its own + * declared face, {@link ObjectKanbanSchema} (`./objectql.ts`), and an + * `object-kanban` document has always been judged by it alone. Retiring this + * arm therefore changes the verdict on `type: 'kanban'` documents only. The + * keys this arm alone declared — `columns`, `cardTitle`, `swimlaneField`, + * `grouping`, `conditionalFormatting`, `navigation` — were never part of the + * `object-kanban` face and are not being removed from it. + * + * ## ⚠️ Why the Zod mirror is a NAMED REFUSAL and not a deletion + * + * {@link BaseSchema} closes with `[key: string]: any` and its Zod twin ends + * `.passthrough()`. A dropped MEMBER key is therefore KEPT, not refused — the + * failure objectui#7664's own first cut shipped at `onCardClick`. A dropped + * TYPE LITERAL behaves differently on a DISCRIMINATED union (`AnyComponentSchema` + * selects one arm from the authored literal), so removal alone would already + * refuse — but only with the union's generic `Invalid input`, naming no remedy. + * ⇒ `zod/complex.zod.ts` keeps an arm for the literal and refuses it BY NAME + * through `retiredNodeType()`, pointing the author at `object-kanban`. + * + * The TypeScript half of the refusal is this deletion: with no `kanban` arm in + * {@link ComplexSchema} and no `'kanban'` entry in `SchemaRegistry`, `tsc` + * refuses the literal at the authoring site. + * + * ## ⛔ The layer that did NOT move + * + * `kanban` also names a STORED `NamedListView.type` (`./objectql.ts`) — the + * value `CreateViewDialog` writes and every tenant's database holds. It is + * UNTOUCHED. `plugin-view`'s `ObjectView` maps a stored `kanban` view onto the + * node type `object-kanban` already, as it does for all twelve stored view + * types, so every board any user ever created through the console already + * renders through the surviving spelling and this retirement moves zero stored + * documents. + * + * {@link KanbanCard}, {@link KanbanColumn}, {@link CardTemplate} and + * {@link ColumnWidthConfig} are NOT retired — the renderer, the `CardTemplates` + * component and the `useColumnWidths` hook still consume them. + * + * Pinned in `./__tests__/bare-kanban-node-key-retired-8802.test.ts`. */ -export interface KanbanSchema extends BaseSchema { - type: 'kanban'; - - /** - * Object name to fetch data from. - */ - objectName?: string; - - /** - * Field to group records by (maps to column IDs). - */ - groupBy?: string; - - /** - * Field for swimlane rows (2D grouping). When set, cards are grouped - * vertically by `groupBy` (columns) and horizontally by `swimlaneField` (rows). - */ - swimlaneField?: string; - - /** - * Field to use as the card title. - */ - cardTitle?: string; - - /** - * Fields to display on the card. - */ - cardFields?: string[]; - - /** - * Static data or bound data. Stays a raw-row input: objectui#7651 (a - * record-source ladder for the board) was ruled B and closed not_planned. - */ - data?: any[]; - - /** - * Row cap for the fetch. Defaults to `DEFAULT_KANBAN_LIMIT` (100); a board - * renders every fetched record into a lane and has no pagination control, so - * this is the author's window rather than a page size. A bound `dataSource` - * writes it here too — the binding's own `limit`, or the named view's - * `pagination.pageSize`. - * - * Not to be confused with {@link KanbanColumn.limit}, one level down: that is - * a lane's WIP limit (the card count at which the lane warns) and never - * reaches the query. - */ - limit?: number; - - /** - * Array of columns to display in the kanban board. - * Each column contains an array of cards. - */ - columns?: KanbanColumn[]; - - /** - * Callback function when a card is moved between columns or reordered. - * - * RUNTIME SLOT (objectui#6124) — a host-supplied function, NOT authorable - * metadata: JSON has no function value, so the zod twin refuses this key by - * name and points at the node-type spelling. Kept callable here because - * `KanbanRenderer` forwards it (`onCardMove={schema.onCardMove}`); the - * object-bound board (`ObjectKanban`) supplies its own persisting handler. - */ - onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void; - - /** - * Callback function when a card is clicked. - * - * RUNTIME SLOT (objectui#6124) — a host-supplied function, NOT authorable - * metadata: JSON has no function value, so the zod twin refuses this key by - * name and points at the node-type spelling. Kept callable here because it is - * read on every channel measured (objectui#7664, the contract review of - * PR #7743): - * - * - `KanbanRenderer` forwards it (`onCardClick={schema.onCardClick}`) in the - * same block as {@link KanbanSchema.onCardMove} and - * {@link KanbanSchema.onQuickAdd}; - * - on the `'kanban'` and `'object-kanban'` keys `ObjectKanban` substitutes - * its own function — and substitutes `onCardMove` in the very same object - * literal, so that reading retires both keys or neither; - * - and its substitute CALLS the authored handler: `ObjectKanban` declares - * an `onCardClick` PROP (`onCardMove` has none), which `SchemaRenderer` - * supplies by spreading every non-metadata schema key as a React prop. - * - * ⛔ Do not "simplify" this back into a deletion. `BaseSchema` is - * `.passthrough()`, so removing the key does not refuse it — it stops being - * judged and the value is kept, which is how the first cut of objectui#7664 - * turned a refused key into an accepted one with every ratchet green. - * `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx` derives the - * forwarded key set from the read site and goes red on that deletion. - * - * The event is `unknown` rather than a mouse event because this package - * declares zero dependencies and has no React types; `KanbanImpl` narrows it - * to `React.MouseEvent` at the call site. - */ - onCardClick?: (card: KanbanCard, event?: unknown) => void; - - /** - * Optional CSS class name to apply custom styling. - */ - className?: string; - - /** - * Enable Quick Add button at the bottom of each column. - * When true, a "+" button appears allowing inline card creation. - * @default false - */ - quickAdd?: boolean; - - /** - * Callback when a new card is created via Quick Add. - * - * RUNTIME SLOT (objectui#6124) — a host-supplied function, NOT authorable - * metadata: JSON has no function value, so the zod twin refuses this key by - * name and points at the node-type spelling. Kept callable here because - * `KanbanRenderer` forwards it (`onQuickAdd={schema.onQuickAdd}`), and - * `ObjectKanban` spreads the authored schema into that renderer. - */ - onQuickAdd?: (columnId: string, title: string) => void; - - /** - * Field name to use as cover image on cards. - * The field value should be a URL string or file object with a `url` property. - */ - coverImageField?: string; - - /** - * Conditional formatting rules for card coloring. Accepts the native - * `{ field, operator, value }` shape and the spec `{ condition, style }` CEL - * shape (issue #1584). - */ - conditionalFormatting?: KanbanConditionalFormattingRule[]; - - /** - * Grouping configuration from ListView. - * When set, the first grouping field is used as swimlaneField fallback. - */ - grouping?: GroupingConfig; - - /** - * Record navigation behaviour when a card is clicked (drawer / dialog / - * page). Defaults to an inline right-side drawer; set `{ mode: 'page' }` to - * route to the standalone detail page instead. Read at `ObjectKanban.tsx` - * (`navConfig`), which feeds it to `useNavigationOverlay`. - * - * DECLARED by objectui#7742 (decision batch #70) on the gantt precedent - * objectui#5903 set: the read existed and this face did not name it, so an - * authored overlay mode rode {@link BaseSchema}'s `[key: string]: any` — - * admitted, never examined — and the read site had to spell itself - * `(schema as any).navigation`. Declaring it WIDENS the published accept set; - * it is the one widening in a card whose other rows all narrow. - * - * The spec owns the member list — `mode`, `view`, `preventNavigation`, - * `openNewTab`, `size`, `width` — and its schema REFUSES anything else. Do - * not restate the vocabulary here. - * - * Same spec type as {@link ObjectGanttSchema.navigation} and - * {@link ObjectGridSchema.navigation} — aligned with `@objectstack/spec` - * `ListView.navigation` rather than restated, so the vocabulary cannot fork. - */ - navigation?: ViewNavigationConfig; - - /** - * RETIRED (objectui#7742, ADR-0049, maintainer decision batch #70, - * 2026-09-07) — declared on both faces and read by NO registered board. - * - * Measured on this branch over `packages/plugin-kanban/src`, every file - * including tests: `allowCollapse` 0 hits / 0 files, with `groupBy` (85/27), - * `cardTitle` (18/9) and `coverImageField` (17/3) firing as controls on the - * same instrument, so the zero is a reading and not a dead grep. The - * capability EXISTS through another channel — `KanbanEnhanced` collapses a - * lane off `KanbanColumn.collapsed` — so an author who wrote - * `allowCollapse: true` validated green and got a board that never collapsed - * off that key. A board-level switch wired to the per-lane mechanism is a new - * card if it is ever wanted; the ruling did not order one. - * - * A tombstone rather than a plain removal on PRONG 2 of the discriminator the - * precedent changesets state (objectui#5941, #7526): the key was TAUGHT as - * working — `content/docs/api/schema-reference.md` carried the row - * "`allowCollapse` | `boolean` | Allow columns to be collapsed." Prong 1 does - * not apply: there is no live replacement KEY to name, only a different - * channel. - * - * ⚠️ Inertness is why the key is retired, not why it is tombstoned. {@link - * BaseSchema} is `.passthrough()`, so dropping it from the mirror would leave - * a document naming it silently ACCEPTED with the value kept — the failure - * objectui#7664's own first cut shipped at {@link KanbanSchema.onCardClick}. - * - * ⛔ NOT the same key as `ObjectKanbanSchema.allowCollapse` (`objectql.ts`), - * which the batch #70 ruling did not reach and which stays declared on the - * `object-kanban` arm. - * @deprecated Not part of this contract — the value was inert. - */ - allowCollapse?: never; - - /** - * RETIRED (objectui#7742, ADR-0049, maintainer decision batch #70, - * 2026-09-07) — declared on both faces and read by NO registered board. - * - * Measured with the census above: `cardTemplates` 0 hits / 0 files across - * `packages/plugin-kanban/src`, same run and same firing controls. The - * capability exists through a COMPONENT PROP — `CardTemplates.tsx` takes - * `templates: CardTemplate[]` — never off the schema, so nothing an author - * wrote here ever reached it. {@link CardTemplate} itself stays exported: - * that prop and `plugin-kanban`'s re-export still consume the type. - * - * Tombstoned on PRONG 2, same as {@link KanbanSchema.allowCollapse}: - * `content/docs/api/schema-reference.md` carried the row "`cardTemplates` | - * `CardTemplate[]` | Predefined quick-add templates." - * @deprecated Not part of this contract — the value was inert. - */ - cardTemplates?: never; - - /** - * RETIRED (objectui#7742, ADR-0049, maintainer decision batch #70, - * 2026-09-07) — declared on both faces and read by NO registered board. - * - * Measured with the census above: `columnWidths` 0 hits / 0 files across - * `packages/plugin-kanban/src`, same run and same firing controls. The - * capability exists through a HOOK OPTION — `useColumnWidths` takes a - * `ColumnWidthConfig` argument — never off the schema. {@link - * ColumnWidthConfig} itself stays exported: that hook and `plugin-kanban`'s - * re-export still consume the type. - * - * ⚠️ The repo-wide name census for this key is NOT zero (22 hits / 11 files) - * and every one of those is a DIFFERENT key of the same spelling on the grid - * surface — `data-table.tsx`, `ObjectGrid.tsx`, `RecordPickerDialog.tsx`. The - * board's zero is the `packages/plugin-kanban/src` reading, and the grid key - * is untouched by this retirement. - * - * Tombstoned on PRONG 2, same as {@link KanbanSchema.allowCollapse}: - * `content/docs/api/schema-reference.md` carried the row "`columnWidths` | - * `ColumnWidthConfig` | Column width configuration." - * @deprecated Not part of this contract — the value was inert. - */ - columnWidths?: never; - - /** - * RETIRED on THIS arm (objectui#7742, ADR-0049, maintainer decision batch - * #70, 2026-09-07) — one arm, one spelling: write {@link - * KanbanSchema.cardTitle}. - * - * ⚠️ This tombstone is NOT an inertness finding, and reading it as one gets - * the mechanism backwards. `ObjectKanban.tsx` DOES read the key — - * `schema.cardTitle || schema.titleField` and `schema.cardTitle ?? - * schema.titleField` — and that read is load-bearing for the SIBLING arm: - * `ObjectKanbanSchema` (`objectql.ts`) declares `titleField` and the batch #70 - * ruling says in as many words that the `object-kanban` arm KEEPS it - * (objectui#7322 item ②, PR #8153, re-measured here from `25907cd70`). What - * is retired is this arm's ACCEPTANCE of the legacy spelling, not the read. - * - * So a `{ "type": "kanban" }` document naming `titleField` is now refused BY - * NAME and pointed at `cardTitle`; a `{ "type": "object-kanban" }` document - * naming it still validates and still renders. One renderer, two node types, - * two accept sets — which is the shape objectui#7322 item ② already gave the - * component's prop union. - * - * ⚠️ Never declared on this face before now: it rode {@link BaseSchema}'s - * `[key: string]: any`, which is why both reads were spelled `(schema as - * any).titleField` until PR #8153 widened the prop to the two-arm union. - * Declaring the tombstone is therefore the FIRST time this face judges the - * key at all — a narrowing, not a re-narrowing. - * @deprecated Not part of this contract on the `kanban` arm — write `cardTitle`. - */ - titleField?: never; - - /** - * RETIRED with the declarative face (objectui#7664, ADR-0049) — `draggable` - * was a `DeclarativeKanbanSchema` member and no registered board reads it - * (measured: zero `draggable` read sites in `@object-ui/plugin-kanban`; - * drag-and-drop is always on). - * - * A tombstone rather than a plain removal on PRONG 2 of the discriminator the - * precedent changesets state (objectui#5941, #7526; the one-line form is - * under correction as objectui#7678) — a tombstone exists (1) to steer - * authors to a named live replacement KEY, or (2) to keep loud a key the docs - * taught as working. Prong 1 does not apply: drag-and-drop is unconditional, - * so there is no replacement key to name, and the remedy is to delete the - * member. Prong 2 carries it: `content/docs/api/schema-reference.md` taught - * this key as working — before this card its kanban example opened with - * `"draggable": true` and its property table read "`draggable` | `boolean` | - * Enable drag-and-drop between columns." - * - * ⚠️ Inertness is why the key is retired, not why it is tombstoned. A key - * this documented must be refused by NAME rather than dropped: {@link - * BaseSchema} is `.passthrough()`, so dropping it from the mirror would leave - * a document naming it silently accepted with the value kept — the failure - * this card's own first cut shipped at {@link KanbanSchema.onCardClick}. - * @deprecated Not part of this contract — the value was inert. - */ - draggable?: never; - /** - * RETIRED (objectui#6124, ADR-0049) — JSON has no function value, and the - * `kanban` renderer takes `({ schema })` and never reads it. Carried over - * from the retired declarative face so the successor arm under the same - * `'kanban'` key keeps refusing the spelling by name; author behaviour as a - * node type (`{ "type": "toast" }`, an `action:button` node) instead. - * @deprecated Not part of this contract — the value was inert. - */ - onColumnAdd?: never; - /** - * RETIRED (objectui#6124, ADR-0049) — JSON has no function value, and the - * `kanban` renderer takes `({ schema })` and never reads it. Carried over - * from the retired declarative face so the successor arm under the same - * `'kanban'` key keeps refusing the spelling by name; author behaviour as a - * node type (`{ "type": "toast" }`, an `action:button` node) instead. - * @deprecated Not part of this contract — the value was inert. - */ - onCardAdd?: never; -} /** * A predefined card template with pre-filled field values. @@ -2180,7 +1916,11 @@ export interface DashboardComponentSchema extends BaseSchema { * Union type of all complex schemas */ export type ComplexSchema = - | KanbanSchema + // ⛔ `KanbanSchema` (`type: 'kanban'`) RETIRED here — objectui#8802, + // maintainer ruling 2026-09-09. Its Zod twin keeps an arm for the literal so + // an authored `type: "kanban"` is refused BY NAME and pointed at + // `object-kanban`; on this face the retirement IS the absence, which is what + // makes `tsc` refuse the literal at the authoring site. | CalendarViewSchema | FilterBuilderSchema | CarouselSchema diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2319b5936e..6b1e2cefda 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -309,7 +309,9 @@ export type { export type { KanbanCard, KanbanColumn, - KanbanSchema, + // ⛔ `KanbanSchema` RETIRED with the bare `kanban` node type key + // (objectui#8802, maintainer ruling 2026-09-09) — `ObjectKanbanSchema` below + // is the surviving kanban face. CardTemplate, ColumnWidthConfig, CalendarViewMode, diff --git a/packages/types/src/registry.ts b/packages/types/src/registry.ts index eb39756db6..d52e465588 100644 --- a/packages/types/src/registry.ts +++ b/packages/types/src/registry.ts @@ -92,7 +92,6 @@ import type { } from './navigation.js'; import type { - KanbanSchema, CalendarViewSchema, FilterBuilderSchema, CarouselSchema, @@ -184,21 +183,25 @@ export interface SchemaRegistry { 'breadcrumb': BreadcrumbSchema; 'pagination': PaginationSchema; - // Complex - // `'kanban'` names the face the registered renderer honours — the plugin - // dialect, declared in THIS package since objectui#7664 (maintainer ruling - // (a), 2026-09-05): `@object-ui/plugin-kanban` imports `KanbanSchema` from here - // and conforms to it, so the map's value and the renderer's prop type are one - // declaration. Between objectui#7645 (PR #7662) and that ruling this entry - // read `BaseSchema & { type: 'kanban' }` — the weakest true claim — because - // this layer could not name the plugin's type (a phantom dependency, and a - // cycle). Moving the dialect down here is the route that comment named, not - // the one it forbade. Pinned in - // `src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts` (the key - // survives in `keyof`; the value IS the declared arm) and the same file name - // under `plugin-kanban/src/__tests__/` (the renderer's own prop type IS this - // declaration). - 'kanban': KanbanSchema; + // ⛔ `'kanban'` RETIRED (objectui#8802, maintainer ruling 2026-09-09). The + // bare node type key published two faces with opposite verdicts — the plugin's + // registry `inputs` accepted `titleField`, the Zod arm refused it by name — and + // the maintainer dissolved the divergence by retiring the key rather than + // patching either face. `KanbanSchema` retired with it (`complex.ts`). + // + // ⇒ `ComponentType` (`keyof SchemaRegistry`) no longer offers `'kanban'`, which + // is the compile-time half of the refusal; the Zod half is + // `RetiredKanbanNodeSchema` in `zod/complex.zod.ts`, which names `object-kanban` + // as the remedy. The surviving spelling `'object-kanban'` is declared by + // `ObjectKanbanSchema` (`objectql.ts`) and reached through + // `ObjectQLComponentSchema`, and the STORED `NamedListView.type` value of the + // same spelling is a different layer and untouched. + // + // ⚠️ Measured, and reported rather than fixed here: this map has NEVER had an + // `'object-kanban'` entry, so with `'kanban'` gone the kanban family has no + // entry in it at all. That is the objectui#7665 sweep's territory (this map's + // coverage), not this retirement's — adding one would be a widening of + // `ComponentType`, which is a ruling. 'calendar-view': CalendarViewSchema; 'filter-builder': FilterBuilderSchema; 'carousel': CarouselSchema; diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index eb96c3dd84..13f47d7ee9 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -17,17 +17,14 @@ */ import { z } from 'zod'; -import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { handlerKeyRefusal, retiredNodeType, retirementTombstone } from './tombstone.zod.js'; import { ChartTypeSchema as SpecChartTypeSchema, DashboardSchema as SpecDashboardSchema, DashboardWidgetSchema as SpecDashboardWidgetSchema, GlobalFilterSchema as SpecGlobalFilterSchema, - GroupingConfigSchema as SpecGroupingConfigSchema, - NavigationConfigSchema as SpecNavigationConfigSchema, } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema, specFieldsExcept } from './base.zod.js'; -import { KanbanConditionalFormattingRuleSchema } from './objectql.zod.js'; import { DASHBOARD_COLOR_VARIANTS, DASHBOARD_WIDGET_TYPES } from '../designer.js'; import { DASHBOARD_COMPONENT_WIDGET_TYPES, @@ -89,35 +86,12 @@ const retiredDeclarativeKanbanKey = (key: string, where: string, remedy: string) `an object-bound board, or \`columns[].cards[]\` for a static one. ${remedy}`, ); -/** - * The ZERO-READ members this arm declared, named once so every refusal below - * says the same thing (objectui#7742, ADR-0049, maintainer decision batch #70, - * 2026-09-07: 「同意」). - * - * ⛔ Deliberately NOT {@link retiredDeclarativeKanbanKey}. That helper's message - * says the key "belonged to the retired `DeclarativeKanbanSchema` dialect", - * which is TRUE of `draggable` and the column `color` and FALSE of these three: - * they were members of the PLUGIN dialect objectui#7664 ruled authoritative, - * carried over member-for-member, and retired one card later for a different - * reason — declared on both faces, read by no registered board. Sharing the - * older message would hand the author a false history of their own document. - * - * The zeros are readings, not a dead grep: measured over - * `packages/plugin-kanban/src` with every file including tests, `allowCollapse` - * / `cardTemplates` / `columnWidths` returned 0 hits / 0 files while `groupBy` - * (85/27), `cardTitle` (18/9) and `coverImageField` (17/3) fired as controls on - * the same instrument. - * - * Refused BY NAME rather than dropped, for the reason this file states twice - * already: {@link BaseSchema} is `.passthrough()`, so a dropped key is KEPT, - * not refused. - */ -const retiredZeroReadKanbanKey = (key: string, taught: string, remedy: string) => - retirementTombstone( - `\`${key}\` is RETIRED (objectui#7742, ADR-0049) — the \`kanban\` arm declared ` + - 'it on both faces and NO registered board ever read it, so a document that set ' + - `it validated green and changed nothing. ${taught} ${remedy}`, - ); +// ⛔ `retiredZeroReadKanbanKey` RETIRED with its only three call sites +// (objectui#8802): `allowCollapse` / `cardTemplates` / `columnWidths` were +// tombstones on the `kanban` arm, and the arm itself is gone. The batch #70 +// refusals it carried were arm-scoped by construction — a `type: "kanban"` +// document is now refused whole, and a `type: "object-kanban"` one is judged by +// `objectql.zod.ts#ObjectKanbanSchema` exactly as it always was. /** * Kanban Card Schema — mirrors {@link KanbanCard} in `../complex.ts` key for key. @@ -185,68 +159,35 @@ export const ColumnWidthConfigSchema = z.object({ }); /** - * Kanban Schema — the `'kanban'` arm of {@link ComplexSchema}, mirroring - * {@link KanbanSchema} in `../complex.ts` key for key: the shape - * `@object-ui/plugin-kanban`'s registered renderers read (objectui#7664). + * ⛔ The `'kanban'` arm is RETIRED (objectui#8802, maintainer ruling 2026-09-09) + * — this is its NAMED REFUSAL, the half a deletion would not have given. + * + * {@link KanbanSchema}'s member-by-member mirror of `../complex.ts` went with + * the TypeScript interface. What stays is an arm claiming the literal, so + * `AnyComponentSchema`'s discriminator still routes a `type: "kanban"` document + * HERE and the author reads why the spelling went and what to write instead — + * rather than the union's own remedy-free `Invalid input`. * - * `onCardMove` / `onCardClick` / `onQuickAdd` are RUNTIME SLOTS (objectui#6124): - * `KanbanRenderer` forwards all three off `schema.*` in one block, so the - * TypeScript twin keeps them callable and this mirror refuses them by name. - * ⛔ None of the three may be dropped instead of refused — `BaseSchema` is - * `.passthrough()`, so a dropped key is KEPT rather than refused (the first cut - * of objectui#7664 dropped `onCardClick` and turned a refused document into an - * accepted one). `onColumnAdd` / `onCardAdd` are the two - * retired handler keys carried over from the declarative face so the successor - * arm keeps refusing the spelling; `draggable` is that face's own retired key. - * `conditionalFormatting` and `grouping` are the same schemas the `object-kanban` - * and `object-gallery` arms use (`objectql.zod.ts`, `@objectstack/spec`). + * ⚠️ Read `retiredNodeType`'s own docblock before changing this: the reason an + * arm is needed at all is NOT `BaseSchema`'s `.passthrough()` (that rule governs + * dropped MEMBER keys), it is that the union's generic discriminator message + * names no remedy. + * + * The keys this arm alone declared — `columns`, `cardTitle`, `swimlaneField`, + * `grouping`, `conditionalFormatting`, `navigation` — were never on the + * `object-kanban` face (`objectql.zod.ts#ObjectKanbanSchema`) and are not being + * removed from it: an `object-kanban` document is judged exactly as it was. + * + * Pinned in `../__tests__/bare-kanban-node-key-retired-8802.test.ts`. */ -export const KanbanSchema = BaseSchema.extend({ - type: z.literal('kanban'), - objectName: z.string().optional().describe('Object name to fetch data from'), - groupBy: z.string().optional().describe('Field to group records by (maps to column IDs)'), - swimlaneField: z.string().optional().describe('Field for swimlane rows (2D grouping)'), - cardTitle: z.string().optional().describe('Field to use as the card title'), - cardFields: z.array(z.string()).optional().describe('Fields to display on the card'), - data: z.array(z.any()).optional().describe('Static data or bound data (raw rows)'), - limit: z.number().optional().describe('Row cap for the fetch (defaults to 100)'), - columns: z.array(KanbanColumnSchema).optional().describe('Columns to display, each carrying its cards'), - onCardMove: handlerKeyRefusal('onCardMove', 'runtime-slot', 'Card move handler'), - onCardClick: handlerKeyRefusal('onCardClick', 'runtime-slot', 'Card click handler'), - className: z.string().optional().describe('CSS class name'), - quickAdd: z.boolean().optional().describe('Enable the Quick Add button at the bottom of each column'), - onQuickAdd: handlerKeyRefusal('onQuickAdd', 'runtime-slot', 'Quick Add handler'), - coverImageField: z.string().optional().describe('Field name to use as cover image on cards'), - allowCollapse: retiredZeroReadKanbanKey( - 'allowCollapse', - 'The capability exists on another channel:', - 'the enhanced board collapses a lane off that lane\'s own `collapsed` key, so write `columns[].collapsed`.', - ), - conditionalFormatting: z.array(KanbanConditionalFormattingRuleSchema).optional().describe('Card conditional formatting rules'), - cardTemplates: retiredZeroReadKanbanKey( - 'cardTemplates', - 'The capability exists on another channel:', - '`CardTemplates` takes its `templates` as a COMPONENT PROP, not off the board node; there is no authorable spelling for it.', - ), - columnWidths: retiredZeroReadKanbanKey( - 'columnWidths', - 'The capability exists on another channel:', - '`useColumnWidths` takes its `ColumnWidthConfig` as a HOOK OPTION, not off the board node; there is no authorable spelling for it. (The grid surface has an unrelated key of the same name — that one is untouched.)', - ), - grouping: stripImportedDefaults(SpecGroupingConfigSchema).optional().describe('Grouping configuration from ListView; its first field is the swimlaneField fallback'), - navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Record navigation behaviour on card click (drawer/dialog/page)'), - titleField: retirementTombstone( - '`titleField` is RETIRED on the `kanban` arm (objectui#7742, ADR-0049) — one arm, one ' + - 'spelling. Write `cardTitle`, which selects the same record field and which this arm ' + - 'has always declared. ⛔ This is NOT an inertness retirement: `ObjectKanban` still ' + - 'reads the key, because the SIBLING `object-kanban` arm declares it and keeps it ' + - '(objectui#7322 item ②). A `type: "object-kanban"` document naming `titleField` is ' + - 'still accepted; a `type: "kanban"` one is not.', - ), - draggable: retiredDeclarativeKanbanKey('draggable', 'board', 'Drag-and-drop is always on; delete the key.'), - onColumnAdd: handlerKeyRefusal('onColumnAdd', 'retired', 'Column add handler'), - onCardAdd: handlerKeyRefusal('onCardAdd', 'retired', 'Card add handler'), -}); +export const RetiredKanbanNodeSchema = retiredNodeType( + 'kanban', + 'Author `object-kanban` instead — the same board, the same renderer, and the ' + + 'spelling every stored kanban view already renders through (`ObjectView` maps a ' + + 'stored `kanban` view type onto the `object-kanban` node type). ⚠️ The STORED ' + + '`NamedListView.type` value `"kanban"` is a DIFFERENT layer and is unaffected — ' + + 'do not rewrite it.', +); /** * Calendar View Mode — the registered renderer's rendered set. @@ -1180,7 +1121,7 @@ export const DashboardConfigSchema = z.object({ * Complex Schema Union - All complex component schemas */ export const ComplexSchema = z.discriminatedUnion('type', [ - KanbanSchema, + RetiredKanbanNodeSchema, CalendarViewSchema, FilterBuilderSchema, CarouselSchema, diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index d6fe74c4a6..2307622df6 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -251,7 +251,11 @@ export { KanbanColumnSchema, CardTemplateSchema, ColumnWidthConfigSchema, - KanbanSchema, + // ⛔ `KanbanSchema` RETIRED with the bare `kanban` node type key + // (objectui#8802, maintainer ruling 2026-09-09). `RetiredKanbanNodeSchema` + // takes its place inside `ComplexSchema` so an authored `type: "kanban"` is + // refused BY NAME and pointed at `object-kanban`; it is deliberately NOT + // exported — nothing outside this package parses against a refusal arm. CalendarViewModeSchema, CalendarEventSchema, CalendarViewSchema, diff --git a/packages/types/src/zod/tombstone.zod.ts b/packages/types/src/zod/tombstone.zod.ts index d431a1f9b2..7cc8bdcba2 100644 --- a/packages/types/src/zod/tombstone.zod.ts +++ b/packages/types/src/zod/tombstone.zod.ts @@ -187,3 +187,56 @@ export function aliasKeyRefusal(alias: string, canonical: string, surface: strin `Unrecognized key(s) on ${surface}: \`${alias}\`. Did you mean \`${alias}\` → \`${canonical}\`? ${detail}`; return z.never({ error: guidance }).optional().describe(guidance); } + +/** + * Declare a RETIRED NODE TYPE arm: a component `type` literal the vocabulary no + * longer serves, kept in the discriminated union ONLY so an authored document + * naming it is refused BY NAME and pointed at the surviving spelling + * (objectui#8802, maintainer ruling 2026-09-09). + * + * ## Why an arm rather than a deletion — and why that is not the passthrough rule + * + * The family's other three retirements in that batch (`gantt` objectui#8008, + * `kanban-ui` / `kanban-enhanced` objectui#8257) are registration-only: no + * schema face ever declared them, so unregistering IS the retirement. This + * helper exists for the fourth case, where a face DID declare the literal. + * + * ⚠️ Two different mechanisms, and conflating them is how a retirement ships as + * a silent accept: + * + * - a dropped MEMBER key is KEPT, not refused — {@link retirementTombstone}'s + * whole reason, because `BaseSchemaCore` ends `.passthrough()`; + * - a dropped TYPE LITERAL on a DISCRIMINATED union IS refused, because + * `AnyComponentSchema` selects one arm from the authored literal and an + * unclaimed literal matches none. + * + * ⇒ Deleting the arm would already refuse. What it would NOT do is say why, or + * what to write instead: the union answers a missed discriminator with its own + * `Invalid input`, naming no remedy. Measured on zod 4.4.3: an arm carrying the + * literal plus a failing check reports `custom` at `path: ['type']` with this + * guidance, while the same document against a union without the arm reports + * `invalid_union` / "No matching discriminator" and nothing else. + * + * ⛔ Not {@link retirementTombstone}: that helper's `z.never()` sits at a MEMBER + * position inside an arm. A discriminated union needs the arm itself to claim + * the literal (`propValues`), so the refusal has to be a check on the object, + * not a member type. + * + * Same discipline as its three siblings in this file: ONE string feeds BOTH + * author-facing channels — the parse-time issue message and the `.describe()` + * metadata — so they cannot drift apart. + * + * @param type the retired literal, spelled into the message so the issue is + * addressed even when read without its path + * @param guidance why it retired and what to author instead + */ +export function retiredNodeType(type: string, guidance: string) { + const text = `\`${type}\` is a RETIRED node type (ADR-0049). ${guidance}`; + return z + .object({ type: z.literal(type) }) + .catchall(z.unknown()) + .check((ctx) => { + ctx.issues.push({ code: 'custom', message: text, input: ctx.value, path: ['type'] }); + }) + .describe(text); +} diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index dee45abae7..7d7f394b07 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -483,6 +483,15 @@ const OPEN_REGISTRATION_SITES = { */ const DOC_TYPE_EXEMPTIONS = { 'content/docs/api/schema-reference.md': { + kanban: + 'ViewSwitcher `views[].type` — the VIEW-TYPE vocabulary (`ViewType`, ' + + 'packages/types/src/views.ts), which is what a switcher tab names, not a node type; the ' + + 'nested `schema` in the same snippet carries `object-kanban`, the node type. Needed from ' + + 'objectui#8802, which retired the bare `kanban` NODE type key — until then the value ' + + 'passed by coincidence, the two vocabularies sharing one spelling. ⛔ The stored/view-type ' + + 'spelling is deliberately NOT retired (`ObjectView` maps a stored `kanban` view onto the ' + + '`object-kanban` node type). Same vocabulary as the `components/complex/view-switcher.mdx` ' + + 'entry below.', action: 'ActionSchema discriminant under an ACTION LIST, never a rendered child — an action\'s own ' + '`dialog.actions[]` and `chain[]`, a detail page\'s `actions[]` and a CRUD dialog\'s ' + @@ -530,6 +539,15 @@ const DOC_TYPE_EXEMPTIONS = { 'filter enum, alongside `date-picker` / `number-range`.', }, 'content/docs/components/complex/view-switcher.mdx': { + kanban: + 'ViewSwitcher `views[].type` — the VIEW-TYPE vocabulary (`ViewType`, ' + + 'packages/types/src/views.ts), which is what a switcher tab names, not a node type. ' + + 'The nested `schema` in the very same snippet carries the node type. Needed from ' + + 'objectui#8802, which retired the bare `kanban` NODE type key — until then the value ' + + 'passed by coincidence, the two vocabularies sharing one spelling. ⛔ The stored/view-type ' + + 'spelling is deliberately NOT retired: `ObjectView` maps a stored `kanban` view onto the ' + + '`object-kanban` node type, and renaming it would break every stored kanban view in every ' + + 'deployment.', share: 'First member of a TypeScript union of view-action ids (`\'share\' | \'settings\' | ' + '\'duplicate\' | \'delete\'`) in a Schema API declaration, not a node type.', diff --git a/skills/objectui/guides/page-builder.md b/skills/objectui/guides/page-builder.md index 6df13594df..ac30a6e6c0 100644 --- a/skills/objectui/guides/page-builder.md +++ b/skills/objectui/guides/page-builder.md @@ -254,7 +254,7 @@ renderers do (`schema.objectName`, `schema.columns`, `schema.fields`, ```json { - "type": "kanban", + "type": "object-kanban", "objectName": "tasks", "groupBy": "status", "bind": "tasks" @@ -265,7 +265,7 @@ renderers do (`schema.objectName`, `schema.columns`, `schema.fields`, ```json { - "type": "gantt", + "type": "object-gantt", "objectName": "project_task", "gantt": { "titleField": "name", From 99983ee1b4f6346c9ccc8f10082d545a38d285d7 Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 9 Sep 2026 16:26:04 +0000 Subject: [PATCH 2/7] test(kanban,gantt): re-point the retirement pins and add the two family retirement pins Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../bare-gantt-node-key-retired-8008.test.ts | 79 +++++++++++++++++++ .../check-handler-key-read-sites.test.ts | 64 +++++++++++---- 2 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 packages/plugin-gantt/src/__tests__/bare-gantt-node-key-retired-8008.test.ts diff --git a/packages/plugin-gantt/src/__tests__/bare-gantt-node-key-retired-8008.test.ts b/packages/plugin-gantt/src/__tests__/bare-gantt-node-key-retired-8008.test.ts new file mode 100644 index 0000000000..856a99aabd --- /dev/null +++ b/packages/plugin-gantt/src/__tests__/bare-gantt-node-key-retired-8008.test.ts @@ -0,0 +1,79 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The bare `gantt` NODE TYPE key is retired; `object-gantt` is the one spelling + * this plugin serves (objectui#8008, maintainer ruling 2026-09-09, route 3). + * + * ## ⚠️ Why this is a plain unregistration and not a named refusal + * + * `BaseSchema` is `.passthrough()`, so a dropped MEMBER key is KEPT, not + * refused, and a retirement that forgets it ships a document that validates + * green and renders nothing (objectui#7664). That hazard needs a schema face to + * arise on. This key never had one: `@object-ui/types` declares `gantt` as a + * component node type ZERO times — `ObjectGanttSchema.type` is the literal + * `'object-gantt'` and nothing else names the bare spelling. ⇒ Unregistering IS + * the retirement here, which is exactly what separates this card from its + * `kanban` sibling (objectui#8802), where an arm DID exist and therefore got a + * named refusal. + * + * ## ⛔ The layer this does not touch + * + * `gantt` also names a STORED `NamedListView.type`. `plugin-view`'s `ObjectView` + * maps a stored `gantt` view onto the node type `object-gantt` already, so no + * saved view moves. That layer is pinned in `@object-ui/types` + * (`__tests__/bare-kanban-node-key-retired-8802.test.ts`, which guards BOTH + * spellings) and deliberately not restated here — this package cannot see it. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ComponentRegistry } from '@object-ui/core'; +import '../index'; + +const INDEX_TSX = join(dirname(fileURLToPath(import.meta.url)), '..', 'index.tsx'); + +describe('the bare `gantt` node type key is retired (objectui#8008)', () => { + it('`gantt` resolves to nothing, under the bare AND the namespaced spelling', () => { + // `register(key, C, { namespace })` stores BOTH, so checking one spelling + // would leave the other resolving. + expect(ComponentRegistry.has('gantt')).toBe(false); + expect(ComponentRegistry.getConfig('gantt', 'view')).toBeFalsy(); + }); + + it('FIRING CONTROL — `object-gantt` still resolves, so the `false` above is a reading', () => { + // Without this the assertion above would also pass against a registry that + // had failed to load this package at all. + expect(ComponentRegistry.has('object-gantt')).toBe(true); + expect(ComponentRegistry.getConfig('object-gantt', 'plugin-gantt')).toBeTruthy(); + }); + + it('the source registers ONE key, measured off disk rather than through the registry', () => { + // The registry answer above is about this process; this one is about the + // file, so a registration added in a form the registry happens not to reach + // still shows up. + // ⚠️ Comments are STRIPPED first, and that is load-bearing rather than + // tidy: the retirement left a tombstone docblock that quotes the removed + // `ComponentRegistry.register('gantt', …)` call verbatim, so a raw scan + // reads the retired key back out of the prose that records its removal. + // Measured — this exact leg failed that way on its first run. + const src = readFileSync(INDEX_TSX, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^[ \t]*\/\/.*$/gm, ''); + const keys = [...src.matchAll(/ComponentRegistry\.register\(\s*'([^']+)'/g)].map((m) => m[1]); + // Anti-vacuity for the extraction: a regex that matched nothing would make + // the equality below hold forever. + expect(keys.length).toBeGreaterThan(0); + expect(keys).toEqual(['object-gantt']); + // And the stripper is not eating the code: the surviving registration's own + // renderer name survives the strip. + expect(src).toContain('ObjectGanttRenderer'); + }); +}); diff --git a/scripts/__tests__/check-handler-key-read-sites.test.ts b/scripts/__tests__/check-handler-key-read-sites.test.ts index b67363099f..3c4056859a 100644 --- a/scripts/__tests__/check-handler-key-read-sites.test.ts +++ b/scripts/__tests__/check-handler-key-read-sites.test.ts @@ -441,18 +441,34 @@ describe('check-handler-key-read-sites — this repository', () => { ).toEqual([]); // Named rather than counted: these three are the reason this gate exists. - // They sit four hops from `ComponentRegistry.register('kanban', …)`, so a - // walk that stopped following the document would leave the green above - // intact while losing exactly the instance the card was filed for. + // They sit four hops from the kanban plugin's registration, so a walk that + // stopped following the document would leave the green above intact while + // losing exactly the instance the card was filed for. + // + // ⚠️ Re-keyed by objectui#8802, and the re-key CHANGES ONE READING rather + // than merely renaming a string. The rows used to be `kanban.*`, DECLARED, + // carrying the RUNTIME SLOT disposition off the `'kanban'` Zod arm. That + // arm retired with the bare node type key, and the surviving + // `object-kanban` face declares none of the three — so the walk still finds + // all three reads (which is what this leg is for) and now reports them + // UNDECLARED, waived by the `object-kanban::…` rows objectui#7804 already + // owns in `KNOWN_UNDECLARED_READS`. ⛔ Not repaired here: declaring them on + // `ObjectKanbanSchema` widens a published accept set, which is a ruling. const judged = result.census.map((c) => `${c.type}.${c.key}`); - expect(judged).toContain('kanban.onCardClick'); - expect(judged).toContain('kanban.onCardMove'); - expect(judged).toContain('kanban.onQuickAdd'); + expect(judged).toContain('object-kanban.onCardClick'); + expect(judged).toContain('object-kanban.onCardMove'); + expect(judged).toContain('object-kanban.onQuickAdd'); for (const key of ['onCardClick', 'onCardMove', 'onQuickAdd']) { - const row = result.census.find((c) => c.type === 'kanban' && c.key === key); - expect(row?.declared, `'kanban'.${key} must be a declared member`).toBe(true); - expect(row?.disposition, `'kanban'.${key} must carry the RUNTIME SLOT disposition`).toBe('runtime-slot'); + const row = result.census.find((c) => c.type === 'object-kanban' && c.key === key); + expect(row?.declared, `'object-kanban'.${key} is undeclared since objectui#8802`).toBe(false); } + + // FIRING CONTROL for the `false`s above: the census still reports DECLARED + // runtime slots elsewhere, so `declared: false` is a reading about this face + // and not a census that lost its dispositions. + const chatbotSend = result.census.find((c) => c.type === 'chatbot' && c.key === 'onSend'); + expect(chatbotSend?.declared).toBe(true); + expect(chatbotSend?.disposition).toBe('runtime-slot'); }); /** @@ -491,11 +507,29 @@ describe('check-handler-key-read-sites — this repository', () => { it('derives its arms from the mirrors, including the one the card is about', () => { const { arms } = collectArms(repoRoot); - const kanban = arms.get('kanban'); - expect(kanban?.schema).toBe('KanbanSchema'); - expect(kanban?.file).toBe('complex.zod.ts'); - expect(kanban?.unresolved, 'the `kanban` arm must resolve completely, or its reads go unjudged').toEqual([]); - expect(kanban?.members.get('onCardClick')).toBe('runtime-slot'); - expect(kanban?.members.get('onColumnAdd')).toBe('retired'); + + // ⚠️ The worked example was the `'kanban'` arm (`complex.zod.ts`, + // `KanbanSchema`) until objectui#8802 retired the bare node type key and the + // arm with it. `object-kanban` is the surviving kanban face and is what the + // gate now resolves the plugin's reads against. + const objectKanban = arms.get('object-kanban'); + expect(objectKanban?.schema).toBe('ObjectKanbanSchema'); + expect(objectKanban?.file).toBe('objectql.zod.ts'); + expect( + objectKanban?.unresolved, + 'the `object-kanban` arm must resolve completely, or its reads go unjudged', + ).toEqual([]); + // It declares real members — the anti-vacuity half, so "resolves completely" + // is not satisfied by an empty arm. + expect(objectKanban?.members.has('groupBy')).toBe(true); + // ⛔ And it declares NONE of the three handler keys the plugin reads. That is + // the reading objectui#8802 moved; it is recorded, not repaired. + expect(objectKanban?.members.get('onCardClick')).toBeUndefined(); + + // A live arm that still carries both dispositions, so this leg keeps + // proving the resolver can read them at all. + const chatbot = arms.get('chatbot'); + expect(chatbot?.members.get('onSend')).toBe('runtime-slot'); + expect(chatbot?.members.get('onSendMessage')).toBe('retired'); }); }); From c695a1c8d774d7d251d0c1ef3b95603f944d5c4d Mon Sep 17 00:00:00 2001 From: os-warren Date: Wed, 9 Sep 2026 17:05:23 +0000 Subject: [PATCH 3/7] refactor(types): drop the three imports that left with the retired `kanban` arm Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- packages/types/src/complex.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 9825086681..ba3d4d3883 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -19,10 +19,13 @@ import type { DashboardWidget as SpecDashboardWidget, DateRangeDefaultRange as SpecDateRangeDefaultRange, GlobalFilter as SpecGlobalFilter, - GroupingConfig, } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; -import type { KanbanConditionalFormattingRule, ViewNavigationConfig } from './objectql.js'; +// `GroupingConfig`, `KanbanConditionalFormattingRule` and `ViewNavigationConfig` +// were imported for `KanbanSchema`'s `grouping`, `conditionalFormatting` and +// `navigation` members and had no other reader in this module; they left with +// the retired `'kanban'` arm (objectui#8802). All three are still declared and +// still exported from their own modules. /** * Kanban card — the shape the registered `'kanban'` renderer reads. From 4dcf5445203c329127b69743c2b6c202e54bfa6f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 23:49:33 +0000 Subject: [PATCH 4/7] chore(skills): split the governed page-builder guide out of this PR `skills/objectui/guides/page-builder.md` is a governed surface (`GOVERNED_SURFACES` -> `skills-catalog`), so a PR containing it can only be merged by a human. Carrying it here held `packages/types/src/complex.ts`, `zod/complex.zod.ts` and `packages/plugin-kanban/**` against every other slice. Maintainer ruling, 2026-09-09: split the skills file out. This commit reverts that one file to its merge-base blob (6df13594d), leaving this PR at 49 files on the normal queue path. The edit itself moves, unchanged, to its own PR for a human to merge. No content of the guide is changed here and nothing else on this branch is touched: the revert restores blob 6df13594d exactly, and the same two one-word type-literal corrections are reproduced byte-identically on the new branch (blob ac30a6e6c). History is NOT rewritten -- this is an added commit, no rebase, no amend, no force-push, because other seats read this branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- skills/objectui/guides/page-builder.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/objectui/guides/page-builder.md b/skills/objectui/guides/page-builder.md index ac30a6e6c0..6df13594df 100644 --- a/skills/objectui/guides/page-builder.md +++ b/skills/objectui/guides/page-builder.md @@ -254,7 +254,7 @@ renderers do (`schema.objectName`, `schema.columns`, `schema.fields`, ```json { - "type": "object-kanban", + "type": "kanban", "objectName": "tasks", "groupBy": "status", "bind": "tasks" @@ -265,7 +265,7 @@ renderers do (`schema.objectName`, `schema.columns`, `schema.fields`, ```json { - "type": "object-gantt", + "type": "gantt", "objectName": "project_task", "gantt": { "titleField": "name", From 6169732146866b19820d8ec9024bda8f606a376b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 02:09:15 +0000 Subject: [PATCH 5/7] fix(runner): re-key the plugin-integration pin to the surviving object-kanban key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract-review REWORK round on the kanban/gantt family retirement. F1 (blocking, CI red and this PR caused it) — packages/runner was outside the previous round's sweep and outside both --filter type-checks, so the retirement of the bare `kanban` key in `kanbanComponents` left `packages/runner/src/plugin-integration.test.ts` reading a key that no longer exists: `Type Check` red with 4x TS2339, `Test (shard 2/4)` red with 3 failing assertions. Re-keyed to `kanbanComponents['object-kanban']` and added a control that pins BOTH directions — the survivor resolves, and `kanban` / `kanban-ui` / `kanban-enhanced` resolve to nothing — so the re-key cannot be satisfied by a map that was merely renamed wholesale. F3 — the package README still taught the retired spelling in two places the gates cannot see (they compile annotated fences, and an untyped object literal in an annotated fence compiles either way): the "Zero Configuration" bullet and the "Automatic Registration" block, the latter authoring a shape `object-kanban` now refuses (no `groupBy`, no record source). Both migrated to the shape the catalog fixture carries. Same class found and fixed in plugin-gantt/README.md, which this PR already edits: four untyped `type: 'gantt'` literals and one sentence asserting that `type: 'gantt'` "is a registered type", which this PR makes false. `content/docs/plugins/plugin-kanban.mdx` imported `ObjectKanbanSchema` from `@object-ui/plugin-kanban`, which does not export it; pointed at `@object-ui/types`, with the note kept out of the fence so the `check:doc-fences` ledger reading does not move. F4 — the changeset and the barrel both claimed `KanbanEnhanced` is "still importable from `@object-ui/plugin-kanban/KanbanEnhanced`". That subpath has never resolved: `exports` publishes `.` and `./style.css` only, and the barrel never re-exported the component. Both statements corrected to what is true; the file itself is deliberately left in place, since deleting published-but- unreachable source is a further narrowing that needs its own ruling. F5 — the FROM->TO table omitted the namespaced spellings. `register(type, C, { namespace })` stores both keys, so `view:kanban` and `view:gantt` retire with the registrations. Named in the changeset with the consequence: in apps/console those keys now fall to the protocol placeholder rather than OBJUI-001, and the generated CLI list still blesses them (via the placeholder registration), so `objectui check` stays green while the node renders a placeholder. F6 — the changeset omitted `@object-ui/components`, whose `src/` tree changed. Added at `patch`, together with `@object-ui/runner` for the same reason: the presence gate counts 7 released packages with changed source and the changeset now names all 7. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- ...257-8008-kanban-gantt-family-retirement.md | 50 ++++++++++++++++++- content/docs/plugins/plugin-kanban.mdx | 8 ++- packages/plugin-gantt/README.md | 10 ++-- packages/plugin-kanban/README.md | 22 ++++++-- packages/plugin-kanban/src/index.tsx | 15 ++++-- .../runner/src/plugin-integration.test.ts | 27 ++++++++-- 6 files changed, 112 insertions(+), 20 deletions(-) diff --git a/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md b/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md index 238c17476e..4c01a680be 100644 --- a/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md +++ b/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md @@ -2,8 +2,10 @@ '@object-ui/types': minor '@object-ui/plugin-kanban': minor '@object-ui/plugin-gantt': minor +'@object-ui/components': patch '@object-ui/cli': patch '@object-ui/console': patch +'@object-ui/runner': patch --- Four node type keys retire, and the kanban and gantt families converge on their @@ -69,5 +71,49 @@ pinned where it can be seen rather than left to be rediscovered. `objectName`; a purely static board (lanes carrying their own cards, no record source) adds `"groupBy"` and `"data": []`. `kanban-ui` and `kanban-enhanced` have no authored documents anywhere in this repository to migrate. -`KanbanRenderer` and `KanbanEnhanced` are still exported and still importable — -only their registry keys are gone. + +**⚠️ The namespaced spellings retire with the registrations — `view:kanban` and +`view:gantt` are the same two keys.** `ComponentRegistry.register(type, C, +{ namespace })` stores BOTH `namespace:type` and a bare-`type` fallback, so +every one of these keys had a namespaced twin that goes with it: + +| retired spelling | namespaced twin | author instead | +| --- | --- | --- | +| `kanban` | `view:kanban` | `object-kanban` | +| `gantt` | `view:gantt` | `object-gantt` | +| `kanban-ui` | `plugin-kanban:kanban-ui` | `object-kanban` | +| `kanban-enhanced` | `plugin-kanban:kanban-enhanced` | `object-kanban` | + +Both spellings are pinned as gone, each against a firing control on the +surviving key, in `plugin-kanban/src/__tests__/kanban-family-registry-keys-retired-8257.test.ts` +and `plugin-gantt/src/__tests__/bare-gantt-node-key-retired-8008.test.ts`. + +**What an unmigrated `view:kanban` / `view:gantt` node now renders depends on +the host.** In `apps/console` it renders the protocol **placeholder** panel, not +the OBJUI-001 "Unknown component type" error: the console calls the opt-in +`registerPlaceholders()` (`@object-ui/components`, `renderers/placeholders.tsx`) +*after* its plugin registrations, `view:kanban` and `view:gantt` are both in +that file's `PROTOCOL_COMPONENTS` list, and the placeholder only claims a key +nothing else has taken — which, until this change, `@object-ui/plugin-kanban` +and `@object-ui/plugin-gantt` had. In every other host, which does not call that +bootstrap, the same node renders OBJUI-001. + +**⚠️ `objectui check` will NOT flag either namespaced spelling.** The CLI's +`known-schema-types.ts` is generated from the repository's real registration +calls, and the placeholder registration is a real one — so `view:kanban` and +`view:gantt` are still on that list and still validate green, while the node +renders a placeholder rather than a board. The bare `kanban` / `gantt` entries +DID leave the generated list; only the namespaced pair survives, and only +because of the placeholder. Grep your documents for the namespaced spellings +directly; do not rely on `objectui check` to find them. + +`KanbanRenderer` is still exported from this package's entry point +(`@object-ui/plugin-kanban`); only its registry key is gone. ⚠️ `KanbanEnhanced` +is a different case, and the earlier draft of this note stated it wrongly: this +package's `exports` map has exactly two entries — `.` and `./style.css` — and +the barrel never re-exported the component, so +`@object-ui/plugin-kanban/KanbanEnhanced` has never been a resolvable specifier +for a consumer. With `kanban-enhanced` unregistered, `KanbanEnhanced.tsx` has +zero non-test importers. ⛔ The file is deliberately left in place: deleting +published-but-unreachable source is a further narrowing and needs its own +maintainer ruling, which this change does not have. diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index d49f35d643..bdb4e16a27 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -423,8 +423,14 @@ The kanban board includes: ## TypeScript Support +`ObjectKanbanSchema` is published by `@object-ui/types` (and its Zod mirror by +`@object-ui/types/zod`); `@object-ui/plugin-kanban` publishes the card and column +shapes. Importing all three from the plugin does not resolve — its barrel never +exported the schema type. + ```plaintext -import type { ObjectKanbanSchema, KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban' +import type { ObjectKanbanSchema } from '@object-ui/types' +import type { KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban' const column: KanbanColumn = { id: 'todo', diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md index d0a3f9cfb4..a38755b6b6 100644 --- a/packages/plugin-gantt/README.md +++ b/packages/plugin-gantt/README.md @@ -230,7 +230,7 @@ chart renders empty: ```typescript const recordSource = { - type: 'gantt', + type: 'object-gantt', // Pick ONE of the three: objectName: 'project_tasks', // load through the host DataSource @@ -257,7 +257,7 @@ block was discarded silently. ```typescript const fieldMapping = { - type: 'gantt', + type: 'object-gantt', objectName: 'project_tasks', // (a) flat spelling — read only when there is no `gantt` block, @@ -290,7 +290,7 @@ the renderer's `'day'` fallback. Earlier revisions of this README showed a task-array schema. Those keys have no read site anywhere in `src/` — a schema built from them renders an **empty -chart with no diagnostic**, because `type: 'gantt'` *is* a registered type, so +chart with no diagnostic**, because `type: 'object-gantt'` *is* a registered type, so the node mounts and simply finds nothing to draw: | Key shown before | Status | Use instead | @@ -442,7 +442,7 @@ names are yours; only the `*Field` keys are fixed vocabulary. ```typescript const schema = { - type: 'gantt', + type: 'object-gantt', viewMode: 'week', startDateField: 'start', endDateField: 'end', @@ -579,7 +579,7 @@ it needs the field mapping beside it (or a `gantt` block of its own): ```typescript const schema = { - type: 'gantt', + type: 'object-gantt', viewMode: 'month', objectName: 'project_tasks', startDateField: 'start_date', diff --git a/packages/plugin-kanban/README.md b/packages/plugin-kanban/README.md index 10e0796a66..66d6b4a24e 100644 --- a/packages/plugin-kanban/README.md +++ b/packages/plugin-kanban/README.md @@ -5,7 +5,7 @@ A lazy-loaded kanban board component for Object UI based on @dnd-kit for drag-an ## Features - **Internal Lazy Loading**: @dnd-kit libraries are loaded on-demand using `React.lazy()` and `Suspense` -- **Zero Configuration**: Just import the package and use `type: 'kanban'` in your schema +- **Zero Configuration**: Just import the package and use `type: 'object-kanban'` in your schema - **Automatic Registration**: Components auto-register with the ComponentRegistry - **Skeleton Loading**: Shows a skeleton while @dnd-kit loads - **Drag and Drop**: Full drag-and-drop support for cards between columns @@ -39,9 +39,19 @@ themed styling at all ([#4929](https://github.com/objectstack-ai/objectui/issues // In your app entry point (e.g., App.tsx or main.tsx) import '@object-ui/plugin-kanban'; -// Now you can use kanban type in your schemas +// Now you can author the `object-kanban` node type in your schemas. +// ⛔ The bare `kanban` node type key RETIRED in objectui#8802 — a +// `{ type: 'kanban' }` document is now refused BY NAME and told to write +// `object-kanban`. (The STORED `NamedListView.type` value `"kanban"` is a +// different layer and is unaffected — do not rewrite saved views.) +// +// `groupBy` and ONE record source (`data`, `bind` or `objectName`) are what the +// surviving face requires of every board; the shape below is the one the +// catalog fixture `plugin-kanban/basic-kanban-board.json` carries. const schema = { - type: 'kanban', + type: 'object-kanban', + groupBy: 'status', + data: [], columns: [ { id: 'todo', @@ -145,7 +155,9 @@ interface KanbanColumn { cards: KanbanCard[]; limit?: number; // WIP limit — the count at which the lane warns className?: string; - collapsed?: boolean; // Lane renders collapsed (honoured by KanbanEnhanced) + collapsed?: boolean; // Lane renders collapsed (read by the KanbanEnhanced + // source module, which objectui#8257 left + // with no registry key — see CHANGELOG) color?: never; // RETIRED — refused by name; style a lane through className } @@ -189,7 +201,7 @@ When bundled, Vite automatically creates separate chunks: - `index.js` (~200 bytes) - The entry point - `KanbanImpl-xxx.js` (~100-150 KB) - The lazy-loaded implementation -The @dnd-kit libraries are only downloaded when a `kanban` component is actually rendered, not on initial page load. +The @dnd-kit libraries are only downloaded when an `object-kanban` component is actually rendered, not on initial page load. ## Bundle Size Impact diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 4e8b86c8aa..7ec8e6479e 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -330,9 +330,18 @@ export const kanbanComponents = { * the same firing control (`object-grid`, 2 JSON / 128 TS) and silent control * (`zzz-not-a-type`, 0) the `kanban-ui` note above cites. * - * `KanbanEnhanced` itself is untouched and still importable from - * `@object-ui/plugin-kanban/KanbanEnhanced`; what is gone is the registry key - * and the `React.lazy` wrapper that existed only to serve it. + * `KanbanEnhanced.tsx` itself is untouched on disk. ⛔ It is NOT, and never + * was, reachable from outside this package: `package.json` `exports` publishes + * exactly two entries — `.` and `./style.css` — and this barrel does not + * re-export the component, so `@object-ui/plugin-kanban/KanbanEnhanced` has + * never been a resolvable specifier for a consumer. (An earlier revision of + * this note claimed it was; that claim was wrong and is corrected here rather + * than deleted, because it is what a reader would otherwise copy.) What this + * card removes is the registry key and the `React.lazy` wrapper that existed + * only to serve it; what it leaves behind is a module with zero non-test + * importers — `cardPredicateScope.test.tsx` reaches it by relative path. + * ⛔ Deleting the file is a FURTHER narrowing of published source and needs its + * own maintainer ruling, which this card does not carry, so it stays. * * Pinned in `src/__tests__/kanban-family-registry-keys-retired-8257.test.ts`. */ diff --git a/packages/runner/src/plugin-integration.test.ts b/packages/runner/src/plugin-integration.test.ts index 3273700b83..f1224839f9 100644 --- a/packages/runner/src/plugin-integration.test.ts +++ b/packages/runner/src/plugin-integration.test.ts @@ -16,13 +16,32 @@ describe('Plugin Integration Protocol', () => { describe('Kanban Plugin', () => { it('should export components object for manual registration', () => { expect(kanbanComponents).toBeDefined(); - expect(kanbanComponents.kanban).toBeDefined(); + expect(kanbanComponents['object-kanban']).toBeDefined(); }); it('should contain valid React component', () => { - const Component = kanbanComponents.kanban; + const Component = kanbanComponents['object-kanban']; expect(typeof Component).toBe('function'); // React components are functions }); + + /** + * Control for the re-key above (objectui#8802 / objectui#8257). + * + * This map published a bare `kanban` key until those two cards retired the + * `kanban`, `kanban-ui` and `kanban-enhanced` node type keys. Asserting the + * surviving key on its own would pass just as well against a map that had + * merely been renamed wholesale, so pin BOTH directions: the new key + * resolves to a component, and the retired spellings resolve to nothing. + * `kanbanComponents` is the manual-registration face a host copies keys + * from, so a retired key silently reappearing here would re-teach it. + */ + it('publishes only the surviving `object-kanban` key', () => { + expect(Object.keys(kanbanComponents)).toEqual(['object-kanban']); + const retired = kanbanComponents as Record; + expect(retired.kanban).toBeUndefined(); + expect(retired['kanban-ui']).toBeUndefined(); + expect(retired['kanban-enhanced']).toBeUndefined(); + }); }); describe('Charts Plugin', () => { @@ -45,8 +64,8 @@ describe('Plugin Integration Protocol', () => { // Note: In a real app we wouldn't clear, but here we want to prove registration works // Act: Manually register - if (kanbanComponents?.kanban) { - ComponentRegistry.register('test-kanban-manual', kanbanComponents.kanban); + if (kanbanComponents?.['object-kanban']) { + ComponentRegistry.register('test-kanban-manual', kanbanComponents['object-kanban']); } // Assert From cf31b51ed6542c5c8251ed8d83df0663b0158b53 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 02:36:00 +0000 Subject: [PATCH 6/7] =?UTF-8?q?docs(plugin-kanban):=20close=20F8=20?= =?UTF-8?q?=E2=80=94=20document=20data/bind=20and=20stop=20authoring=20onC?= =?UTF-8?q?ardMove=20as=20a=20document=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two doc-only defects from the CONTRACT_REVIEW_TIER verdict's F8 (PR comment 5610751162), missed by the prior repair round's truncated dispatch (F1-F6 only): 1. content/docs/api/schema-reference.md's ObjectKanbanSchema table omitted `data`/`bind` even though this PR's own migration note requires `"data": []` for a purely static board. Both ride BaseSchema (declared there, linked from this section) rather than being narrowed on ObjectKanbanSchema itself, so they are documented with a prose note in the same style already used here for the `columns` collateral-class disclosure — no schema face changes. 2. content/docs/plugins/plugin-kanban.mdx authored `onCardMove` as a member of typed `ObjectKanbanSchema` object literals in five places (Basic Usage, the Schema API shape + Properties table, Project Task Board, and the Event Handling `useBoard` hook), contradicting packages/plugin-kanban/README.md:130 ("`onCardMove` is NOT a document key: it is a React prop the host supplies"). Every instance now splits `onCardMove` out into its own binding, matching the README's corrected pattern. check:doc-fences ledger unmoved (80 files / 89 blocks, exit 0) — the explanatory notes went in prose above fences, never as a fence-opening comment inside one. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- content/docs/api/schema-reference.md | 2 + content/docs/plugins/plugin-kanban.mdx | 53 ++++++++++++++++---------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index afa34e5743..ee2be61890 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -946,6 +946,8 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t > The retired `kanban` arm declared `columns`, `cardTitle`, `swimlaneField`, `grouping` and `navigation`; the `object-kanban` face never did, and it is unchanged. The renderer still reads those keys, so a board may carry them — they are simply not judged. The board's React host supplies `onCardMove` / `onCardClick` / `onQuickAdd` as props; none of the three is authorable in JSON. +> `data` and `bind` are [`BaseSchema`](#baseschema) members, not narrowed here, but this face requires **one of** `bind`, `data`, `objectName` — the renderer's own record-source ladder (an external `data` prop → `bind` via `useDataScope` → this schema's own `data` → a fetch keyed by `objectName`). A purely static board (lanes carrying their own cards, no record source) authors `"groupBy"` and `"data": []`. + **Related:** [ObjectViewSchema](#objectviewschema), [ObjectGridSchema](#objectgridschema) --- diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index bdb4e16a27..c5180a0843 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -58,10 +58,13 @@ const schema: ObjectKanbanSchema = { title: 'Done', cards: [] } - ], - onCardMove: (cardId: string, fromCol: string, toCol: string, index: number) => { - console.log(`Card ${cardId} moved`) - } + ] +} + +// `onCardMove` is NOT a document key — JSON has no function value. The host +// supplies it as a React prop, separate from `schema` above. +const onCardMove = (cardId: string, fromCol: string, toCol: string, index: number) => { + console.log(`Card ${cardId} moved`) } ``` @@ -75,13 +78,16 @@ const schema: ObjectKanbanSchema = { ## Schema API +`onCardMove` is NOT a document key: it is a React prop the host supplies (JSON +has no function value), never a member of the schema object below — +`packages/plugin-kanban/README.md` documents this the same way. + ```plaintext { type: 'object-kanban', groupBy: 'status', data: [], columns?: KanbanColumn[], - onCardMove?: (cardId, fromColumnId, toColumnId, newIndex) => void, className?: string } @@ -111,7 +117,6 @@ interface KanbanCard { | `columns` | KanbanColumn[] | Array of column definitions | | `filter` | ViewFilterRule[] | Query filter for an object-driven board, lowered to `$filter` | | `limit` | number | Rows fetched by an object-driven board (default 100) | -| `onCardMove` | function | Callback when a card is moved | | `className` | string | Additional Tailwind CSS classes | ### Column Properties @@ -233,11 +238,14 @@ const taskBoard: ObjectKanbanSchema = { title: 'Done', cards: [] } - ], - onCardMove: (cardId: string, fromCol: string, toCol: string, index: number) => { - // Update backend/state - console.log(`Moved ${cardId} from ${fromCol} to ${toCol}`) - } + ] +} + +// `onCardMove` is NOT a document key — the host supplies it as a React prop, +// separate from `taskBoard` above. +const onCardMove = (cardId: string, fromCol: string, toCol: string, index: number) => { + // Update backend/state + console.log(`Moved ${cardId} from ${fromCol} to ${toCol}`) } ``` @@ -390,18 +398,21 @@ export function useBoard(initialColumns: KanbanColumn[]) { groupBy: 'status', data: [], columns, - // `onCardMove` returns void, so the callback cannot be awaited by the board: - // start the write and update local state without blocking the drop. - onCardMove: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => { - // Update database - void updateCardColumn(cardId, toColumnId, newIndex) - - // Update local state - setColumns((prev) => moveCard(prev, cardId, toColumnId, newIndex)) - }, } - return schema + // `onCardMove` is NOT a document key — the host supplies it as a React prop, + // separate from `schema` above. It returns void, so the callback cannot be + // awaited by the board: start the write and update local state without + // blocking the drop. + const onCardMove = (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => { + // Update database + void updateCardColumn(cardId, toColumnId, newIndex) + + // Update local state + setColumns((prev) => moveCard(prev, cardId, toColumnId, newIndex)) + } + + return { schema, onCardMove } } ``` From 9b36ffdb278fc046a010c06ce5b6c3df5abc9d6d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 07:28:24 +0000 Subject: [PATCH 7/7] fix(sdui-parser,plugin-kanban): re-key the objectui#8285 Quick Add diagnostic to the surviving object-kanban tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#8922 landed `quickAddIsDiagnosedNotDropped-8285.test.ts` after this branch was cut. It re-derives its host-tag set from `plugin-kanban/src/index.tsx`'s `ComponentRegistry.register(…, ObjectKanbanRenderer)` calls and builds its manifest from the LIVE registry, so it is an importer of exactly what this branch retires — and it goes red only on the MERGED tree, which is what the queue builds and what a `mergeable_state` computed against a stale base cannot see. Measured on the merge of origin/main f4abff499: 4 failed / 5 passed, against 9/9 green on pure main and a file that does not exist on this branch's head. The subject stays pinned: the html tier NAMES the Quick Add pair instead of dropping the key. Only the set of tags it holds over shrinks. `QUICK_ADD_HOST_TYPES` drops `kanban`, and that is a measurement, not a convenience. `checkKanbanQuickAdd` has exactly one call site — `validate.ts`'s per-prop walk — and that walk runs only in the branch where `manifest.components[node.type]` resolved. A tag no registration produces is answered one level up by `unknown-component`, an error, and its props are never walked at all. On a manifest built from the live registry `` therefore draws `error/unknown-component` and nothing else, against a firing control on `` that draws `warning/inert-quick-add`. Nothing is silently dropped by the narrowing: the retired spelling is refused BY NAME at the tag, which is louder than this warning, and stacking both is the two-diagnostics-for-one-mistake shape `checkMemberTypes` already refuses. The diagnostic's remedy text is falsified by the same retirement and moves with it: it used to send authors to ``, a tag objectui#8257 retires, so a page written to the old advice draws `unknown-component`. It now names `KanbanRenderer` from `@object-ui/plugin-kanban`, which is still exported and still forwards both halves by identity. `content/docs/plugins/plugin-kanban.mdx` carried the same false sentence and is corrected the same way. The retired spellings keep a hand-built manifest row in `packages/sdui-parser` asserting they are NOT hosts — the discrimination a synthetic manifest can make and the live one cannot. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- ...257-8008-kanban-gantt-family-retirement.md | 25 +++++++ content/docs/plugins/plugin-kanban.mdx | 18 +++-- ...quickAddIsDiagnosedNotDropped-8285.test.ts | 46 ++++++++++--- .../__tests__/kanban-quick-add-8285.test.ts | 69 +++++++++++++------ packages/sdui-parser/src/kanban-quick-add.ts | 44 +++++++++--- 5 files changed, 156 insertions(+), 46 deletions(-) diff --git a/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md b/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md index 4c01a680be..dc9d713057 100644 --- a/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md +++ b/.changeset/8802-8257-8008-kanban-gantt-family-retirement.md @@ -6,6 +6,7 @@ '@object-ui/cli': patch '@object-ui/console': patch '@object-ui/runner': patch +'@object-ui/sdui-parser': minor --- Four node type keys retire, and the kanban and gantt families converge on their @@ -117,3 +118,27 @@ for a consumer. With `kanban-enhanced` unregistered, `KanbanEnhanced.tsx` has zero non-test importers. ⛔ The file is deliberately left in place: deleting published-but-unreachable source is a further narrowing and needs its own maintainer ruling, which this change does not have. + +**⚠️ `@object-ui/sdui-parser`: `QUICK_ADD_HOST_TYPES` loses `kanban` with the +registration.** The `inert-quick-add` diagnostic (objectui#8285) named the two +tags `ObjectKanbanRenderer` answered to; one of them retires here, so the set is +now `{ 'object-kanban' }`. ⛔ Nothing is silently dropped by that narrowing, and +this is measured rather than argued: `checkKanbanQuickAdd` has exactly one call +site — `validate.ts`'s per-prop walk — and that walk runs only in the branch +where the manifest RESOLVED the tag. A tag no registration produces is answered +one level up by `unknown-component`, an **error**, and its props are never +walked, so on a manifest built from the live registry a `` node +draws `error/unknown-component` and nothing else, against a firing control on +`` that still draws `warning/inert-quick-add`. Keeping +`kanban` in the set would have been reachable only through a hand-built manifest +declaring a component of that name — which, after this retirement, is somebody +else's component, and the message asserts things about `ObjectKanban` that would +be false of it. This supersedes the `kanban` half of the objectui#8285 entry. + +**The diagnostic's remedy text moves from a tag to a component.** It used to end +"render `` from a React host that passes `onQuickAdd`". That sentence +is falsified by this change: `kanban-ui` is no longer a node type key, so a page +written to the old advice draws `unknown-component`. It now names +`KanbanRenderer` from `@object-ui/plugin-kanban` — still exported, still +forwarding both halves by identity — which is the surviving way to get the pair. +`content/docs/plugins/plugin-kanban.mdx` says the same thing the same way. diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index ea56031d36..79405c2b8b 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -416,25 +416,31 @@ export function useBoard(initialColumns: KanbanColumn[]) { } ``` -### Quick Add is a `kanban-ui` capability, not an object-driven one +### Quick Add is a React-host capability, not a document one The Quick Add button at the bottom of a lane is gated on **both** halves of a pair: `quickAdd`, and an `onQuickAdd` handler. `onQuickAdd` is a runtime slot — a function — so it can only be supplied by a React host that mounts the board -directly, which is the `kanban-ui` block above. +component directly: `KanbanRenderer`, exported from `@object-ui/plugin-kanban`, +forwards both halves by identity. + +⚠️ There is **no node type key** that reaches it. The `kanban-ui` registration +retired with objectui#8257, so a document naming that tag is now an +`unknown-component` **error** rather than a board with a working pair. Import the +component instead. An **object-driven** board (`object-kanban`, or a kanban view inside `ListView` / `ObjectView`) supplies no `onQuickAdd` of its own, and a page written as constrained JSX cannot write one either: that tier parses and never executes, so -a handler attribute is refused there. An authored `quickAdd: true` on those tags +a handler attribute is refused there. An authored `quickAdd: true` on that tag therefore renders no control. The authoring validator (`validateTree` in `@object-ui/sdui-parser`) reports it as an `inert-quick-add` **warning** naming the missing half — the document still parses, saves and renders. - `quickAdd` is being retired from `object-kanban` / `kanban`; it stays on - `kanban-ui`, where the pair works. Inline record creation from a board is not a - capability the object-driven boards have. + `quickAdd` is being retired from `object-kanban`; it stays on the + `KanbanRenderer` component, where the pair works. Inline record creation from + an object-driven board is not a capability those boards have. ## Bundle Size diff --git a/packages/plugin-kanban/src/__tests__/quickAddIsDiagnosedNotDropped-8285.test.ts b/packages/plugin-kanban/src/__tests__/quickAddIsDiagnosedNotDropped-8285.test.ts index 976a5992bf..2097920bac 100644 --- a/packages/plugin-kanban/src/__tests__/quickAddIsDiagnosedNotDropped-8285.test.ts +++ b/packages/plugin-kanban/src/__tests__/quickAddIsDiagnosedNotDropped-8285.test.ts @@ -8,8 +8,18 @@ /** * objectui#8285 — what a REAL author is told when they write `quickAdd` on an - * `object-kanban` / `kanban` page (director-seat ruling of 2026-09-08, batch - * #91, slice C). + * `object-kanban` page (director-seat ruling of 2026-09-08, batch #91, slice + * C). + * + * ⚠️ This file landed while `ObjectKanbanRenderer` still answered to TWO keys. + * objectui#8802 / objectui#8257 retired `kanban`, `kanban-ui` and + * `kanban-enhanced`, and this file is the importer that retirement had to + * carry: it re-derives its host set from the registration calls and reads the + * LIVE registry, so it goes red on the merged tree the day the registrations + * go — which is the point of writing it this way. Its SUBJECT is unchanged and + * still pinned: the html tier NAMES the Quick Add pair instead of dropping the + * key. Only the set of tags that subject holds over has shrunk to the one key + * that resolves. * * The rule itself is pinned next door, over a fixture manifest * (`packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts`). THIS @@ -27,8 +37,9 @@ * * 3. THE TAG SET — `QUICK_ADD_HOST_TYPES` is restated data in a package that * must not depend on this one, so it is re-derived HERE from the - * registration calls. A third `ObjectKanbanRenderer` tag, or a rename, - * reddens this row rather than silently narrowing the diagnostic. + * registration calls. A second `ObjectKanbanRenderer` tag, or a rename, + * reddens this row rather than silently narrowing the diagnostic — and it + * is what caught the retirement. * 4. STILL NOT DECLARED — the ruling DIAGNOSES, it does not widen. A "fix" * that added `quickAdd` to `OBJECT_KANBAN_INPUTS` would publish a key the * renderer cannot honour, which is what objectui#8201 escalated rather @@ -90,9 +101,19 @@ const codesFor = (type: string, props: Record, key: string): st const declaredInputNames = (type: string, namespace?: string): string[] => ((ComponentRegistry.getConfig(type, namespace) as any)?.inputs ?? []).map((i: any) => i.name); +/** + * ⚠️ ONE entry since objectui#8802 retired the bare `kanban` key (and with it + * `view:kanban`, its namespaced twin). ⛔ Do not re-add a row here to "restore + * coverage": a tag no registration produces is not in the manifest, so + * `validate.ts` answers it with `unknown-component` and never walks its props + * — every row below would assert an empty diagnostic list for the wrong reason. + * The retired spellings are pinned as gone in + * `kanban-family-registry-keys-retired-8257.test.ts`, and the non-host half of + * the rule is pinned over a hand-built manifest in + * `packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts`. + */ const KANBAN_TAGS = [ { label: 'object-kanban', type: 'object-kanban', namespace: 'plugin-kanban' }, - { label: 'view:kanban', type: 'kanban', namespace: 'view' }, ] as const; /** The key names a strict parse of the block's spec schema refuses BY NAME. */ @@ -115,12 +136,19 @@ describe('objectui#8285 — the html tier names the Quick Add pair instead of dr it('the diagnostic covers exactly the tags `index.tsx` registers this renderer under', () => { expect(registeredKeys()).toEqual([...QUICK_ADD_HOST_TYPES].sort()); // Anti-vacuity + discrimination: a regex that matched nothing would return - // `[]`, and the two blocks registered in the SAME file to OTHER renderers - // must not be in the set — `kanban-ui` is the one the ruling keeps. - expect(registeredKeys().length).toBe(2); + // `[]`, so the count is asserted separately from the equality above. + expect(registeredKeys().length).toBe(1); + expect(registeredKeys()).not.toContain('kanban'); expect(registeredKeys()).not.toContain('kanban-ui'); expect(registeredKeys()).not.toContain('kanban-enhanced'); - expect(ComponentRegistry.has('kanban-ui')).toBe(true); + // ⚠️ Was `true`: the ruling this file landed under kept `kanban-ui`'s pair + // reachable as a TAG. objectui#8257 retired that registration, so the pair + // now survives only on the exported `KanbanRenderer` COMPONENT — which is + // what the diagnostic's message names. Kept as a reading rather than + // deleted: it is the discrimination that stops a future "restore the + // kanban-ish tags" fix from passing silently, and the retirement itself is + // pinned next door in `kanban-family-registry-keys-retired-8257.test.ts`. + expect(ComponentRegistry.has('kanban-ui')).toBe(false); for (const key of registeredKeys()) expect(ComponentRegistry.get(key)).toBe(ObjectKanbanRenderer); }); diff --git a/packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts b/packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts index 9b6864a60c..84eead2d98 100644 --- a/packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts +++ b/packages/sdui-parser/src/__tests__/kanban-quick-add-8285.test.ts @@ -1,5 +1,5 @@ /** - * ObjectUI — `quickAdd` on the two `ObjectKanbanRenderer` tags is DIAGNOSED, + * ObjectUI — `quickAdd` on the `ObjectKanbanRenderer` tag is DIAGNOSED, * and diagnosed truthfully (objectui#8285, ruling of 2026-09-08, batch #91) * * The mechanism, over a hand-built manifest that mirrors the real @@ -23,9 +23,11 @@ * * - the unknown-prop control — a fix that suppressed the generic walk for * this block rather than for this one key; - * - `kanban-ui` — a fix scoped to "a kanban-ish tag", which would hit the one - * block the ruling explicitly keeps untouched (a React host there CAN pass - * the function); + * - the two RETIRED kanban-ish tags (`kanban`, `kanban-ui`) — a fix scoped + * to "a kanban-ish tag" rather than to the one registration that serves + * `ObjectKanbanRenderer`. Both left the registry with objectui#8802 / + * objectui#8257, so neither is a host any more; they stay declared in the + * hand-built manifest below precisely so this row can still discriminate; * - `quickAdd: false` — a fix keyed on the KEY's presence rather than on the * author asking for the control, which would warn about a value that got * exactly what it asked for; @@ -46,9 +48,17 @@ import { import type { Diagnostic, Manifest } from '../types.js'; /** - * The three kanban blocks, carrying the inputs their real registrations - * declare — `quickAdd` on NONE of them, which is the state this change leaves - * untouched. `card` is a non-kanban control block. + * The three kanban blocks, carrying the inputs their registrations declared — + * `quickAdd` on NONE of them, which is the state this change leaves untouched. + * `card` is a non-kanban control block. + * + * ⚠️ `kanban` and `kanban-ui` are RETIRED registrations (objectui#8802, + * objectui#8257) and are kept here ON PURPOSE, as the discrimination controls + * below: a manifest is an argument to `validateTree`, so this file can still + * ask what the rule says about a tag the live registry no longer produces — + * and the answer must be "not a host". ⛔ Do not read their presence as a + * claim that either tag resolves; the live-registry half of that question is + * the sibling test's, and it answers `unknown-component`. */ const manifest: Manifest = manifestFromConfigs([ { @@ -80,10 +90,19 @@ const codesFor = (node: Record, key: string): string[] => const HOST_TAGS = [...QUICK_ADD_HOST_TYPES].sort(); describe('objectui#8285 — an authored `quickAdd` is diagnosed on the ObjectKanban tags', () => { - it('the host set is the two ObjectKanbanRenderer tags, and is not empty', () => { + it('the host set is the one surviving ObjectKanbanRenderer tag, and is not empty', () => { // Anti-vacuity for every row below: an empty set would make the negative // rows trivially true and the positive rows unreachable. - expect(HOST_TAGS).toEqual(['kanban', 'object-kanban']); + // + // ⚠️ This row is the ONLY thing in this package that a change to + // `QUICK_ADD_HOST_TYPES` reddens — every other row is `it.each(HOST_TAGS)` + // and re-derives itself from the constant, so a narrowing would otherwise + // just delete cases silently. `kanban` left the set with its registration + // (objectui#8802): `checkKanbanQuickAdd` is reached only from + // `validate.ts`'s prop walk, which runs only for a tag the manifest + // RESOLVED, so a tag no registration produces is answered by + // `unknown-component` one level up and never reaches this module. + expect(HOST_TAGS).toEqual(['object-kanban']); }); it.each(HOST_TAGS)('<%s> — an authored `quickAdd: true` draws exactly one warning', (tag) => { @@ -109,7 +128,12 @@ describe('objectui#8285 — an authored `quickAdd` is diagnosed on the ObjectKan // to the contract with no explanation, which is the state being fixed. const [{ message }] = diagnose({ type: tag, objectName: 'task', quickAdd: true }); expect(message).toContain('onQuickAdd'); - expect(message).toContain('kanban-ui'); + // ⚠️ The remedy names the COMPONENT, not the `kanban-ui` TAG it used to + // name: objectui#8257 retired that registration, so a page written to the + // old advice would now draw `unknown-component` — an ERROR. `KanbanRenderer` + // is still exported from `@object-ui/plugin-kanban` and still forwards both + // halves by identity, so it is the surviving way to get the pair. + expect(message).toContain('KanbanRenderer'); }); it.each(HOST_TAGS)('<%s> — control: a genuinely unknown prop is still reported', (tag) => { @@ -139,16 +163,21 @@ describe('objectui#8285 — an authored `quickAdd` is diagnosed on the ObjectKan ).toEqual(['unknown-prop']); }); - it('control: `kanban-ui` is untouched — the ruling keeps its pair, a React host can supply it', () => { - // The one block where `quickAdd` / `onQuickAdd` is honoured, because - // `KanbanRenderer` forwards both halves by identity. Green in both worlds; - // guards a fix scoped to "a kanban-ish tag". - const diagnostics = diagnose({ type: 'kanban-ui', columns: [], quickAdd: true }); - expect(diagnostics.map((d) => d.code)).not.toContain(INERT_QUICK_ADD); - expect(codesFor({ type: 'kanban-ui', columns: [], quickAdd: true }, QUICK_ADD_KEY)).toEqual([ - 'unknown-prop', - ]); - }); + it.each(['kanban-ui', 'kanban'])( + 'control: `%s` is NOT a host — a kanban-ish tag alone does not arm this diagnostic', + (tag) => { + // Guards a fix scoped to "a kanban-ish tag". Both spellings are RETIRED + // registrations (objectui#8257, objectui#8802), so neither can reach this + // module through a manifest built from the live registry at all — they are + // declared in this file's hand-built manifest so the discrimination is + // still measurable here, which is the one thing a synthetic manifest can + // do that the live one cannot. `kanban-ui`'s pair itself survives on the + // exported `KanbanRenderer` component, which no tag resolves to. + const node = { type: tag, columns: [], quickAdd: true }; + expect(diagnose(node).map((d) => d.code)).not.toContain(INERT_QUICK_ADD); + expect(codesFor(node, QUICK_ADD_KEY)).toEqual(['unknown-prop']); + }, + ); it('control: a non-kanban block is untouched', () => { expect(diagnose({ type: 'card', quickAdd: true }).map((d) => d.code)).toEqual(['unknown-prop']); diff --git a/packages/sdui-parser/src/kanban-quick-add.ts b/packages/sdui-parser/src/kanban-quick-add.ts index 1562ef23b8..57e10ca01d 100644 --- a/packages/sdui-parser/src/kanban-quick-add.ts +++ b/packages/sdui-parser/src/kanban-quick-add.ts @@ -1,5 +1,5 @@ /** - * ObjectUI — the inert `quickAdd` on the two `ObjectKanbanRenderer` tags + * ObjectUI — the inert `quickAdd` on the `ObjectKanbanRenderer` tag * (objectui#8285, director-seat ruling of 2026-09-08, decision batch #91) * * ## What is wrong, measured rather than argued @@ -66,10 +66,14 @@ * * ## Scope — the three places this deliberately does not fire * - * - `kanban-ui`. The ruling keeps the `quickAdd` / `onQuickAdd` pair there - * untouched: that block is `KanbanRenderer`, which forwards both halves by - * identity, and a React host mounting it CAN pass the function. An authored - * `quickAdd` there still draws whatever the generic prop rules draw. + * - `KanbanRenderer`, the component. The ruling keeps the `quickAdd` / + * `onQuickAdd` pair there untouched: it forwards both halves by identity, + * and a React host mounting it CAN pass the function. ⚠️ It is no longer + * reachable as a TAG — objectui#8257 retired the `kanban-ui` registration, + * so on this tier `` is now an `unknown-component` ERROR, not a + * block with a working pair. The component stays exported from + * `@object-ui/plugin-kanban`, which is the surviving way to get the pair, + * and is what this module's message names. * - `quickAdd: false`, and any other falsy value. The renderer's own gate is * `quickAdd && onQuickAdd`, so a falsy value asks for no control and gets * none — the author got what they wrote, and nothing was dropped. The @@ -88,17 +92,35 @@ export const INERT_QUICK_ADD = 'inert-quick-add'; export const QUICK_ADD_KEY = 'quickAdd'; /** - * The tags served by `ObjectKanbanRenderer` — the two registrations in - * `packages/plugin-kanban/src/index.tsx`, which is where a third one would + * The tags served by `ObjectKanbanRenderer` — the registrations in + * `packages/plugin-kanban/src/index.tsx`, which is where another one would * appear. Restated here as data because this package is deliberately free of * any dependency on the registry or on a plugin (see `RegistryConfigLike` in * `index.ts`), and re-derived from that file's registration calls by * `packages/plugin-kanban/src/__tests__/quickAddIsDiagnosedNotDropped-8285.test.ts`, * so a renamed or added tag reddens a named row rather than silently narrowing - * this set. `kanban-ui` and `kanban-enhanced` are registered in the same file - * to OTHER renderers and are not here — see the scope notes above. + * this set. + * + * ⚠️ **`kanban` left this set with its registration** (objectui#8802, ruled + * 2026-09-09; `kanban-ui` and `kanban-enhanced` went the same way under + * objectui#8257). Keeping it would have been dead data, MEASURED and not + * assumed: `checkKanbanQuickAdd` has exactly one call site, inside + * `validate.ts`'s prop walk, and that walk runs only in the branch where + * `manifest.components[node.type]` RESOLVED. A tag no registration produces is + * answered one level up, by `unknown-component`, and its props are never walked + * at all — so on a manifest built from the live registry a `` + * node draws `error/unknown-component` and nothing else, against a firing + * control on `` that draws `warning/inert-quick-add`. + * ⛔ Nothing is silently dropped by the narrowing: the retired spelling is + * refused BY NAME at the tag, which is a louder answer than this warning, and + * stacking both would be the two-diagnostics-for-one-mistake shape + * `checkMemberTypes` already refuses (objectui#8067). The one path that could + * still reach a `kanban` entry is a HAND-BUILT manifest declaring a component + * of that name — which, after the retirement, is somebody else's component, and + * the message below asserts things about `ObjectKanban` that would be false of + * it. */ -export const QUICK_ADD_HOST_TYPES: ReadonlySet = new Set(['object-kanban', 'kanban']); +export const QUICK_ADD_HOST_TYPES: ReadonlySet = new Set(['object-kanban']); const isPlainObject = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); @@ -133,7 +155,7 @@ export function checkKanbanQuickAdd(tag: string, key: string, value: unknown): D `BOTH "${QUICK_ADD_KEY}" and an "onQuickAdd" handler, and this block supplies neither half ` + `of the pair: "onQuickAdd" takes a FUNCTION, which no page on this tier can write (this tier ` + `parses, never executes) and which this board substitutes none of its own for. Drop the key, ` + - `or render from a React host that passes "onQuickAdd".`, + `or mount KanbanRenderer from "@object-ui/plugin-kanban" in a React host that passes "onQuickAdd".`, tag, }; }