From e050689af7100c96fb135bd3f4b9d696f555b139 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 09:09:54 +0000 Subject: [PATCH 1/3] feat(types,plugin-kanban): execute the batch #70 ruling on the `kanban` arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire `allowCollapse` / `cardTemplates` / `columnWidths` (declared on both faces, read by no registered board) and `titleField` (the legacy spelling of `cardTitle`; the `object-kanban` arm keeps its own) as named refusals on both faces. Declare `navigation`, which `ObjectKanban` reads and no face named, on the gantt precedent objectui#5903 — the `(schema as any)` cast goes. Move `objectFields` off the schema bag onto `KanbanRendererProps` as a real React prop: an internal channel from the one caller that fetched the object definition, not an authoring surface. Fixes #7742 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .changeset/7742-kanban-arm-batch70.md | 66 +++++++++ content/docs/api/schema-reference.md | 6 +- packages/plugin-kanban/src/ObjectKanban.tsx | 36 +++-- packages/plugin-kanban/src/index.tsx | 44 ++++-- packages/types/src/complex.ts | 141 +++++++++++++++++--- packages/types/src/zod/complex.zod.ts | 58 +++++++- 6 files changed, 302 insertions(+), 49 deletions(-) create mode 100644 .changeset/7742-kanban-arm-batch70.md diff --git a/.changeset/7742-kanban-arm-batch70.md b/.changeset/7742-kanban-arm-batch70.md new file mode 100644 index 0000000000..91d337f3c1 --- /dev/null +++ b/.changeset/7742-kanban-arm-batch70.md @@ -0,0 +1,66 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-kanban': minor +--- + +**BREAKING (scored `minor` per this repo's version-alignment convention)** — the +`'kanban'` arm retires four accepted spellings and declares one read it never +named (objectui#7742, ADR-0049, maintainer decision batch #70, 2026-09-07: +「同意」). + +The accept set moves in **both** directions in one change, which is why the PR +carries `needs:contract-review`. + +## Narrowing — four keys the `'kanban'` arm no longer accepts + +Each is a `?: never` tombstone on the TypeScript face and a named refusal arm on +the `@object-ui/types/zod` mirror, so a document that names one is **refused by +name** with the remedy in the message. ⛔ None is dropped instead of refused: +`BaseSchema` is `.passthrough()`, so a dropped key is *kept*, not refused — the +failure objectui#7664's own first cut shipped at `onCardClick`. + +- **`allowCollapse`, `cardTemplates`, `columnWidths`** — declared on both faces + since the dialect was carried over, and read by **no registered board**. + Re-measured for this change over `packages/plugin-kanban/src`, every file + including tests: 0 hits / 0 files each, with `groupBy` (85 hits / 27 files), + `cardTitle` (18/9) and `coverImageField` (17/3) firing as controls on the same + instrument. An author who wrote `allowCollapse: true` validated green and got + a board that never collapsed off that key. Each capability exists on a + *different* channel, and the refusal message names it: a lane's own + `columns[].collapsed` for collapsing, a `templates` component prop for card + templates, a `useColumnWidths` hook option for widths. Wiring a board-level + switch to any of them would be new behaviour and is not ordered here. + `CardTemplate` and `ColumnWidthConfig` stay exported — the prop and the hook + still consume the types. +- **`titleField`** — ⚠️ **not** an inertness retirement, and reading it as one + gets the mechanism backwards. `ObjectKanban` still reads the key and the read + stays, because the **sibling `object-kanban` arm declares it and keeps it** + (objectui#7322 item ②). What retires is *this* arm's acceptance of the legacy + spelling: one arm, one spelling, and the refusal points at `cardTitle`. A + `type: "object-kanban"` document naming `titleField` still validates and still + renders; a `type: "kanban"` one is now refused. The key was never declared on + this face before — it rode `BaseSchema`'s index signature — so this is the + first time this face judges it at all. + +## Widening — one key the board reads and no face named + +- **`navigation`** is now declared on `KanbanSchema` (both faces), on the gantt + precedent objectui#5903. `ObjectKanban` reads it to choose the record-detail + overlay mode and defaults it to a drawer; until now an authored overlay mode + rode `BaseSchema`'s `[key: string]: any` — admitted, never examined — and the + read site had to spell itself `(schema as any).navigation`. That cast is gone. + The member list is `@objectstack/spec`'s `NavigationConfig` by reference, not + restated, so the vocabulary cannot fork. + +## `@object-ui/plugin-kanban` — one internal channel leaves the schema bag + +`objectFields` (the fetched object's field definitions, which card conditional +formatting needs so a rule comparing a relation sees the stored foreign key) is +now a **React prop on `KanbanRendererProps`**, a sibling of `schema`, rather than +a member of the `schema` bag. It is an internal channel from the one caller that +fetched the object definition, never an authoring surface. Inside `schema` it was +reachable by an *author*: on the schema-only `kanban-ui` entry, which has no +object schema of its own to substitute, an authored `objectFields` reached +`resolveConditionalFormatting` verbatim while no schema face declared or judged +it. Callers that render `KanbanRenderer` directly and passed `objectFields` +inside `schema` must move it to the prop. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 9cd20de03d..fbd5518ddb 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -973,15 +973,15 @@ A static board carries its cards inline: | `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. | +| `navigation` | `ViewNavigationConfig` | Record navigation behaviour when a card is clicked (drawer / dialog / page). Defaults to an inline right-side drawer. | | `onCardMove` | `function` | Runtime slot supplied by a React host, `(cardId, fromColumnId, toColumnId, newIndex)`; not authorable in JSON. | | `onCardClick` | `function` | Runtime slot supplied by a React host, `(card, event?)`; not authorable in JSON. On the object-bound board the host's handler runs alongside the record-detail overlay. | | `onQuickAdd` | `function` | Runtime slot supplied by a React host, `(columnId, title)`; not authorable in JSON. | +> Four spellings the `kanban` arm once accepted are now refused by name (objectui#7742, ADR-0049). `allowCollapse`, `cardTemplates` and `columnWidths` were declared and read by no registered board — collapse a lane with `columns[].collapsed`; card templates and column widths reach the board through a component prop and a hook option, not through the node. `titleField` is the legacy spelling of `cardTitle` and is retired on this arm only: write `cardTitle`. An `object-kanban` node still accepts `titleField`. + > 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) diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 76baff1a91..68aa01ba9f 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -902,7 +902,10 @@ export const ObjectKanban: React.FC = ({ // CLOSED, not open — do not re-open it as a cleanup. If bucket-vocabulary // unification ever becomes a product direction that is a fresh ruling, // with visual-regression evidence across all four surfaces in one stroke. - const navConfig = (schema as any).navigation ?? { mode: 'drawer' }; + // `navigation` is DECLARED on `KanbanSchema` since objectui#7742 (gantt + // precedent objectui#5903), so this read is typed rather than cast. It stayed + // `(schema as any)` for exactly as long as no schema face named the key. + const navConfig = schema.navigation ?? { mode: 'drawer' }; // When this kanban is embedded in an ObjectView, the parent provides // `onRowClick`/`onCardClick` and owns the unified record-detail overlay. // We must always forward to the parent in that case — otherwise we'd open @@ -1143,23 +1146,30 @@ export const ObjectKanban: React.FC = ({ return ( <> - { - navigation.handleClick(card, event); - onCardClick?.(card); - }, - onCardMove: handleCardMove, - }} /> + // + // A PROP, not a schema key (objectui#7742, decision batch #70): it is an + // internal channel from the one caller that fetched the object + // definition, never an authoring surface. On the schema bag it was + // reachable by an author through `BaseSchema`'s passthrough. + objectFields={objectDef?.fields} + schema={{ + ...effectiveSchema, + // objectui#8307 — the lane headers count rows that came back, so when + // the fetch saturated its window they must say `77+`, not `77`. + countsAreWindowed, + onCardClick: (card: any, event?: any) => { + navigation.handleClick(card, event); + onCardClick?.(card); + }, + onCardMove: handleCardMove, + }} + /> {pendingMove && ( void; coverImageField?: string; conditionalFormatting?: KanbanConditionalFormattingRule[]; - /** - * The object's field definitions, injected by `ObjectKanban` (the only - * entry point that fetches an object schema). Card conditional formatting - * needs them so a rule comparing a relation field sees the stored foreign - * key rather than the record `$expand` substituted for it — the board - * expands relations exactly as the grid does, so without this the SAME - * rule on the SAME view worked on the grid and silently never matched on - * the board (objectui#3501). Absent on the schema-only `kanban-ui` entry, - * which has no object schema to offer; there the payload is used verbatim, - * as before. - */ - objectFields?: unknown; /** * The lane counts below are counts of a fetched WINDOW, not of the group * (objectui#8307). Injected by `ObjectKanban`, the only entry point that @@ -175,13 +163,41 @@ export interface KanbanRendererProps { */ countsAreWindowed?: boolean; }; + /** + * The object's field definitions, injected by `ObjectKanban` (the only entry + * point that fetches an object schema). Card conditional formatting needs + * them so a rule comparing a relation field sees the stored foreign key + * rather than the record `$expand` substituted for it — the board expands + * relations exactly as the grid does, so without this the SAME rule on the + * SAME view worked on the grid and silently never matched on the board + * (objectui#3501). + * + * ⛔ AN INTERNAL CHANNEL, NOT AN AUTHORING SURFACE (objectui#7742, maintainer + * decision batch #70, 2026-09-07). It sits HERE — a React prop, a sibling of + * `schema` — and deliberately NOT inside `schema`, which is where it used to + * live. Inside `schema` it was reachable by an AUTHOR: `BaseSchema` is + * `.passthrough()`, `SchemaRenderer` hands the node through, and on the + * schema-only `kanban-ui` entry (which has no object schema of its own to + * substitute) an authored `objectFields` reached + * `resolveConditionalFormatting` verbatim. Nothing declared it on any schema + * face, so nothing judged it either. As a prop the only writer is the one + * caller that can actually know the answer. + * + * Absent on the schema-only `kanban-ui` entry, which has no object schema to + * offer; there conditional formatting reads the card payload as before. + * + * ⚠️ `countsAreWindowed` above is the SAME shape and the same argument, and + * the batch #70 ruling did not name it — it stays on the schema bag, recorded + * rather than fixed here. + */ + objectFields?: unknown; } /** * KanbanRenderer - The public API for the kanban board component * This wrapper handles lazy loading internally using React.Suspense */ -export const KanbanRenderer: React.FC = ({ schema }) => { +export const KanbanRenderer: React.FC = ({ schema, objectFields }) => { const { t } = useUncolumnedT(); // ⚡️ Adapter: Map flat 'data' + 'groupBy' to nested 'cards' structure. const processedColumns = React.useMemo( @@ -207,7 +223,7 @@ export const KanbanRenderer: React.FC = ({ schema }) => { onQuickAdd={schema.onQuickAdd} coverImageField={schema.coverImageField} conditionalFormatting={schema.conditionalFormatting} - objectFields={schema.objectFields} + objectFields={objectFields} swimlaneField={schema.swimlaneField} countsAreWindowed={schema.countsAreWindowed} /> diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 0b86a76a1b..8c83a05d11 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -22,7 +22,7 @@ import type { GroupingConfig, } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; -import type { KanbanConditionalFormattingRule } from './objectql.js'; +import type { KanbanConditionalFormattingRule, ViewNavigationConfig } from './objectql.js'; /** * Kanban card — the shape the registered `'kanban'` renderer reads. @@ -310,12 +310,6 @@ export interface KanbanSchema extends BaseSchema { */ 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 @@ -324,22 +318,137 @@ export interface KanbanSchema extends BaseSchema { conditionalFormatting?: KanbanConditionalFormattingRule[]; /** - * Predefined card templates for quick-add. - * Each template pre-fills the quick-add form with default values. + * Grouping configuration from ListView. + * When set, the first grouping field is used as swimlaneField fallback. */ - cardTemplates?: CardTemplate[]; + grouping?: GroupingConfig; /** - * Custom column width configuration. - * Supports per-column overrides with min/max constraints. + * Record navigation behaviour when a card is clicked (drawer / dialog / + * page). Defaults to an inline right-side drawer; set `{ mode: 'page' }` to + * route to the standalone detail page instead. Read at `ObjectKanban.tsx` + * (`navConfig`), which feeds it to `useNavigationOverlay`. + * + * DECLARED by objectui#7742 (decision batch #70) on the gantt precedent + * objectui#5903 set: the read existed and this face did not name it, so an + * authored overlay mode rode {@link BaseSchema}'s `[key: string]: any` — + * admitted, never examined — and the read site had to spell itself + * `(schema as any).navigation`. Declaring it WIDENS the published accept set; + * it is the one widening in a card whose other rows all narrow. + * + * The spec owns the member list — `mode`, `view`, `preventNavigation`, + * `openNewTab`, `size`, `width` — and its schema REFUSES anything else. Do + * not restate the vocabulary here. + * + * Same spec type as {@link ObjectGanttSchema.navigation} and + * {@link ObjectGridSchema.navigation} — aligned with `@objectstack/spec` + * `ListView.navigation` rather than restated, so the vocabulary cannot fork. */ - columnWidths?: ColumnWidthConfig; + navigation?: ViewNavigationConfig; /** - * Grouping configuration from ListView. - * When set, the first grouping field is used as swimlaneField fallback. + * RETIRED (objectui#7742, ADR-0049, maintainer decision batch #70, + * 2026-09-07) — declared on both faces and read by NO registered board. + * + * Measured on this branch over `packages/plugin-kanban/src`, every file + * including tests: `allowCollapse` 0 hits / 0 files, with `groupBy` (85/27), + * `cardTitle` (18/9) and `coverImageField` (17/3) firing as controls on the + * same instrument, so the zero is a reading and not a dead grep. The + * capability EXISTS through another channel — `KanbanEnhanced` collapses a + * lane off `KanbanColumn.collapsed` — so an author who wrote + * `allowCollapse: true` validated green and got a board that never collapsed + * off that key. A board-level switch wired to the per-lane mechanism is a new + * card if it is ever wanted; the ruling did not order one. + * + * A tombstone rather than a plain removal on PRONG 2 of the discriminator the + * precedent changesets state (objectui#5941, #7526): the key was TAUGHT as + * working — `content/docs/api/schema-reference.md` carried the row + * "`allowCollapse` | `boolean` | Allow columns to be collapsed." Prong 1 does + * not apply: there is no live replacement KEY to name, only a different + * channel. + * + * ⚠️ Inertness is why the key is retired, not why it is tombstoned. {@link + * BaseSchema} is `.passthrough()`, so dropping it from the mirror would leave + * a document naming it silently ACCEPTED with the value kept — the failure + * objectui#7664's own first cut shipped at {@link KanbanSchema.onCardClick}. + * + * ⛔ NOT the same key as `ObjectKanbanSchema.allowCollapse` (`objectql.ts`), + * which the batch #70 ruling did not reach and which stays declared on the + * `object-kanban` arm. + * @deprecated Not part of this contract — the value was inert. */ - grouping?: GroupingConfig; + allowCollapse?: never; + + /** + * RETIRED (objectui#7742, ADR-0049, maintainer decision batch #70, + * 2026-09-07) — declared on both faces and read by NO registered board. + * + * Measured with the census above: `cardTemplates` 0 hits / 0 files across + * `packages/plugin-kanban/src`, same run and same firing controls. The + * capability exists through a COMPONENT PROP — `CardTemplates.tsx` takes + * `templates: CardTemplate[]` — never off the schema, so nothing an author + * wrote here ever reached it. {@link CardTemplate} itself stays exported: + * that prop and `plugin-kanban`'s re-export still consume the type. + * + * Tombstoned on PRONG 2, same as {@link KanbanSchema.allowCollapse}: + * `content/docs/api/schema-reference.md` carried the row "`cardTemplates` | + * `CardTemplate[]` | Predefined quick-add templates." + * @deprecated Not part of this contract — the value was inert. + */ + cardTemplates?: never; + + /** + * RETIRED (objectui#7742, ADR-0049, maintainer decision batch #70, + * 2026-09-07) — declared on both faces and read by NO registered board. + * + * Measured with the census above: `columnWidths` 0 hits / 0 files across + * `packages/plugin-kanban/src`, same run and same firing controls. The + * capability exists through a HOOK OPTION — `useColumnWidths` takes a + * `ColumnWidthConfig` argument — never off the schema. {@link + * ColumnWidthConfig} itself stays exported: that hook and `plugin-kanban`'s + * re-export still consume the type. + * + * ⚠️ The repo-wide name census for this key is NOT zero (22 hits / 11 files) + * and every one of those is a DIFFERENT key of the same spelling on the grid + * surface — `data-table.tsx`, `ObjectGrid.tsx`, `RecordPickerDialog.tsx`. The + * board's zero is the `packages/plugin-kanban/src` reading, and the grid key + * is untouched by this retirement. + * + * Tombstoned on PRONG 2, same as {@link KanbanSchema.allowCollapse}: + * `content/docs/api/schema-reference.md` carried the row "`columnWidths` | + * `ColumnWidthConfig` | Column width configuration." + * @deprecated Not part of this contract — the value was inert. + */ + columnWidths?: never; + + /** + * RETIRED on THIS arm (objectui#7742, ADR-0049, maintainer decision batch + * #70, 2026-09-07) — one arm, one spelling: write {@link + * KanbanSchema.cardTitle}. + * + * ⚠️ This tombstone is NOT an inertness finding, and reading it as one gets + * the mechanism backwards. `ObjectKanban.tsx` DOES read the key — + * `schema.cardTitle || schema.titleField` and `schema.cardTitle ?? + * schema.titleField` — and that read is load-bearing for the SIBLING arm: + * `ObjectKanbanSchema` (`objectql.ts`) declares `titleField` and the batch #70 + * ruling says in as many words that the `object-kanban` arm KEEPS it + * (objectui#7322 item ②, PR #8153, re-measured here from `25907cd70`). What + * is retired is this arm's ACCEPTANCE of the legacy spelling, not the read. + * + * So a `{ "type": "kanban" }` document naming `titleField` is now refused BY + * NAME and pointed at `cardTitle`; a `{ "type": "object-kanban" }` document + * naming it still validates and still renders. One renderer, two node types, + * two accept sets — which is the shape objectui#7322 item ② already gave the + * component's prop union. + * + * ⚠️ Never declared on this face before now: it rode {@link BaseSchema}'s + * `[key: string]: any`, which is why both reads were spelled `(schema as + * any).titleField` until PR #8153 widened the prop to the two-arm union. + * Declaring the tombstone is therefore the FIRST time this face judges the + * key at all — a narrowing, not a re-narrowing. + * @deprecated Not part of this contract on the `kanban` arm — write `cardTitle`. + */ + titleField?: never; /** * RETIRED with the declarative face (objectui#7664, ADR-0049) — `draggable` diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 489fe69f45..adcafb97bf 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -24,6 +24,7 @@ import { DashboardWidgetSchema as SpecDashboardWidgetSchema, GlobalFilterSchema as SpecGlobalFilterSchema, GroupingConfigSchema as SpecGroupingConfigSchema, + NavigationConfigSchema as SpecNavigationConfigSchema, } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema, specFieldsExcept } from './base.zod.js'; import { KanbanConditionalFormattingRuleSchema } from './objectql.zod.js'; @@ -88,6 +89,36 @@ const retiredDeclarativeKanbanKey = (key: string, where: string, remedy: string) `an object-bound board, or \`columns[].cards[]\` for a static one. ${remedy}`, ); +/** + * The ZERO-READ members this arm declared, named once so every refusal below + * says the same thing (objectui#7742, ADR-0049, maintainer decision batch #70, + * 2026-09-07: 「同意」). + * + * ⛔ Deliberately NOT {@link retiredDeclarativeKanbanKey}. That helper's message + * says the key "belonged to the retired `DeclarativeKanbanSchema` dialect", + * which is TRUE of `draggable` and the column `color` and FALSE of these three: + * they were members of the PLUGIN dialect objectui#7664 ruled authoritative, + * carried over member-for-member, and retired one card later for a different + * reason — declared on both faces, read by no registered board. Sharing the + * older message would hand the author a false history of their own document. + * + * The zeros are readings, not a dead grep: measured over + * `packages/plugin-kanban/src` with every file including tests, `allowCollapse` + * / `cardTemplates` / `columnWidths` returned 0 hits / 0 files while `groupBy` + * (85/27), `cardTitle` (18/9) and `coverImageField` (17/3) fired as controls on + * the same instrument. + * + * Refused BY NAME rather than dropped, for the reason this file states twice + * already: {@link BaseSchema} is `.passthrough()`, so a dropped key is KEPT, + * not refused. + */ +const retiredZeroReadKanbanKey = (key: string, taught: string, remedy: string) => + retirementTombstone( + `\`${key}\` is RETIRED (objectui#7742, ADR-0049) — the \`kanban\` arm declared ` + + 'it on both faces and NO registered board ever read it, so a document that set ' + + `it validated green and changed nothing. ${taught} ${remedy}`, + ); + /** * Kanban Card Schema — mirrors {@link KanbanCard} in `../complex.ts` key for key. * @@ -186,11 +217,32 @@ export const KanbanSchema = BaseSchema.extend({ 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'), + allowCollapse: retiredZeroReadKanbanKey( + 'allowCollapse', + 'The capability exists on another channel:', + 'the enhanced board collapses a lane off that lane\'s own `collapsed` key, so write `columns[].collapsed`.', + ), conditionalFormatting: z.array(KanbanConditionalFormattingRuleSchema).optional().describe('Card conditional formatting rules'), - cardTemplates: z.array(CardTemplateSchema).optional().describe('Predefined card templates for quick-add'), - columnWidths: ColumnWidthConfigSchema.optional().describe('Custom column width configuration'), + cardTemplates: retiredZeroReadKanbanKey( + 'cardTemplates', + 'The capability exists on another channel:', + '`CardTemplates` takes its `templates` as a COMPONENT PROP, not off the board node; there is no authorable spelling for it.', + ), + columnWidths: retiredZeroReadKanbanKey( + 'columnWidths', + 'The capability exists on another channel:', + '`useColumnWidths` takes its `ColumnWidthConfig` as a HOOK OPTION, not off the board node; there is no authorable spelling for it. (The grid surface has an unrelated key of the same name — that one is untouched.)', + ), grouping: stripImportedDefaults(SpecGroupingConfigSchema).optional().describe('Grouping configuration from ListView; its first field is the swimlaneField fallback'), + navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Record navigation behaviour on card click (drawer/dialog/page)'), + titleField: retirementTombstone( + '`titleField` is RETIRED on the `kanban` arm (objectui#7742, ADR-0049) — one arm, one ' + + 'spelling. Write `cardTitle`, which selects the same record field and which this arm ' + + 'has always declared. ⛔ This is NOT an inertness retirement: `ObjectKanban` still ' + + 'reads the key, because the SIBLING `object-kanban` arm declares it and keeps it ' + + '(objectui#7322 item ②). A `type: "object-kanban"` document naming `titleField` is ' + + 'still accepted; a `type: "kanban"` one is not.', + ), draggable: retiredDeclarativeKanbanKey('draggable', 'board', 'Drag-and-drop is always on; delete the key.'), onColumnAdd: handlerKeyRefusal('onColumnAdd', 'retired', 'Column add handler'), onCardAdd: handlerKeyRefusal('onCardAdd', 'retired', 'Card add handler'), From a218a93772f1499dcc34f37fc0d2581b199b2e34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 09:18:38 +0000 Subject: [PATCH 2/3] test(types,plugin-kanban): pin both accept-set directions and the objectFields channel The batch #70 ruling moves the `kanban` arm's accept set in both directions in one change, so a file that pinned only one half would pass on a change that forgot the other. Every refusal is asserted at its own path with an undeclared key on the same parse as the control (a `.passthrough()` object accepts unknown keys, so "the document failed" proves nothing alone), `navigation` is asserted to JUDGE its value rather than merely admit it, and the sibling `object-kanban` arm is asserted to still accept `titleField`. The `objectFields` pin discriminates the two channels behaviourally through an outcome only the field catalogue can produce, with the prop leg asserted first as the firing control for the schema leg's negative. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../objectFieldsIsNotAuthorable-7742.test.tsx | 115 ++++++++++++ .../__tests__/kanban-arm-batch70-7742.test.ts | 168 ++++++++++++++++++ ...-plugin-dialect-authoritative-7664.test.ts | 25 ++- 3 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 packages/plugin-kanban/src/__tests__/objectFieldsIsNotAuthorable-7742.test.tsx create mode 100644 packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts diff --git a/packages/plugin-kanban/src/__tests__/objectFieldsIsNotAuthorable-7742.test.tsx b/packages/plugin-kanban/src/__tests__/objectFieldsIsNotAuthorable-7742.test.tsx new file mode 100644 index 0000000000..ae0d4c32d6 --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/objectFieldsIsNotAuthorable-7742.test.tsx @@ -0,0 +1,115 @@ +/** + * 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. + */ + +/** + * `objectFields` is an INTERNAL CHANNEL, not an authoring surface + * (objectui#7742, ADR-0049, maintainer decision batch #70, 2026-09-07). + * + * ## What moved, and why it was a defect + * + * `objectFields` carries the fetched object's field definitions so that a + * conditional-formatting rule comparing a RELATION field sees the stored + * foreign key rather than the record the board's `$expand` substituted for it + * (objectui#3501). Only `ObjectKanban` can know the answer — it is the one + * entry point that fetches an object definition. + * + * It used to travel INSIDE the `schema` bag. That made it reachable by an + * AUTHOR: `BaseSchema` is `.passthrough()`, `SchemaRenderer` hands the node + * down, and on the schema-only `kanban-ui` entry — which has no object schema + * of its own to overwrite it with — an authored `objectFields` reached + * `resolveConditionalFormatting` verbatim. No schema face declared the key, so + * nothing judged it either: an author could hand the predicate layer a + * fabricated field catalogue and change which cards a rule matched. Batch #70 + * ruled it an internal channel; it is now a React PROP, a sibling of `schema`. + * + * ## How this file discriminates, and why the fixture is shaped this way + * + * The two channels are told apart by an outcome that ONLY the field catalogue + * can produce. The card's `owner` arrives EXPANDED (`{ _id: 'u1' }`), and the + * rule compares `owner` against the bare id `'u1'`: + * + * - with a catalogue naming `owner` a `lookup`, `toPredicateRecord` collapses + * the expanded value back to `'u1'`, the rule MATCHES, and the card is + * painted; + * - with no catalogue, `owner` stays an object, the rule does NOT match, and + * the card is unpainted. + * + * So "painted" is a positive reading of the channel and "unpainted" is a + * negative one — and the positive case is asserted FIRST, as the firing control + * for the negative. Without it, "the schema key did nothing" would also be + * produced by a fixture that never worked on either channel, which is the + * failure mode this pairing exists to rule out. + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { KanbanRenderer } from '../index'; + +// Pay the board's lazy chunk at import time, not inside a `findBy` budget +// (AGENTS.md 测试纪律). The specifier stays byte-identical to the one in +// `../index` — ESM caches by resolved specifier, so this is what makes that +// module's own `React.lazy` factory resolve immediately. +import '../KanbanImpl'; + +/** The paint the matching rule applies — a colour no other element uses. */ +const PAINT = 'rgb(255, 0, 0)'; + +const RULE = [{ field: 'owner', operator: 'equals', value: 'u1', backgroundColor: PAINT }]; + +/** `owner` arrives EXPANDED, the way the board's own `$expand` delivers it. */ +const CARD = { id: 'c1', title: 'Painted card', owner: { _id: 'u1', name: 'Ann' } }; + +/** The catalogue that makes `owner` collapsible. `lookup` is an expandable type. */ +const FIELD_CATALOGUE = { owner: { type: 'lookup' } }; + +const BOARD = { + type: 'kanban', + columns: [{ id: 'todo', title: 'To Do', cards: [CARD] }], + conditionalFormatting: RULE, +} as const; + +/** The rendered card's own background, read off the element the title sits in. */ +async function paintOfTheCard(): Promise { + const title = await screen.findByText('Painted card'); + let el: HTMLElement | null = title; + while (el) { + const bg = el.style?.backgroundColor; + if (bg) return bg; + el = el.parentElement; + } + return ''; +} + +describe('`objectFields` reaches the predicate layer as a PROP (objectui#7742)', () => { + it('THE CONTROL — passed as a prop, the relation rule matches and the card is painted', async () => { + // ⭐ Asserted first and on purpose. It proves the fixture CAN produce a + // paint, so the negative below is a reading about the channel and not about + // a rule that never matched on any channel. + render(); + expect(await paintOfTheCard()).toBe(PAINT); + }); +}); + +describe('`objectFields` is no longer read off the schema bag (objectui#7742)', () => { + it('an AUTHORED `objectFields` inside `schema` does not reach the predicate layer', async () => { + // The same catalogue, the same rule, the same card — written where an + // author can write it. It must now do nothing. + render( + , + ); + expect(await paintOfTheCard()).not.toBe(PAINT); + }); + + it('and with neither channel supplied the card is unpainted — the paint is not unconditional', async () => { + // The third leg: without it, "unpainted" above could be the board's default + // for every card and would say nothing about `objectFields` at all. + render(); + expect(await paintOfTheCard()).not.toBe(PAINT); + }); +}); diff --git a/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts b/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts new file mode 100644 index 0000000000..0f5e54e453 --- /dev/null +++ b/packages/types/src/__tests__/kanban-arm-batch70-7742.test.ts @@ -0,0 +1,168 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The `'kanban'` arm's accept set after decision batch #70 (objectui#7742, + * ADR-0049, maintainer 2026-09-07: 「同意」). + * + * ## Why this file exists next to `kanban-plugin-dialect-authoritative-7664` + * + * That file pins the arm's declared BODY — which members exist, live or + * tombstoned — with a TypeScript AST census. This one pins what a DOCUMENT gets + * back, through `safeValidateSchema` itself. The two are not interchangeable: + * `BaseSchema` is `.passthrough()`, so a key can be absent from the shape and + * still parse green, and a key can be present in the shape as a refusal arm and + * make a document fail. Only a parse answers the accept-set question. + * + * ## The unusual thing about this card, stated so a reader checks both halves + * + * The accept set moves in BOTH directions in one change, which is why the PR + * carries `needs:contract-review`: + * + * - NARROWER — `allowCollapse`, `cardTemplates`, `columnWidths` and + * `titleField` were accepted and are now refused BY NAME. + * - WIDER — `navigation` was undeclared (admitted through `BaseSchema`'s + * index signature, never examined) and is now declared and JUDGED. + * + * A file that only checked the narrowing would pass on a change that forgot the + * widening entirely, and the reverse. Both directions are asserted here. + * + * ## Every refusal is paired with a CONTROL that fires the other way + * + * A `.passthrough()` object accepts an unknown key. So "the document failed" + * proves nothing on its own — it has to be shown that an ARBITRARY key does + * NOT fail on the same call, or the refusal could just be strictness that + * arrived some other way. Each refusal assertion below therefore runs against a + * document that also carries a bogus key, and asserts the bogus key draws NO + * issue while the retired key draws one at its own path. + */ + +import { describe, it, expect } from 'vitest'; +import { safeValidateSchema } from '../zod/index.zod'; + +/** Zod 4 nests a failing `z.union` arm's issues under `invalid_union.errors`. */ +type IssueLike = { path: PropertyKey[]; message: string; errors?: IssueLike[][] }; +function flattenIssues(issues: IssueLike[]): Array<{ path: string; message: string }> { + return issues.flatMap((i) => + i.errors + ? i.errors.flat().flatMap((nested) => flattenIssues([nested])) + : [{ path: i.path.join('.'), message: i.message }], + ); +} +function refusals(schema: unknown): Array<{ path: string; message: string }> { + const r = safeValidateSchema(schema); + return r.success ? [] : flattenIssues(r.error.issues as unknown as IssueLike[]); +} + +/** A board that validates today, used as the base every probe is added to. */ +const LIVE_BOARD = { + type: 'kanban', + objectName: 'tasks', + groupBy: 'status', + cardTitle: 'title', +} as const; + +/** + * The key a control uses. It must be a name no arm of the union declares, so a + * `.passthrough()` accept is the only correct answer for it. + */ +const BOGUS = 'thisKeyIsDeclaredByNoArm'; + +/** The four keys batch #70 retired from this arm, with what each points at. */ +const RETIRED = [ + { key: 'allowCollapse', value: true, remedyMentions: 'collapsed' }, + { key: 'cardTemplates', value: [{ id: 't', name: 'Bug', values: {} }], remedyMentions: 'COMPONENT PROP' }, + { key: 'columnWidths', value: { defaultWidth: 280 }, remedyMentions: 'HOOK OPTION' }, + { key: 'titleField', value: 'name', remedyMentions: 'cardTitle' }, +] as const; + +describe('batch #70 NARROWS the `kanban` arm — four keys are refused by name (objectui#7742)', () => { + for (const { key, value, remedyMentions } of RETIRED) { + it(`refuses \`${key}\` at its own path, while an undeclared key on the same call is accepted`, () => { + const found = refusals({ ...LIVE_BOARD, [key]: value, [BOGUS]: 'anything' }); + + // The retired key draws an issue AT ITS OWN PATH — so the author reads + // which key is wrong, not just that the document is. + const atKey = found.filter((f) => f.path === key); + expect(atKey, `no refusal at \`${key}\`: ${JSON.stringify(found)}`).not.toEqual([]); + + // THE CONTROL, on the same parse: an arbitrary undeclared key draws + // nothing. Without this the assertion above would also pass under a + // validator that had simply become strict. + expect( + found.filter((f) => f.path === BOGUS), + 'the control key drew an issue — this arm is not passthrough any more, so the refusal above is not a NAMED one', + ).toEqual([]); + + // The message carries the remedy, not just a type complaint. This is what + // `retirementTombstone` exists for: zod's own text would read "expected + // never, received string" and tell the author nothing. + expect( + atKey.map((f) => f.message).join('\n'), + `\`${key}\`'s refusal does not name its remedy`, + ).toContain(remedyMentions); + }); + } + + it('the same four keys were ACCEPTED before this card — the base board without them still passes', () => { + // The other half of a narrowing claim: the narrowing is the KEY, not the + // board. A board that never named them is untouched. + expect(refusals(LIVE_BOARD)).toEqual([]); + }); + + it('`cardTitle` — the spelling `titleField`\'s refusal points at — still validates', () => { + expect(refusals({ type: 'kanban', objectName: 'tasks', cardTitle: 'name' })).toEqual([]); + }); +}); + +describe('the SIBLING `object-kanban` arm keeps `titleField` (objectui#7322 item ②, PR #8153)', () => { + it('accepts a `titleField` document, so the retirement is arm-scoped and not global', () => { + // ⛔ This is the assertion that makes the `titleField` row a RETIREMENT OF A + // SPELLING rather than a removal of a capability. `ObjectKanban` renders + // both node types and still reads the key; only the `kanban` arm stops + // accepting it. + expect(refusals({ type: 'object-kanban', objectName: 'tasks', groupBy: 'status', titleField: 'name' })).toEqual([]); + }); + + it('and the `kanban` arm refuses the same key on the same call shape — the two answers differ', () => { + // The pair is the point: identical documents but for `type`, opposite + // verdicts. Asserted together so neither can drift without the other. + // (`groupBy` is on both because the `object-kanban` arm REQUIRES it — a + // control that fired on the first cut of this file and is kept here as the + // reason the two probes are spelled the way they are.) + expect(refusals({ type: 'kanban', objectName: 'tasks', groupBy: 'status', titleField: 'name' })).not.toEqual([]); + }); +}); + +describe('batch #70 WIDENS the `kanban` arm — `navigation` is declared (objectui#7742, gantt precedent objectui#5903)', () => { + it('accepts a declared `navigation` config', () => { + expect(refusals({ ...LIVE_BOARD, navigation: { mode: 'drawer' } })).toEqual([]); + expect(refusals({ ...LIVE_BOARD, navigation: { mode: 'page' } })).toEqual([]); + }); + + it('JUDGES the value, not just the key — a bad `mode` is refused inside `navigation`', () => { + // ⭐ The verdict that separates "declared" from "admitted". Before this card + // `navigation` rode `BaseSchema`'s `[key: string]: any`, so ANY value passed + // — including this one. A test that only asserted the good config would + // have passed on `origin/main` too and pinned nothing. + const found = refusals({ ...LIVE_BOARD, navigation: { mode: 'not-a-mode' } }); + expect(found, 'an invalid navigation mode was accepted — the key is admitted, not judged').not.toEqual([]); + expect( + found.some((f) => f.path.startsWith('navigation')), + `no issue under \`navigation\`: ${JSON.stringify(found)}`, + ).toBe(true); + }); + + it('and the member list is the spec\'s — a key the spec does not declare is refused inside `navigation`', () => { + // The vocabulary is `@objectstack/spec`'s `NavigationConfig` BY REFERENCE, + // not restated here, so it cannot fork. `basePath` is the name the package + // README once showed and the spec never declared. + const found = refusals({ ...LIVE_BOARD, navigation: { mode: 'drawer', basePath: '/tasks' } }); + expect(found, 'an undeclared navigation member was accepted').not.toEqual([]); + }); +}); diff --git a/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts b/packages/types/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts index e6348a84d5..3808710de8 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 @@ -220,6 +220,11 @@ describe("the 'kanban' validator arm accepts what the registered renderer reads 'objectName', 'groupBy', 'swimlaneField', 'cardTitle', 'cardFields', 'data', 'limit', 'columns', 'onCardMove', 'className', 'quickAdd', 'onQuickAdd', 'coverImageField', 'allowCollapse', 'conditionalFormatting', 'cardTemplates', 'columnWidths', 'grouping', + // objectui#7742 (decision batch #70): `navigation` joins as a LIVE member + // and `titleField` joins as a refusal arm. Both are on the shape for the + // same reason the four keys above still are — a REFUSED key is declared, + // and only a DROPPED key would be missing here. + 'navigation', 'titleField', ]) { expect(declared, `\`${key}\` missing from the kanban mirror's shape`).toContain(key); } @@ -294,17 +299,27 @@ describe('the declared body is measured, not inherited (objectui#7664)', () => { })); } - it('KanbanSchema declares 20 live members — the dialect\'s 19 (the ruling said 18; `type` is the difference) plus `onCardClick` — and exactly 3 tombstones', () => { + it('KanbanSchema declares 18 live members and exactly 7 tombstones after the batch #70 ruling', () => { + // objectui#7742 moved this count in BOTH directions in one change. It was + // 20 live / 3 tombstoned: three zero-read members (`allowCollapse`, + // `cardTemplates`, `columnWidths`) and the legacy `titleField` spelling + // retired, and `navigation` — a read this face never named — was declared. + // 20 - 3 + 1 = 18 live; 3 + 4 = 7 tombstones. `titleField` is the one that + // ADDS a member rather than converting one: it was never declared here at + // all, it rode `BaseSchema`'s index signature. const members = membersOf('KanbanSchema'); const live = members.filter((m) => !m.never).map((m) => m.name); const tombstoned = members.filter((m) => m.never).map((m) => m.name); expect(live).toEqual([ 'type', 'objectName', 'groupBy', 'swimlaneField', 'cardTitle', 'cardFields', 'data', 'limit', 'columns', - 'onCardMove', 'onCardClick', 'className', 'quickAdd', 'onQuickAdd', 'coverImageField', 'allowCollapse', - 'conditionalFormatting', 'cardTemplates', 'columnWidths', 'grouping', + 'onCardMove', 'onCardClick', 'className', 'quickAdd', 'onQuickAdd', 'coverImageField', + 'conditionalFormatting', 'grouping', 'navigation', + ]); + expect(live).toHaveLength(18); + expect(tombstoned).toEqual([ + 'allowCollapse', 'cardTemplates', 'columnWidths', 'titleField', + 'draggable', 'onColumnAdd', 'onCardAdd', ]); - expect(live).toHaveLength(20); - expect(tombstoned).toEqual(['draggable', 'onColumnAdd', 'onCardAdd']); // Both directions against the mirror, so the number above is the mirror's // too: every declared member is a key of the shape, and every shape key the // arm ADDS over `BaseSchema` is a declared member. (The parity ratchet, From 547dade73297aaea2630dd448b49903b11a6f42a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 10:46:05 +0000 Subject: [PATCH 3/3] docs(plugin-kanban,changeset): scope the `objectFields` claim to the `kanban` arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The move takes `objectFields` off the schema READ PATH, and on the ruled `kanban` arm that closes the key outright — `ObjectKanbanRenderer` serves that type key and discards its rest-spread (`void _props;`). It does NOT make the key author-unreachable in general: `objectFields` is absent from `SchemaRenderer`'s stripped-metadata list, so on the schema-only `kanban-ui` entry an authored value survives the generic prop spread and lands on the very prop this change introduced. Measured through the real `SchemaRenderer` — a `kanban-ui` node with an authored `objectFields` paints the card; the same node without it, and the same document as `type: "kanban"`, do not. Three texts read as if the general claim held, and the changeset is the one that ships: a changeset is this PR's input to the release notes, so a measured-false claim there becomes a release note. All three now state the scope at the arm level rather than dropping the claim: - .changeset/7742-kanban-arm-batch70.md - the KanbanRendererProps docblock in packages/plugin-kanban/src/index.tsx - the pin file's docblock, and the pin file's NAME Renamed objectFieldsIsNotAuthorable-7742.test.tsx to objectFieldsIsAPropNotASchemaKey-7742.test.tsx. Its describes were always accurate; only the filename claimed the generality the measurement refutes, and a filename is the widest-reach text here — it prints in every vitest run and in every file listing. Nothing in the repo referenced the old path, the two imports are same-directory relative, and check-type-check-coverage enumerates test files from the package root, so coverage is re-derived from disk (42/42 packages). Also corrects the countsAreWindowed docblock, whose "same shape as objectFields above" pointer this PR's own move had inverted, and the stale "Absent on the schema-only kanban-ui entry" line that the new scope paragraph contradicted. Wording only: no behaviour, no refusal, no declaration, no pin assertion and no ablation leg is touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .changeset/7742-kanban-arm-batch70.md | 25 +++++++---- ...tFieldsIsAPropNotASchemaKey-7742.test.tsx} | 18 +++++++- packages/plugin-kanban/src/index.tsx | 43 +++++++++++++------ 3 files changed, 64 insertions(+), 22 deletions(-) rename packages/plugin-kanban/src/__tests__/{objectFieldsIsNotAuthorable-7742.test.tsx => objectFieldsIsAPropNotASchemaKey-7742.test.tsx} (83%) diff --git a/.changeset/7742-kanban-arm-batch70.md b/.changeset/7742-kanban-arm-batch70.md index 91d337f3c1..d081080901 100644 --- a/.changeset/7742-kanban-arm-batch70.md +++ b/.changeset/7742-kanban-arm-batch70.md @@ -52,15 +52,24 @@ failure objectui#7664's own first cut shipped at `onCardClick`. The member list is `@objectstack/spec`'s `NavigationConfig` by reference, not restated, so the vocabulary cannot fork. -## `@object-ui/plugin-kanban` — one internal channel leaves the schema bag +## `@object-ui/plugin-kanban` — `objectFields` moves from the schema bag to a React prop `objectFields` (the fetched object's field definitions, which card conditional formatting needs so a rule comparing a relation sees the stored foreign key) is now a **React prop on `KanbanRendererProps`**, a sibling of `schema`, rather than -a member of the `schema` bag. It is an internal channel from the one caller that -fetched the object definition, never an authoring surface. Inside `schema` it was -reachable by an *author*: on the schema-only `kanban-ui` entry, which has no -object schema of its own to substitute, an authored `objectFields` reached -`resolveConditionalFormatting` verbatim while no schema face declared or judged -it. Callers that render `KanbanRenderer` directly and passed `objectFields` -inside `schema` must move it to the prop. +a member of the `schema` bag. `ObjectKanban` — the one caller that fetches the +object definition — injects it there. Callers that render `KanbanRenderer` +directly and passed `objectFields` inside `schema` must move it to the prop. + +⚠️ **What this closes, stated at the arm level** — measured through the real +`SchemaRenderer`, not assumed. It closes the *schema read path*, and on the +`'kanban'` arm that closes the key outright: `ObjectKanbanRenderer` serves that +type key and discards its rest-spread (`void _props;`), so an authored +`objectFields` on a `type: "kanban"` node reaches nothing. It does **not** make +`objectFields` unreachable to an author in general. On the schema-only +`'kanban-ui'` entry, which `KanbanRenderer` serves directly, the key is absent +from `SchemaRenderer`'s stripped-metadata list, so it survives that renderer's +generic prop spread and arrives on the very prop this change adds — an authored +`objectFields` still reaches `resolveConditionalFormatting` there, as it did +before, and no schema face declares or judges it. Closing that entry is a +separate change and is not made here. diff --git a/packages/plugin-kanban/src/__tests__/objectFieldsIsNotAuthorable-7742.test.tsx b/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx similarity index 83% rename from packages/plugin-kanban/src/__tests__/objectFieldsIsNotAuthorable-7742.test.tsx rename to packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx index ae0d4c32d6..c5638d9f4b 100644 --- a/packages/plugin-kanban/src/__tests__/objectFieldsIsNotAuthorable-7742.test.tsx +++ b/packages/plugin-kanban/src/__tests__/objectFieldsIsAPropNotASchemaKey-7742.test.tsx @@ -7,7 +7,7 @@ */ /** - * `objectFields` is an INTERNAL CHANNEL, not an authoring surface + * `objectFields` is a PROP, no longer a key read off the `schema` bag * (objectui#7742, ADR-0049, maintainer decision batch #70, 2026-09-07). * * ## What moved, and why it was a defect @@ -27,6 +27,22 @@ * fabricated field catalogue and change which cards a rule matched. Batch #70 * ruled it an internal channel; it is now a React PROP, a sibling of `schema`. * + * ## ⚠️ Scope — what the move closes, and what it does NOT + * + * It closes the SCHEMA READ PATH, which is exactly what the assertions below + * pin: `KanbanRenderer` no longer reads `schema.objectFields`. On the `'kanban'` + * arm that closes the key outright — `ObjectKanbanRenderer` serves that type key + * and discards its rest-spread (`void _props;`), so an authored `objectFields` + * on a `type: 'kanban'` node reaches nothing. + * + * ⛔ It does NOT make `objectFields` author-unreachable in general, and this + * file must not be read as claiming that. `objectFields` is absent from + * `SchemaRenderer`'s stripped-metadata list, so on the schema-only `'kanban-ui'` + * entry an authored `objectFields` survives that renderer's generic prop spread + * and arrives on the very prop asserted below. The tests here render + * `KanbanRenderer` DIRECTLY and so never exercise `SchemaRenderer`: that entry + * is measured but NOT pinned here, and closing it is a separate change. + * * ## How this file discriminates, and why the fixture is shaped this way * * The two channels are told apart by an outcome that ONLY the field catalogue diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 5631f73f32..c33616d641 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -156,10 +156,14 @@ export interface KanbanRendererProps { * The lane counts below are counts of a fetched WINDOW, not of the group * (objectui#8307). Injected by `ObjectKanban`, the only entry point that * issues the windowed `$top` query and can therefore know the answer; - * absent on the schema-only `kanban-ui` entry, whose `data` arrives whole - * from its author and whose counts are complete by construction. Not an - * authorable input for exactly that reason — same shape as `objectFields` - * above, and likewise absent from this component's registry `inputs`. + * `ObjectKanban` supplies nothing on the schema-only `kanban-ui` entry, + * whose `data` arrives whole from its author and whose counts are complete + * by construction. Not MEANT to be an authorable input for exactly that + * reason — same shape and same argument as the `objectFields` prop BELOW, + * and likewise absent from this component's registry `inputs`. ⚠️ Unlike + * that prop it still rides this schema bag, so on `kanban-ui` an author can + * in fact write it; batch #70 did not name the key, so it is recorded here + * rather than moved (objectui#7742). */ countsAreWindowed?: boolean; }; @@ -172,19 +176,32 @@ export interface KanbanRendererProps { * SAME view worked on the grid and silently never matched on the board * (objectui#3501). * - * ⛔ AN INTERNAL CHANNEL, NOT AN AUTHORING SURFACE (objectui#7742, maintainer - * decision batch #70, 2026-09-07). It sits HERE — a React prop, a sibling of - * `schema` — and deliberately NOT inside `schema`, which is where it used to - * live. Inside `schema` it was reachable by an AUTHOR: `BaseSchema` is - * `.passthrough()`, `SchemaRenderer` hands the node through, and on the + * ⛔ INTENDED AS AN INTERNAL CHANNEL, NOT AN AUTHORING SURFACE (objectui#7742, + * maintainer decision batch #70, 2026-09-07). It sits HERE — a React prop, a + * sibling of `schema` — and deliberately NOT inside `schema`, which is where + * it used to live. Inside `schema` it was reachable by an AUTHOR: `BaseSchema` + * is `.passthrough()`, `SchemaRenderer` hands the node through, and on the * schema-only `kanban-ui` entry (which has no object schema of its own to * substitute) an authored `objectFields` reached * `resolveConditionalFormatting` verbatim. Nothing declared it on any schema - * face, so nothing judged it either. As a prop the only writer is the one - * caller that can actually know the answer. + * face, so nothing judged it either. * - * Absent on the schema-only `kanban-ui` entry, which has no object schema to - * offer; there conditional formatting reads the card payload as before. + * ⚠️ THE MOVE CLOSES THE `kanban` ARM, NOT THE KEY — measured through the + * real `SchemaRenderer`, so do NOT read this prop as proof that only + * `ObjectKanban` can write it. `ObjectKanbanRenderer` serves `type: 'kanban'` + * and discards its rest-spread (`void _props;`), so an authored `objectFields` + * on a `'kanban'` node reaches nothing — that arm is genuinely closed. But + * `objectFields` is NOT on `SchemaRenderer`'s stripped-metadata list (the + * destructure that feeds its `...componentProps` rest), so on the `kanban-ui` + * registration below — which THIS component serves — an authored + * `objectFields` survives the generic prop spread and lands right here, and + * still reaches `resolveConditionalFormatting` exactly as it did before. + * Stripping the key at that entry is a separate change, not made here. + * + * `ObjectKanban` supplies nothing on the schema-only `kanban-ui` entry — it + * has no object schema to offer — so unless an author wrote the key and it + * arrived by the spread above, conditional formatting there reads the card + * payload as before. * * ⚠️ `countsAreWindowed` above is the SAME shape and the same argument, and * the batch #70 ruling did not name it — it stays on the schema bag, recorded