diff --git a/.changeset/7664-kanban-arm-plugin-dialect.md b/.changeset/7664-kanban-arm-plugin-dialect.md
new file mode 100644
index 000000000..f699bdff0
--- /dev/null
+++ b/.changeset/7664-kanban-arm-plugin-dialect.md
@@ -0,0 +1,90 @@
+---
+'@object-ui/types': minor
+---
+
+**BREAKING** — the `'kanban'` validator arm now accepts the shape the registered
+renderer reads, and the six `DeclarativeKanban*` exports retire (objectui#7664,
+maintainer ruling (a), 2026-09-05).
+
+For an authored `type: 'kanban'` document two different types were
+authoritative depending on who asked. `safeValidateSchema` — what the CLI's
+`validate` / `check` commands apply — honoured `DeclarativeKanbanSchema`
+(`columns` with `color`, `draggable`, cards with `labels` / `assignees` /
+`priority`), while the renderer registered for the key, `ObjectKanbanRenderer`
+in `@object-ui/plugin-kanban`, consumed that package's own `KanbanSchema`
+(`objectName` / `groupBy` / `cardTitle` / `cardFields`, cards with `badges`).
+The two were unrelated dialects, so a board could pass `objectui validate` and
+render **empty**. The ruling: the plugin dialect is authoritative.
+
+**What changes on this package's published surface:**
+
+- **The `'kanban'` arm's accept set is replaced.** `ComplexSchema` →
+ `AnyComponentSchema` → `safeValidateSchema` now validate the plugin dialect,
+ declared here as `KanbanSchema` / `KanbanColumn` / `KanbanCard` /
+ `CardTemplate` / `ColumnWidthConfig` (TypeScript) and `KanbanSchema` /
+ `KanbanColumnSchema` / `KanbanCardSchema` / `CardTemplateSchema` /
+ `ColumnWidthConfigSchema` (`@object-ui/types/zod`). An `objectName` /
+ `groupBy` board passes. A static `columns[].cards[]` board passes — that
+ spelling is the same document in both dialects and always rendered. A board
+ in the retired dialect is **refused by name** at the keys that betray it: a
+ board-level `draggable` and a column `color` are `?: never` tombstones on the
+ TypeScript face and named refusal arms on the mirror, each message naming the
+ retired `DeclarativeKanbanSchema` shape and the spelling to write instead.
+ Both were measured inert (zero read sites in the plugin). The retired card
+ keys are deliberately *not* refused: a card is an open record
+ (`[key: string]: any`), and `priority` or `dueDate` are legitimate record
+ fields.
+- **Every handler key the retired arm refused is still refused, and one more
+ joins them.** The successor arm carries all five `#6124` refusal arms under
+ the same `'kanban'` key — `onCardMove`, `onCardClick` and `onQuickAdd` as
+ RUNTIME SLOTS (callable on the TypeScript face, refused by name on the
+ mirror: `KanbanRenderer` forwards all three off `schema.*` in one block),
+ `onColumnAdd` and `onCardAdd` as `?: never` tombstones. `onQuickAdd` is the
+ one that is newly refused — the plugin dialect declared it, the retired
+ declarative face did not. ⚠️ `onCardClick` is the key this arm must never
+ drop rather than refuse: the plugin dialect it is modelled on never declared
+ the member (the renderer read it undeclared), and because `BaseSchema` is
+ `.passthrough()`, leaving it out does not refuse it — it stops being judged
+ and the value is kept. Measured on the built dist, `{ type: 'kanban',
+ columns: [], onCardClick: { action: 'toast' } }` is REFUSED, beside the same
+ document at `onCardMove` / `onQuickAdd` / `onColumnAdd` / `draggable`.
+- **Six exports retire — the second step of the objectui#6172 rename.**
+ objectui#6172 (PR #7643, same release line) renamed this package's trio from
+ the bare names to `DeclarativeKanbanSchema` / `DeclarativeKanbanColumn` /
+ `DeclarativeKanbanCard` and the three Zod mirrors to `DeclarativeKanban*Schema`
+ so the bare names could belong to the renderer's dialect. objectui#6172's own
+ stop condition was "if the renamed copy has no retained value, escalate", and
+ the retained value it cited was precisely the validator arm. This ruling moves
+ that arm to the plugin dialect, so the renamed copies have no consumer left
+ and retire under ADR-0049 (enforce-or-remove): `DeclarativeKanbanSchema`,
+ `DeclarativeKanbanColumn`, `DeclarativeKanbanCard` from `@object-ui/types` and
+ `DeclarativeKanbanSchema`, `DeclarativeKanbanColumnSchema`,
+ `DeclarativeKanbanCardSchema` from `@object-ui/types/zod` are gone.
+ Importing any of them is a compile error (TS2305).
+- **`SchemaRegistry['kanban']` is `KanbanSchema`.** objectui#7645 (PR #7662)
+ weakened the entry to `BaseSchema & { type: 'kanban' }` because this layer
+ could not name the plugin's type; it now names the declaration the plugin
+ itself imports. `keyof SchemaRegistry` — the published `ComponentType` union —
+ is unchanged.
+- **The bare names return to this package with a different shape than they had
+ before objectui#6172.** `KanbanSchema` here is now the plugin dialect, not the
+ declarative one the pre-rename `KanbanSchema` was. A consumer that never
+ migrated off the old bare name and expected `columns` to be required, or
+ `draggable` to exist, gets a type error rather than a silent change.
+- `KanbanConditionalFormattingRuleSchema` is newly exported from
+ `@object-ui/types/zod`: the rule union the `'object-kanban'` arm already
+ applied, now shared with the `'kanban'` arm.
+
+**Migration.** Author boards in the plugin dialect — `objectName` + `groupBy`
+for an object-bound board, or `columns[].cards[]` with `badges` for a static
+one. Replace `DeclarativeKanbanSchema` imports with `KanbanSchema` (from
+`@object-ui/types`, or the Zod `KanbanSchema` from `@object-ui/types/zod`;
+`@object-ui/plugin-kanban` re-exports the same `KanbanSchema` type). Delete
+`draggable` (drag-and-drop is always on) and column `color` (style a lane
+through `className`). `content/docs/api/schema-reference.md`'s kanban section
+now documents this dialect.
+
+This is a breaking change shipped as `minor`: this repository's
+version-alignment rule keeps objectui's major pinned to `@objectstack`'s and
+ships objectui's own breaking changes as `minor` with the break spelled out in
+the changeset body, which is what the bullets above are.
diff --git a/.changeset/7664-plugin-kanban-declared-schema.md b/.changeset/7664-plugin-kanban-declared-schema.md
new file mode 100644
index 000000000..3c814de4e
--- /dev/null
+++ b/.changeset/7664-plugin-kanban-declared-schema.md
@@ -0,0 +1,30 @@
+---
+'@object-ui/plugin-kanban': patch
+---
+
+`KanbanSchema` / `KanbanColumn` / `KanbanCard` / `CardTemplate` /
+`ColumnWidthConfig` are now the `@object-ui/types` declarations, re-exported
+from this package rather than declared in it (objectui#7664, maintainer ruling
+(a)). Nothing this package renders changed and every existing import keeps
+resolving; what changed is that `safeValidateSchema` in `@object-ui/types` now
+validates an authored `type: 'kanban'` document against this very shape, so a
+board that validates is a board these renderers draw.
+
+**The shape is not member-for-member what this package declared — it is that
+shape plus four members**, counted off `origin/main`'s
+`plugin-kanban/src/types.ts` (19 members on `KanbanSchema`, 6 on
+`KanbanColumn`, 7 on `KanbanCard`) against the `@object-ui/types`
+declarations:
+
+- **`onCardClick` is DECLARED for the first time.** This package's dialect
+ never had the member, while `KanbanRenderer` has always forwarded
+ `onCardClick={schema.onCardClick}` — an undeclared read (objectui#7742). It
+ is declared here as a `#6124` RUNTIME SLOT: callable on the TypeScript face,
+ refused by name on the mirror, like the `onCardMove` and `onQuickAdd` beside
+ it in the same forward block.
+- **Four `?: never` tombstones** carry the retired declarative face's keys
+ under the same `'kanban'` key so those spellings keep being refused by name:
+ `draggable`, `onColumnAdd` and `onCardAdd` on `KanbanSchema`, and `color` on
+ `KanbanColumn`. None of the four was ever a member of this package's dialect;
+ each is refused, not silently accepted, because the retired face taught it.
+ The full accept-set statement is on the sibling `@object-ui/types` entry.
diff --git a/content/docs/api/index.md b/content/docs/api/index.md
index d1e4316fc..39825c007 100644
--- a/content/docs/api/index.md
+++ b/content/docs/api/index.md
@@ -19,5 +19,5 @@ Complete reference for every ObjectUI schema type with annotated JSON examples c
- **Data Display** — `TableSchema`, `ChartSchema`, `TreeViewSchema`
- **CRUD** — `ActionSchema`, `DetailSchema`, `CRUDDialogSchema`
- **ObjectQL** — `ObjectGridSchema`, `ObjectFormSchema`, `ObjectViewSchema`
-- **Complex** — `DeclarativeKanbanSchema`, `DashboardSchema`, `CalendarViewSchema`
+- **Complex** — `KanbanSchema`, `DashboardSchema`, `CalendarViewSchema`
- **Views** — `DetailViewSchema`, `ViewSwitcherSchema`
diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md
index 5c2f9c90f..7f2221b0f 100644
--- a/content/docs/api/schema-reference.md
+++ b/content/docs/api/schema-reference.md
@@ -896,28 +896,44 @@ A complete object management interface combining grid, form, search, filters, an
## Complex Schemas
-### DeclarativeKanbanSchema
+### KanbanSchema
-A drag-and-drop Kanban board with columns and cards.
+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`.
+
+```json
+{
+ "type": "kanban",
+ "objectName": "tasks",
+ "groupBy": "status",
+ "cardTitle": "title",
+ "cardFields": ["assignee", "due_date"],
+ "quickAdd": true
+}
+```
+
+A static board carries its cards inline:
```json
{
"type": "kanban",
- "draggable": true,
"columns": [
{
"id": "todo",
"title": "To Do",
- "color": "#6366f1",
"cards": [
- { "id": "task-1", "title": "Design mockups", "description": "Create wireframes for new feature" },
+ {
+ "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",
- "color": "#f59e0b",
+ "limit": 3,
"cards": [
{ "id": "task-3", "title": "API integration", "description": "Connect to payment gateway" }
]
@@ -925,7 +941,6 @@ A drag-and-drop Kanban board with columns and cards.
{
"id": "done",
"title": "Done",
- "color": "#22c55e",
"cards": []
}
]
@@ -934,10 +949,26 @@ A drag-and-drop Kanban board with columns and cards.
| Property | Type | Description |
|----------|------|-------------|
-| `columns` | `DeclarativeKanbanColumn[]` | **Required.** Board columns, each with `id`, `title`, `color`, and `cards`. |
-| `draggable` | `boolean` | Enable drag-and-drop between columns. |
-| `onCardMove` | `function` | Callback when a card is moved: `(cardId, fromColumn, toColumn, position)`. |
-| `onCardClick` | `function` | Callback when a card is clicked. |
+| `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. |
+| `cardFields` | `string[]` | Fields rendered on each card. |
+| `data` | `any[]` | Inline records, bucketed into lanes by `groupBy`. |
+| `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 columns to be collapsed. |
+| `conditionalFormatting` | `KanbanConditionalFormattingRule[]` | Card colouring rules — native `{ field, operator, value }` or spec `{ condition, style }`. |
+| `cardTemplates` | `CardTemplate[]` | Predefined quick-add templates. |
+| `columnWidths` | `ColumnWidthConfig` | Column width configuration. |
+| `grouping` | `GroupingConfig` | ListView grouping config; its first field is the swimlane fallback. |
+| `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. |
+
+> 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.
**Related:** [ObjectViewSchema](#objectviewschema), [ObjectGridSchema](#objectgridschema)
@@ -1211,7 +1242,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), [DeclarativeKanbanSchema](#declarativekanbanschema), [CalendarViewSchema](#calendarviewschema)
+**Related:** [ObjectViewSchema](#objectviewschema), [KanbanSchema](#kanbanschema), [CalendarViewSchema](#calendarviewschema)
---
@@ -1286,7 +1317,7 @@ import type { ActionSchema, DetailSchema } from '@object-ui/types';
import type { ObjectGridSchema, ObjectFormSchema, ObjectViewSchema } from '@object-ui/types';
// Complex
-import type { DeclarativeKanbanSchema, DashboardComponentSchema, CalendarViewSchema } from '@object-ui/types';
+import type { KanbanSchema, DashboardComponentSchema, CalendarViewSchema } from '@object-ui/types';
// Views
import type { DetailViewSchema, ViewSwitcherSchema } from '@object-ui/types';
diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx
index 56a8acb64..6db73eb7f 100644
--- a/packages/plugin-kanban/src/ObjectKanban.tsx
+++ b/packages/plugin-kanban/src/ObjectKanban.tsx
@@ -28,7 +28,7 @@ import {
} from '@object-ui/core';
import { getBadgeColorClasses, getBadgeHexAppearance, getCellRenderer, resolveCellRendererType } from '@object-ui/fields';
import { KanbanRenderer, KANBAN_UNCOLUMNED_ID } from './index';
-import { KanbanSchema } from './types';
+import type { KanbanSchema } from './types';
import {
collectRequiredWhenPromptFields,
type RequiredWhenPromptField,
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
new file mode 100644
index 000000000..e356e4ab2
--- /dev/null
+++ b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx
@@ -0,0 +1,253 @@
+/**
+ * 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.
+ */
+
+/**
+ * Which authored `on*` keys reach a registered kanban board — measured, per
+ * registration — and the guard that a bare deletion of one of them goes red on
+ * (objectui#7664; the contract-review remediation of PR #7743).
+ *
+ * ## What went wrong, and why a hand-written ledger could not see it
+ *
+ * The first cut of this card removed `onCardClick` from the successor
+ * `'kanban'` arm outright, on the recorded rationale "the object-bound board
+ * owns the click". `BaseSchema` is `.passthrough()`, so a removed key is not
+ * refused — it stops being judged and the value is KEPT. Measured on the built
+ * dist at that head, `{ type: 'kanban', columns: [], onCardClick: { action:
+ * 'toast' } }` went from REFUSED to ACCEPTED, with the value surviving into the
+ * parsed output, while `onCardMove` / `onQuickAdd` / `onColumnAdd` /
+ * `draggable` stayed REFUSED. Every ratchet stayed green because the #6124
+ * ledger lists keys BY NAME and by hand: substituting one entry for another
+ * holds its length constant and no assertion is derived from the read site.
+ * Suite 3 below is that missing derivation.
+ *
+ * ## 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
+ * 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.
+ *
+ * ## 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
+ * 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
+ * the control that the wrapper is genuinely interposed.
+ *
+ * ⇒ On every channel measured, `onCardClick` is at least as live as
+ * `onCardMove`. Its #6124 disposition is RUNTIME SLOT, not `?: never`.
+ *
+ * ## 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.
+ *
+ * ## Predictions, written before the first run (red-first)
+ *
+ * On the tree before the remediation (`bd1fc7111` merged with `main`):
+ * - suites 1 and 2 pass unchanged — the forwards and the prop wiring are not
+ * what this card moved;
+ * - suite 3 fails on exactly one key, `onCardClick`: forwarded by
+ * `KanbanRenderer`, absent from `KanbanSchema.shape`.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import React from 'react';
+import { render, waitFor } from '@testing-library/react';
+import { readFileSync } from 'node:fs';
+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 '../index';
+
+/** Every props object either board implementation was rendered with, in order. */
+const recorded = vi.hoisted(() => ({
+ impl: [] as Array>,
+ enhanced: [] as Array>,
+}));
+
+// Both lazy chunks are replaced by prop recorders: the question this file asks
+// is what reaches the board's props, not what the board draws with them.
+vi.mock('../KanbanImpl', () => ({
+ default: (props: Record) => {
+ recorded.impl.push(props);
+ return null;
+ },
+}));
+vi.mock('../KanbanEnhanced', () => ({
+ default: (props: Record) => {
+ recorded.enhanced.push(props);
+ return null;
+ },
+}));
+
+const STATIC_COLUMNS = [{ id: 'todo', title: 'To Do', cards: [{ id: '1', title: 'One' }] }];
+
+function authored() {
+ return { onCardClick: vi.fn(), onCardMove: vi.fn(), onQuickAdd: vi.fn() };
+}
+
+/** Wait for one more board render to be recorded, then return the props it got. */
+async function lastBoardProps(log: 'impl' | 'enhanced', before: number, unmount: () => void) {
+ await waitFor(() => expect(recorded[log].length).toBeGreaterThan(before));
+ const received = recorded[log][recorded[log].length - 1];
+ unmount();
+ return received;
+}
+
+/**
+ * Render the REGISTERED renderer for `type` directly with an authored board,
+ * and return the props its board implementation was handed last. The provider
+ * is what `ObjectKanbanRenderer`'s `useSchemaContext()` requires; its
+ * `dataSource` is explicitly `undefined` because these boards author their
+ * lanes statically and nothing here fetches. (The prop is required and typed
+ * `any`, so the value has to be spelled rather than omitted.)
+ */
+async function boardPropsFor(type: string, log: 'impl' | 'enhanced', schemaKeys: Record) {
+ const Renderer = ComponentRegistry.get(type) as React.ComponentType>;
+ expect(Renderer, `\`${type}\` is not registered`).toBeDefined();
+ const before = recorded[log].length;
+ const { unmount } = render(
+
+
+ ,
+ );
+ return lastBoardProps(log, 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
+ * React prop on the registered renderer.
+ */
+async function boardPropsViaSchemaRenderer(schema: Record) {
+ const before = recorded.impl.length;
+ const { unmount } = render(
+
+
+ ,
+ );
+ return lastBoardProps('impl', before, unmount);
+}
+
+describe('which authored handler keys reach a registered kanban board (objectui#7664)', () => {
+ it("`'kanban-ui'` (KanbanRenderer) forwards onCardClick, onCardMove and onQuickAdd by identity", async () => {
+ const spies = authored();
+ const props = await boardPropsFor('kanban-ui', 'impl', spies);
+ expect({
+ onCardClick: props.onCardClick === spies.onCardClick,
+ onCardMove: props.onCardMove === spies.onCardMove,
+ onQuickAdd: props.onQuickAdd === spies.onQuickAdd,
+ }).toEqual({ onCardClick: true, onCardMove: true, onQuickAdd: true });
+ });
+
+ it.each(['kanban', 'object-kanban'])(
+ "`'%s'` (ObjectKanban) passes onQuickAdd through and replaces BOTH onCardClick and onCardMove with its own",
+ async (type) => {
+ const spies = authored();
+ const props = await boardPropsFor(type, 'impl', spies);
+ expect({
+ onQuickAdd: props.onQuickAdd === spies.onQuickAdd,
+ onCardClick: props.onCardClick === spies.onCardClick,
+ onCardMove: props.onCardMove === spies.onCardMove,
+ onCardClickType: typeof props.onCardClick,
+ onCardMoveType: typeof props.onCardMove,
+ }).toEqual({
+ onQuickAdd: true,
+ onCardClick: false,
+ onCardMove: false,
+ onCardClickType: 'function',
+ onCardMoveType: 'function',
+ });
+ },
+ );
+
+ it("`'kanban-enhanced'` forwards onCardMove and onQuickAdd; onCardClick is not forwarded there", async () => {
+ const spies = authored();
+ const props = await boardPropsFor('kanban-enhanced', 'enhanced', spies);
+ expect({
+ onCardMove: props.onCardMove === spies.onCardMove,
+ onQuickAdd: props.onQuickAdd === spies.onQuickAdd,
+ onCardClick: props.onCardClick,
+ }).toEqual({ onCardMove: true, onQuickAdd: true, onCardClick: undefined });
+ });
+});
+
+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 () => {
+ const onCardClick = vi.fn();
+ const card = { id: '1', title: 'One' };
+ const props = await boardPropsViaSchemaRenderer({
+ type: 'kanban',
+ columns: STATIC_COLUMNS,
+ onCardClick,
+ });
+
+ // Control: the board did NOT get the authored function itself — the
+ // wrapper is genuinely interposed, so a call reaching the spy can only
+ // have arrived through it.
+ expect(props.onCardClick).not.toBe(onCardClick);
+ (props.onCardClick as (c: unknown, e?: unknown) => void)(card);
+ expect(onCardClick).toHaveBeenCalledWith(card);
+ });
+});
+
+describe("every handler key KanbanRenderer forwards is declared on the 'kanban' arm (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);
+ 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])
+ .sort();
+ }
+
+ it('the read site is measured, not listed: KanbanRenderer forwards exactly these three', () => {
+ 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 })),
+ );
+ });
+});
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
new file mode 100644
index 000000000..ad58decdf
--- /dev/null
+++ b/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts
@@ -0,0 +1,104 @@
+/**
+ * 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 renderer's face IS the declared `'kanban'` arm (objectui#7664, maintainer
+ * ruling (a), 2026-09-05).
+ *
+ * ## Why this pin lives here
+ *
+ * `@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`.
+ *
+ * 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 instrument
+ *
+ * Compile-time. Vitest strips types without checking them, so a green run of
+ * this file proves nothing on its own; the assertions are read by
+ * `tsc -p packages/plugin-kanban/tsconfig.test.json`, chained off this
+ * 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:
+ * 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 { ObjectKanbanComponentProps } from '../ObjectKanban';
+import type { KanbanRendererProps } from '../index';
+import '../index';
+
+/* -------------------------------------------------------------------------- */
+/* 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;
+
+// Non-vacuity controls: `any` on either side would satisfy every `extends`
+// below while checking nothing.
+type _PluginFaceIsReal = 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>;
+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>;
+
+// 3. The renderers' props type-check against the declared schema:
+// - `ObjectKanban` (behind `ObjectKanbanRenderer`, registered for `'kanban'`
+// AND `'object-kanban'`) takes exactly the declared face as its `schema`;
+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.
+
+// 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 _CardIsAnOpenRecord = Assert, true>>;
+
+describe('the registered kanban renderers consume 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'));
+ });
+});
diff --git a/packages/plugin-kanban/src/__tests__/schema-registry-kanban-honesty-7645.test.ts b/packages/plugin-kanban/src/__tests__/schema-registry-kanban-honesty-7645.test.ts
deleted file mode 100644
index 27b087457..000000000
--- a/packages/plugin-kanban/src/__tests__/schema-registry-kanban-honesty-7645.test.ts
+++ /dev/null
@@ -1,84 +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 renderer's own face satisfies what `SchemaRegistry['kanban']` asserts.
- *
- * ## Why this pin lives here and can live nowhere else
- *
- * objectui#7645: `@object-ui/types`' `SchemaRegistry` advertises itself as the
- * Single Source of Truth for component type lookups, and its `'kanban'` entry
- * named the DECLARATIVE authoring face while the renderer registered for that
- * key — `ObjectKanbanRenderer`, `ComponentRegistry.register('kanban', …)` in
- * `../index` — consumes {@link KanbanSchema} from this package. The two are
- * unrelated dialects (objectui#6172 ruled this package KEEPS the bare names).
- *
- * The entry was therefore weakened to the claim that layer can prove and both
- * dialects satisfy: a schema node tagged `'kanban'`. That claim is only worth
- * anything if it is actually TRUE of the renderer's face — and `@object-ui/types`
- * cannot check that: it cannot name this package (the import is a phantom
- * dependency; declaring it would close the cycle `@object-ui/types` →
- * `@object-ui/plugin-kanban` → `@object-ui/types`). This package depends on
- * `@object-ui/types`, so it is the only place in the workspace that can see both
- * sides at once — which is why this file exists, not one more pin by the map.
- *
- * ⛔ This file does not touch {@link KanbanSchema} — it only reads it. It is the
- * face objectui#6172 kept, and the one objectui#7664's ruling (a) (2026-09-05)
- * makes the declared shape; `KanbanSchema.data` stays a raw-row input, since
- * objectui#7651 was ruled B and closed as not_planned (2026-09-05T02:09:54Z).
- *
- * ## The instrument
- *
- * Compile-time only. Vitest strips types without checking them, so a green run
- * of this file proves nothing on its own; the assertions are read by
- * `tsc -p packages/plugin-kanban/tsconfig.test.json`, chained off this
- * 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.
- */
-
-import { describe, it, expect } from 'vitest';
-import type { SchemaRegistry, DeclarativeKanbanSchema } from '@object-ui/types';
-import type { KanbanSchema } from '../types';
-
-/* -------------------------------------------------------------------------- */
-/* 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;
-
-describe("the registered kanban renderer's schema satisfies the registry entry", () => {
- it('is pinned at compile time', () => {
- // Non-vacuity controls: `any` on either side would satisfy every `extends`
- // below while checking nothing.
- type _PluginFaceIsReal = Assert, false>>;
- type _RegistryIsReal = Assert, false>>;
-
- // 1. The claim the map now makes for `'kanban'` is TRUE of the face the
- // registered renderer actually consumes. This is the assertion that
- // makes the weakened entry honest rather than merely vaguer.
- type _PluginFaceSatisfiesTheEntry = Assert<
- KanbanSchema extends SchemaRegistry['kanban'] ? true : false
- >;
-
- // 2. …and, at this commit, still two dialects. objectui#7664's ruling (a)
- // (2026-09-05) schedules their convergence; when it lands, the weakened
- // entry has outlived its reason and this pin retires with the re-point.
- type _StillTwoDialects = Assert<
- Equal, false>
- >;
-
- // 3. Both faces agree on the one thing the map asserts: the tag.
- type _PluginFaceIsTagged = Assert>;
-
- expect(true).toBe(true);
- });
-});
diff --git a/packages/plugin-kanban/src/types.ts b/packages/plugin-kanban/src/types.ts
index b253d4b50..3ca61eefb 100644
--- a/packages/plugin-kanban/src/types.ts
+++ b/packages/plugin-kanban/src/types.ts
@@ -6,226 +6,39 @@
* LICENSE file in the root directory of this source tree.
*/
-import type React from 'react';
-import type { BaseSchema, GroupingConfig, KanbanConditionalFormattingRule } from '@object-ui/types';
-
-/**
- * Kanban card interface.
- */
-export interface KanbanCard {
- id: string;
- title: string;
- description?: string;
- badges?: Array<{
- label: string;
- variant?: "default" | "secondary" | "destructive" | "outline";
- /**
- * Optional Tailwind class string applied to the badge. When set, it
- * overrides `variant` so callers can reuse the same colors as list/grid
- * cells.
- *
- * Derive it the way the grid cell derives it, or the same option renders
- * two colours on one screen (objectui#5183): prefer
- * `getBadgeHexAppearance(color)` from `@object-ui/fields` and use its
- * `className` — passing its `colorStyle` too — and fall back to
- * `getBadgeColorClasses(color, value)` only when it returns `undefined`.
- */
- colorClass?: string;
- /**
- * Inline style accompanying `colorClass`. **Required whenever the class
- * string came from `getBadgeHexAppearance`** — that className reads CSS
- * custom properties which only this style declares, so a badge carrying
- * the class without the style references undefined variables. Pass the
- * helper's `style` verbatim; leave unset on the palette-family path.
- */
- colorStyle?: React.CSSProperties;
- }>;
- /**
- * Synthesized card subtitle (e.g. "Account: Acme · Amount: $150K"). Rendered
- * in preference to `description` so we don't have to overwrite the record's
- * real `description` field — which would corrupt detail-view and edit-form
- * displays once a card is opened.
- *
- * Read by `KanbanImpl`; absent on a board that renders plain descriptions.
- */
- cardSubtitle?: string;
- /**
- * Structured per-field cells. When provided, the card body renders each
- * field via the unified `@object-ui/fields` cell-renderer pipeline (same
- * as Grid/Gallery), so lookup/user/email/url/phone/boolean/etc. fields
- * keep their semantic styling instead of being flattened to a text join.
- *
- * Takes precedence over `cardSubtitle` / `description` when present.
- */
- cardFieldCells?: Array<{ field: string; label?: string; node: React.ReactNode }>;
- /**
- * Resolved cover-image URL for the card, derived from the board's
- * `coverImageField`. Read by both board implementations.
- */
- coverImage?: string;
- [key: string]: any;
-}
-
/**
- * Kanban column interface.
- */
-export interface KanbanColumn {
- id: string;
- title: string;
- cards: KanbanCard[];
- limit?: number;
- className?: string;
- /**
- * Whether the lane renders collapsed. Honoured by `KanbanEnhanced` (the
- * implementation that ships column collapsing); the plain board ignores it.
- */
- collapsed?: boolean;
-}
-
-/**
- * Kanban Board component schema.
- * Renders a drag-and-drop kanban board for task management.
- */
-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.
- */
- 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.
- */
- onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => 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.
- */
- 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;
-
- /**
- * Allow columns to be collapsed/expanded.
- * @default false
- */
- allowCollapse?: boolean;
-
- /**
- * Conditional formatting rules for card coloring. Accepts the native
- * `{ field, operator, value }` shape and the spec `{ condition, style }` CEL
- * shape (issue #1584).
- */
- conditionalFormatting?: KanbanConditionalFormattingRule[];
-
- /**
- * Predefined card templates for quick-add.
- * Each template pre-fills the quick-add form with default values.
- */
- cardTemplates?: CardTemplate[];
-
- /**
- * Custom column width configuration.
- * Supports per-column overrides with min/max constraints.
- */
- columnWidths?: ColumnWidthConfig;
-
- /**
- * Grouping configuration from ListView.
- * When set, the first grouping field is used as swimlaneField fallback.
- */
- grouping?: GroupingConfig;
-}
-
-/**
- * A predefined card template with pre-filled field values.
- */
-export interface CardTemplate {
- /** Unique template identifier */
- id: string;
- /** Human-readable template name */
- name: string;
- /** Optional Lucide icon name */
- icon?: string;
- /** Pre-filled field values */
- values: Record;
-}
-
-/**
- * Configuration for custom column widths.
+ * The board's schema vocabulary — declared in `@object-ui/types`, re-exported
+ * here (objectui#7664, maintainer ruling (a), 2026-09-05).
+ *
+ * Until that ruling this file DECLARED `KanbanCard` / `KanbanColumn` /
+ * `KanbanSchema` / `CardTemplate` / `ColumnWidthConfig` as the plugin's own
+ * dialect, while `@object-ui/types` declared an unrelated board under the same
+ * `'kanban'` key (the `DeclarativeKanban*` trio) and validated authored
+ * documents against THAT — so a board could pass `objectui validate` and render
+ * empty. The ruling made this dialect the authoritative one: the declaration
+ * moved down to `@object-ui/types` (`complex.ts`, with a Zod mirror in
+ * `zod/complex.zod.ts` that `safeValidateSchema` now applies to every
+ * `type: 'kanban'` document), and this package imports it back. Member for
+ * member the shape is what this file declared, so nothing this package renders
+ * changed; the trio retired under ADR-0049.
+ *
+ * ⛔ Do not re-declare any of these names here. One declaration, one authority
+ * (`scripts/__tests__/one-authority-per-exported-name-6273.test.ts` is the
+ * recurrence guard) — a plain re-export is one declaration with two export
+ * sites, which is what this file now is. `SchemaRegistry['kanban']` in
+ * `@object-ui/types` names the same `KanbanSchema`, pinned in
+ * `__tests__/kanban-plugin-dialect-authoritative-7664.test.ts`.
+ *
+ * `InlineFieldDefinition` stays local: it is the quick-add FORM's field
+ * definition (`InlineQuickAdd.tsx`), not a member of the authored board.
*/
-export interface ColumnWidthConfig {
- /** Default column width in pixels */
- defaultWidth?: number;
- /** Minimum column width in pixels */
- minWidth?: number;
- /** Maximum column width in pixels */
- maxWidth?: number;
- /** Per-column width overrides keyed by column ID */
- overrides?: Record;
-}
+export type {
+ KanbanCard,
+ KanbanColumn,
+ KanbanSchema,
+ CardTemplate,
+ ColumnWidthConfig,
+} from '@object-ui/types';
/**
* Field definition for inline quick-add forms.
diff --git a/packages/plugin-kanban/tsconfig.test.json b/packages/plugin-kanban/tsconfig.test.json
index a98ee894b..7ba33f260 100644
--- a/packages/plugin-kanban/tsconfig.test.json
+++ b/packages/plugin-kanban/tsconfig.test.json
@@ -13,8 +13,14 @@
// a global augmentation, not an import, and `@testing-library/jest-dom` does
// not live under `@types/` — so it is never picked up automatically and has
// to be named here. Naming `types` at all switches off automatic `@types/*`
- // inclusion, which is fine: nothing in these tests touches Node globals.
- "types": ["@testing-library/jest-dom"],
+ // inclusion, which is why `node` is named alongside it:
+ // `__tests__/kanban-handler-slots-7664.test.tsx` reads `../index.tsx` off
+ // disk, deriving the handler keys `KanbanRenderer` forwards from the read
+ // site rather than from a list a re-key could hold constant (objectui#7664).
+ // Same reason `packages/plugin-calendar/tsconfig.test.json` names it, and
+ // same reason it stays OUT of `tsconfig.json`: package SOURCE ships to
+ // browsers and must not compile against Node APIs.
+ "types": ["@testing-library/jest-dom", "node"],
// Drop the root tsconfig's source-tree `paths` so `@object-ui/*` and
// `@objectstack/spec` resolve through the workspace dependency's built
// `.d.ts` instead of pulling sibling sources in as program inputs (TS6059).
diff --git a/packages/types/README.md b/packages/types/README.md
index c8f086fac..8cba9476b 100644
--- a/packages/types/README.md
+++ b/packages/types/README.md
@@ -195,7 +195,7 @@ Menus and navigation:
Advanced composite components:
-- `DeclarativeKanbanSchema` - Kanban board
+- `KanbanSchema` - Kanban board (the dialect `@object-ui/plugin-kanban` renders)
- `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 c8755c466..69f1c08c3 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,
- DeclarativeKanbanSchema,
+ KanbanSchema,
} from '../src/zod/index.zod';
// The failure accessor below is `error.issues`. Zod 4 removed the `.errors`
@@ -131,9 +131,15 @@ if (!dataTableResult.success) {
console.error('DataTable errors:', dataTableResult.error.issues);
}
-// Example 6: Validate a Kanban component
+// 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`.
const kanbanExample = {
type: 'kanban' as const,
+ objectName: 'tasks',
+ groupBy: 'status',
+ cardTitle: 'title',
+ cardFields: ['assignee', 'due_date'],
columns: [
{
id: 'todo',
@@ -143,7 +149,7 @@ const kanbanExample = {
id: '1',
title: 'Task 1',
description: 'Do something',
- priority: 'high' as const,
+ badges: [{ label: 'High', variant: 'destructive' as const }],
},
],
},
@@ -158,10 +164,9 @@ const kanbanExample = {
cards: [],
},
],
- draggable: true,
};
-const kanbanResult = DeclarativeKanbanSchema.safeParse(kanbanExample);
+const kanbanResult = KanbanSchema.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__/component-docs-retired-handler-keys-7340.test.ts b/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts
index 0c047fbcc..bf6525592 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
@@ -58,13 +58,13 @@
* the page used. This is the net for doc-LOCAL interface names, which rule
* 2 cannot resolve by construction (`plugins/*.mdx` document `Overview` /
* `Features` / `Properties` blocks with no shipped counterpart).
- * 4. CONTROL — six measured runtime-slot rows are present, callable, and
+ * 4. CONTROL — seven measured runtime-slot rows are present, callable, and
* cross-checked against source as NOT `?: never`. This is the
* blanket-sweep control: an edit that deleted every `on*` row under
* `content/docs` turns these red, and rules 2 and 3 alone would call that
- * a pass. Two of the six sit on pages this card edited (`input-otp.mdx`
- * `onChange`, `schema-reference.md` `onCardMove` / `onCardClick`), which
- * is where an over-broad edit would land first.
+ * a pass. Four of the seven sit on pages this card edited (`input-otp.mdx`
+ * `onChange`, `schema-reference.md` `onCardMove` / `onCardClick` /
+ * `onQuickAdd`), which is where an over-broad edit would land first.
* 5. PROSE — the reader flags rows, never mentions. Counter-probes feed it a
* sentence naming a retired key and a JSON example key and assert neither
* becomes a row, because "the retired `onComplete`" in running prose is
@@ -253,8 +253,12 @@ 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 = [
- { page: 'api/schema-reference.md', owner: 'DeclarativeKanbanSchema', key: 'onCardMove' },
- { page: 'api/schema-reference.md', owner: 'DeclarativeKanbanSchema', key: 'onCardClick' },
+ // objectui#7664: the page documents 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' },
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 ba1aaafa7..e1d839763 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,7 @@ import {
ChatbotEnhancedSchema as ChatbotEnhancedZod,
ChatbotFloatingSchema as ChatbotFloatingZod,
FilterBuilderSchema as FilterBuilderZod,
- DeclarativeKanbanSchema as KanbanZod,
+ KanbanSchema as KanbanZod,
} from '../zod/complex.zod';
import {
AlertSchema as AlertZod,
@@ -145,7 +145,7 @@ import type {
ChatbotEnhancedSchema,
ChatbotFloatingSchema,
FilterBuilderSchema,
- DeclarativeKanbanSchema,
+ KanbanSchema,
} from '../complex';
import type { AlertSchema, DataTableSchema, ListItem, TreeViewSchema } from '../data-display';
import type { AccordionSchema, CollapsibleSchema, ToggleGroupSchema } from '../disclosure';
@@ -224,8 +224,19 @@ const objectOf = (mirror: z.ZodType, key: string): z.ZodObject =>
* `toFormControlDomProps` whitelist, card's ``).
*/
const RUNTIME_SLOT: readonly Site[] = [
- ['complex.zod.ts', 'DeclarativeKanbanSchema', 'onCardMove', KanbanZod],
- ['complex.zod.ts', 'DeclarativeKanbanSchema', 'onCardClick', KanbanZod],
+ // 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],
['complex.zod.ts', 'CalendarViewSchema', 'onViewChange', CalendarViewZod],
['complex.zod.ts', 'FilterBuilderSchema', 'onChange', FilterBuilderZod],
['complex.zod.ts', 'ChatbotSchema', 'onError', ChatbotZod],
@@ -285,8 +296,10 @@ const RUNTIME_SLOT: readonly Site[] = [
* from the declared `(value: string) => void`, not a consumer of it.
*/
const RETIRED: readonly Site[] = [
- ['complex.zod.ts', 'DeclarativeKanbanSchema', 'onColumnAdd', KanbanZod],
- ['complex.zod.ts', 'DeclarativeKanbanSchema', 'onCardAdd', KanbanZod],
+ // 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],
['complex.zod.ts', 'CarouselSchema', 'onSlideChange', CarouselZod],
['complex.zod.ts', 'ChatbotSchema', 'onSendMessage', ChatbotZod],
['data-display.zod.ts', 'AlertSchema', 'onDismiss', AlertZod],
@@ -371,17 +384,20 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje
]);
});
- it('66 sites are ledgered, 44 runtime slots + 22 retired, with no key filed twice', () => {
+ it('67 sites are ledgered, 45 runtime slots + 22 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).
- expect(RUNTIME_SLOT).toHaveLength(44);
+ // (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);
const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`);
- expect(new Set(ids).size).toBe(66);
+ expect(new Set(ids).size).toBe(67);
});
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) => {
@@ -504,8 +520,8 @@ type KeepsFunction = [Extract, (...args: never[]) => unknown>]
: true;
export type assertionRetiredKeysAreTombstoned = [
- Expect>,
- Expect>,
+ Expect>,
+ Expect>,
Expect>,
Expect>,
Expect>,
@@ -529,8 +545,9 @@ export type assertionRetiredKeysAreTombstoned = [
];
export type assertionRuntimeSlotsKeepTheirFunctionType = [
- Expect>,
- Expect>,
+ Expect>,
+ Expect>,
+ Expect>,
Expect>,
Expect>,
Expect>,
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
new file mode 100644
index 000000000..e6348a84d
--- /dev/null
+++ b/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts
@@ -0,0 +1,328 @@
+/**
+ * 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',
+ ]) {
+ 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 20 live members — the dialect\'s 19 (the ruling said 18; `type` is the difference) plus `onCardClick` — and exactly 3 tombstones', () => {
+ 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', 'allowCollapse',
+ 'conditionalFormatting', 'cardTemplates', 'columnWidths', 'grouping',
+ ]);
+ expect(live).toHaveLength(20);
+ expect(tombstoned).toEqual(['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-kanban-honesty-7645.test.ts b/packages/types/src/__tests__/schema-registry-kanban-honesty-7645.test.ts
deleted file mode 100644
index f8a094d4b..000000000
--- a/packages/types/src/__tests__/schema-registry-kanban-honesty-7645.test.ts
+++ /dev/null
@@ -1,94 +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.
- */
-
-/**
- * `SchemaRegistry['kanban']` — the key survives, the value stops asserting.
- *
- * ## What this pins, and why the key half is the load-bearing half
- *
- * `ComponentType = keyof SchemaRegistry` is a PUBLISHED union. The remedy for
- * objectui#7645 (this map's `'kanban'` value described the declarative
- * authoring face, not the type the registered renderer honours) had to leave
- * that union byte-identical: dropping the key would silently narrow a
- * published type, turning a false claim into a missing one. So the value was
- * weakened to what this layer can prove and the KEY was kept — and only a pin
- * can tell those two edits apart afterwards, because deleting the entry
- * outright also removes the false claim and every runtime suite stays green.
- *
- * ## Why the value cannot simply be corrected
- *
- * The renderer registered for `'kanban'` is `ObjectKanbanRenderer` in
- * `@object-ui/plugin-kanban`, which consumes that package's `KanbanSchema`.
- * `@object-ui/types` cannot name it: the import is a phantom dependency
- * (`check:phantom-deps` names the pair), and declaring the dependency closes
- * the cycle `@object-ui/types` → `@object-ui/plugin-kanban` → `@object-ui/types`.
- * objectui#6172's ruling (2026-08-31) kept the plugin's bare names there;
- * objectui#7664's ruling (a) (2026-09-05) reverses that half — this package's
- * `'kanban'` arm is rewritten to the plugin's shape and the entry re-pointed at
- * it, so the value pinned below is TRANSITIONAL. What IS provable here — a
- * node tagged `'kanban'` — is what it states; the plugin's face satisfies it in
- * `packages/plugin-kanban/src/__tests__/schema-registry-kanban-honesty-7645.test.ts`.
- *
- * ## These assertions are compile-time only
- *
- * They mean something only because this package type-checks its tests:
- * `tsconfig.json` excludes test files (they must not emit into `dist`) and
- * `tsconfig.test.json` picks them back up, chained off the package's
- * `type-check` script. Vitest strips types without checking them, so a green
- * vitest run is NOT evidence about anything below. The instrument is
- * `tsc -p packages/types/tsconfig.test.json`.
- */
-
-import { describe, it, expect } from 'vitest';
-import type {
- SchemaRegistry,
- ComponentType,
- DeclarativeKanbanSchema,
-} from '../index';
-
-/* -------------------------------------------------------------------------- */
-/* 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;
-
-describe("SchemaRegistry's kanban key outlives its value's retreat", () => {
- it('is pinned at compile time', () => {
- // Non-vacuity controls. `Equal` is `false` and `any` satisfies
- // every `extends`, so an `any` on either side would let the pins below
- // pass while checking nothing at all.
- type _RegistryIsReal = Assert, false>>;
- type _DeclarativeIsReal = Assert, false>>;
-
- // 1. The published union still yields `'kanban'`. `Extract` collapses to
- // `never` if the key is ever removed, which fails this pin loudly.
- type _KeyKept = Assert, 'kanban'>>;
-
- // 2. The value no longer claims the declarative authoring face. This is
- // the objectui#7645 defect itself; re-pointing the entry turns it red.
- type _ValueIsNotTheDeclarativeFace = Assert<
- Equal, false>
- >;
-
- // 3. What it DOES assert is true and non-empty: a node tagged `'kanban'`.
- // `never` or `unknown` in that slot fails here rather than passing as a
- // quieter kind of nothing.
- type _ValueIsATaggedNode = Assert>;
-
- // 4. Nothing was invalidated by the retreat: the declarative face still
- // satisfies the weaker claim, as does the plugin's face (pinned in
- // `@object-ui/plugin-kanban`, the only package that can name both).
- type _DeclarativeStillSatisfiesIt = Assert<
- DeclarativeKanbanSchema extends SchemaRegistry['kanban'] ? true : false
- >;
-
- expect(true).toBe(true);
- });
-});
diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts
index 957d14a97..e87df8ebc 100644
--- a/packages/types/src/__tests__/zod-mirror-parity.test.ts
+++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts
@@ -57,7 +57,11 @@
* written-down constant `EXPECTED_MIRROR_PAIRS` by the runtime census at the
* bottom of this file (objectui#7433); `assertionRegistryHalvesAgree` already pins
* it equal to `keyof Declared`. ⛔ Read the constant, not this sentence — a digit
- * here is the artefact that rotted four times. 155 until objectui#7655 registered the
+ * here is the artefact that rotted four times. 157 until objectui#7664 retired the
+ * three `DeclarativeKanban*` pairs and registered the five plugin-dialect ones
+ * (`KanbanCardSchema`, `KanbanColumnSchema`, `KanbanSchema`, `CardTemplateSchema`,
+ * `ColumnWidthConfigSchema` — the `'kanban'` arm rewritten to the shape the
+ * registered renderer reads, ruling (a)); 155 until objectui#7655 registered the
* `ChatbotEnhancedSchema` and `ChatbotFloatingSchema` twins; 154 until objectui#7352
* registered `data-display.zod.ts#DrillDownConfigSchema` — a nested config mirror
* paired with the local `DrillDownConfig`, the `ObjectMapConfigSchema` precedent —
@@ -219,7 +223,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, DeclarativeKanbanCardSchema, DeclarativeKanbanColumnSchema, DeclarativeKanbanSchema, 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, KanbanSchema, CardTemplateSchema, ColumnWidthConfigSchema, FilterBuilderConditionSchema, FilterGroupSchema } from '../zod/complex.zod.js';
import { ActionCallbackSchema, 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';
@@ -234,7 +238,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, DeclarativeKanbanCard as Ts_KanbanCard, DeclarativeKanbanColumn as Ts_KanbanColumn, DeclarativeKanbanSchema as Ts_KanbanSchema } 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, KanbanSchema as Ts_KanbanSchema, CardTemplate as Ts_CardTemplate, ColumnWidthConfig as Ts_ColumnWidthConfig } from '../complex';
import type { DashboardConfig as Ts_DashboardConfig, DashboardWidgetConfig as Ts_DashboardWidgetConfig } from '../designer';
import type { ActionCallback as Ts_ActionCallback, 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';
@@ -585,9 +589,11 @@ const MIRRORS = {
'complex.zod.ts#DashboardWidgetSchema': DashboardWidgetSchema,
'complex.zod.ts#FilterBuilderSchema': FilterBuilderSchema,
'complex.zod.ts#FilterFieldSchema': FilterFieldSchema,
- 'complex.zod.ts#DeclarativeKanbanCardSchema': DeclarativeKanbanCardSchema,
- 'complex.zod.ts#DeclarativeKanbanColumnSchema': DeclarativeKanbanColumnSchema,
- 'complex.zod.ts#DeclarativeKanbanSchema': DeclarativeKanbanSchema,
+ '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#ActionCallbackSchema': ActionCallbackSchema,
'crud.zod.ts#CRUDDialogSchema': CRUDDialogSchema,
'crud.zod.ts#DetailSchema': DetailSchema,
@@ -746,9 +752,11 @@ interface Declared {
'complex.zod.ts#DashboardWidgetSchema': Ts_DashboardWidgetSchema;
'complex.zod.ts#FilterBuilderSchema': Ts_FilterBuilderSchema;
'complex.zod.ts#FilterFieldSchema': Ts_FilterField;
- 'complex.zod.ts#DeclarativeKanbanCardSchema': Ts_KanbanCard;
- 'complex.zod.ts#DeclarativeKanbanColumnSchema': Ts_KanbanColumn;
- 'complex.zod.ts#DeclarativeKanbanSchema': Ts_KanbanSchema;
+ '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#ActionCallbackSchema': Ts_ActionCallback;
'crud.zod.ts#CRUDDialogSchema': Ts_CRUDDialogSchema;
'crud.zod.ts#DetailSchema': Ts_DetailSchema;
@@ -959,8 +967,33 @@ interface KnownDrift {
'complex.zod.ts#FilterBuilderSchema': 'fields' | 'onChange';
/** 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) ×2: `plugin-kanban` forwards `onCardMove` / `onCardClick` off `schema.*` into the board. (`onColumnAdd` / `onCardAdd` are NOT here: nothing reads them, so both faces retire them — `?: never` meets the refusal arm and the pair does not drift on those keys.) */
- 'complex.zod.ts#DeclarativeKanbanSchema': 'onCardMove' | 'onCardClick';
+ /**
+ * 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.
+ *
+ * ⚠️ `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.
+ */
+ '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
@@ -1754,10 +1787,6 @@ interface WiderThanDeclared {
/** CONCRETE: the mirror's operator enum and the declared operator union are not the same set; also in `KnownDrift`. */
'complex.zod.ts#FilterFieldSchema': 'operators';
/** SCHEMA-NODE. */
- 'complex.zod.ts#DeclarativeKanbanColumnSchema': 'cards';
- /** SCHEMA-NODE. */
- 'complex.zod.ts#DeclarativeKanbanSchema': 'columns';
- /** SCHEMA-NODE. */
'crud.zod.ts#DetailSchema': 'groups' | 'tabs';
/**
* CONCRETE. `columns` compares an inline element shape against the named
@@ -2195,6 +2224,8 @@ const EXCLUSIONS: Readonly> = {
"a union (`string | { dialect?, source }`) with no `.shape` of its own — the predicate WIRE shape `BaseSchema`'s `visible` / `hidden` / `disabled` and the form predicate keys carry (objectui#7530); its TS twin `ExpressionWire` (`../expression.ts`) is a type alias, not a key set, and the two faces are pinned equal in `base-schema-predicate-envelope-7530.test.ts`",
'index.zod.ts#SCHEMA_VERSION':
"a version string, not a schema",
+ 'objectql.zod.ts#KanbanConditionalFormattingRuleSchema':
+ "a union of two rule dialects (native `{ field, operator, value }` | spec `{ condition, style }`) with no `.shape` of its own — exported by objectui#7664 so the `'kanban'` arm (`complex.zod.ts#KanbanSchema`) and the `'object-kanban'` arm mirror `conditionalFormatting` from ONE rule declaration; its TS twin `KanbanConditionalFormattingRule` (`../objectql.ts`) is a type union, not a key set, and both arms' `conditionalFormatting` keys are compared where they are declared",
};
/* ── Which pairs depend on @objectstack/spec ────────────────────────────────── */
@@ -2236,6 +2267,9 @@ 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',
'form.zod.ts#SelectOptionSchema',
'layout.zod.ts#PageNodeSchema',
'objectql.zod.ts#ObjectGallerySchema',
@@ -2259,7 +2293,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 = 157;
+const EXPECTED_MIRROR_PAIRS = 159;
/** This file, so the census can read its own type-level ledgers. */
const SELF = fileURLToPath(import.meta.url);
@@ -2346,7 +2380,7 @@ function exportedConsts(): string[] {
* POSITIVE one: a scanner that stopped seeing comments AND stopped seeing real
* references would go green on the list below while checking nothing. The fixture
* suite asserts both directions, and the re-check itself is the live positive
- * proof — all eight pairs in `SPEC_DERIVED_PAIRS` are found by code reference alone.
+ * proof — every pair in `SPEC_DERIVED_PAIRS` is found by code reference alone.
*/
export function specReferencingExports(fileName: string, source: string): Set {
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts
index 4f857f5c8..78de4cd0b 100644
--- a/packages/types/src/complex.ts
+++ b/packages/types/src/complex.ts
@@ -19,41 +19,106 @@ 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 } from './objectql.js';
/**
- * Kanban column — the DECLARATIVE (authoring / validation) face.
+ * Kanban card — the shape the registered `'kanban'` renderer reads.
*
- * ## Why the name carries a `Declarative` prefix (objectui#6172)
+ * ## Why this dialect lives here (objectui#7664, maintainer ruling (a), 2026-09-05)
*
- * This trio and `@object-ui/plugin-kanban`'s `KanbanCard` / `KanbanColumn` /
- * `KanbanSchema` were two declarations of one set of names, in two dialects.
- * The 2026-08-31 maintainer ruling (决裁批 #14, option A) settled the authority:
- * **the plugin KEEPS the bare names**, because those are what all four
- * registered kanban renderers (`kanban`, `kanban-ui`, `kanban-enhanced`,
- * `object-kanban`) consume, and objectui#6086 measured the failure mode of
- * getting that backwards — an IDE or agent auto-importing the bare name picks
- * whichever copy sorts first, and the wrong one produces a **confident empty
- * board** instead of an abstention. So the surviving bare name has to be the
- * one a renderer honours.
+ * `@object-ui/types` used to declare a DIFFERENT board under the same
+ * `'kanban'` key — the `DeclarativeKanban*` trio (`columns` with `color`,
+ * `draggable`, cards with `labels` / `assignees` / `priority`), which was the
+ * `'kanban'` arm of `ComplexSchema` → `AnyComponentSchema` → `safeValidateSchema`
+ * — while the renderer registered for that key (`ObjectKanbanRenderer` in
+ * `@object-ui/plugin-kanban`) consumed the plugin's own `KanbanSchema`. A board
+ * could pass `objectui validate` and render EMPTY, because the validator and the
+ * renderer honoured two unrelated faces (objectui#6086 measured the consequence).
*
- * What survives here is the face this package really serves: the authoring
- * shape and its Zod mirror (`zod/complex.zod.ts`), which is the `'kanban'` arm
- * of `ComplexSchema` → `AnyComponentSchema` → `safeValidateSchema` and so
- * validates every authored `{ "type": "kanban" }` document the CLI's
- * `validate` / `check` commands see. Renaming rather than retiring was the
- * ruled outcome; ⛔ do not re-point these at the plugin — `@object-ui/types` is
- * the zero-workspace-dependency bottom layer and cannot depend on a plugin.
+ * The ruling: for an authored `type: 'kanban'` document the PLUGIN dialect is
+ * authoritative, so this package declares exactly that dialect and
+ * `@object-ui/plugin-kanban` imports it back rather than declaring its own
+ * (`packages/plugin-kanban/src/types.ts` re-exports these names — one
+ * declaration, one authority, the dependency direction unchanged: `types`
+ * declares, the plugin conforms). The declarative trio and its three Zod
+ * mirrors are retired in the same change (ADR-0049 enforce-or-remove): its only
+ * retained value was the validator arm, and that arm now validates this shape.
+ * objectui#6172's "keep both faces" half is what this reverses; the ruling says
+ * so explicitly.
+ *
+ * Every member below was carried from the plugin's declaration verbatim. The
+ * runtime-computed members (`cardSubtitle`, `cardFieldCells`, `coverImage`,
+ * a badge's `colorStyle`) are what `ObjectKanban` writes onto the cards it
+ * hands the board and what `KanbanImpl` / `KanbanEnhanced` read back; the Zod
+ * mirror (`zod/complex.zod.ts`) passes those through the way it passes
+ * `TableColumn.headerIcon` through (objectui#6424).
+ *
+ * `React.CSSProperties` / `React.ReactNode` resolve through the ambient
+ * namespace, the way `data-display.ts` already spells `headerIcon` and
+ * `rowStyle` — this package still declares no React dependency.
*/
-export interface DeclarativeKanbanColumn {
+export interface KanbanCard {
+ id: string;
+ title: string;
+ description?: string;
+ badges?: Array<{
+ label: string;
+ variant?: "default" | "secondary" | "destructive" | "outline";
+ /**
+ * Optional Tailwind class string applied to the badge. When set, it
+ * overrides `variant` so callers can reuse the same colors as list/grid
+ * cells.
+ *
+ * Derive it the way the grid cell derives it, or the same option renders
+ * two colours on one screen (objectui#5183): prefer
+ * `getBadgeHexAppearance(color)` from `@object-ui/fields` and use its
+ * `className` — passing its `colorStyle` too — and fall back to
+ * `getBadgeColorClasses(color, value)` only when it returns `undefined`.
+ */
+ colorClass?: string;
+ /**
+ * Inline style accompanying `colorClass`. **Required whenever the class
+ * string came from `getBadgeHexAppearance`** — that className reads CSS
+ * custom properties which only this style declares, so a badge carrying
+ * the class without the style references undefined variables. Pass the
+ * helper's `style` verbatim; leave unset on the palette-family path.
+ */
+ colorStyle?: React.CSSProperties;
+ }>;
+ /**
+ * Synthesized card subtitle (e.g. "Account: Acme · Amount: $150K"). Rendered
+ * in preference to `description` so we don't have to overwrite the record's
+ * real `description` field — which would corrupt detail-view and edit-form
+ * displays once a card is opened.
+ *
+ * Read by `KanbanImpl`; absent on a board that renders plain descriptions.
+ */
+ cardSubtitle?: string;
/**
- * Unique column identifier
+ * Structured per-field cells. When provided, the card body renders each
+ * field via the unified `@object-ui/fields` cell-renderer pipeline (same
+ * as Grid/Gallery), so lookup/user/email/url/phone/boolean/etc. fields
+ * keep their semantic styling instead of being flattened to a text join.
+ *
+ * Takes precedence over `cardSubtitle` / `description` when present.
*/
- id: string;
+ cardFieldCells?: Array<{ field: string; label?: string; node: React.ReactNode }>;
/**
- * Column title
+ * Resolved cover-image URL for the card, derived from the board's
+ * `coverImageField`. Read by both board implementations.
*/
+ coverImage?: string;
+ [key: string]: any;
+}
+
+/**
+ * Kanban column — a lane of the registered `'kanban'` renderer.
+ */
+export interface KanbanColumn {
+ id: string;
title: string;
/**
* Cards in this column.
@@ -62,117 +127,293 @@ export interface DeclarativeKanbanColumn {
* document writes: `KanbanImpl` (12 lines), `KanbanEnhanced` (8) and
* `bucketCardsIntoColumns` all read `column.cards`, and the two catalog
* entries, the plugin docs and `content/docs/api/schema-reference.md` all
- * author it. This member was spelled `items` until objectui#6939 — a
- * spelling with zero read sites, which made every authored board fail
- * `safeValidateSchema` while rendering correctly (objectui#6318's bucket).
- */
- cards: DeclarativeKanbanCard[];
- /**
- * Column color/variant
+ * author it. The retired declarative face spelled this `items` until
+ * objectui#6939 — a spelling with zero read sites, which made every authored
+ * board fail `safeValidateSchema` while rendering correctly (objectui#6318's
+ * bucket).
*/
- color?: string;
+ cards: KanbanCard[];
/**
- * Maximum number of cards allowed
+ * WIP limit — the card count at which the lane warns. Never reaches the
+ * query; the board's fetch window is {@link KanbanSchema.limit}.
*/
limit?: number;
+ className?: string;
/**
- * Whether column is collapsed
+ * Whether the lane renders collapsed. Honoured by `KanbanEnhanced` (the
+ * implementation that ships column collapsing); the plain board ignores it.
*/
collapsed?: boolean;
+ /**
+ * RETIRED with the declarative face (objectui#7664, ADR-0049) — `color` was a
+ * `DeclarativeKanbanColumn` member, and no registered board reads a column
+ * colour (measured: zero `column.color` read sites across `KanbanImpl`,
+ * `KanbanEnhanced`, `ObjectKanban`).
+ *
+ * A tombstone rather than a plain removal on BOTH prongs 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: `className` is that live replacement — style a lane through
+ * it;
+ * - prong 2: `content/docs/api/schema-reference.md` taught this key as
+ * working. Before this card its kanban example authored a `color` on
+ * every one of its three columns (`"color": "#6366f1"` and two more) and
+ * its `columns` row read "each with `id`, `title`, `color`, and `cards`".
+ *
+ * ⚠️ The hazard prong 2 guards here is a SILENT STRIP, not a silent keep:
+ * `KanbanColumn` does not extend {@link BaseSchema}, so its mirror is a plain
+ * (non-passthrough) object. Measured on the built mirror: an undeclared
+ * column key is accepted and dropped from the parsed output, while this
+ * tombstone refuses `color` by name. A board that has always authored lane
+ * colours therefore gets told, instead of quietly losing them.
+ * @deprecated Not part of this contract — the value was inert.
+ */
+ color?: never;
}
/**
- * Kanban card
+ * 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`.
+ *
+ * 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`).
*/
-export interface DeclarativeKanbanCard {
+export interface KanbanSchema extends BaseSchema {
+ type: 'kanban';
+
/**
- * Unique card identifier
+ * Object name to fetch data from.
*/
- id: string;
+ objectName?: string;
+
/**
- * Card title
+ * Field to group records by (maps to column IDs).
*/
- title: string;
+ groupBy?: string;
+
/**
- * Card description
+ * Field for swimlane rows (2D grouping). When set, cards are grouped
+ * vertically by `groupBy` (columns) and horizontally by `swimlaneField` (rows).
*/
- description?: string;
+ swimlaneField?: string;
+
/**
- * Card labels/tags
+ * Field to use as the card title.
*/
- labels?: string[];
+ cardTitle?: string;
+
/**
- * Card assignees
+ * Fields to display on the card.
*/
- assignees?: string[];
+ cardFields?: string[];
+
/**
- * Card due date
+ * 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.
*/
- dueDate?: string | Date;
+ data?: any[];
+
/**
- * Card priority
+ * 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.
*/
- priority?: 'low' | 'medium' | 'high' | 'critical';
+ limit?: number;
+
/**
- * Custom card content
+ * Array of columns to display in the kanban board.
+ * Each column contains an array of cards.
*/
- content?: SchemaNode | SchemaNode[];
+ columns?: KanbanColumn[];
+
/**
- * Additional card data
+ * 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.
*/
- data?: any;
-}
+ 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;
-/**
- * Kanban board component
- */
-export interface DeclarativeKanbanSchema extends BaseSchema {
- type: 'kanban';
/**
- * Kanban columns
+ * Optional CSS class name to apply custom styling.
*/
- columns: DeclarativeKanbanColumn[];
+ className?: string;
+
/**
- * Enable drag and drop
- * @default true
+ * Enable Quick Add button at the bottom of each column.
+ * When true, a "+" button appears allowing inline card creation.
+ * @default false
*/
- draggable?: boolean;
+ quickAdd?: boolean;
+
/**
- * Card move handler
+ * 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 it is
- * forwarded by `plugin-kanban` (`onCardMove={schema.onCardMove}`).
+ * 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.
*/
- onCardMove?: (cardId: string, fromColumn: string, toColumn: string, position: number) => void;
+ 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;
+
+ /**
+ * Allow columns to be collapsed/expanded.
+ * @default false
+ */
+ allowCollapse?: boolean;
+
+ /**
+ * Conditional formatting rules for card coloring. Accepts the native
+ * `{ field, operator, value }` shape and the spec `{ condition, style }` CEL
+ * shape (issue #1584).
+ */
+ conditionalFormatting?: KanbanConditionalFormattingRule[];
+
/**
- * Card click handler
+ * Predefined card templates for quick-add.
+ * Each template pre-fills the quick-add form with default values.
+ */
+ cardTemplates?: CardTemplate[];
+
+ /**
+ * Custom column width configuration.
+ * Supports per-column overrides with min/max constraints.
+ */
+ columnWidths?: ColumnWidthConfig;
+
+ /**
+ * Grouping configuration from ListView.
+ * When set, the first grouping field is used as swimlaneField fallback.
+ */
+ grouping?: GroupingConfig;
+
+ /**
+ * 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).
*
- * 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
- * forwarded by `plugin-kanban` (`onCardClick={schema.onCardClick}`).
+ * 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.
*/
- onCardClick?: (card: DeclarativeKanbanCard) => void;
+ draggable?: never;
/**
* RETIRED (objectui#6124, ADR-0049) — JSON has no function value, and the
- * `kanban` renderer takes `({ schema })` and never reads it. The zod twin
- * refuses it by name; author behaviour as a node type (`{ "type": "toast" }`,
- * an `action:button` node) instead.
+ * `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. The zod twin
- * refuses it by name; author behaviour as a node type (`{ "type": "toast" }`,
- * an `action:button` node) instead.
+ * `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.
+ */
+export interface CardTemplate {
+ /** Unique template identifier */
+ id: string;
+ /** Human-readable template name */
+ name: string;
+ /** Optional Lucide icon name */
+ icon?: string;
+ /** Pre-filled field values */
+ values: Record;
+}
+
+/**
+ * Configuration for custom column widths.
+ */
+export interface ColumnWidthConfig {
+ /** Default column width in pixels */
+ defaultWidth?: number;
+ /** Minimum column width in pixels */
+ minWidth?: number;
+ /** Maximum column width in pixels */
+ maxWidth?: number;
+ /** Per-column width overrides keyed by column ID */
+ overrides?: Record;
+}
+
/**
* Calendar view mode — the registered `calendar-view` renderer's rendered set.
*
@@ -1571,7 +1812,7 @@ export interface DashboardComponentSchema extends BaseSchema {
* Union type of all complex schemas
*/
export type ComplexSchema =
- | DeclarativeKanbanSchema
+ | KanbanSchema
| CalendarViewSchema
| FilterBuilderSchema
| CarouselSchema
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 6e5bbfbf9..57ee75342 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -294,9 +294,11 @@ export type {
// Complex Components - Advanced/Composite Components
// ============================================================================
export type {
- DeclarativeKanbanColumn,
- DeclarativeKanbanCard,
- DeclarativeKanbanSchema,
+ KanbanCard,
+ KanbanColumn,
+ KanbanSchema,
+ CardTemplate,
+ ColumnWidthConfig,
CalendarViewMode,
CalendarEvent,
CalendarViewSchema,
diff --git a/packages/types/src/registry.ts b/packages/types/src/registry.ts
index b4b0b62ab..3b5ce94b2 100644
--- a/packages/types/src/registry.ts
+++ b/packages/types/src/registry.ts
@@ -6,8 +6,6 @@
* LICENSE file in the root directory of this source tree.
*/
-import type { BaseSchema } from './base.js';
-
import type {
DivSchema,
BoxSchema,
@@ -94,6 +92,7 @@ import type {
} from './navigation.js';
import type {
+ KanbanSchema,
CalendarViewSchema,
FilterBuilderSchema,
CarouselSchema,
@@ -184,38 +183,20 @@ export interface SchemaRegistry {
'pagination': PaginationSchema;
// Complex
- // ⚠️ `'kanban'` is the one key whose value this layer cannot state
- // precisely, so it deliberately states LESS rather than stating it wrongly
- // (objectui#7645).
- //
- // The renderer registered for this key is `ObjectKanbanRenderer` in
- // `@object-ui/plugin-kanban` (`ComponentRegistry.register('kanban', …)`,
- // `plugin-kanban/src/index.tsx`), and it consumes THAT package's
- // `KanbanSchema`. `@object-ui/types` cannot name that type, measured two
- // ways: importing it is a phantom dependency this package does not declare
- // (`check:phantom-deps` rejects it by file and pair), and declaring the
- // dependency would close the cycle `@object-ui/types` →
- // `@object-ui/plugin-kanban` → `@object-ui/types` — this is the
- // zero-workspace-dependency bottom layer. objectui#6172's ruling (option A,
- // 2026-08-31) kept the plugin's bare names rather than relocating that
- // dialect down here. objectui#7664's ruling (a) (2026-09-05) reverses that
- // half: this package's `'kanban'` arm is rewritten to the plugin's shape and
- // this entry is re-pointed at the declared type — this value is TRANSITIONAL.
- //
- // Until then the entry asserts only what this layer can PROVE, and what BOTH
- // dialects satisfy: a schema node tagged `'kanban'`. It no longer names
- // `DeclarativeKanbanSchema` — the AUTHORING/validation face (the `'kanban'`
- // arm of `ComplexSchema` → `AnyComponentSchema` → `safeValidateSchema`),
- // not the type the registered renderer honours. Naming it here made a map
- // that calls itself the Single Source of Truth describe the wrong component.
- //
- // ⛔ Do not "restore" a precise type here without moving the renderer's
- // dialect into a layer this package may depend on. Two compile-time pins
- // hold the shape: `src/__tests__/schema-registry-kanban-honesty-7645.test.ts`
- // (the key survives in `keyof`; the value no longer claims the declarative
- // face) and the same file name under `plugin-kanban/src/__tests__/` (the
- // renderer's own `KanbanSchema` satisfies what this entry asserts).
- 'kanban': BaseSchema & { type: 'kanban' };
+ // `'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;
'calendar-view': CalendarViewSchema;
'filter-builder': FilterBuilderSchema;
'carousel': CarouselSchema;
diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md
index a16b77558..07a12c6b9 100644
--- a/packages/types/src/zod/README.md
+++ b/packages/types/src/zod/README.md
@@ -210,7 +210,7 @@ function validateComponent(config: unknown) {
- `PaginationSchema`, `NavigationMenuSchema`, `ButtonGroupSchema`
### Complex Components (5)
-- `DeclarativeKanbanSchema`, `CalendarViewSchema`
+- `KanbanSchema`, `CalendarViewSchema`
- `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema`
## Schema Structure
diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts
index 53fd0ed05..4bb6bcf1b 100644
--- a/packages/types/src/zod/complex.zod.ts
+++ b/packages/types/src/zod/complex.zod.ts
@@ -17,14 +17,16 @@
*/
import { z } from 'zod';
-import { handlerKeyRefusal } from './tombstone.zod.js';
+import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js';
import {
ChartTypeSchema as SpecChartTypeSchema,
DashboardSchema as SpecDashboardSchema,
DashboardWidgetSchema as SpecDashboardWidgetSchema,
GlobalFilterSchema as SpecGlobalFilterSchema,
+ GroupingConfigSchema as SpecGroupingConfigSchema,
} 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,
@@ -32,41 +34,132 @@ import {
} from '../complex.js';
/**
- * Kanban Card Schema
+ * The retired declarative face, named once so every refusal below says the
+ * same thing (objectui#7664, maintainer ruling (a), 2026-09-05: for an authored
+ * `type: 'kanban'` document the PLUGIN dialect is authoritative; the
+ * `DeclarativeKanbanSchema` / `DeclarativeKanbanColumn` / `DeclarativeKanbanCard`
+ * trio and its three mirrors retired under ADR-0049 in the same change).
+ *
+ * A board written in that dialect used to PASS `safeValidateSchema` and render
+ * EMPTY, because the validator and the registered renderer honoured two
+ * unrelated shapes. The keys that dialect had and this one does not are refused
+ * BY NAME, so the author reads which shape they wrote and what to write instead
+ * — the named-refusal outcome objectui#5474 records as intended, not a silent
+ * strip.
*/
-export const DeclarativeKanbanCardSchema = z.object({
+const retiredDeclarativeKanbanKey = (key: string, where: string, remedy: string) =>
+ retirementTombstone(
+ `\`${key}\` is RETIRED (objectui#7664, ADR-0049) — it belonged to the retired ` +
+ `\`DeclarativeKanbanSchema\` dialect (a ${where} key of the old \`@object-ui/types\` ` +
+ 'kanban face), which no registered kanban renderer ever read. The `kanban` type key ' +
+ 'now validates the shape `@object-ui/plugin-kanban` renders: `objectName` + `groupBy` for ' +
+ `an object-bound board, or \`columns[].cards[]\` for a static one. ${remedy}`,
+ );
+
+/**
+ * Kanban Card Schema — mirrors {@link KanbanCard} in `../complex.ts` key for key.
+ *
+ * `.passthrough()` because the declaration carries `[key: string]: any`: a card
+ * is a record, and `bucketCardsIntoColumns` pushes raw records into lanes with
+ * their fields intact (conditional formatting reads them back). The three
+ * runtime-computed members `ObjectKanban` writes onto a card — `cardFieldCells`
+ * (rendered `React.ReactNode` cells) and a badge's `colorStyle` (the
+ * `getBadgeHexAppearance` style object) — are PASSED THROUGH as `z.any()`, the
+ * way `data-display.zod.ts` passes `TableColumn.headerIcon` through
+ * (objectui#6424): a non-strict object would otherwise silently strip what the
+ * renderer honours, which is the second de-facto contract that card closed.
+ */
+export const KanbanCardSchema = z.object({
id: z.string().describe('Card ID'),
title: z.string().describe('Card title'),
description: z.string().optional().describe('Card description'),
- labels: z.array(z.string()).optional().describe('Card labels'),
- assignees: z.array(z.string()).optional().describe('Card assignees'),
- dueDate: z.union([z.string(), z.date()]).optional().describe('Due date'),
- priority: z.enum(['low', 'medium', 'high', 'critical']).optional().describe('Card priority'),
- content: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Custom content'),
- data: z.any().optional().describe('Custom card data'),
-});
+ badges: z.array(z.object({
+ label: z.string().describe('Badge label'),
+ variant: z.enum(['default', 'secondary', 'destructive', 'outline']).optional().describe('Badge variant'),
+ colorClass: z.string().optional().describe('Tailwind class string applied to the badge; overrides `variant`'),
+ colorStyle: z.any().optional().describe('Inline style accompanying `colorClass` — the `getBadgeHexAppearance` style object, passed through verbatim'),
+ })).optional().describe('Card badges'),
+ cardSubtitle: z.string().optional().describe('Synthesized card subtitle, rendered in preference to `description`'),
+ cardFieldCells: z.array(z.object({
+ field: z.string().describe('Field name the cell renders'),
+ label: z.string().optional().describe('Cell label'),
+ node: z.any().describe('Rendered cell node — written by `ObjectKanban` from `cardFields`, passed through verbatim'),
+ })).optional().describe('Structured per-field cells rendered through the `@object-ui/fields` cell pipeline'),
+ coverImage: z.string().optional().describe('Resolved cover-image URL, derived from the board\'s `coverImageField`'),
+}).passthrough();
/**
- * Kanban Column Schema
+ * Kanban Column Schema — mirrors {@link KanbanColumn} in `../complex.ts`.
*/
-export const DeclarativeKanbanColumnSchema = z.object({
+export const KanbanColumnSchema = z.object({
id: z.string().describe('Column ID'),
title: z.string().describe('Column title'),
- cards: z.array(DeclarativeKanbanCardSchema).describe('Column cards'),
- color: z.string().optional().describe('Column color'),
- limit: z.number().optional().describe('Card limit'),
- collapsed: z.boolean().optional().describe('Whether column is collapsed'),
+ cards: z.array(KanbanCardSchema).describe('Column cards'),
+ limit: z.number().optional().describe('WIP limit — the card count at which the lane warns'),
+ className: z.string().optional().describe('Column class name'),
+ collapsed: z.boolean().optional().describe('Whether the lane renders collapsed (honoured by the enhanced board)'),
+ color: retiredDeclarativeKanbanKey('color', 'column', 'Style a lane through its `className`.'),
+});
+
+/**
+ * Card Template Schema — mirrors {@link CardTemplate} in `../complex.ts`.
+ */
+export const CardTemplateSchema = z.object({
+ id: z.string().describe('Unique template identifier'),
+ name: z.string().describe('Human-readable template name'),
+ icon: z.string().optional().describe('Optional Lucide icon name'),
+ values: z.record(z.string(), z.any()).describe('Pre-filled field values'),
+});
+
+/**
+ * Column Width Config Schema — mirrors {@link ColumnWidthConfig} in `../complex.ts`.
+ */
+export const ColumnWidthConfigSchema = z.object({
+ defaultWidth: z.number().optional().describe('Default column width in pixels'),
+ minWidth: z.number().optional().describe('Minimum column width in pixels'),
+ maxWidth: z.number().optional().describe('Maximum column width in pixels'),
+ overrides: z.record(z.string(), z.number()).optional().describe('Per-column width overrides keyed by column ID'),
});
/**
- * Kanban Schema - Kanban board component
+ * 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).
+ *
+ * `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`).
*/
-export const DeclarativeKanbanSchema = BaseSchema.extend({
+export const KanbanSchema = BaseSchema.extend({
type: z.literal('kanban'),
- columns: z.array(DeclarativeKanbanColumnSchema).describe('Kanban columns'),
- draggable: z.boolean().optional().describe('Whether cards are draggable'),
+ 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: z.boolean().optional().describe('Allow columns to be collapsed/expanded'),
+ conditionalFormatting: z.array(KanbanConditionalFormattingRuleSchema).optional().describe('Card conditional formatting rules'),
+ cardTemplates: z.array(CardTemplateSchema).optional().describe('Predefined card templates for quick-add'),
+ columnWidths: ColumnWidthConfigSchema.optional().describe('Custom column width configuration'),
+ grouping: SpecGroupingConfigSchema.optional().describe('Grouping configuration from ListView; its first field is the swimlaneField fallback'),
+ 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'),
});
@@ -872,7 +965,7 @@ export const DashboardConfigSchema = z.object({
* Complex Schema Union - All complex component schemas
*/
export const ComplexSchema = z.discriminatedUnion('type', [
- DeclarativeKanbanSchema,
+ KanbanSchema,
CalendarViewSchema,
FilterBuilderSchema,
CarouselSchema,
diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts
index 2b5a8db76..0cfb26c67 100644
--- a/packages/types/src/zod/index.zod.ts
+++ b/packages/types/src/zod/index.zod.ts
@@ -223,9 +223,11 @@ export {
// Complex Components - Advanced/Composite Components
// ============================================================================
export {
- DeclarativeKanbanCardSchema,
- DeclarativeKanbanColumnSchema,
- DeclarativeKanbanSchema,
+ KanbanCardSchema,
+ KanbanColumnSchema,
+ CardTemplateSchema,
+ ColumnWidthConfigSchema,
+ KanbanSchema,
CalendarViewModeSchema,
CalendarEventSchema,
CalendarViewSchema,
diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts
index d9dbffe7f..97b7a6681 100644
--- a/packages/types/src/zod/objectql.zod.ts
+++ b/packages/types/src/zod/objectql.zod.ts
@@ -890,7 +890,14 @@ export const ObjectCalendarSchema = BaseSchema.extend({
// kanban rule accepts BOTH the native `{ field, operator, value }` shape and the
// spec `{ condition, style }` shape (a CEL predicate + style map) — matching
// list/grid `conditionalFormatting`. The type/schema now match the runtime.
-const KanbanConditionalFormattingRuleSchema = z.union([
+//
+// Exported since objectui#7664 so `complex.zod.ts`'s `KanbanSchema` (the
+// `'kanban'` arm) mirrors `conditionalFormatting` with the SAME rule union as
+// this `'object-kanban'` arm — one declaration of the rule, two arms. It is a
+// union of two rule dialects with no `.shape` of its own, so the parity census
+// EXCLUDES it rather than pairing it; its TS twin is the type union
+// `KanbanConditionalFormattingRule` (`../objectql.ts`).
+export const KanbanConditionalFormattingRuleSchema = z.union([
z.object({
field: z.string().describe('Field name to check'),
operator: z.enum(['equals', 'not_equals', 'contains', 'in']).describe('Comparison operator'),
diff --git a/scripts/__tests__/one-authority-per-exported-name-6273.test.ts b/scripts/__tests__/one-authority-per-exported-name-6273.test.ts
index 49574c8ac..4e49d1ff2 100644
--- a/scripts/__tests__/one-authority-per-exported-name-6273.test.ts
+++ b/scripts/__tests__/one-authority-per-exported-name-6273.test.ts
@@ -421,7 +421,13 @@ const KNOWN_COLLISIONS: ReadonlyMap = new Map([
// surviving bare name must be the one a renderer honours, because
// objectui#6086 measured that auto-importing the wrong copy yields a confident
// EMPTY BOARD rather than an abstention. One authority each now, so all three
- // entries would fail the stale-baseline direction.
+ // entries would fail the stale-baseline direction. objectui#7664 (maintainer
+ // ruling (a), 2026-09-05) then reversed the "keep both faces" half: the ONE
+ // declaration of `KanbanSchema` / `KanbanColumn` / `KanbanCard` (and
+ // `CardTemplate` / `ColumnWidthConfig`) moved down to `@object-ui/types`
+ // (`complex.ts`), the `DeclarativeKanban*` trio retired, and
+ // `plugin-kanban/src/types.ts` re-exports the five — a plain re-export, not
+ // an authority, so the entries stay retired.
// `MarkdownSchema` sat here, colliding between
// `packages/plugin-markdown/src/types.ts` and `packages/types/src/data-display.ts`.
// The copies differed on ONE member — `content`, required there and optional here —