From e7d043253f6a8c39c0ea006bfc39e9b50168c406 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:47:59 +0000 Subject: [PATCH 1/2] feat(spec): object-kanban props declare `limit`, the row cap objectui already reads `ComponentPropsMap['object-kanban']` is strict and refused `limit` by name while objectui's plugin-kanban reads `schema.limit` as the query's `$top`, `OBJECT_KANBAN_DATA_SOURCE` maps `limit: 'limit'`, `KanbanSchema` declares `limit?: number` and the plugin docs teach `limit: 250`. Declare the key (`z.number().int().positive().optional()`), pin both directions (the documented shape parses; an undeclared sibling is still refused), and add the minor changeset. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .changeset/object-kanban-limit-row-cap.md | 17 ++++++ packages/spec/src/ui/component.test.ts | 63 +++++++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 37 +++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 .changeset/object-kanban-limit-row-cap.md diff --git a/.changeset/object-kanban-limit-row-cap.md b/.changeset/object-kanban-limit-row-cap.md new file mode 100644 index 0000000000..532a0d8cca --- /dev/null +++ b/.changeset/object-kanban-limit-row-cap.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `ComponentPropsMap['object-kanban']` declares `limit`, the row cap four objectui faces already implement (#16503, the spec half of objectui#8172) + +`object-kanban` gains one optional authorable key: + +```ts +limit: z.number().int().positive().optional() +``` + +Maximum number of records loaded onto the board (row cap), lowered to the top-level `$top` of the board's one query. The renderer default stays 100 and is documented rather than declared, so an unset key remains unset. The component-level `dataSource.limit` wins when both are set, and a bound named view's `pagination.pageSize` fills the key only when the component authored none — the `ElementDataSourceGate` precedence table, unchanged. + +Measured at the objectui pin this repo builds against (`.objectui-sha` = `a472b0716`): `plugin-kanban` reads `schema.limit` as the query's `$top` (wired by objectui#4025), `OBJECT_KANBAN_DATA_SOURCE` maps `limit: 'limit'`, `KanbanSchema` declares `limit?: number`, and `content/docs/plugins/plugin-kanban.mdx` teaches it with a typed snippet (`limit: 250`) plus a Properties row. The strict props map refused the key by name, so an author following the published docs wrote a node the save gate rejected with the same `unrecognized_keys` verdict a typo gets. Decision batch #68 (2026-09-07, option A): the contract declares the capability that is already implemented, documented and in use. + +Widening a published accept set (Clause-② yes): `safeParse({ objectName: 'x', limit: 250 })` now succeeds; every other undeclared key on the node is refused exactly as before. objectui#8172 publishes the key in the registry declaration on its side. diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index d9081a04b1..eb98b96c41 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -21,6 +21,7 @@ import { ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ObjectMetricPropsSchema, + ObjectKanbanPropsSchema, } from './component.zod'; import { PageComponentSchema, PageSchema, PageComponentType, ElementDataSourceSchema } from './page.zod'; @@ -2772,6 +2773,68 @@ describe('#7751 — object-* block props schemas', () => { }); }); +// #16503 — the spec half of objectui#8172 (decision batch #68, 2026-09-07, +// option A: the contract declares the capability that already ships, is +// documented and is in use). Measured at the objectui pin this repo builds +// against (`.objectui-sha` = `a472b0716`): `plugin-kanban/src/ObjectKanban.tsx:264` +// queries `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` (100, `:71`), +// `plugin-kanban/src/index.tsx:395-398` maps `limit: 'limit'` in +// `OBJECT_KANBAN_DATA_SOURCE`, `plugin-kanban/src/types.ts:134` declares +// `KanbanSchema.limit?: number`, and `content/docs/plugins/plugin-kanban.mdx` +// teaches `limit: 250` with a Properties row. The strict map refused the key by +// name — the same `unrecognized_keys` verdict as the `bogusProp` control — so an +// author following the published docs wrote a node the save gate rejected. +describe('ObjectKanbanPropsSchema limit — the row cap four objectui faces already implement (#16503)', () => { + const kanban = ComponentPropsMap['object-kanban']; + + it("accepts the documented shape `{ objectName: 'x', limit: 250 }` and carries the value through", () => { + const result = kanban.safeParse({ objectName: 'x', limit: 250 }); + expect(result.success).toBe(true); + const parsed = (result.success ? result.data : undefined) as { limit?: number } | undefined; + // Carried through to the parsed output, not stripped: what the board + // lowers to `$top` is what the author wrote. + expect(parsed?.limit).toBe(250); + }); + + it('still refuses an undeclared sibling on the same node — the accept above is not vacuous', () => { + // The card's own control, and the half that proves the object stayed + // strict: without it the green above would also be green on a map that + // had stopped refusing anything. + const result = kanban.safeParse({ objectName: 'x', bogusProp: 250 }); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys') as + | { keys?: string[] } + | undefined; + expect(issue?.keys).toEqual(['bogusProp']); + }); + + it('refuses a cap the query could not lower to `$top` — zero, negative, fractional, or a string — at the VALUE, not the key', () => { + // `z.number().int().positive()`: the shape `element:record_picker` and + // `record:related_list` declare for the same `$top` read, so the flat row + // caps in this map are one contract rather than three dialects. The key is + // recognised (no `unrecognized_keys`); the value is what fails. + for (const limit of [0, -1, 1.5, '250']) { + const result = kanban.safeParse({ objectName: 'x', limit }); + expect(result.success, JSON.stringify(limit)).toBe(false); + const codes = (result.error?.issues ?? []).map((i) => i.code); + expect(codes, JSON.stringify(limit)).not.toContain('unrecognized_keys'); + expect(result.error?.issues[0]?.path, JSON.stringify(limit)).toEqual(['limit']); + } + }); + + it('keeps a `.describe()` that names the `$top` the board lowers it to and the binding that outranks it', () => { + // The describe is the artifact an auditor reads instead of hunting across + // repos, and the row the generated reference page prints; deleting it is + // what re-opens the "is this key live?" question this record answers. + const shape = (ObjectKanbanPropsSchema as unknown as { + def: { shape: Record }; + }).def.shape; + expect(shape.limit?.description).toContain('$top'); + expect(shape.limit?.description).toContain('row cap'); + expect(shape.limit?.description).toContain('dataSource.limit'); + }); +}); + // #10053 — the accept-pins for the last two `icon` slots in this file whose // describes stated only the VOCABULARY. "Icon name (Lucide)" is equally true of // the `page:header` `icon` retired in #6946 *because nothing reads it*, so the diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index a549b82496..3d939c5e3d 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2644,6 +2644,9 @@ export type ObjectMetricProps = z.input; * forwarded schema `quickAdd`/`coverImageField`/`conditionalFormatting` * (`KanbanRenderer`, index.tsx). `groupField` is the DESIGNER's spelling with * zero read points (#7973 class) — aliased to the `groupBy` the board reads. + * `limit` (#16503) was measured later, at the pin this repo builds against + * (`.objectui-sha` = `a472b0716`): `ObjectKanban.tsx:264`, the `$top` of the + * board's one query — its docblock below carries the four-face record. */ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ surface: 'this `object-kanban`', @@ -2662,6 +2665,40 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ columns: z.array(z.unknown()).optional() .describe('Swimlane definitions ({ id, title } per `groupBy` value, or bare value strings) — NOT a field projection'), filter: z.unknown().optional().describe('Base query filter, handed to the wire `$filter`'), + /** + * Row cap (#16503 — the spec half of objectui#8172; decision batch #68, + * 2026-09-07, option A: the contract declares the capability that already + * ships, is documented and is in use). Measured at the objectui pin this + * repo builds against (`.objectui-sha` = `a472b0716`), four faces agreed + * while this map refused the key by name: the board's one query is + * `dataSource.find(objectName, { $filter: schema.filter, $top: schema.limit + * ?? DEFAULT_KANBAN_LIMIT })` (`plugin-kanban/src/ObjectKanban.tsx:262-266`, + * the default `100` at `:71` — a REAL top-level `$top` since objectui#4025; + * before that the cap sat under a `options` key no adapter read), + * `OBJECT_KANBAN_DATA_SOURCE` maps `limit: 'limit'` + * (`plugin-kanban/src/index.tsx:395-398`), `KanbanSchema` — the type + * `ObjectKanban.tsx:143` reads `schema` through — declares `limit?: number` + * (`plugin-kanban/src/types.ts:134`), and `content/docs/plugins/plugin-kanban.mdx` + * teaches it with a typed snippet (`limit: 250`) plus a Properties row. So + * an author following the published docs wrote a node the save gate + * refused, with the same `unrecognized_keys` verdict a typo gets. + * + * Why the carrier is `limit` and not the bound view's `pagination.pageSize` + * (the alternative the card opened): precedence is the `ElementDataSourceGate` + * table, not this key's. The component-level `dataSource.limit` overrides + * this key, and a bound named view's `pagination.pageSize` is LOWERED INTO + * it through the `limit: 'limit'` mapping only when the component authored + * none (`react/src/element-data-source/ElementDataSourceGate.tsx:236-241`, + * `readLimit`/`writeLimit` keyed by `ElementDataSourceLimitKey`). The board + * has no `pagination` read point, so declaring that spelling here would name + * a key the renderer ignores — the accepted-and-dropped defect this section + * exists to remove. Same shape as the `element:record_picker` and + * `record:related_list` row caps (one `$top` contract, not a third dialect), + * and like them the renderer's 100 is documented rather than declared: + * a schema default would materialize `limit: 100` on every parsed board. + */ + limit: z.number().int().positive().optional() + .describe("Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills it only when unset"), data: z.array(z.unknown()).optional().describe('Static inline cards — bypasses the object query'), cardTitle: z.string().optional().describe('Field rendered as each card title'), titleField: z.string().optional().describe('Legacy fallback for `cardTitle` (the board reads `cardTitle || titleField`). Prefer `cardTitle`'), From fb654377e285aa973ef0d3931d975da9eca3cba2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:57:17 +0000 Subject: [PATCH 2/2] chore(spec): regenerate authorable-surface and reference docs for object-kanban `limit` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm --filter @objectstack/spec build` (gen:schema) adds the `ui/ObjectKanbanProps:limit` row to `authorable-surface/ui.json`; `check:generated` proved only `content/docs/references/**` stale and `gen:docs` regenerated it. No `api-surface/` shard moves — a key on an existing props map adds no export. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- content/docs/references/ui/component.mdx | 1 + packages/spec/authorable-surface/ui.json | 1 + 2 files changed, 2 insertions(+) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 87f23995ee..eabf540fc4 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -457,6 +457,7 @@ Sort field and direction pair | **groupBy** | `string` | optional | Field whose values become the board columns | | **columns** | `any[]` | optional | Swimlane definitions (`{ id, title }` per `groupBy` value, or bare value strings) — NOT a field projection | | **filter** | `any` | optional | Base query filter, handed to the wire `$filter` | +| **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills it only when unset | | **data** | `any[]` | optional | Static inline cards — bypasses the object query | | **cardTitle** | `string` | optional | Field rendered as each card title | | **titleField** | `string` | optional | Legacy fallback for `cardTitle` (the board reads `cardTitle \|\| titleField`). Prefer `cardTitle` | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index eb3c99d7ba..1b29088887 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -778,6 +778,7 @@ "ui/ObjectKanbanProps:filter", "ui/ObjectKanbanProps:groupBy", "ui/ObjectKanbanProps:grouping", + "ui/ObjectKanbanProps:limit", "ui/ObjectKanbanProps:objectName", "ui/ObjectKanbanProps:quickAdd", "ui/ObjectKanbanProps:swimlaneField",