From 8b2c2bb558e2fc3f6377da0553775c0142dffe4c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 00:40:41 +0000 Subject: [PATCH 1/3] feat(spec)!: object-grid exportOptions takes the list view's export options object by identity (#21229) The page-component row was z.unknown(), so a bare ['csv'] passed every door and ObjectGrid exported its csv/json default. The row now takes ListViewExportOptionsSchema - the strict five-member object, moved verbatim with its format enum and 'pdf' prescription into the non-barrel ui/list-view-export-options.ts (VIEW_HISTORY into ui/view-history.ts) - by identity, not the list view's lifting union. A bare array is refused with the object form named. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- ...ct-grid-export-options-members.pin.test.ts | 185 ++++++++---------- packages/spec/src/ui/component.zod.ts | 28 ++- .../spec/src/ui/list-view-export-options.ts | 126 ++++++++++++ packages/spec/src/ui/view-history.ts | 19 ++ packages/spec/src/ui/view.zod.ts | 81 ++------ 5 files changed, 268 insertions(+), 171 deletions(-) create mode 100644 packages/spec/src/ui/list-view-export-options.ts create mode 100644 packages/spec/src/ui/view-history.ts diff --git a/packages/spec/src/ui/component-object-grid-export-options-members.pin.test.ts b/packages/spec/src/ui/component-object-grid-export-options-members.pin.test.ts index eb2122414e0..f2e7a09805e 100644 --- a/packages/spec/src/ui/component-object-grid-export-options-members.pin.test.ts +++ b/packages/spec/src/ui/component-object-grid-export-options-members.pin.test.ts @@ -1,127 +1,110 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#17166] `ComponentPropsMap['object-grid'].exportOptions`'s `.describe()` must - * name every member the renderer reads — because here the prose IS the shape. + * [#21229] `ComponentPropsMap['object-grid'].exportOptions` is the list view's + * export options OBJECT, by identity — and judged like it. * - * ## Why this key is different from its neighbours + * ## What this pin replaced * - * The entry is `z.unknown()`. Nothing is parsed, nothing is refused, nothing is - * stripped: an author who writes a member that does not exist gets no error and - * no effect, and an author who omits one that does exist has no way to discover - * it. So the `.describe()` string is not a SUMMARY of an enforced shape — it is - * the entire account of the shape that exists at this position, and it projects - * straight into `content/docs/references/ui/component.mdx`, which is what an - * author (or a generating model, ADR-0033) reads. + * Until #21229 the key was `z.unknown()`, and this file (#17166) held its + * `.describe()` to naming every member `ListViewExportOptionsSchema` declares, + * because the prose was then the whole account of the shape. Its last test + * asserted the key was still unvalidated, so that giving it a real shape would + * red here and be decided deliberately. #21229 is that decision (triage ruling, + * not overturned): the row takes the five-member strict object by identity, so + * the members are no longer prose to keep in step — they are the schema. * - * Until #17166 that string named two members, `formats` and `streaming`, of the - * five the only renderer reads. + * ## Why identity, and why not the union * - * ## What was measured, and on which tree + * `ListViewSchema.exportOptions` is a two-arm union: the legacy bare format + * array, which LIFTS to `{ formats }` at parse, and the object. objectui's + * `ObjectGrid` reads `exportOptions.formats` and lifts nothing, so on this row a + * bare array was dropped for the csv/json default. A legacy spelling does not + * spread to a new surface: the row takes the object arm, the same instance the + * union holds, and a bare array is refused with the object form named. * - * Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` - * — objectui `packages/plugin-grid/src/ObjectGrid.tsx`, through the - * `schema.exportOptions` expression and the `exportConfig` local bound to it, - * with objectui's own scanner (`ObjectGrid.exportOptionsKeys.test.ts`, whose - * comment/string stripping is what keeps a prose mention of a key from being - * counted as a read): - * - * formats 2 · streaming 2 · maxRecords 1 · includeHeaders 1 · fileNamePrefix 1 - * - * and an absent-name control (`zzzNotAMember`) reading 0 on the same instrument, - * which is what makes those five counts readings rather than a matcher that - * matches anything. ⚠️ Those counts are a dated observation and belong to that - * tree; this pin does NOT re-derive them, and ⛔ must not be read as asserting - * them today. - * - * ## Why the list is DERIVED here and not restated - * - * A restated list is a third copy of the contract, and the copy is what drifts — - * which is the whole defect this file closes. So the expected member list is - * read from `ListViewSchema.exportOptions`'s object branch - * (`ListViewExportOptionsSchema`), the spec's OWN five-key declaration of this - * same authoring block, itself derived from that same read set at #8010. Both - * spellings — the page-component `object-grid` props and the list view's - * `exportOptions` — reach one renderer, so the two surfaces describe one block. - * - * ⇒ Narrowing or widening the declared block reds this pin instead of leaving - * the `z.unknown()` prose quietly behind, which is the direction of rot that has - * no other guard: the declared side has parse failures, this side has nothing. - * - * ⛔ This pin does NOT ask the key to stop being `z.unknown()`. Giving it a real - * shape is an accept-set change with its own review requirements; the last test - * below records that it is unvalidated TODAY, so that change reds here and is - * made deliberately rather than by accident. + * The expected instance is READ from the union rather than imported by name, so + * the pin holds the row to whatever object the list view actually declares — a + * second declaration of the five members on either side reds here. */ import { describe, it, expect } from 'vitest'; import { ComponentPropsMap } from './component.zod'; import { ListViewSchema } from './view.zod'; -/** - * The member names enumerated inside the first `({ … })` group of a describe - * string, e.g. `Export config ({ formats, streaming })` -> `['formats', 'streaming']`. - * - * Deliberately anchored to the parenthesised group rather than "any identifier - * in the sentence": the prose around it names `z.unknown()` and - * `ListViewSchema.exportOptions`, and a scan that read those as members would - * pass for the wrong reason. The self-test below is what proves the anchor - * discriminates instead of matching anything. - */ -function describedMembers(description: string): string[] { - const group = /\(\{([^}]*)\}\)/.exec(description); - if (!group) return []; - return group[1]!.split(',').map((k) => k.trim()).filter(Boolean); -} +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[] }; +type Parsed = { success: boolean; data?: Record; error?: { issues: Issue[] } }; +type Schema = { safeParse(v: unknown): Parsed; unwrap?: () => unknown; options?: unknown[]; shape?: unknown }; + +const gridProps = ComponentPropsMap['object-grid'] as unknown as Schema & { shape: Record }; -/** The spec's own declaration of this block: the object branch of the list view's union. */ -function declaredMembers(): string[] { - const optional = (ListViewSchema as unknown as { shape: Record }) - .shape.exportOptions as { unwrap(): { options: Array<{ shape?: Record }> } }; - const branches = optional.unwrap().options; - const objectBranch = branches.find((b) => b.shape !== undefined); - return objectBranch === undefined ? [] : Object.keys(objectBranch.shape!); +/** The list view's own object arm: the one union member with a `shape`. */ +function listViewObjectArm(): unknown { + const optional = (ListViewSchema as unknown as { shape: Record }).shape.exportOptions; + const union = optional.unwrap!() as Schema; + return (union.options as Schema[]).find((o) => o.shape !== undefined); } -const gridProps = ComponentPropsMap['object-grid'] as unknown as { - shape: Record; - safeParse(v: unknown): { success: boolean }; -}; -const description = gridProps.shape.exportOptions?.description ?? ''; +const grid = (exportOptions: unknown): Parsed => gridProps.safeParse({ objectName: 'account', exportOptions }); +const listView = (exportOptions: unknown): Parsed => + (ListViewSchema as unknown as Schema).safeParse({ type: 'grid', columns: ['name'], exportOptions }); -describe('object-grid `exportOptions` — the describe names every declared member (#17166)', () => { - it('the parser discriminates: it reads a member group and does not invent one', () => { - // Lit control — a planted group is read back exactly. - expect(describedMembers('Export config ({ alpha, beta })')).toEqual(['alpha', 'beta']); - // Dark control — a sentence with no member group yields nothing, so a - // green equality below can never come from a matcher that matches anything. - expect(describedMembers('Export config, unvalidated.')).toEqual([]); - // And a name absent from the group is not produced by prose that mentions it. - expect(describedMembers('Export config ({ alpha }) — zzzNotAMember is not a member')) - .toEqual(['alpha']); +describe('object-grid `exportOptions` — the list view\'s export options object, by identity (#21229)', () => { + it('is the very instance the list view\'s union holds as its object arm — not the union', () => { + const arm = listViewObjectArm(); + // Non-vacuity: an identity check against `undefined` would pass for the + // worst reason if the union ever lost its object arm. + expect(arm).toBeDefined(); + const inner = gridProps.shape.exportOptions.unwrap!() as Schema; + expect(inner).toBe(arm); + // Not the union: a union carries `options`, the object does not. + expect(inner.options).toBeUndefined(); }); - it('scans something: both sides are non-empty and the authority is the five-key block', () => { - // Non-vacuity floor. The assertion below is an equality, and an equality - // between two empty lists passes for the worst possible reason. - expect(description).not.toBe(''); - expect(describedMembers(description).length).toBeGreaterThanOrEqual(5); - expect(declaredMembers().length).toBeGreaterThanOrEqual(5); - expect(declaredMembers()).toContain('formats'); + it('refuses a bare format array at `exportOptions`, naming the object form', () => { + const r = grid(['csv']); + expect(r.success).toBe(false); + const issue = r.error!.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['exportOptions']); + expect(issue.message).toContain("{ formats: ['csv', 'xlsx'] }"); + // The control: the same array on the list view is still the legacy spelling + // and still lifts, so the refusal belongs to this row, not to the block. + const lifted = listView(['csv']); + expect(lifted.success).toBe(true); + expect(lifted.data!.exportOptions).toStrictEqual({ formats: ['csv'] }); }); - it('names exactly the members `ListViewExportOptionsSchema` declares', () => { - // Named rather than counted: a failure must say WHICH member the prose is - // short of, because the fix is to name it — the reader gets nothing else. - expect([...describedMembers(description)].sort()).toEqual([...declaredMembers()].sort()); + it('accepts the object form, every member, unchanged', () => { + const all = { formats: ['csv', 'xlsx', 'json'], maxRecords: 500, includeHeaders: false, fileNamePrefix: 'accounts', streaming: false }; + const r = grid(all); + expect(r.success).toBe(true); + expect(r.data!.exportOptions).toStrictEqual(all); + expect(grid({}).success).toBe(true); + expect(gridProps.safeParse({ objectName: 'account' }).success).toBe(true); + }); + + it('refuses a format outside the enum at its own index, and `pdf` with its retirement text', () => { + const r = grid({ formats: ['csv', 'xml'] }); + expect(r.success).toBe(false); + expect(r.error!.issues[0]!.code).toBe('invalid_value'); + expect(r.error!.issues[0]!.path).toEqual(['exportOptions', 'formats', 1]); + expect(r.error!.issues[0]!.message).not.toMatch(/was removed/); + + const pdf = grid({ formats: ['pdf'] }); + expect(pdf.success).toBe(false); + expect(pdf.error!.issues[0]!.path).toEqual(['exportOptions', 'formats', 0]); + expect(pdf.error!.issues[0]!.message).toMatch(/'pdf' was removed from `view\.exportOptions` formats/); }); - it('is still unvalidated, which is why the prose carries the whole account', () => { - // The premise of this file, asserted rather than assumed: an undeclared - // member is neither refused nor honoured here. If this ever goes red the - // key grew an accept set and the describe's "Unvalidated here" sentence — - // and this pin's reason to exist — need re-deciding, deliberately. - expect(gridProps.safeParse({ exportOptions: { zzzNotAMember: 1 } }).success).toBe(true); - expect(description).toContain('Unvalidated here'); + it('refuses a key the block does not declare, with the block\'s own surface and rename', () => { + const r = grid({ formats: ['csv'], maxRecord: 10 }); + expect(r.success).toBe(false); + const issue = r.error!.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(['exportOptions']); + expect(issue.keys).toEqual(['maxRecord']); + expect(issue.message).toMatch(/this export options block/); + expect(issue.message).toMatch(/maxRecords/); }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 35459aca218..5eb2a657697 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -27,6 +27,11 @@ import { // component `ListView` does, so one declaration judges both doors. EmptyStateSchema, } from './view.zod'; +// [#21229] `object-grid.exportOptions` is the list view's export options OBJECT, +// by identity — not the list view's union, whose legacy bare-array arm lifts to +// `{ formats }` while the grid reads `.formats` and lifts nothing. Declared +// outside the `ui` barrel so the two carriers share it without publishing it. +import { ListViewExportOptionsSchema } from './list-view-export-options'; import { InlineActionSchema, ActionLocationSchema } from './action.zod'; import { ACTION_TARGET_ALIASES } from './action-target-aliases'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; @@ -4020,8 +4025,27 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ reorderableColumns: z.boolean().optional().describe('Allow column drag-reorder'), frozenColumns: z.number().optional().describe('How many leading columns stay frozen (default 1)'), showColumnTypeIcons: z.boolean().optional().describe('Show field-type icons in column headers'), - exportOptions: z.unknown().optional() - .describe('Export config ({ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }). Unvalidated here (`z.unknown()`), so this list is the whole account of the shape; `ListViewSchema.exportOptions` declares the same five members with their per-member contract'), + /** + * [#21229] The list view's export options object, BY IDENTITY — + * `ListViewExportOptionsSchema`, the strict five-member block + * (`./list-view-export-options.ts`) — and ⛔ not `ListViewSchema.exportOptions` + * itself: that is a union whose legacy bare-array arm LIFTS to `{ formats }`, + * and `ObjectGrid` reads `exportOptions.formats` and lifts nothing, so the + * legacy spelling does not spread to this surface (triage ruling on #21229). + * + * Until #21229 this was `z.unknown()`: a bare `['csv']` passed every door and + * the grid exported its csv/json default instead, with the author's list + * dropped and nothing reported. A bare array is now refused here with the + * object form named, and so is a format outside the enum (`'pdf'` with its + * own retirement text) and a key the block does not declare. + * + * No conversion lifts the array: it never worked on this surface. Read by the + * component-props gate (advisory); a stored page still saves and loads, + * because page-component `properties` is not parsed on the metadata save or + * load path (the `ui-object-grid-export-options-closed` semantic entry). + */ + exportOptions: ListViewExportOptionsSchema.optional() + .describe("Export config — the object `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, the same block a list view's `exportOptions` declares, with `formats` drawn from `csv`, `xlsx` and `json`. A bare format array is refused: the grid reads `exportOptions.formats`, so write `{ formats: ['csv', 'xlsx'] }`"), operations: z.unknown().optional().describe('Operation toggles ({ export: false, … })'), /** * Data source binding — `ViewDataSchema`, the #5090-pinned authority the diff --git a/packages/spec/src/ui/list-view-export-options.ts b/packages/spec/src/ui/list-view-export-options.ts new file mode 100644 index 00000000000..044319e8dd0 --- /dev/null +++ b/packages/spec/src/ui/list-view-export-options.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The EXPORT OPTIONS block, declared once (#8010, #21229): the format enum, the + * retired-`'pdf'` prescription, and the strict five-member object. + * + * One block reaches one renderer — objectui's `ObjectGrid`, which reads + * `exportOptions.formats`, `.maxRecords`, `.includeHeaders`, `.fileNamePrefix` + * and `.streaming` — from two carriers: + * + * - a list view's `exportOptions` (`./view.zod.ts`), the union of this object + * and the legacy bare format array, which LIFTS to `{ formats }` at parse; + * - an `object-grid` page component's `properties.exportOptions` + * (`./component.zod.ts`), which takes this object BY IDENTITY and not the + * union: the grid reads `.formats` and lifts nothing, so the legacy spelling + * does not spread to the page component (#21229, triage ruling). Until then + * that row was `z.unknown()`, and a bare array passed every door and was + * dropped for the csv/json default. + * + * A module of its own, and outside the `ui` barrel, so the two carriers share + * one declaration without it becoming published API (the + * `./analytics-carrier-filter.ts` precedent). It moved here verbatim from + * `./view.zod.ts` when the page component became its second carrier, with one + * addition: the object's own answer to a bare array (see + * {@link LIST_VIEW_EXPORT_OPTIONS_ARRAY_FORM}). The list view's published JSON + * Schema did not move with it. + */ + +import { z } from 'zod'; +import { closedObject, strictObjectError } from '../shared/strict-object'; +import { VIEW_HISTORY } from './view-history'; + +// `'pdf'` retirement prescription (#8010). Declared with `//` on purpose — the +// hook-body precedent's placement note applies here too: build-docs takes a +// file's first JSDoc per exported symbol, and this constant needs no doc page. +export const LIST_VIEW_EXPORT_PDF_RETIRED = + "'pdf' was removed from `view.exportOptions` formats in @objectstack/spec 17.0.0 " + + '(PDF export itself was declined as NOT PLANNED) — no renderer has ever produced a PDF ' + + 'export: ObjectGrid dropped the declared format from the export menu with only a runtime ' + + "console.warn, so authoring it was a parse-clean no-op. Delete the value; the surviving " + + "formats are 'csv', 'xlsx' and 'json'. " + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.'; + +/** + * Export formats the platform actually delivers (#8010): `csv`/`json` on both + * export paths, `xlsx` on the server stream only. + * + * `'pdf'` was REMOVED in 17 (#8010): PDF export was declined platform-side + * (#1301 NOT_PLANNED), so the enum member was a declared-but-unrenderable + * format whose only failure signal was a browser console line. This is an + * enum-VALUE narrowing, so there is no `retiredKey()` tombstone to hang the + * prescription on — the enum's own error map carries it + * ({@link LIST_VIEW_EXPORT_PDF_RETIRED}), keyed on `issue.input` so that only + * the value which used to be legal gets the "was removed" message (the + * `HookBodyCapability` / `object.managedBy: 'system'` precedent). + */ +export const ListViewExportFormatSchema = z.enum(['csv', 'xlsx', 'json'], { + error: (issue) => (issue.input === 'pdf' ? LIST_VIEW_EXPORT_PDF_RETIRED : undefined), +}); + +/** + * [#21229] The object's own answer to a bare format array — the one + * `invalid_type` the block names, carried on the object's error map because + * that is the only map a type failure at this position consults (a map on an + * enclosing row or wrapper is never reached, and an object-level refinement + * never runs once a property has failed its type). + * + * Worded to be true on BOTH carriers, because both read this one declaration. + * On an `object-grid` it is the whole refusal of `exportOptions: ['csv']`. On a + * list view a bare array is the legacy spelling and the union's other arm + * lifts it, so this text appears only nested under a union failure whose array + * arm failed too — where naming the object form is still the right advice. + */ +const LIST_VIEW_EXPORT_OPTIONS_ARRAY_FORM = + 'Expected the export options object `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, ' + + 'streaming? }`, received a bare array. Put the format list in `formats`: ' + + "`{ formats: ['csv', 'xlsx'] }`."; + +const LIST_VIEW_EXPORT_OPTIONS_SHAPE = { + formats: z.array(ListViewExportFormatSchema).optional() + .describe("Formats offered in the export menu (default: ['csv', 'json']). XLSX is delivered by the server stream only."), + maxRecords: z.number().int().nonnegative().optional() + .describe('Maximum number of records to export; 0 or absent = unlimited'), + includeHeaders: z.boolean().optional() + .describe('Include column headers in the exported file (default true)'), + fileNamePrefix: z.string().optional() + .describe('Download file name prefix — replaces the object label and suppresses the view label in the generated file name'), + streaming: z.boolean().optional() + .describe('Set false to force the client-side export path (csv/json only) instead of the server stream'), +}; + +/** + * The unknown-key map `strictObject()` would build for this block. Spelled out + * rather than through `strictObject()` only so the array answer above can sit + * in front of it; the surface, the history and the registered declaration are + * the ones the block has always had. + */ +const exportOptionsUnknownKeyError = strictObjectError({ + surface: 'this export options block', + history: VIEW_HISTORY, +}, LIST_VIEW_EXPORT_OPTIONS_SHAPE); + +/** + * Object form of `view.exportOptions` (#8010, maintainer ruling 2026-08-12 — + * option A). The declared key set is exactly what the only renderer reads, + * measured on objectui `origin/main@878140b` (`ObjectGrid.tsx:1596–1642`): + * `formats`, `maxRecords`, `includeHeaders`, `fileNamePrefix`, and the + * previously UNDECLARED `streaming` opt-out — declared here so no + * undeclared-but-read key survives the fix. Declaring anything more would be + * capability surface with no reader; declaring less recreates the defect. + * + * [#21229] Also `ComponentPropsMap['object-grid'].exportOptions`, by identity. + * Closed exactly as `strictObject()` closes a shape; the `prime` handle is + * forwarded so the unknown-key map is still built on the refusal path + * (`closedObject`'s contract, #19581). + */ +export const ListViewExportOptionsSchema = closedObject(z.object(LIST_VIEW_EXPORT_OPTIONS_SHAPE, { + error: Object.assign( + (issue: Parameters[0]) => ( + issue.code === 'invalid_type' && Array.isArray(issue.input) + ? LIST_VIEW_EXPORT_OPTIONS_ARRAY_FORM + : exportOptionsUnknownKeyError(issue) + ), + { prime: () => (exportOptionsUnknownKeyError as { prime?: () => void }).prime?.() }, + ), +}).strict()); diff --git a/packages/spec/src/ui/view-history.ts b/packages/spec/src/ui/view-history.ts new file mode 100644 index 00000000000..9493fdd3e0d --- /dev/null +++ b/packages/spec/src/ui/view-history.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Shared history for the view face (#4001). + * + * Views are the surface an author iterates on visually, which is exactly why a + * dropped key hides here: the view still renders, just not the way it was + * described. `FormFieldBaseSchema` / `FormSectionSchema` / `FormButtonConfig` + * were closed years ago (ADR-0089 D3a); the other forty-odd shapes in + * `./view.zod.ts` kept the posture those three were rescued from. + * + * A module of its own, and outside the `ui` barrel, so a view-face shape that + * moved out of `./view.zod.ts` to be shared — `./list-view-export-options.ts` + * (#21229) — keeps the sentence its refusals have always carried, without the + * constant becoming published API. + */ +export const VIEW_HISTORY = + 'Until these shapes were closed an unknown key was dropped silently — the view still ' + + 'rendered, without whatever the key was meant to configure.'; diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index f4ef1b615ae..f0bf84e68f8 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -84,19 +84,17 @@ import { BulkActionDefSchema } from './bulk-action.zod'; */ import { HttpMethodSubsetSchema, HttpRequestSchema } from '../shared/http.zod'; import { lazySchema } from '../shared/lazy-schema'; - -/** - * Shared history for this file (#4001). - * - * Views are the surface an author iterates on visually, which is exactly why a - * dropped key hides here: the view still renders, just not the way it was - * described. `FormFieldBaseSchema` / `FormSectionSchema` / `FormButtonConfig` - * were closed years ago (ADR-0089 D3a); the other forty-odd shapes in this file - * kept the posture those three were rescued from. - */ -const VIEW_HISTORY = - 'Until these shapes were closed an unknown key was dropped silently — the view still ' - + 'rendered, without whatever the key was meant to configure.'; +// This file's shared refusal history (#4001), declared outside it since #21229 +// so the export options block it shares with the `object-grid` row keeps it. +import { VIEW_HISTORY } from './view-history'; +// [#21229] The export options block — its format enum, the retired-`'pdf'` +// prescription and the five-member object — declared once, outside the `ui` +// barrel: the `object-grid` page-component row takes the object by identity. +import { + LIST_VIEW_EXPORT_PDF_RETIRED, + ListViewExportFormatSchema, + ListViewExportOptionsSchema, +} from './list-view-export-options'; export { HttpMethodSubsetSchema, HttpRequestSchema }; @@ -2240,7 +2238,7 @@ export const NavigationModeSchema = lazySchema(() => z.enum([ // #126 item 4 (maintainer ruling 2026-09-13, option B: retire). Declared with // `//` on purpose: build-docs takes a file's first JSDoc per exported symbol, // and this constant needs no doc page (the `LIST_VIEW_EXPORT_PDF_RETIRED` -// placement note in this same file). +// placement note, in `./list-view-export-options.ts` since #21229). // // The key promised "the form view to use for details" and nothing from spec to // console ever resolved a view BY NAME. Its one read in the shipped console put @@ -2306,59 +2304,6 @@ export const NavigationConfigSchema = lazySchema(() => strictObject({ width: z.union([z.string(), z.number()]).optional().describe('[DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the `size` bucket.'), })); -// `'pdf'` retirement prescription (#8010). Declared with `//` on purpose — the -// hook-body precedent's placement note applies here too: build-docs takes a -// file's first JSDoc per exported symbol, and this constant needs no doc page. -const LIST_VIEW_EXPORT_PDF_RETIRED = - "'pdf' was removed from `view.exportOptions` formats in @objectstack/spec 17.0.0 " - + '(PDF export itself was declined as NOT PLANNED) — no renderer has ever produced a PDF ' - + 'export: ObjectGrid dropped the declared format from the export menu with only a runtime ' - + "console.warn, so authoring it was a parse-clean no-op. Delete the value; the surviving " - + "formats are 'csv', 'xlsx' and 'json'. " - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.'; - -/** - * Export formats the platform actually delivers (#8010): `csv`/`json` on both - * export paths, `xlsx` on the server stream only. - * - * `'pdf'` was REMOVED in 17 (#8010): PDF export was declined platform-side - * (#1301 NOT_PLANNED), so the enum member was a declared-but-unrenderable - * format whose only failure signal was a browser console line. This is an - * enum-VALUE narrowing, so there is no `retiredKey()` tombstone to hang the - * prescription on — the enum's own error map carries it - * ({@link LIST_VIEW_EXPORT_PDF_RETIRED}), keyed on `issue.input` so that only - * the value which used to be legal gets the "was removed" message (the - * `HookBodyCapability` / `object.managedBy: 'system'` precedent). - */ -const ListViewExportFormatSchema = z.enum(['csv', 'xlsx', 'json'], { - error: (issue) => (issue.input === 'pdf' ? LIST_VIEW_EXPORT_PDF_RETIRED : undefined), -}); - -/** - * Object form of `view.exportOptions` (#8010, maintainer ruling 2026-08-12 — - * option A). The declared key set is exactly what the only renderer reads, - * measured on objectui `origin/main@878140b` (`ObjectGrid.tsx:1596–1642`): - * `formats`, `maxRecords`, `includeHeaders`, `fileNamePrefix`, and the - * previously UNDECLARED `streaming` opt-out — declared here so no - * undeclared-but-read key survives the fix. Declaring anything more would be - * capability surface with no reader; declaring less recreates the defect. - */ -const ListViewExportOptionsSchema = strictObject({ - surface: 'this export options block', - history: VIEW_HISTORY, -}, { - formats: z.array(ListViewExportFormatSchema).optional() - .describe("Formats offered in the export menu (default: ['csv', 'json']). XLSX is delivered by the server stream only."), - maxRecords: z.number().int().nonnegative().optional() - .describe('Maximum number of records to export; 0 or absent = unlimited'), - includeHeaders: z.boolean().optional() - .describe('Include column headers in the exported file (default true)'), - fileNamePrefix: z.string().optional() - .describe('Download file name prefix — replaces the object label and suppresses the view label in the generated file name'), - streaming: z.boolean().optional() - .describe('Set false to force the client-side export path (csv/json only) instead of the server stream'), -}); - /** * Loud top-level refusal for a retired `'pdf'` anywhere in `exportOptions` * (#8010). Without this, the prescription raised inside a union BRANCH is @@ -4147,7 +4092,7 @@ function refineFormFieldFeaturesRoot( // Form-view `layout` retired-value prescriptions (#20221, ADR-0049 // enforce-or-remove). Declared with `//` on purpose — the -// `LIST_VIEW_EXPORT_PDF_RETIRED` placement note above applies here too. An +// `LIST_VIEW_EXPORT_PDF_RETIRED` placement note applies here too. An // enum-VALUE narrowing: the enum's own error map carries the prescription, // keyed on `issue.input`, so only a value which used to be legal gets the "was // removed" message. The twin of `OBJECT_FORM_LAYOUT_RETIRED` From af3b3638f7f4089822442965194786613336aca7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 00:44:01 +0000 Subject: [PATCH 2/3] feat(spec): D3 entry ui-object-grid-export-options-closed, step-18 rationale and changeset (#21229) Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- ...21229-object-grid-export-options-closed.md | 36 ++++++++++ ...18.ui-object-grid-export-options-closed.ts | 54 +++++++++++++++ packages/spec/src/migrations/registry.ts | 67 +++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 .changeset/21229-object-grid-export-options-closed.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-grid-export-options-closed.ts diff --git a/.changeset/21229-object-grid-export-options-closed.md b/.changeset/21229-object-grid-export-options-closed.md new file mode 100644 index 00000000000..5892ffa7d96 --- /dev/null +++ b/.changeset/21229-object-grid-export-options-closed.md @@ -0,0 +1,36 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an `object-grid` page block's `exportOptions` is the list view's export options object, and a bare format array is refused (#21229) + +Clause-②: yes (narrowing) + + + +**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the row: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path. + +**`@objectstack/spec`** + +- **`ComponentPropsMap['object-grid'].exportOptions`** was `z.unknown()`, so any value passed. The console's `ObjectGrid` reads `exportOptions.formats`, `.maxRecords`, `.includeHeaders`, `.fileNamePrefix` and `.streaming`, and lifts nothing: a bare format array — legal on a list view, which lifts it to `{ formats }` at parse — showed the export menu with its csv/json default and dropped the author's list without a report. The row now takes the list view's own five-member export options object, by identity and not the list view's union, so the legacy spelling does not spread to the grid: + - a bare array is refused with the object form named (`{ formats: ['csv', 'xlsx'] }`); + - a format outside `csv` / `xlsx` / `json` is refused at its index, and `pdf` keeps its retirement text; + - a key the object does not declare is named, with the rename a near-miss gets (`maxRecord` → `maxRecords`); + - `null` and other non-object values are refused. +- **`ObjectGridProps['exportOptions']`** (and `ObjectGridPropsParsed`) is the object type `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }` instead of `unknown`. +- The list view's `exportOptions` accepts and lifts exactly what it did. One message changed there, nested only: when a bare array also fails the array arm (a format outside the enum), the object arm's branch of the union now names the object form instead of zod's `expected object, received array`. + +## FROM → TO + +| you wrote on an `object-grid` | write instead | +|:--|:--| +| `exportOptions: ['csv', 'xlsx']` | `exportOptions: { formats: ['csv', 'xlsx'] }` — the grid now offers exactly those formats; write `{}` to keep the csv/json default it has been offering | +| `exportOptions: { formats: ['csv', 'pdf'] }` | `exportOptions: { formats: ['csv'] }` | +| `exportOptions: { formats: ['csv'], maxRecord: 100 }` | `exportOptions: { formats: ['csv'], maxRecords: 100 }` | +| `exportOptions: null` | omit `exportOptions` | + +The one-line fix: write `exportOptions` on an `object-grid` as the object `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, with `formats` drawn from `csv`, `xlsx` and `json`. + +## Who is affected, measured + +On `origin/main` `f148852752`: zero `object-grid` blocks authoring `exportOptions` in the examples, the package fixtures, the documentation and the published skills, against ten authored `object-grid` blocks through the same census (nine in TypeScript, one in a YAML documentation example) and four list-view `exportOptions` authorings as the key's control. No conversion is registered: nothing on the metadata load path refuses the shape, and a bare array has no rewrite that both keeps what the grid shows today and honours the author's list. Deployed metadata was not measured. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-export-options-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-export-options-closed.ts new file mode 100644 index 00000000000..db103d57424 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-export-options-closed.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21229 — an `object-grid` page block's `exportOptions` was `z.unknown()`, so a +// bare format array (the list view's legacy spelling, which the list view lifts +// to `{ formats }`) was accepted on the grid, whose renderer reads +// `exportOptions.formats` and lifts nothing. The row now takes the list view's +// five-member export options OBJECT by identity, not the list view's union. D3 +// only: page-component `properties` is not parsed on the metadata save or load +// path, so a stored page is never refused and there is no load-path refusal for +// a conversion to pre-empt; the bare array never worked here, and lifting it +// would change the export menu a deployed grid shows today; the authored census +// found nothing to respell. +export const entry: SemanticMigration = { + id: 'ui-object-grid-export-options-closed', + surface: 'page `object-grid` components — `properties.exportOptions` (which used to accept any value)', + replacement: 'the export options object a list view\'s `exportOptions` declares: `{ formats?, ' + + 'maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, with `formats` drawn from ' + + '`csv`, `xlsx` and `json`, `maxRecords` a non-negative integer, and `includeHeaders` / ' + + '`streaming` booleans. Where a bare format array was written, write `{ formats: [...] }` to ' + + 'offer the formats you listed — the grid will now offer exactly those — or `{}` to keep the ' + + 'csv/json default the grid has been offering. Delete `pdf` from `formats`, and any key the ' + + 'object does not declare; delete an `exportOptions: null` (it never enabled the menu).', + reason: 'The grid reads one export options block — `exportOptions.formats`, `.maxRecords`, ' + + '`.includeHeaders`, `.fileNamePrefix` and `.streaming` — the block a list view declares, ' + + 'but the page-component row declared the key `z.unknown()`, so any value passed the ' + + 'component-props gate. The trap was the list view\'s legacy spelling: a bare format array is ' + + 'legal on a list view, which lifts it to `{ formats }` at parse, and was accepted on the grid, ' + + 'which lifts nothing — the export menu appeared, offering the csv/json default, and the ' + + 'author\'s list was dropped without a report. The row now takes the list view\'s export ' + + 'options object itself rather than its union, so a legacy spelling does not spread to a ' + + 'surface that never read it: a bare array is refused with the object form named, a format ' + + 'outside the enum is refused at its index (`pdf` with its retirement text), and a key the ' + + 'object does not declare is named. It is read where every page component\'s props are: the ' + + 'component-props gate reports these as an advisory `component-props-invalid` / ' + + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and ' + + '`objectstack lint`, and a stored page still saves and loads, because a page component\'s ' + + '`properties` is not parsed on the metadata save or load path. No conversion is registered: ' + + 'nothing on the load path refuses the shape; a bare array has no rewrite that both keeps ' + + 'what the grid shows today and honours what the author wrote, which is the judgment this ' + + 'entry leaves to the upgrader; and the authored census found nothing to respell. Population ' + + 'measured at the change, on origin/main f148852752: zero `object-grid` blocks authoring ' + + '`exportOptions` in the examples, the package fixtures, the documentation and the published ' + + 'skills, against ten authored `object-grid` blocks through the same matcher (nine in ' + + 'TypeScript, one in a YAML documentation example) and four list-view `exportOptions` ' + + 'authorings as the key\'s control. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-grid` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding on a ' + + '`properties.exportOptions` path. Every `exportOptions` on an `object-grid` is an object ' + + 'carrying only the five declared keys, with every `formats` entry `csv`, `xlsx` or `json`, ' + + 'and the grid\'s export menu offers the declared formats the active export path delivers ' + + '(`xlsx` on the server stream only).', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e35b44f130a..75234219865 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6008,6 +6008,23 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`columns` untouched) on `object-form` page components, on every form payload a view ' + 'carries, and on the assembled-manifest `viewItems` channel.', }, + { + id: 'ui-object-grid-export-options-closed', + order: 59, + text: + 'It also closes the export options of an `object-grid` page block (#21229). `exportOptions` ' + + 'was `z.unknown()`, so a bare format array — the list view\'s legacy spelling, which the list ' + + 'view lifts to `{ formats }` — was accepted on the grid, whose renderer reads ' + + '`exportOptions.formats` and lifts nothing: the export menu offered its csv/json default and ' + + 'the author\'s list was dropped. The row now takes the list view\'s five-member export ' + + 'options object by identity, not the list view\'s union, and refuses a bare array with the ' + + 'object form named, a format outside the enum and an undeclared key. Page-component ' + + '`properties` is read by the component-props gate, which reports these as advisory ' + + 'findings, and is not parsed on the metadata save or load path, so a stored page still saves ' + + 'and loads and no conversion is registered: the bare array never worked here, and lifting it ' + + 'would change the menu a deployed grid shows. The authored census found nothing to respell. ' + + 'Its D3 record is the semantic entry `ui-object-grid-export-options-closed`.', + }, { id: 'ui-object-master-detail-form-details-closed', order: 56, @@ -18698,6 +18715,56 @@ const step18: MigrationStep = { + 'group per value of that field, and a board that showed one swimlane shows one swimlane per value — ' + 'check that this is the grouping you meant.', }, + // #21229 — an `object-grid` page block's `exportOptions` was `z.unknown()`, so a + // bare format array (the list view's legacy spelling, which the list view lifts + // to `{ formats }`) was accepted on the grid, whose renderer reads + // `exportOptions.formats` and lifts nothing. The row now takes the list view's + // five-member export options OBJECT by identity, not the list view's union. D3 + // only: page-component `properties` is not parsed on the metadata save or load + // path, so a stored page is never refused and there is no load-path refusal for + // a conversion to pre-empt; the bare array never worked here, and lifting it + // would change the export menu a deployed grid shows today; the authored census + // found nothing to respell. + { + id: 'ui-object-grid-export-options-closed', + surface: 'page `object-grid` components — `properties.exportOptions` (which used to accept any value)', + replacement: 'the export options object a list view\'s `exportOptions` declares: `{ formats?, ' + + 'maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, with `formats` drawn from ' + + '`csv`, `xlsx` and `json`, `maxRecords` a non-negative integer, and `includeHeaders` / ' + + '`streaming` booleans. Where a bare format array was written, write `{ formats: [...] }` to ' + + 'offer the formats you listed — the grid will now offer exactly those — or `{}` to keep the ' + + 'csv/json default the grid has been offering. Delete `pdf` from `formats`, and any key the ' + + 'object does not declare; delete an `exportOptions: null` (it never enabled the menu).', + reason: 'The grid reads one export options block — `exportOptions.formats`, `.maxRecords`, ' + + '`.includeHeaders`, `.fileNamePrefix` and `.streaming` — the block a list view declares, ' + + 'but the page-component row declared the key `z.unknown()`, so any value passed the ' + + 'component-props gate. The trap was the list view\'s legacy spelling: a bare format array is ' + + 'legal on a list view, which lifts it to `{ formats }` at parse, and was accepted on the grid, ' + + 'which lifts nothing — the export menu appeared, offering the csv/json default, and the ' + + 'author\'s list was dropped without a report. The row now takes the list view\'s export ' + + 'options object itself rather than its union, so a legacy spelling does not spread to a ' + + 'surface that never read it: a bare array is refused with the object form named, a format ' + + 'outside the enum is refused at its index (`pdf` with its retirement text), and a key the ' + + 'object does not declare is named. It is read where every page component\'s props are: the ' + + 'component-props gate reports these as an advisory `component-props-invalid` / ' + + '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and ' + + '`objectstack lint`, and a stored page still saves and loads, because a page component\'s ' + + '`properties` is not parsed on the metadata save or load path. No conversion is registered: ' + + 'nothing on the load path refuses the shape; a bare array has no rewrite that both keeps ' + + 'what the grid shows today and honours what the author wrote, which is the judgment this ' + + 'entry leaves to the upgrader; and the authored census found nothing to respell. Population ' + + 'measured at the change, on origin/main f148852752: zero `object-grid` blocks authoring ' + + '`exportOptions` in the examples, the package fixtures, the documentation and the published ' + + 'skills, against ten authored `object-grid` blocks through the same matcher (nine in ' + + 'TypeScript, one in a YAML documentation example) and four list-view `exportOptions` ' + + 'authorings as the key\'s control. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-grid` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding on a ' + + '`properties.exportOptions` path. Every `exportOptions` on an `object-grid` is an object ' + + 'carrying only the five declared keys, with every `formats` entry `csv`, `xlsx` or `json`, ' + + 'and the grid\'s export menu offers the declared formats the active export path delivers ' + + '(`xlsx` on the server stream only).', + }, { id: 'ui-object-grid-page-size-positive-integer-refused', surface: '`object-grid` page-component page sizes ' From 39fef70e429e7d91b2404a4b0af89be21f0775dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 00:53:37 +0000 Subject: [PATCH 3/3] chore(spec): regenerate the component reference page and the ui strictness counts (#21229) Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 12 +++++++++++- .../ui.md | 12 ++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 7e9113fd048..a8c1d725f1a 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -724,7 +724,7 @@ Sort field and direction pair | **reorderableColumns** | `boolean` | optional | Allow column drag-reorder | | **frozenColumns** | `number` | optional | How many leading columns stay frozen (default 1) | | **showColumnTypeIcons** | `boolean` | optional | Show field-type icons in column headers | -| **exportOptions** | `any` | optional | Export config (`{ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }`). Unvalidated here (`z.unknown()`), so this list is the whole account of the shape; `ListViewSchema.exportOptions` declares the same five members with their per-member contract | +| **exportOptions** | `{ formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export config — the object `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, the same block a list view's `exportOptions` declares, with `formats` drawn from `csv`, `xlsx` and `json`. A bare format array is refused: the grid reads `exportOptions.formats`, so write `{ formats: ['csv', 'xlsx'] }` | | **operations** | `any` | optional | Operation toggles (`{ export: false, … }`) | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged` | | **staticData** | `any[]` | optional | Deprecated bare-array static-rows shortcut the renderer still reads. Prefer `data: { provider: 'value', items: [...] }` | @@ -772,6 +772,16 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **fields** | `{ field: string; order: Enum<'asc' \| 'desc'>; collapsed: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` | +### Nested Shape: `ObjectGridProps.exportOptions` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **formats** | `Enum<'csv' \| 'xlsx' \| 'json'>[]` | optional | Formats offered in the export menu (default: ['csv', 'json']). XLSX is delivered by the server stream only. | +| **maxRecords** | `integer` | optional | Maximum number of records to export; 0 or absent = unlimited | +| **includeHeaders** | `boolean` | optional | Include column headers in the exported file (default true) | +| **fileNamePrefix** | `string` | optional | Download file name prefix — replaces the object label and suppresses the view label in the generated file name | +| **streaming** | `boolean` | optional | Set false to force the client-side export path (csv/json only) instead of the server stream | + ### Nested Shape: `ObjectGridProps.data[provider='object']` | Property | Type | Required | Description | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index e44f5b48756..c7e4c9902b9 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 189 | 179 | 3 | 0 | 7 | +| `ui/` | 188 | 178 | 3 | 0 | 7 | ## `ui/` — sites @@ -44,9 +44,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `report.zod.ts` | 3 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | -| `view.zod.ts` | 61 | +| `view.zod.ts` | 60 | | `widget.zod.ts` | 1 | -| **total** | **189** | +| **total** | **188** | ## `ui/` — open @@ -54,15 +54,15 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 189**, in 4 file(s). +**7 strip of 188**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 19 | -| `view.zod.ts` | 4 | 61 | +| `view.zod.ts` | 4 | 60 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **189** | +| **total** | **7** | **188** | | Bucket | Sites | |---|---|