Skip to content

Commit 1b82c51

Browse files
os-billclaude
andauthored
feat(spec): declare the author-settable row ceiling for the page-shaped view configs (#19226)
Fixes #17393 Clause-②: yes (widening) `GalleryConfigSchema`, `KanbanConfigSchema` and `TimelineConfigSchema` each gain a `limit` member — `z.number().int().positive().default(100)` — and `DEFAULT_VIEW_ROW_LIMIT` is exported beside them. The key's own text states both halves of the contract: the default it applies, and that the renderer must show a visible truncation signal when the ceiling applies. ## The one design call the card delegates: which shape, and why **Chosen: a shared `limit` on the three page-shaped config blocks. Rejected: a member on the base view config (`ListViewShapeSchema`).** Three properties of this tree decide it, not taste: 1. **The base shape already carries the row-bounding knob every view type reaches** — `pagination.pageSize` (`PaginationConfigSchema`, default 25). A second base-level row key would leave one view with two base-level row bounds and no declared precedence between them, and the `virtualScroll` tombstone at the bottom of that same shape already prescribes `pagination` for exactly that question ("large datasets page via `pagination`"). 2. **A base member is reachable from every `type`** — the non-grid four (gantt / calendar / map / tree) included. Their ceiling is a platform constant the renderer owns (objectui#7210) and this card scopes them out by name, so a base member would publish an authorable ceiling on four view kinds no renderer reads: declared-but-unenforced on the day it lands. 3. **The per-kind block is what actually reaches the renderer.** Measured in objectui at `dda8f3815d`: `ListView`'s kanban branch destructures the merged block and spreads the rest flat onto the generated `object-kanban` node (`packages/plugin-list/src/ListView.tsx`, the `...restKanban` in that branch's return), so a protocol `kanban.limit` lands exactly where `ObjectKanban.tsx:573` already reads `schema.limit`. A base-level key is forwarded into no per-kind node at all. The NAME follows the same evidence: `limit` is the name the consumers already read, so this declaration absorbs the two consumer-local keys instead of buying a second spelling. ## The default, and the truncation signal The default is **applied**, not merely described. A `.describe()` naming a default the schema does not apply is a second contract nothing enforces, so the two are pinned to each other: the test parses each minimal block, reads the number out of the member's own describe text, and asserts they are the same value. Change one without the other and the case reddens. The truncation signal cannot be enforced from a schema — it is the renderer's half. What the protocol can do is say it is owed, which is what the describe text does, and a pin asserts the sentence is there. That sentence is the one the objectui#7390 dispatch turns on: adding `$top` without a signal trades "unbounded and silent" for "bounded and silent", which is worse, because the user then believes they are seeing everything. ## The consumer-local keys, measured here rather than repeated from the card Read-only measurement of objectui at its current head `dda8f3815d` (this card touches that repo in no way): - **kanban** — `ObjectKanbanSchema.limit`, declared in `@object-ui/types` alone (`packages/types/src/zod/objectql.zod.ts:1762`, `z.number().int().positive().optional()`, describe "default 100 (DEFAULT_KANBAN_LIMIT)"); read at `packages/plugin-kanban/src/ObjectKanban.tsx:573` as `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT`, with that constant `= 100` at `:84`. - **timeline** — `limit?: number` on `ObjectTimeline`'s own props interface (`packages/plugin-timeline/src/ObjectTimeline.tsx:129`) and on no published schema at all; read at `:328` as `$top: schema.limit ?? DEFAULT_TIMELINE_LIMIT`, that constant `= 100` at `:29`. - **gallery** — zero `$top` and zero `limit` in `packages/plugin-list/src/ObjectGallery.tsx`: the unbounded fetch objectui#7390 is ruled to close by reading an author-settable ceiling. So the name this PR chose is the one the consumer already reads, at the same type (int, positive), and the number it declares — 100 — is the value both existing renderer constants carry, which is what the ruling asked the implementing seat to align `DEFAULT_GALLERY_LIMIT` with. The card's claim was re-derived, not inherited, and it holds. ## Ablation — three runs, each restored byte-clean Every leg ran through `scripts/ablation-replace.mjs`, which proves the write on disk (anchor count, replacement count, blob hash) and proves the restore against `HEAD` rather than against an exit code. `packages/spec/src/ui/view.test.ts` imports `./view.zod` relatively, so the pins resolve through source and no `dist` leg is involved. | leg | mutation | predicted | observed | |---|---|---|---| | A | drop `.int().positive()` from the member | the refusal case reddens, the other five hold | exactly that: `refuses a value that could not bound a fetch — and refuses it BY NAME` failed with `gallery limit=0: expected true to be false`; 1 failed, 5 passed | | B | delete `limit: rowLimitKey('gallery')` | the acceptance-side cases redden for gallery, the scope case holds | 5 failed, 1 passed — including `expected undefined to be 100` and the parser reporting `Unrecognized key(s) on this gallery configuration` | | C | plant the ceiling on `GanttConfigSchema` | the scope case reddens naming gantt | 1 failed, 5 passed: `gantt accepts an authorable row ceiling it should not declare: expected [] to include 'limit'` | Leg C exists because legs A and B never moved the scope pin, and a pin never observed to fail is not a pin. **Two things went wrong on the way there and are reported rather than buried:** - C's first attempt was **vacuous**: the replacement kept the anchor text, so `ablation-replace` refused (`the anchor count moved 1 -> 1, a drop of 0, not the declared 1`), restored, and ran nothing. It is re-run with the anchor consumed. - C's first real run reddened for the wrong reason: with the key accepted there was no `unrecognized_keys` issue to stringify, so the assertion complained about argument types instead of about gantt. The pin now asserts on the refused KEY LIST, which is what makes its red a sentence about the view type (commit `5dd3911`). ## What the change dragged with it, named rather than buried `KanbanConfigSchema` had never carried a default, so it was on the ADR-0122 isomorphic-pin list (`Iso829`). An applied default gives it a second shape, which is precisely the event that list exists to catch, so the prescribed follow-through landed with it: `KanbanConfigParsed` declared beside the bare alias, the pin line removed with its own receipt, and the pinned count plus both prose statements of it moved 785 to 784. `check:spec-parsed-alias` is green on the result. That is one file outside the dispatch's declared landing surface — `packages/spec/src/type-alias-convention.pin.test.ts` — and it is the only one. `GalleryConfig` and `TimelineConfig` needed nothing: both already carried defaults and therefore both halves of the alias pair. The generated artifacts moved by a real (never `OS_SKIP_DTS=1`) build and the repo's own regenerators: `authorable-surface/ui.json` and `authorable-defaults/ui.json` (three rows each), `api-surface/ui.json`, `export-origins/ui.json`, the five `api-surface-declarations/*.txt` the view schema is embedded in, and `content/docs/references/**`. None was hand-edited. ## Readings All at head `5dd3911`, foreground, exit codes captured before any pipe. - `pnpm check:adr-anchors` — exit 0: "OK (53 anchored file(s), every governing ADR still referenced; 133 decision number(s) ...; 36673 citation(s) across 4724 file(s) resolve)". Every ADR id in this diff (ADR-0122, ADR-0049, ADR-0079, ADR-0087) resolves to a real record. - `pnpm --filter @objectstack/spec check:generated` — exit 0: "All 16 generated artifacts are up to date." - `pnpm --filter @objectstack/spec test` — exit 0: "Test Files 499 passed (499) / Tests 14606 passed (14606)", the six new pins among them. - `pnpm --filter @objectstack/spec typecheck` — exit 0 (`tsc --noEmit`, scripts project, and `check:test-typecheck`: "54 file(s) / 259 error(s) / 144 pinned signature(s)", the ledger unmoved). - `pnpm lint` — exit 0 over the whole repo (`eslint . --no-inline-config`), so no narrowing claim is needed. - `node scripts/pm/dispatch-gates.mjs --ran` — "106 derived famil(ies) accounted for — 103 run, 3 NOT-MEASURED", 0 unrun. **NOT MEASURED, with the exit code and the reason.** Four, not the tool's three — the fourth is declared here because its exit code cannot say so itself: | family | exit | why nothing was measured | |---|---|---| | `pnpm check:dual-build-cjs-loads` | 3 | reads built output; adapters and apps have no `dist` in this worktree | | `pnpm check:lean-entry-closure` | 3 | loads built entry points; `packages/objectql/dist/core.mjs` absent | | `pnpm check:type-check-debt` | 3 | `--re-measure` refuses: 30 workspace dependencies of the ledgered packages have no built type entry point | | `pnpm --filter @objectstack/spec check:skill-examples` | **1** | "packages/client-react/dist holds no .d.ts declarations — the package is not built". It refused after enumerating its populations and measured no block. Its exit 1 is indistinguishable from a finding, so `--ran` counted it among the 103 run | All four want a repo-wide build this worktree does not carry; CI builds everything and runs them there. The three families whose prerequisite WAS bounded were built and re-run rather than declared: `@objectstack/lint`'s closure (4 packages) turned `check:doc-formula-expressions`, `check:doc-security-posture` and `check:docs-transcript-drift` from exit 3 into exit 0. ## Acceptance notes - **Noted, not filed — an exit-code class worth a card, and the seat's to file.** Six spec gates refuse a stale `dist` with **exit 1**, the code a finding uses (`check:api-surface`, `check:api-surface-declarations`, `check:exported-any`, `check:dual-source-exports`, `check:entry-nameability`, `check:skill-examples`), while this repo declares a distinct code for exactly that class and explains why in `scripts/import-prerequisite.mjs`: "Exit 1 from an unmet prerequisite and exit 1 from a real finding are the same reading — which is why the guarded refusal below does NOT keep that number". The consequence is measurable and was measured here: `dispatch-gates --ran` classifies by exit code, so it reported "103 run, 3 NOT-MEASURED" over a record in which four families measured nothing. Successor: whoever owns `scripts/import-prerequisite.mjs`'s vocabulary. Dedupe words: prerequisite, exit code, stale dist, api-surface, dispatch-gates. - The card's two line numbers for the declaration sites are stale (`:945` / `:1241`); the tree reads `:1135` and `:1437` on `0046a41b43`, as the claim comment already corrected. Its substantive claim — both are `strictObject`s declaring no row ceiling — holds, and the third site (`TimelineConfigSchema`) is declared in spec, so the card's "timeline if spec declares a timeline config" condition is met and timeline is in. This PR is a draft on purpose: it owes the spec lane's at-tier contract review, which is the seat's to run. No labels were written from here, and the body was written once, at creation. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9059a94 commit 1b82c51

17 files changed

Lines changed: 511 additions & 15 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Gallery, kanban and timeline view configs declare an author-settable row ceiling.
6+
7+
`GalleryConfigSchema`, `KanbanConfigSchema` and `TimelineConfigSchema` each gain a
8+
`limit` member — a positive integer, default **100** — saying how many records the
9+
view fetches. The default is APPLIED by the schema rather than only described, and
10+
the key's own text states the other half of the contract: when the ceiling applies,
11+
the renderer must show a visible truncation signal, because a bounded view that
12+
looks complete is worse than an unbounded one. `DEFAULT_VIEW_ROW_LIMIT` is exported
13+
so a consumer reads that number instead of re-declaring it.
14+
15+
The knob belongs in the protocol because two renderers already cap by author choice
16+
off keys the protocol never declared: objectui's kanban board fetches
17+
`$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` with `limit` declared in
18+
`@object-ui/types` alone, its timeline does the same off a component props
19+
interface, and its gallery caps not at all. `limit` is the name those consumers
20+
already read, so this declaration absorbs the consumer-local keys instead of
21+
introducing a second spelling of one concept.
22+
23+
Nothing is removed, renamed or narrowed, and no document that parsed before is
24+
refused now. Two things to know when upgrading:
25+
26+
- a parsed gallery / kanban / timeline config carries `limit: 100` where the author
27+
wrote no ceiling, so code that compares a parsed config against a literal object
28+
sees the new member;
29+
- `KanbanConfigParsed` is now declared (ADR-0122) because that schema has two shapes
30+
for the first time; `KanbanConfig` is unchanged and remains the author state.
31+
32+
The non-grid four — gantt, calendar, map and tree — are deliberately untouched:
33+
their rows stay bounded by a platform ceiling the renderer owns, because a gantt's
34+
range, a map's camera fit and a tree's parent chain are computed over the whole set.
35+
36+
Clause-②: yes (widening)

‎content/docs/references/api/protocol.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1674,7 +1674,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
16741674
| **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration |
16751675
| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) |
16761676
| **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration |
1677-
| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
1677+
| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
16781678
| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout |
16791679
| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
16801680
| **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration |
@@ -1759,7 +1759,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
17591759
| **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration |
17601760
| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) |
17611761
| **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration |
1762-
| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
1762+
| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
17631763
| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout |
17641764
| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
17651765
| **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration |

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ const result = ApiMethod.parse(data);
374374
| **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration |
375375
| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) |
376376
| **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration |
377-
| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
377+
| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
378378
| **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout |
379379
| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
380380
| **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration |

‎content/docs/references/ui/component.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,7 @@ View filter rule
828828
| **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. |
829829
| **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color |
830830
| **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale |
831+
| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. |
831832

832833
### Nested Shape: `ObjectTimelineProps.filter[number]`
833834

0 commit comments

Comments
 (0)