From 120106fe2cf3059bb2a3c1c29ac9b70940f8186d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:45:59 +0000 Subject: [PATCH 1/4] feat(types): the `kanban` arm declares the plugin dialect; the DeclarativeKanban* trio retires For an authored `type: 'kanban'` document two types were authoritative depending on who asked: `safeValidateSchema` honoured `DeclarativeKanbanSchema` while the renderer registered for the key consumed `@object-ui/plugin-kanban`'s own `KanbanSchema`, so a board could validate and render empty. Maintainer ruling (a) (2026-09-05, batch #41): the plugin dialect is authoritative. - `@object-ui/types` declares `KanbanSchema` / `KanbanColumn` / `KanbanCard` / `CardTemplate` / `ColumnWidthConfig` (member for member the plugin's shape), mirrors them in `zod/complex.zod.ts`, and `SchemaRegistry['kanban']` names the declaration; `plugin-kanban/src/types.ts` re-exports the five. - The retired dialect's inert keys (`draggable`, column `color`) are `?: never` tombstones refused by name; `onColumnAdd` / `onCardAdd` carried over. - The six `DeclarativeKanban*` exports retire (ADR-0049) with their two objectui#7645 pins; a 7664 pin on each side replaces them. The parity, handler-key and docs-retired ratchets are re-keyed; the docs page moves to the plugin dialect. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .changeset/7664-kanban-arm-plugin-dialect.md | 76 ++++ .../7664-plugin-kanban-declared-schema.md | 12 + content/docs/api/index.md | 2 +- content/docs/api/schema-reference.md | 56 ++- packages/plugin-kanban/src/ObjectKanban.tsx | 2 +- ...-plugin-dialect-authoritative-7664.test.ts | 104 ++++++ ...chema-registry-kanban-honesty-7645.test.ts | 84 ----- packages/plugin-kanban/src/types.ts | 249 ++----------- packages/types/README.md | 2 +- .../types/examples/zod-validation-example.ts | 15 +- ...ent-docs-retired-handler-keys-7340.test.ts | 8 +- .../handler-keys-json-refusal-6124.test.ts | 25 +- ...-plugin-dialect-authoritative-7664.test.ts | 320 +++++++++++++++++ ...chema-registry-kanban-honesty-7645.test.ts | 94 ----- .../src/__tests__/zod-mirror-parity.test.ts | 80 +++-- packages/types/src/complex.ts | 338 +++++++++++++----- packages/types/src/index.ts | 8 +- packages/types/src/registry.ts | 49 +-- packages/types/src/zod/README.md | 2 +- packages/types/src/zod/complex.zod.ts | 132 +++++-- packages/types/src/zod/index.zod.ts | 8 +- packages/types/src/zod/objectql.zod.ts | 9 +- ...e-authority-per-exported-name-6273.test.ts | 8 +- 23 files changed, 1079 insertions(+), 604 deletions(-) create mode 100644 .changeset/7664-kanban-arm-plugin-dialect.md create mode 100644 .changeset/7664-plugin-kanban-declared-schema.md create mode 100644 packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts delete mode 100644 packages/plugin-kanban/src/__tests__/schema-registry-kanban-honesty-7645.test.ts create mode 100644 packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts delete mode 100644 packages/types/src/__tests__/schema-registry-kanban-honesty-7645.test.ts diff --git a/.changeset/7664-kanban-arm-plugin-dialect.md b/.changeset/7664-kanban-arm-plugin-dialect.md new file mode 100644 index 0000000000..05f0c6f4e9 --- /dev/null +++ b/.changeset/7664-kanban-arm-plugin-dialect.md @@ -0,0 +1,76 @@ +--- +'@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. +- **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 0000000000..28c487014a --- /dev/null +++ b/.changeset/7664-plugin-kanban-declared-schema.md @@ -0,0 +1,12 @@ +--- +'@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)). Member for member the shape is what this package declared, so 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. diff --git a/content/docs/api/index.md b/content/docs/api/index.md index d1e4316fcb..39825c0079 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 d0a817e4ce..55ab72f150 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,25 @@ 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. | +| `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 +1241,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 +1316,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 56a8acb649..6db73eb7fe 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-plugin-dialect-authoritative-7664.test.ts b/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts new file mode 100644 index 0000000000..ad58decdf6 --- /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 27b0874579..0000000000 --- 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 b253d4b503..3ca61eefb7 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/types/README.md b/packages/types/README.md index c8f086faca..8cba9476b5 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 a5a147f38d..ed4bc2051f 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 0c047fbccd..859e78e017 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 @@ -63,7 +63,7 @@ * 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 + * `onChange`, `schema-reference.md` `onCardMove` / `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 @@ -253,8 +253,10 @@ 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 two runtime slots are `onCardMove` / `onQuickAdd`. + { page: 'api/schema-reference.md', owner: 'KanbanSchema', key: 'onCardMove' }, + { 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 ba1aaafa74..f47662a879 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,11 @@ 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: `KanbanRenderer` + // forwards `schema.onCardMove` and `schema.onQuickAdd`; the retired declarative + // face's `onCardClick` slot left with it (the object-bound board owns the click). + ['complex.zod.ts', 'KanbanSchema', 'onCardMove', 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 +288,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], @@ -504,8 +509,8 @@ type KeepsFunction = [Extract, (...args: never[]) => unknown>] : true; export type assertionRetiredKeysAreTombstoned = [ - Expect>, - Expect>, + Expect>, + Expect>, Expect>, Expect>, Expect>, @@ -529,8 +534,8 @@ export type assertionRetiredKeysAreTombstoned = [ ]; export type assertionRuntimeSlotsKeepTheirFunctionType = [ - 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 0000000000..24c227904e --- /dev/null +++ b/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts @@ -0,0 +1,320 @@ +/** + * 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 19 live + * members plus the 3 tombstones, by name. + * + * ## 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 19 live members (the ruling said 18; a same-day census said 19 — `type` is the difference) 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', 'className', 'quickAdd', 'onQuickAdd', 'coverImageField', 'allowCollapse', + 'conditionalFormatting', 'cardTemplates', 'columnWidths', 'grouping', + ]); + expect(live).toHaveLength(19); + 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 f8a094d4ba..0000000000 --- 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 748a76e662..afe7be7ef0 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -28,7 +28,7 @@ * * It reads `.shape` and not `keyof z.input` because that spelling is * vacuous — `.passthrough()` collapses the inferred key union to bare `string`. - * `assertionNoVacuousEntry` below pins that for all 157 entries at once. + * `assertionNoVacuousEntry` below pins that for all 159 entries at once. * * ## What is registered * @@ -53,8 +53,12 @@ * framing, and objectui#6058's own dispatch, both inherited "163"). On a file whose * entire subject is measurement that is worth stating explicitly: * - * - **157 pairs** — `Object.keys(MIRRORS).length`, which `assertionRegistryHalvesAgree` - * already pins equal to `keyof Declared`. 155 until objectui#7655 registered the + * - **159 pairs** — `Object.keys(MIRRORS).length`, which `assertionRegistryHalvesAgree` + * already pins equal to `keyof Declared`. 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 — @@ -65,7 +69,7 @@ * rather than by reading the prose: `4ca30d044` wrote "160" when `MIRRORS` held * **163**; `d88e20f55` (objectui#7432) took the registry to **154** without touching * the sentence; objectui#7352 then added its 1 to the stale baseline and wrote 161. - * Two independent derivations agree on 157 today — a TypeScript AST walk counting + * Two independent derivations agreed on 157 the day before objectui#7664 — a TypeScript AST walk counting * `PropertyAssignment` nodes in the `MIRRORS` initializer, and a line-oriented parse * of the same block — and they agree on the historical figures above. ⛔ Do not add * a delta to this number; count the registry. Nothing asserts it against a written @@ -119,14 +123,14 @@ * the first pair whose ONLY ledger entry is a runtime-only one * (objectui#6150 declared `onNodeClick` on an otherwise clean pair), which is why * the union of the two unmirrored ledgers is **15** pairs and not 14. - * - **142 pairs with no entry in either** unmirrored ledger — 157 − 15, measured, - * not stepped. ⚠️ This line used to carry a running chain of deltas (141 → 142 → + * - **144 pairs with no entry in either** unmirrored ledger — 159 − 15, measured, + * not stepped (142 = 157 − 15 until objectui#7664). ⚠️ This line used to carry a running chain of deltas (141 → 142 → * 143 → 144 → 147, one per card). Every one of those was computed against the * stale pair count above, so they were arithmetic on a wrong base and are NOT * re-derivable from this file; objectui#7352 contract review replaced the chain with - * the measurement (objectui#7655 re-measured: 142 = 157 − 15). ⛔ Do not restart + * the measurement (objectui#7655 re-measured: 142 = 157 − 15; objectui#7664: 144 = 159 − 15). ⛔ Do not restart * the chain — subtract the union from the registry count, both read from the file. - * - 157 − 42 = **115**, the "pairs with no entry" `LedgerMismatch` speaks of. + * - 159 − 42 = **117**, the "pairs with no entry" `LedgerMismatch` speaks of. * * ## Two ratchets, because the forward comparison has two halves * @@ -147,7 +151,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 42 of the 157 pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 42 of the 159 pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -181,7 +185,7 @@ import type { z } from 'zod'; import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zod/app.zod.js'; import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema } 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 } 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 } from '../zod/complex.zod.js'; import { ActionCallbackSchema, 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, TreeViewSchema } from '../zod/data-display.zod.js'; import { AccordionItemSchema, AccordionSchema, CollapsibleSchema, ToggleGroupItemSchema, ToggleGroupSchema } from '../zod/disclosure.zod.js'; @@ -196,7 +200,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'; @@ -313,8 +317,8 @@ export type assertionRatchetAcceptsAgreement = Expect< Equal< ReconcileAgainstLedger< 'p', 'a', 'a' >, never > >; /** - * …and so does a clean pair with no entry — 115 of the 157 on the `KnownDrift` half, - * 142 on the unmirrored half (both measured, not stepped). + * …and so does a clean pair with no entry — 117 of the 159 on the `KnownDrift` half, + * 144 on the unmirrored half (both measured, not stepped). */ export type assertionRatchetAcceptsCleanPair = Expect< Equal< ReconcileAgainstLedger< 'p', never, never >, never > >; @@ -422,9 +426,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, @@ -583,9 +589,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; @@ -814,8 +822,21 @@ 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) ×2: `plugin-kanban`'s `KanbanRenderer` forwards + * `onCardMove` / `onQuickAdd` off `schema.*` into the board. Re-keyed by + * objectui#7664 (ruling (a)): the pair was `DeclarativeKanbanSchema` with + * `onCardMove` / `onCardClick` until that face retired; the plugin dialect this + * arm now declares has no `onCardClick` member (the object-bound board owns + * the click) and forwards `onQuickAdd` instead. (`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' | 'onQuickAdd'; /** * RUNTIME SLOT (objectui#7344): `register('detail', DetailView)` — `DetailView`'s * `handleBack` calls `onBack()` when set. The mirror was `z.any()` (wider than @@ -1007,7 +1028,7 @@ interface KnownDrift { * is being let through, because there was no such thing. Seeding takes the count of * VISIBLE, RATCHETED facts from 0 to 121 and installs a floor: the problem cannot * grow while they are worked off, and a new declared-but-unmirrored key on any of - * the 157 pairs reddens immediately (`assertionRatchetRejectsFreshDrift`) — + * the 159 pairs reddens immediately (`assertionRatchetRejectsFreshDrift`) — * including a callback-shaped one, which reddens until it is filed in * `RuntimeOnlyDeclared` (`assertionSplitLedgerRejectsFreshCallback`). * @@ -1464,7 +1485,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne /** * Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the - * 115 pairs with no entry (157 − 42). + * 117 pairs with no entry (159 − 42). * * Routed through `ReconcileAgainstLedger` rather than spelling the conditional * inline. That is a semantics-preserving refactor and nothing else — the type is @@ -1512,8 +1533,8 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch /** * The SECOND half of the forward comparison: every pair's declared-but-unmirrored - * key set equals what the two ledgers TOGETHER record for it — `never` for the 142 - * pairs with no entry in either (157 − 15). Six of `RuntimeOnlyDeclared`'s seven + * key set equals what the two ledgers TOGETHER record for it — `never` for the 144 + * pairs with no entry in either (159 − 15). Six of `RuntimeOnlyDeclared`'s seven * pairs are a measured subset of `UnmirroredDeclared`'s 14, so objectui#6152's * reclassification left the clean population unchanged; objectui#6150 then added * `TreeViewSchema`, whose only entry is runtime-only, which is why the union is one @@ -1573,7 +1594,7 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as { }[MirrorKey]; /** - * Non-vacuity for all 157 entries at once. + * Non-vacuity for all 159 entries at once. * * `NarrowerThanDeclared` is `never` — green — for an entry whose mirror exposes no * `.shape`, and also for one whose key union has degenerated to bare `string` (the @@ -1740,6 +1761,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 ────────────────────────────────── */ @@ -1778,6 +1801,9 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // classification is deliberately left as it stands here; see the note there. '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', @@ -1847,7 +1873,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 fd59df102b..2e46ad60d4 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,226 @@ 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`). The zod twin refuses it by + * name and points at the retired shape. Style a lane through `className`. + * @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; -/** - * 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; + /** - * Card click handler - * - * 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}`). + * 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; + + /** + * 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). The zod twin refuses it by name and points at + * the retired shape. + * @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. * @@ -1476,7 +1650,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 6e5bbfbf92..57ee75342a 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 b4b0b62abc..3b5ce94b28 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 28916d4964..8ed83afc41 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 b1cd5ac009..b0e4d31c61 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,127 @@ 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` / `onQuickAdd` are RUNTIME SLOTS (objectui#6124): `KanbanRenderer` + * forwards both off `schema.*`, so the TypeScript twin keeps them callable and + * this mirror refuses them by name. `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'), }); @@ -867,7 +955,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 2b5a8db76d..0cfb26c67e 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 70fec93c61..3866c3c9d4 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -859,7 +859,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 49574c8ac1..4e49d1ff2d 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 — From ecdaafaf0dcac1cf51f7c2cde0bac3d7b9c9f590 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:54:58 +0000 Subject: [PATCH 2/4] fix(types): the `kanban` arm refuses `onCardClick` by name, not by omission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The successor `'kanban'` arm dropped `onCardClick` instead of giving it a #6124 disposition. `BaseSchema` is `.passthrough()`, so a dropped key is not refused — it stops being judged and the value is KEPT. Measured on the built dist before this commit, `{ type: 'kanban', columns: [], onCardClick: { action: 'toast' } }` was ACCEPTED with `{"action":"toast"}` surviving into the parsed output, while the same document at `onCardMove` / `onQuickAdd` / `onColumnAdd` / `draggable` was REFUSED. After it, all five are REFUSED. The rationale that was recorded for the deletion — "the object-bound board owns the click" — is contradicted by the source. Measured per registration (`plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx`, new here): - `'kanban-ui'`: `KanbanRenderer` forwards `onCardClick`, `onCardMove` and `onQuickAdd` to the board by identity, from one block. Lit controls: the two keys already ruled runtime slots come out live on this probe. - `'kanban'` / `'object-kanban'`: `ObjectKanban` substitutes its own function for `onCardClick` — and for `onCardMove`, in the same object literal, with `onQuickAdd` arriving by identity as the lit control on this key. The two keys have identical reachability here, so that reading retires both or neither. - and the substitute CALLS the authored handler: `ObjectKanban` declares an `onCardClick` prop (there is no `onCardMove` prop), which `SchemaRenderer` supplies by spreading non-metadata schema keys. An `onCardClick` authored on a `type: 'kanban'` document runs. So the disposition is RUNTIME SLOT: callable on the TypeScript face, refused by name on the mirror. `RUNTIME_SLOT` 44 -> 45, `ALL_SITES` 66 -> 67; `RETIRED` stays 22 and the #7340 docs census stays 26. Why no ratchet caught it: the #6124 ledger is hand-written by key, so a substitution holds its length constant and a deleted key is invisible to it. The new file derives the required key set from the read site instead — it extracts the `schema.on*` reads out of `KanbanRenderer`'s body and requires each to be a declared arm member, which is red on exactly this deletion. Also in this commit, riding with the remediation: - the `@object-ui/plugin-kanban` changeset no longer claims the shape is "member for member what this package declared". Counted with the TypeScript parser against `origin/main`, it is that shape plus four members: three `?: never` tombstones, and `onCardClick`, which the plugin dialect never declared while its renderer read it. - the `@object-ui/types` changeset gains the accept-set bullet for the five handler arms. - the `draggable` / column `color` tombstone JSDoc cites the two-prong discriminator instead of arguing from inertness. Re-measured on `origin/main`: `schema-reference.md` opened its kanban example with `"draggable": true`, documented it in the property table, and authored a `color` on every one of its three columns. - `schema-reference.md` documents `onCardClick` again, and it joins the #7340 blanket-sweep control (six rows -> seven). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .changeset/7664-kanban-arm-plugin-dialect.md | 14 + .../7664-plugin-kanban-declared-schema.md | 28 +- content/docs/api/schema-reference.md | 1 + .../kanban-handler-slots-7664.test.tsx | 251 ++++++++++++++++++ ...ent-docs-retired-handler-keys-7340.test.ts | 12 +- .../handler-keys-json-refusal-6124.test.ts | 26 +- ...-plugin-dialect-authoritative-7664.test.ts | 18 +- .../src/__tests__/zod-mirror-parity.test.ts | 38 ++- packages/types/src/complex.ts | 81 +++++- packages/types/src/zod/complex.zod.ts | 11 +- 10 files changed, 435 insertions(+), 45 deletions(-) create mode 100644 packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx diff --git a/.changeset/7664-kanban-arm-plugin-dialect.md b/.changeset/7664-kanban-arm-plugin-dialect.md index 05f0c6f4e9..f699bdff08 100644 --- a/.changeset/7664-kanban-arm-plugin-dialect.md +++ b/.changeset/7664-kanban-arm-plugin-dialect.md @@ -34,6 +34,20 @@ render **empty**. The ruling: the plugin dialect is authoritative. 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` / diff --git a/.changeset/7664-plugin-kanban-declared-schema.md b/.changeset/7664-plugin-kanban-declared-schema.md index 28c487014a..3c814de4e1 100644 --- a/.changeset/7664-plugin-kanban-declared-schema.md +++ b/.changeset/7664-plugin-kanban-declared-schema.md @@ -5,8 +5,26 @@ `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)). Member for member the shape is what this package declared, so 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. +(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/schema-reference.md b/content/docs/api/schema-reference.md index 55ab72f150..77321a1067 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -965,6 +965,7 @@ A static board carries its cards inline: | `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. 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 0000000000..53dadb24f2 --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx @@ -0,0 +1,251 @@ +/** + * 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; no + * `dataSource` is supplied because these boards author their lanes statically. + */ +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/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 859e78e017..bf6525592d 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` / `onQuickAdd`), 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 @@ -254,8 +254,10 @@ const describeRow = (r: DocRow): string => `${r.page}:${r.line} ${r.owner}.${r.k /** Runtime-slot rows measured present on this tree — the blanket-sweep control. */ const CONTROL = [ // objectui#7664: the page documents the plugin dialect under `KanbanSchema` - // now, whose two runtime slots are `onCardMove` / `onQuickAdd`. + // 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' }, 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 f47662a879..e1d8397635 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 @@ -224,10 +224,18 @@ const objectOf = (mirror: z.ZodType, key: string): z.ZodObject => * `toFormControlDomProps` whitelist, card's ``). */ const RUNTIME_SLOT: readonly Site[] = [ - // objectui#7664 — the `'kanban'` arm is the plugin dialect now: `KanbanRenderer` - // forwards `schema.onCardMove` and `schema.onQuickAdd`; the retired declarative - // face's `onCardClick` slot left with it (the object-bound board owns the click). + // 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], @@ -376,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) => { @@ -535,6 +546,7 @@ export type assertionRetiredKeysAreTombstoned = [ export type assertionRuntimeSlotsKeepTheirFunctionType = [ 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 index 24c227904e..e6348a84d5 100644 --- a/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts +++ b/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts @@ -48,8 +48,16 @@ * 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 19 live - * members plus the 3 tombstones, by name. + * `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` * @@ -286,16 +294,16 @@ describe('the declared body is measured, not inherited (objectui#7664)', () => { })); } - it('KanbanSchema declares 19 live members (the ruling said 18; a same-day census said 19 — `type` is the difference) and exactly 3 tombstones', () => { + 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', 'className', 'quickAdd', 'onQuickAdd', 'coverImageField', 'allowCollapse', + 'onCardMove', 'onCardClick', 'className', 'quickAdd', 'onQuickAdd', 'coverImageField', 'allowCollapse', 'conditionalFormatting', 'cardTemplates', 'columnWidths', 'grouping', ]); - expect(live).toHaveLength(19); + 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 diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index f0b9ecb435..dbc0716fa4 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -833,20 +833,32 @@ interface KnownDrift { /** DISJOINT vocabularies: TS declares `is_empty`/`is_not_empty`, the mirror declares `is_null`/`is_not_null`. One of the two is dead; which one is a ruling. */ 'complex.zod.ts#FilterFieldSchema': 'operators'; /** - * RUNTIME SLOT (objectui#6124) ×2: `plugin-kanban`'s `KanbanRenderer` forwards - * `onCardMove` / `onQuickAdd` off `schema.*` into the board. Re-keyed by - * objectui#7664 (ruling (a)): the pair was `DeclarativeKanbanSchema` with - * `onCardMove` / `onCardClick` until that face retired; the plugin dialect this - * arm now declares has no `onCardClick` member (the object-bound board owns - * the click) and forwards `onQuickAdd` instead. (`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. + * 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' | 'onQuickAdd'; + '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 diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index b4dc746708..84392c98f5 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -145,11 +145,30 @@ export interface KanbanColumn { */ 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`). The zod twin refuses it by - * name and points at the retired shape. Style a lane through `className`. + * 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; @@ -230,6 +249,38 @@ export interface KanbanSchema extends BaseSchema { */ onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void; + /** + * Callback function when a card is clicked. + * + * RUNTIME SLOT (objectui#6124) — a host-supplied function, NOT authorable + * metadata: JSON has no function value, so the zod twin refuses this key by + * name and points at the node-type spelling. Kept callable here because it is + * read on every channel measured (objectui#7664, the contract review of + * PR #7743): + * + * - `KanbanRenderer` forwards it (`onCardClick={schema.onCardClick}`) in the + * same block as {@link KanbanSchema.onCardMove} and + * {@link KanbanSchema.onQuickAdd}; + * - on the `'kanban'` and `'object-kanban'` keys `ObjectKanban` substitutes + * its own function — and substitutes `onCardMove` in the very same object + * literal, so that reading retires both keys or neither; + * - and its substitute CALLS the authored handler: `ObjectKanban` declares + * an `onCardClick` PROP (`onCardMove` has none), which `SchemaRenderer` + * supplies by spreading every non-metadata schema key as a React prop. + * + * ⛔ Do not "simplify" this back into a deletion. `BaseSchema` is + * `.passthrough()`, so removing the key does not refuse it — it stops being + * judged and the value is kept, which is how the first cut of objectui#7664 + * turned a refused key into an accepted one with every ratchet green. + * `plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx` derives the + * forwarded key set from the read site and goes red on that deletion. + * + * The event is `unknown` rather than a mouse event because this package + * declares zero dependencies and has no React types; `KanbanImpl` narrows it + * to `React.MouseEvent` at the call site. + */ + onCardClick?: (card: KanbanCard, event?: unknown) => void; + /** * Optional CSS class name to apply custom styling. */ @@ -294,8 +345,24 @@ export interface KanbanSchema extends BaseSchema { * 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). The zod twin refuses it by name and points at - * the retired shape. + * drag-and-drop is always on). + * + * A tombstone rather than a plain removal on PRONG 2 of the discriminator the + * precedent changesets state (objectui#5941, #7526; the one-line form is + * under correction as objectui#7678) — a tombstone exists (1) to steer + * authors to a named live replacement KEY, or (2) to keep loud a key the docs + * taught as working. Prong 1 does not apply: drag-and-drop is unconditional, + * so there is no replacement key to name, and the remedy is to delete the + * member. Prong 2 carries it: `content/docs/api/schema-reference.md` taught + * this key as working — before this card its kanban example opened with + * `"draggable": true` and its property table read "`draggable` | `boolean` | + * Enable drag-and-drop between columns." + * + * ⚠️ Inertness is why the key is retired, not why it is tombstoned. A key + * this documented must be refused by NAME rather than dropped: {@link + * BaseSchema} is `.passthrough()`, so dropping it from the mirror would leave + * a document naming it silently accepted with the value kept — the failure + * this card's own first cut shipped at {@link KanbanSchema.onCardClick}. * @deprecated Not part of this contract — the value was inert. */ draggable?: never; diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index b5a341dddd..d47e112ca3 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -126,9 +126,13 @@ export const ColumnWidthConfigSchema = z.object({ * {@link KanbanSchema} in `../complex.ts` key for key: the shape * `@object-ui/plugin-kanban`'s registered renderers read (objectui#7664). * - * `onCardMove` / `onQuickAdd` are RUNTIME SLOTS (objectui#6124): `KanbanRenderer` - * forwards both off `schema.*`, so the TypeScript twin keeps them callable and - * this mirror refuses them by name. `onColumnAdd` / `onCardAdd` are the two + * `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` @@ -145,6 +149,7 @@ export const KanbanSchema = BaseSchema.extend({ 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'), From f88304691c173ddc4dbc5997377469586068c46f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 15:15:33 +0000 Subject: [PATCH 3/4] fix(plugin-kanban): the new reachability test compiles under the package's own type-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Type Check` went red on the previous head, in `packages/plugin-kanban`'s own `pnpm run type-check`, on the file the remediation added. Two distinct causes, both reproduced with that exact command before and after: - TS2591 ×3 (`node:fs` / `node:path` / `node:url`) — `tsconfig.test.json` names `types`, which switches off automatic `@types/*` inclusion, and its comment recorded the invariant the new file breaks: "nothing in these tests touches Node globals". The file reads `../index.tsx` off disk on purpose — that is what makes the guard derive the forwarded key set from the read site instead of from a list. `node` is named alongside `@testing-library/jest-dom`, exactly as plugin-calendar / -charts / -chatbot / -dashboard / -detail / -gantt / -grid already do, and the comment now says why. It stays OUT of `tsconfig.json`: package source ships to browsers. - TS2741 ×2 — `SchemaRendererProvider` requires `dataSource`. Spelled `dataSource={undefined}`, the form three other call sites in this repo use for a surface that fetches nothing; these boards author their lanes statically, so it is the same runtime value the measurements were taken with. ⛔ Not fixed by loosening the prop or casting the provider — the subject of this PR is that the type surface must be honest. `packages/plugin-kanban$ pnpm run type-check` exit 2 -> 0, and `turbo run type-check` is 81/81 across the repo. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .../src/__tests__/kanban-handler-slots-7664.test.tsx | 10 ++++++---- packages/plugin-kanban/tsconfig.test.json | 10 ++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx index 53dadb24f2..e356e4ab20 100644 --- a/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx +++ b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx @@ -127,15 +127,17 @@ async function lastBoardProps(log: 'impl' | 'enhanced', before: number, unmount: /** * 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; no - * `dataSource` is supplied because these boards author their lanes statically. + * 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( - + , ); @@ -150,7 +152,7 @@ async function boardPropsFor(type: string, log: 'impl' | 'enhanced', schemaKeys: async function boardPropsViaSchemaRenderer(schema: Record) { const before = recorded.impl.length; const { unmount } = render( - + , ); diff --git a/packages/plugin-kanban/tsconfig.test.json b/packages/plugin-kanban/tsconfig.test.json index a98ee894b7..7ba33f260f 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). From b115f08d033c05785e924502ca026c266271e80c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 18:41:10 +0000 Subject: [PATCH 4/4] test(types): drop the two WiderThanDeclared rows keyed on retired DeclarativeKanban pairs The mirror-wider-than-declared ledger (added by PR 7758) keys two of its 36 rows on pair names this branch retires: 'complex.zod.ts#DeclarativeKanbanColumnSchema': 'cards' 'complex.zod.ts#DeclarativeKanbanSchema': 'columns' `assertionWiderLedgerKeysAreRegistered` requires every ledger key to be a registered `MirrorKey`. After the re-key to `KanbanColumnSchema` / `KanbanSchema`, these two no longer are, and the merged tree is tsc-RED with exactly that one diagnostic. Deleting the rows is the ledger's own documented remedy 4 for a pair that has gone: "a key DISAPPEARED - the faces agree again; correct or delete the entry." Re-keying them to the new pair names instead was measured RED: `WiderOf` for both new pairs is `never`, because the re-key eliminated the drift those rows recorded. No coverage is lost - you cannot watch a pair that no longer exists. Redness inherited from two correct changes landing in sequence, not introduced here. Ruled by the domain:ui PM seat on PR 7743. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- packages/types/src/__tests__/zod-mirror-parity.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 6d2923e534..e87df8ebcc 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -1787,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