@@ -1128,6 +1128,99 @@ export const GroupingConfigSchema = lazySchema(() => strictObject({
11281128 + 'AND-ed into the view filter. Compiled by `compileListViewGroupQuery` / `compileListViewGroupRowsQuery`' ,
11291129) ) ;
11301130
1131+ /*
1132+ * ---------------------------------------------------------------------------
1133+ * `limit` — the author-settable row ceiling of the page-shaped views (#17393)
1134+ * ---------------------------------------------------------------------------
1135+ *
1136+ * Declared here because the PROTOCOL was the thing that was wrong: two
1137+ * renderers already cap by author choice, and the key they read was never a
1138+ * protocol key. Measured in objectui at `dda8f3815d`:
1139+ *
1140+ * - `ObjectKanban.tsx:573` fetches `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT`
1141+ * (`= 100` at `:84`), and that `limit` is declared in `@object-ui/types`
1142+ * alone (`zod/objectql.zod.ts:1762`, `z.number().int().positive().optional()`);
1143+ * - `ObjectTimeline.tsx:328` fetches `$top: schema.limit ?? DEFAULT_TIMELINE_LIMIT`
1144+ * (`= 100` at `:29`), with `limit` on that component's own props interface
1145+ * (`:129`) and on no published schema at all;
1146+ * - `ObjectGallery.tsx` sends no `$top` and reads no ceiling at all — the
1147+ * unbounded fetch objectui#7390 is ruled to close by reading this key.
1148+ *
1149+ * Consumer-local author-settable keys the protocol never declared are the
1150+ * divergence the contract-first directive forbids, so the knob enters the
1151+ * protocol first and the three spellings come under one declaration (the
1152+ * director seat's amendment of 2026-09-10T11:0xZ on objectui#7390, on the
1153+ * maintainer's principle 「我们的项目以objectstack 协议为准,文档应该以实际实现
1154+ * 为准。协议不正确的应该先修改协议。」).
1155+ *
1156+ * ## Why on the per-view config blocks, and not as a member of the list view
1157+ *
1158+ * The alternative shape — one row ceiling on {@link ListViewShapeSchema}
1159+ * itself — is rejected on three properties of this tree:
1160+ *
1161+ * 1. The base shape ALREADY carries the row-bounding knob every view type
1162+ * reaches: `pagination.pageSize` ({@link PaginationConfigSchema}, default
1163+ * 25). A second base-level row key would leave one view with two
1164+ * base-level row bounds and no declared precedence between them — and the
1165+ * `virtualScroll` tombstone at the bottom of this same shape prescribes
1166+ * `pagination` for exactly that question.
1167+ * 2. A base member is reachable from EVERY `type`, the non-grid four
1168+ * (gantt / calendar / map / tree) included. Their ceiling is a platform
1169+ * constant the renderer owns (objectui#7210) and this card does not touch
1170+ * them, so a base member would publish an authorable ceiling on four view
1171+ * kinds no renderer reads — declared-but-unenforced on the day it lands.
1172+ * 3. The per-kind block is what actually REACHES the renderer: objectui's
1173+ * `ListView` merges `schema.<kind>` into the generated node — its kanban
1174+ * branch spreads the rest of the block flat onto `object-kanban`, so
1175+ * `kanban.limit` lands exactly where `schema.limit` is read — while a
1176+ * base-level key is forwarded into no per-kind node at all.
1177+ *
1178+ * The NAME is `limit` for the same reason: it is the name the consumer already
1179+ * reads, so this declaration absorbs the two consumer-local keys instead of
1180+ * buying a second divergence spelled differently.
1181+ *
1182+ * ⚠️ NOT the kanban LANE's `limit`. objectui's node-level
1183+ * `ObjectKanbanLaneSchema.limit` is a WIP warning threshold that never reaches
1184+ * a query; no lane object exists on this face at all
1185+ * ({@link KanbanConfigSchema}'s `columns` is a list of card FIELD names), so
1186+ * the two cannot be confused here.
1187+ */
1188+ export const DEFAULT_VIEW_ROW_LIMIT = 100 ;
1189+
1190+ /** What a page-shaped view's ceiling bounds, per view type. */
1191+ const ROW_LIMIT_SUBJECT = {
1192+ gallery : 'cards the gallery fetches and draws' ,
1193+ kanban : 'records the board fetches across all its lanes' ,
1194+ timeline : 'rows the timeline fetches onto its rail' ,
1195+ } as const ;
1196+
1197+ /** The three view configs that cap by AUTHOR choice (not by platform ceiling). */
1198+ type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT ;
1199+
1200+ /**
1201+ * The `limit` declaration for one page-shaped view config.
1202+ *
1203+ * The default is APPLIED, not merely described: a `.describe()` naming a
1204+ * default the schema does not apply is a second contract that nothing
1205+ * enforces, and the two drift the first time either is edited. The agreement
1206+ * is pinned from both sides in `view.test.ts` (#17393) — the parsed default is
1207+ * compared against the number the describe text states.
1208+ *
1209+ * ⛔ The truncation signal is the renderer's half and cannot be enforced from
1210+ * here; it is stated in the describe because a bounded-and-silent view reads
1211+ * as complete, which is worse than the unbounded-and-silent one this key
1212+ * replaces — the author needs to know the cap is visible, and the renderer
1213+ * author needs to know it is owed.
1214+ */
1215+ const rowLimitKey = ( view : RowLimitView ) =>
1216+ z . number ( ) . int ( ) . positive ( ) . default ( DEFAULT_VIEW_ROW_LIMIT ) . describe (
1217+ `Row ceiling — the most ${ ROW_LIMIT_SUBJECT [ view ] } , sent as the query \`$top\`; default `
1218+ + `${ DEFAULT_VIEW_ROW_LIMIT } when the key is absent. When the ceiling APPLIES (the filtered `
1219+ + 'set is larger than it), the renderer must show a visible truncation signal saying what is '
1220+ + 'on screen is not the whole set — a bounded view that looks complete is worse than an '
1221+ + 'unbounded one.' ,
1222+ ) ;
1223+
11311224/**
11321225 * Gallery View Configuration (Airtable-style)
11331226 * Configures card layout for gallery/card views.
@@ -1141,6 +1234,7 @@ export const GalleryConfigSchema = lazySchema(() => strictObject({
11411234 cardSize : z . enum ( [ 'small' , 'medium' , 'large' ] ) . default ( 'medium' ) . describe ( 'Card size in gallery view' ) ,
11421235 titleField : z . string ( ) . optional ( ) . describe ( 'Field to display as card title' ) ,
11431236 visibleFields : z . array ( z . string ( ) ) . optional ( ) . describe ( 'Fields to display on card body' ) ,
1237+ limit : rowLimitKey ( 'gallery' ) ,
11441238} ) . describe ( 'Gallery/card view configuration' ) ) ;
11451239
11461240/**
@@ -1163,6 +1257,7 @@ export const TimelineConfigSchema = lazySchema(() => strictObject({
11631257 ) ,
11641258 colorField : z . string ( ) . optional ( ) . describe ( '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' ) ,
11651259 scale : z . enum ( [ 'hour' , 'day' , 'week' , 'month' , 'quarter' , 'year' ] ) . default ( 'week' ) . describe ( 'Default timeline scale' ) ,
1260+ limit : rowLimitKey ( 'timeline' ) ,
11661261} ) . describe ( 'Timeline view configuration' ) ) ;
11671262
11681263/**
@@ -1466,6 +1561,7 @@ export const KanbanConfigSchema = lazySchema(() => strictObject({
14661561 */
14671562 titleField : z . string ( ) . optional ( ) . describe ( 'Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain)' ) ,
14681563 columns : z . array ( z . string ( ) ) . describe ( 'Fields to show on cards' ) ,
1564+ limit : rowLimitKey ( 'kanban' ) ,
14691565} ) ) ;
14701566
14711567/**
0 commit comments