From d76f42f2cfb1288b04419a04685e59ae4998db22 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:35:15 +0000 Subject: [PATCH 1/9] wip(spec): object-grid row types seven members by measured shape and tombstones resizableColumns Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- packages/spec/src/ui/component.zod.ts | 186 ++++++++++++++++++++++++-- 1 file changed, 177 insertions(+), 9 deletions(-) diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 81692a690c7..6bb33aa532b 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -26,7 +26,21 @@ import { // by reference: the grid draws the same key through the same shared // component `ListView` does, so one declaration judges both doors. EmptyStateSchema, + // [#21445] `object-grid.rowHeight` / `.rowColor` are the list view's own row + // height and row colour schemas, and `.conditionalFormatting` is the list + // view's own member (read off `ListViewSchema.shape`, the one place that + // declares the rule shape): the grid reads each with exactly that shape, so + // one declaration judges both doors. + RowHeightSchema, + RowColorConfigSchema, + ListViewSchema, } from './view.zod'; +// [#21445] `object-grid.bulkActionDefs` is the list view's bulk-action def, +// by identity — the element `ListViewSchema.bulkActionDefs` declares. +import { BulkActionDefSchema } from './bulk-action.zod'; +// [#21445] `object-grid.aggregations[].type` is the query AST's own aggregation +// vocabulary — the six functions the grid computes are exactly its members. +import { AggregationFunction } from '../data/query.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 @@ -3775,6 +3789,53 @@ const FILTERS_TO_FILTER = { filters: 'filter' } as const; */ const GridPageSizeSchema = z.number().int().positive(); +/** + * [#21445] One `object-grid` group-header aggregation — the shape the grid's + * grouping hooks read (`useGroupedData` / `useServerGroupHeaders`, measured at + * the `.objectui-sha` pin `89cad75d55`): `field` plus `type`, and nothing + * else. Module-private: the member below is its only carrier, and no list-view + * schema declares an `aggregations` member to share it with. + */ +const GridAggregationSchema = lazySchema(() => strictObject({ + surface: 'this `object-grid` aggregation', + history: + 'Until this shape was declared, `aggregations` was `z.unknown()`: an unknown function, a ' + + 'missing `field` or a mis-spelled key passed, and the group header drew no number.', +}, { + field: z.string().describe('Field whose values the function aggregates within each group (ignored by `count`)'), + type: AggregationFunction.describe('Aggregation function — `count` (the group\'s row count), `sum`, `avg`, `min`, `max` or `count_distinct`'), +})); + +/** + * [#21445] `object-grid`'s built-in affordance toggles — the four members a + * read point names at the `.objectui-sha` pin `89cad75d55` (see the member). + * Module-private for the same reason as {@link GridAggregationSchema}. + * + * `read` and `import` are `guidance`, not members: objectui's TypeScript + * `ObjectGridSchema` declares both, and no grid read point reads either, so a + * declaration here would publish two toggles that toggle nothing. + */ +const GridOperationsSchema = lazySchema(() => strictObject({ + surface: 'this `object-grid` operations block', + history: + 'Until this shape was declared, `operations` was `z.unknown()`: any value passed, and a ' + + 'mis-spelled toggle was ignored while the grid applied its replace-not-merge default.', + guidance: { + read: + '`operations.read` has no reader on `object-grid`: the grid always lists the records it is ' + + 'bound to, and a toggle here would toggle nothing. Delete the key; record read access is ' + + 'governed by the object\'s permissions.', + import: + '`operations.import` has no reader on `object-grid`: the grid draws no import affordance, so ' + + 'a toggle here would toggle nothing. Delete the key.', + }, +}, { + create: z.boolean().optional().describe('Show the add-row affordance (also gated by the user\'s create permission)'), + update: z.boolean().optional().describe('Offer the row menu\'s generic Edit entry'), + delete: z.boolean().optional().describe('Offer the row menu\'s generic Delete entry'), + export: z.boolean().optional().describe('`false` hides the export menu that `exportOptions` enables'), +})); + /** * `object-grid` (objectui `plugin-grid/src/ObjectGrid.tsx` @ `eb7f586b`). * Read points per key: `objectName` (throughout), `columns`/`fields` (:714-715), @@ -3804,6 +3865,24 @@ const GridPageSizeSchema = z.number().int().positive(); * control `schema.editable` in `ObjectGrid.tsx`. It is declared ahead of its * reader on purpose (the BUILD objectui#11068 chose), and its describe carries * the `[EXPERIMENTAL — not enforced]` marker that says so; see the member. + * + * [#21445] Seven members re-measured at the `.objectui-sha` pin `89cad75d55`, + * same file, and typed with the shape each read point takes — they were + * `z.unknown()` (`bulkActionDefs` an array of it), so `rowHeight: 42` passed + * every door and the grid substituted or dropped the value in silence: + * `rowHeight` (`resolveRowHeightMode`, :1311, five values, else `compact`), + * `rowColor` (`useRowColor`, :2955, `field` + `colors`), `conditionalFormatting` + * (`resolveConditionalFormatting`, :2964), `navigation` + * (`useNavigationOverlay`, :2894), `aggregations` (`useGroupedData` and + * `useServerGroupHeaders`, :3095 / :3113, `{ field, type }` with the six query + * aggregation functions), `bulkActionDefs` (`resolveBulkActions`, :4778) and + * `operations` (`create` :5468, `update` / `delete` :1898-1899, `export` + * :4088 / :5340 / :6141 — the four members any read point names). The first + * four and `bulkActionDefs` take the list view's own schemas by reference; + * `aggregations` and `operations` have no list-view counterpart and declare + * the measured shape here. In the same change `resizableColumns` — read only + * as `schema.resizable ?? schema.resizableColumns` (:5361) — retires to a + * tombstone naming `resizable`. */ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ surface: 'this `object-grid`', @@ -4035,7 +4114,16 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ searchableFields: z.array(z.string()).optional() .describe('Fields the toolbar search queries; a non-empty list enables search'), showSearch: z.boolean().optional().describe('Show the search box (read only when `searchableFields` is absent)'), - rowHeight: z.unknown().optional().describe('Row density mode (e.g. compact / comfortable)'), + /** + * [#21445] The list view's own {@link RowHeightSchema}, by reference. + * `resolveRowHeightMode` (`ObjectGrid.tsx:1311` at the pin `89cad75d55`) + * admits exactly these five values — it tests membership against a table + * typed `Record`, so the renderer's set and this one are the + * same set — and answers `compact` for anything else, so `42` or `'huge'` + * rendered as a compact grid with no report. + */ + rowHeight: RowHeightSchema.optional() + .describe('Row height — one of `compact`, `short`, `medium`, `tall`, `extra_tall`; the same values a list view\'s `rowHeight` takes'), /** * [#20831] The list view's own `GroupingConfigSchema`, by reference — ⛔ not * a copy of its shape. objectui types this key as the spec's @@ -4047,16 +4135,63 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * every row into one empty group. Judged the same way on every door now. */ grouping: GroupingConfigSchema.optional().describe('Row grouping config'), - aggregations: z.unknown().optional().describe('Group aggregation config (sum/avg/… per column)'), - conditionalFormatting: z.unknown().optional().describe('Conditional row/cell formatting rules'), - rowColor: z.unknown().optional().describe('Row color rules'), + /** + * [#21445] Per-group numbers in a grouped grid's group headers. No list-view + * schema declares this member, so the shape is the one the grid reads: + * `useGroupedData` and `useServerGroupHeaders` (`ObjectGrid.tsx:3095` / + * `:3113` at the pin `89cad75d55`) take an array of `{ field, type }` and + * compute each `type` — `sum`, `count`, `avg`, `min`, `max`, + * `count_distinct`, the query AST's own {@link AggregationFunction} + * vocabulary, by reference. `count` is the group's row count whatever + * `field` names. An object, an unknown function or a missing `field` used to + * pass here and draw no number. + */ + aggregations: z.array(GridAggregationSchema).optional() + .describe('Per-group aggregations drawn in a grouped grid\'s group headers — `[{ field, type }]`, `type` one of `count`, `sum`, `avg`, `min`, `max`, `count_distinct` (`count` is the group\'s row count, whatever `field` names)'), + /** + * [#21445] The list view's own `conditionalFormatting` member, by reference + * (`ListViewSchema.shape.conditionalFormatting` — the rule shape is declared + * nowhere else): an ordered array of `{ condition, style }`, the first rule + * whose CEL predicate holds styling the row. The grid evaluates its rules + * through the same shared evaluator the list view uses + * (`resolveConditionalFormatting`, `ObjectGrid.tsx:2964` at the pin + * `89cad75d55`), so one declaration judges both doors. ⚠️ That evaluator + * also tolerates two objectui-native rule spellings (`{ field, operator, + * value, backgroundColor, … }` and `{ expression, … }`); neither is declared + * on the list view, and the grid does not open a second dialect the list + * view refuses. + */ + conditionalFormatting: ListViewSchema.shape.conditionalFormatting + .describe('Conditional formatting rules — `[{ condition, style }]`, the same rules a list view declares: the first rule whose CEL `condition` holds applies its CSS `style` map to the row'), + /** + * [#21445] The list view's own {@link RowColorConfigSchema}, by reference: + * `useRowColor` (`ObjectGrid.tsx:2955` at the pin `89cad75d55`) reads + * exactly `field` and `colors`. + */ + rowColor: RowColorConfigSchema.optional() + .describe('Row colour by field value — `{ field, colors }`, the same block a list view\'s `rowColor` declares'), selection: z.unknown().optional().describe('Selection config ({ type: none | single | multiple })'), selectable: z.unknown().optional().describe('Legacy selection shorthand, read only when `selection` is absent. Prefer `selection`'), rowActions: z.array(z.unknown()).optional().describe('Per-row action names'), bulkActions: z.array(z.unknown()).optional().describe('Bulk action names shown on selection'), batchActions: z.array(z.unknown()).optional().describe('Alternate spelling the renderer reads FIRST (`batchActions ?? bulkActions`)'), - bulkActionDefs: z.array(z.unknown()).optional().describe('Inline bulk-action definitions (full defs, not names)'), - navigation: z.unknown().optional().describe('Row-click navigation config ({ mode: page | drawer | modal | split | popover | new_window | none }) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema'), + /** + * [#21445] The list view's own bulk-action def, {@link BulkActionDefSchema}, + * by identity — the element `ListViewSchema.bulkActionDefs` declares. The + * grid folds these defs through `resolveBulkActions` (`ObjectGrid.tsx:4778` + * at the pin `89cad75d55`) exactly as a list view's are folded, so the + * schema that refuses a no-op `custom` def there refuses it here. + */ + bulkActionDefs: z.array(BulkActionDefSchema).optional() + .describe('Inline bulk-action definitions (full defs, not names) — the same `BulkActionDef` entries a list view\'s `bulkActionDefs` declares'), + /** + * [#21445] The list view's own {@link NavigationConfigSchema}, by reference + * — the same carrier `object-kanban`, `object-calendar` and + * `object-timeline` take. `useNavigationOverlay` (`ObjectGrid.tsx:2894` at + * the pin `89cad75d55`) types its own mode union as that schema's. + */ + navigation: NavigationConfigSchema.optional() + .describe('Row-click navigation config — the same block `ListViewSchema.navigation` declares ({ mode, size, openNewTab, preventNavigation })'), editable: z.boolean().optional().describe('Enable inline cell editing'), singleClickEdit: z.boolean().optional().describe('Enter cell edit on single click (default true when editable)'), /** @@ -4073,8 +4208,28 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ */ keyboardNavigation: z.boolean().optional() .describe('[EXPERIMENTAL — not enforced] Arrow-key cell navigation on the WAI-ARIA grid pattern. Defaults to on when `editable` is set; a read-only grid keeps its Tab behaviour unless this is `true`. No renderer reads it yet: it is declared ahead of the grid\'s keyboard-navigation build, so authoring it changes nothing today'), - resizable: z.boolean().optional().describe('Allow column resize (read before `resizableColumns`)'), - resizableColumns: z.boolean().optional().describe('Alternate spelling of `resizable` (the renderer reads `resizable ?? resizableColumns`)'), + resizable: z.boolean().optional().describe('Allow column resize (the renderer default is on)'), + /** + * REMOVED (#21445, ADR-0049 enforce-or-remove; objectui#6152 ruling A, + * `resizable` is canonical). The legacy second spelling of `resizable`, + * read only as `schema.resizable ?? schema.resizableColumns` — measured at + * the `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`. + * One switch, two spellings, and a grid authoring both silently ignored this + * one. Zero writers in either repository, so there is no window. objectui#6152 + * retires the read on its own schedule, once a released spec carries this. + * + * The live mechanism is `resizable`. The protocol-18 conversion + * `object-grid-resizable-columns-removed` renames the key when `resizable` + * is absent (the value was the grid's setting) and deletes it when + * `resizable` is present (it was never read then). + */ + resizableColumns: retiredKey( + '`object-grid` property `resizableColumns` was removed in @objectstack/spec 17.7.0 (ADR-0049) — ' + + 'it was the legacy second spelling of `resizable`, read only when `resizable` was absent, so one ' + + 'switch had two spellings and a grid authoring both silently ignored this one. Use `resizable`. ' + + 'Rename the key; the value (a boolean) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), 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'), @@ -4099,7 +4254,20 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ */ 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, … })'), + /** + * [#21445] The grid's built-in affordance toggles. No list-view schema + * declares this member, so the shape is the one the grid reads: four + * booleans, each named by a read point at the pin `89cad75d55` — `create` + * (the add-row affordance, `ObjectGrid.tsx:5468`), `update` / `delete` (the + * row menu's generic Edit / Delete entries, `:1898-1899`) and `export` + * (`:4088`, `:5340`, `:6141`). A declared block REPLACES the grid's default + * rather than merging under it (`:1818-1822`), so a row affordance the block + * does not name is withheld. Nothing reads `read` or `import`, which + * objectui's TypeScript twin declares; each is refused with that reason + * rather than accepted as a toggle that toggles nothing. + */ + operations: GridOperationsSchema.optional() + .describe('Built-in affordance toggles `{ create?, update?, delete?, export? }` (booleans). A declared block replaces the grid\'s default: `update` / `delete` not named are withheld from the row menu, `create` enables the add-row affordance, and `export: false` hides the export menu'), /** * Data source binding — `ViewDataSchema`, the #5090-pinned authority the * objectui registry declares against (`plugin-grid/src/index.tsx:225` From 6671314cbfc869f88ac8b92390edaa79aa2df9be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:36:58 +0000 Subject: [PATCH 2/9] wip(spec): object-grid-resizable-columns-removed conversion, retired-key and D3 entries, step-18 rationale Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- packages/spec/src/conversions/registry.ts | 186 ++++++++++++++++++ ...8.ui__ObjectGridProps__resizableColumns.ts | 20 ++ ...8.object-grid-resizable-columns-retired.ts | 31 +++ .../18.ui-object-grid-row-members-typed.ts | 49 +++++ packages/spec/src/migrations/registry.ts | 120 +++++++++++ 5 files changed, 406 insertions(+) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__resizableColumns.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-grid-resizable-columns-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-grid-row-members-typed.ts diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 9d37792a272..3c1d70fa742 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -9015,6 +9015,191 @@ const objectGridDefaultSortRemoved: MetadataConversion = { }, }; +/** + * `object-grid`'s legacy column-resize spelling leaves the contract (protocol + * 18, #21445, ADR-0049 enforce-or-remove; objectui#6152 ruling A — `resizable` + * is canonical — with the startup rule of immediate retirement). + * + * `resizableColumns` was the second spelling of `resizable`, read only as + * `schema.resizable ?? schema.resizableColumns` — measured at the + * `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`. One + * switch, two spellings, and a grid authoring both silently ignored this one. + * + * The conversion follows that precedence exactly, so it preserves what every + * grid has been doing. Where `resizable` is absent (or null — the `??` reads + * through it) the legacy value WAS the grid's setting, so it moves to + * `resizable` unchanged. Where `resizable` holds a value the legacy key was + * never read, so it strips as a lossless delete — whatever the two values + * were. Zero authored occurrences in either repository's corpora (the card's + * measurement, re-run at dispatch), so this entry exists for stored + * `sys_metadata` rows and for authors outside the repositories. + * + * objectui#6152 retires the renderer's `?? schema.resizableColumns` read on + * its own schedule, once a released spec carries this. + */ +const objectGridResizableColumnsRemoved: MetadataConversion = { + id: 'object-grid-resizable-columns-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.6.0', + surface: 'page.component.object-grid.resizableColumns', + summary: + "object-grid component prop 'resizableColumns' removed (#21445 — the legacy second spelling of " + + "'resizable', read only when 'resizable' was absent; the value moves to 'resizable' when that " + + 'is absent, and is deleted when it is present)', + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'object-grid') return component; + const properties = component.properties; + if (!isDict(properties) || !('resizableColumns' in properties)) return component; + if (properties.resizable != null) { + // `resizable` holds a value: the legacy key was never read — a pure + // lossless delete, whatever it said. + const stripped = stripKeys(properties, ['resizableColumns'], emit, `${path}.properties`); + return { ...component, properties: stripped }; + } + // `resizable` absent (or null, which `??` reads through): the legacy key + // WAS the setting. It moves, value unchanged. + const { resizableColumns, ...rest } = properties; + emit({ from: 'resizableColumns', to: 'resizable', path: `${path}.properties.resizable` }); + return { ...component, properties: { ...rest, resizable: resizableColumns } }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'account_desk', + regions: [ + { + name: 'main', + components: [ + // The legacy key alone: it IS the setting, so it moves. + { + type: 'object-grid', + id: 'g1', + properties: { objectName: 'crm_account', resizableColumns: false }, + }, + // Both spellings with DIFFERENT values: `resizable` wins (the + // renderer's own precedence), so the legacy key strips. + { + type: 'object-grid', + id: 'g2', + properties: { objectName: 'crm_account', resizable: true, resizableColumns: false }, + }, + // The same key name on a component that is NOT an + // `object-grid` — not this entry's key. The strip is scoped + // by component type, never by key name. + { + type: 'object-kanban', + id: 'k1', + properties: { objectName: 'crm_account', resizableColumns: true }, + }, + // A grid without the key rides through untouched. + { + type: 'object-grid', + id: 'g3', + properties: { objectName: 'crm_account', resizable: false }, + }, + // The nested position: a grid inside a card's `children`. + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-grid', + id: 'g4', + properties: { objectName: 'crm_contact', resizableColumns: true }, + }, + ], + }, + }, + ], + }, + ], + }, + // The named-slot shape: a grid authored into a slotted page. + { + name: 'account_desk_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-grid', + id: 'g5', + properties: { objectName: 'crm_account', resizableColumns: false }, + }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'account_desk', + regions: [ + { + name: 'main', + components: [ + { + type: 'object-grid', + id: 'g1', + properties: { objectName: 'crm_account', resizable: false }, + }, + { + type: 'object-grid', + id: 'g2', + properties: { objectName: 'crm_account', resizable: true }, + }, + { + type: 'object-kanban', + id: 'k1', + properties: { objectName: 'crm_account', resizableColumns: true }, + }, + { + type: 'object-grid', + id: 'g3', + properties: { objectName: 'crm_account', resizable: false }, + }, + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-grid', + id: 'g4', + properties: { objectName: 'crm_contact', resizable: true }, + }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'account_desk_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-grid', + id: 'g5', + properties: { objectName: 'crm_account', resizable: false }, + }, + }, + }, + ], + }, + // Four notices: three renames (g1, the nested g4, the slotted g5) and one + // strip (g2, where `resizable` already won). The kanban sibling and the + // grid without the key emit none. + expectedNotices: 4, + }, +}; + /** * `object-kanban`'s per-column quick-add switch leaves the contract (protocol * 18, #17260, ADR-0049 enforce-or-remove; the spec half of the objectui#8285 @@ -14102,6 +14287,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: memoryPersistenceAutoSaveIntervalToMs, order: 27 }, { conversion: metricFiltersRemoved, order: 7 }, { conversion: objectGridDefaultSortRemoved, order: 14 }, + { conversion: objectGridResizableColumnsRemoved, order: 57 }, { conversion: objectKanbanQuickAddRemoved, order: 15 }, { conversion: objectTenancyOrganizationFieldRemoved, order: 35 }, { conversion: pageAssignedProfilesRemoved, order: 31 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__resizableColumns.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__resizableColumns.ts new file mode 100644 index 00000000000..58c3a9e822a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__resizableColumns.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #21445 — ADR-0049 enforce-or-remove (objectui#6152 ruling A: `resizable` is +// canonical; the startup rule of immediate retirement — zero writers in either +// repository, so no window). `resizableColumns` was the legacy second spelling +// of `object-grid`'s `resizable`, read only as +// `schema.resizable ?? schema.resizableColumns` (measured at the +// `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). +// One switch, two spellings; a grid authoring both silently ignored this one. +// +// Registered under 18 for the reason `ui/ObjectGridProps:defaultSort` is: the +// removal ships on the 17.x line (launch-window convention: accept-set +// narrowings ride minor releases) and the prescription lives at the major +// boundary where `migrate meta` users look. Tombstoned with `retiredKey()` in +// `ObjectGridPropsSchema` (the surface baseline line carries `[RETIRED]`); +// sources are rewritten by the D2 conversion +// `object-grid-resizable-columns-removed` (renamed to `resizable` when that is +// absent; a pure lossless delete when it is present, since the legacy key was +// never read then). +export const entry = 'ui/ObjectGridProps:resizableColumns'; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-resizable-columns-retired.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-resizable-columns-retired.ts new file mode 100644 index 00000000000..ce850e73f80 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-grid-resizable-columns-retired.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21445 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `object-grid-resizable-columns-removed` family (one D3 entry per retirement +// family, even when D2 is lossless). The conversion follows the renderer's own +// precedence exactly, so it preserves what every grid did — including where +// what the grid did was not what the author wrote. +export const entry: SemanticMigration = { + id: 'object-grid-resizable-columns-retired', + surface: 'page.component.object-grid.resizableColumns — the legacy second spelling of the grid ' + + 'column-resize switch', + replacement: '`resizable: true | false` — the one spelling the grid reads; the value is the same ' + + 'boolean.', + reason: 'The D2 conversion `object-grid-resizable-columns-removed` follows the renderer\'s own ' + + 'precedence, `resizable ?? resizableColumns`: where `resizable` was absent the legacy value WAS ' + + 'the grid\'s setting, so it moves to `resizable` unchanged; where `resizable` held a value the ' + + 'legacy key was never read, so it is deleted. Both are behaviour-preserving, and the second is ' + + 'where the judgment sits. A grid that authored both keys with DIFFERENT values has always ' + + 'behaved as `resizable` said, while its author may believe the other key governed it. The ' + + 'conversion keeps what users have been seeing and discards the value the author also wrote; ' + + 'only the author can say which one they meant. Code that builds object-grid props (a host, a ' + + 'generator) must also stop emitting the key, which no conversion reaches.', + acceptanceCriteria: 'No `object-grid` component carries `resizableColumns`; the parse refuses it. ' + + 'Each grid that should let users drag column borders either omits `resizable` (the renderer ' + + 'default is on) or sets it to `true`, and each that should not sets `resizable: false`. For ' + + 'every grid that had authored both keys, the author has compared the discarded value with the ' + + 'kept `resizable` and confirmed the kept one.', + conversionIds: ['object-grid-resizable-columns-removed'], +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-row-members-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-row-members-typed.ts new file mode 100644 index 00000000000..64079d1b79c --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-row-members-typed.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21445 — seven members of an `object-grid` page block's props were +// `z.unknown()` (`bulkActionDefs` an array of it) although the grid reads each +// with a fixed shape, so an off-shape value passed every door and the grid +// substituted a default or dropped it in silence. The row now takes the shape +// each read point takes. 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; an off-shape value has no +// rewrite that says what the author meant; and the authored census found +// nothing in either repository's corpora to respell. +export const entry: SemanticMigration = { + id: 'ui-object-grid-row-members-typed', + surface: 'page `object-grid` components — `properties.rowHeight`, `.rowColor`, `.navigation`, ' + + '`.conditionalFormatting`, `.bulkActionDefs`, `.aggregations` and `.operations` (which used to ' + + 'accept any value)', + replacement: 'the shape the grid reads, the list view\'s own where it has one: `rowHeight` one of ' + + '`compact` / `short` / `medium` / `tall` / `extra_tall`; `rowColor` `{ field, colors }`; ' + + '`navigation` `{ mode?, size?, openNewTab?, preventNavigation? }`; `conditionalFormatting` ' + + '`[{ condition, style }]` with a CEL `condition` and a CSS `style` map; `bulkActionDefs` the ' + + 'list view\'s bulk-action defs; `aggregations` `[{ field, type }]` with `type` one of `count`, ' + + '`sum`, `avg`, `min`, `max`, `count_distinct`; `operations` `{ create?, update?, delete?, ' + + 'export? }` booleans. Rewrite an objectui-native formatting rule `{ field, operator, value, ' + + 'backgroundColor }` as `{ condition: "record.FIELD == VALUE", style: { backgroundColor } }`; ' + + 'delete `operations.read` and `operations.import`, which nothing reads.', + reason: 'The grid reads each of these members with one shape, and the page-component row ' + + 'declared them `z.unknown()`, so any value passed the component-props gate and the grid ' + + 'answered an off-shape one with a silent default: an off-preset `rowHeight` such as `42` ' + + 'rendered as `compact`, a `rowColor` of the wrong shape coloured no row, a `navigation` written ' + + 'as a bare mode string opened the record page whatever it named, an aggregation with an ' + + 'unknown function drew no number, and an `operations` ' + + 'toggle nothing reads toggled nothing. The row now takes the list view\'s own schemas for the ' + + 'five members a list view declares, and the measured shape for `aggregations` and ' + + '`operations`, so one value is judged the same way on both doors. It is read where every page ' + + 'component\'s props are: the component-props gate reports a refused value 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, and an off-shape value 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. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-grid` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under the seven members\' ' + + 'paths. Each grid that set one of them now shows it: the declared row height, the row colours ' + + 'its `colors` map names, the navigation mode on a row click, the conditional styles, the bulk ' + + 'actions, the group-header numbers and the affordances `operations` names.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a49da25ca28..7cc0f849e4e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5758,6 +5758,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'shape — when `sort` is absent, and strips it as a pure lossless delete when ' + '`sort` is present (the renderer\'s own precedence made it unread then).', }, + { + id: 'object-grid-resizable-columns-retired', + order: 62, + text: + 'It also retires `object-grid`\'s `resizableColumns` (#21445, ADR-0049 enforce-or-remove; ' + + 'objectui#6152 ruling A, `resizable` is canonical, under the startup rule of immediate ' + + 'retirement): the legacy second spelling of `resizable`, read only as ' + + '`schema.resizable ?? schema.resizableColumns` (measured at the `.objectui-sha` pin ' + + '`89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two spellings, and zero ' + + 'writers in either repository, so there is no window. A retiredKey tombstone on ' + + '`ObjectGridPropsSchema` with one D2 conversion that follows the renderer\'s precedence: the ' + + 'value moves to `resizable` when that is absent, and strips as a lossless delete when it is ' + + 'present (it was never read then). Its D3 record is the semantic entry ' + + '`object-grid-resizable-columns-retired`.', + }, { id: 'object-kanban-quick-add-retired', order: 28, @@ -6072,6 +6087,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '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-grid-row-members-typed', + order: 63, + text: + 'It also types seven members of an `object-grid` page block (#21445): `rowHeight`, ' + + '`rowColor`, `navigation`, `conditionalFormatting`, `bulkActionDefs`, `aggregations` and ' + + '`operations` were `z.unknown()` (an array of it for `bulkActionDefs`), although the grid ' + + 'reads each with one shape, so `rowHeight: 42` passed every door and rendered as `compact`. ' + + 'The five a list view also declares take the list view\'s own schemas by reference; ' + + '`aggregations` takes the measured `[{ field, type }]` with the query AST\'s aggregation ' + + 'functions, and `operations` the four booleans a grid read point names (`create`, ' + + '`update`, `delete`, `export`), refusing `read` and `import`, which nothing reads. Read by ' + + 'the component-props gate (advisory); a stored page still saves and loads, so no conversion ' + + 'is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`.', + }, { id: 'ui-object-master-detail-form-details-closed', order: 56, @@ -14806,6 +14836,33 @@ const step18: MigrationStep = { + 'authored both keys, the author has compared the discarded `defaultSort` pair with the kept ' + '`sort` and confirmed the kept one.', }, + // #21445 (ADR-0049 enforce-or-remove) — the D3 entry of the + // `object-grid-resizable-columns-removed` family (one D3 entry per retirement + // family, even when D2 is lossless). The conversion follows the renderer's own + // precedence exactly, so it preserves what every grid did — including where + // what the grid did was not what the author wrote. + { + id: 'object-grid-resizable-columns-retired', + surface: 'page.component.object-grid.resizableColumns — the legacy second spelling of the grid ' + + 'column-resize switch', + replacement: '`resizable: true | false` — the one spelling the grid reads; the value is the same ' + + 'boolean.', + reason: 'The D2 conversion `object-grid-resizable-columns-removed` follows the renderer\'s own ' + + 'precedence, `resizable ?? resizableColumns`: where `resizable` was absent the legacy value WAS ' + + 'the grid\'s setting, so it moves to `resizable` unchanged; where `resizable` held a value the ' + + 'legacy key was never read, so it is deleted. Both are behaviour-preserving, and the second is ' + + 'where the judgment sits. A grid that authored both keys with DIFFERENT values has always ' + + 'behaved as `resizable` said, while its author may believe the other key governed it. The ' + + 'conversion keeps what users have been seeing and discards the value the author also wrote; ' + + 'only the author can say which one they meant. Code that builds object-grid props (a host, a ' + + 'generator) must also stop emitting the key, which no conversion reaches.', + acceptanceCriteria: 'No `object-grid` component carries `resizableColumns`; the parse refuses it. ' + + 'Each grid that should let users drag column borders either omits `resizable` (the renderer ' + + 'default is on) or sets it to `true`, and each that should not sets `resizable: false`. For ' + + 'every grid that had authored both keys, the author has compared the discarded value with the ' + + 'kept `resizable` and confirmed the kept one.', + conversionIds: ['object-grid-resizable-columns-removed'], + }, { id: 'object-index-unknown-keys-refused', surface: 'object `indexes[]` entries (`IndexSchema`) — undeclared keys', @@ -19227,6 +19284,51 @@ const step18: MigrationStep = { + 'the key or writes the page size they meant, and `os validate` then reports no ' + '`component-props-invalid` finding for that node.', }, + // #21445 — seven members of an `object-grid` page block's props were + // `z.unknown()` (`bulkActionDefs` an array of it) although the grid reads each + // with a fixed shape, so an off-shape value passed every door and the grid + // substituted a default or dropped it in silence. The row now takes the shape + // each read point takes. 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; an off-shape value has no + // rewrite that says what the author meant; and the authored census found + // nothing in either repository's corpora to respell. + { + id: 'ui-object-grid-row-members-typed', + surface: 'page `object-grid` components — `properties.rowHeight`, `.rowColor`, `.navigation`, ' + + '`.conditionalFormatting`, `.bulkActionDefs`, `.aggregations` and `.operations` (which used to ' + + 'accept any value)', + replacement: 'the shape the grid reads, the list view\'s own where it has one: `rowHeight` one of ' + + '`compact` / `short` / `medium` / `tall` / `extra_tall`; `rowColor` `{ field, colors }`; ' + + '`navigation` `{ mode?, size?, openNewTab?, preventNavigation? }`; `conditionalFormatting` ' + + '`[{ condition, style }]` with a CEL `condition` and a CSS `style` map; `bulkActionDefs` the ' + + 'list view\'s bulk-action defs; `aggregations` `[{ field, type }]` with `type` one of `count`, ' + + '`sum`, `avg`, `min`, `max`, `count_distinct`; `operations` `{ create?, update?, delete?, ' + + 'export? }` booleans. Rewrite an objectui-native formatting rule `{ field, operator, value, ' + + 'backgroundColor }` as `{ condition: "record.FIELD == VALUE", style: { backgroundColor } }`; ' + + 'delete `operations.read` and `operations.import`, which nothing reads.', + reason: 'The grid reads each of these members with one shape, and the page-component row ' + + 'declared them `z.unknown()`, so any value passed the component-props gate and the grid ' + + 'answered an off-shape one with a silent default: an off-preset `rowHeight` such as `42` ' + + 'rendered as `compact`, a `rowColor` of the wrong shape coloured no row, a `navigation` written ' + + 'as a bare mode string opened the record page whatever it named, an aggregation with an ' + + 'unknown function drew no number, and an `operations` ' + + 'toggle nothing reads toggled nothing. The row now takes the list view\'s own schemas for the ' + + 'five members a list view declares, and the measured shape for `aggregations` and ' + + '`operations`, so one value is judged the same way on both doors. It is read where every page ' + + 'component\'s props are: the component-props gate reports a refused value 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, and an off-shape value 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. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-grid` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under the seven members\' ' + + 'paths. Each grid that set one of them now shows it: the declared row height, the row colours ' + + 'its `colors` map names, the navigation mode on a row click, the conditional styles, the bulk ' + + 'actions, the group-header numbers and the affordances `operations` names.', + }, // #20928 — the third carrier of the inline grid column. An // `object-master-detail-form` page block's `details` was `z.array(z.unknown())` // while the other two carriers — a relationship field's `inlineColumns` and a @@ -24088,6 +24190,24 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // (wrap-and-rename to `sort: [pair]` when `sort` is absent; a pure lossless // delete when `sort` is present, since the fallback was never read then). 'ui/ObjectGridProps:defaultSort', + // #21445 — ADR-0049 enforce-or-remove (objectui#6152 ruling A: `resizable` is + // canonical; the startup rule of immediate retirement — zero writers in either + // repository, so no window). `resizableColumns` was the legacy second spelling + // of `object-grid`'s `resizable`, read only as + // `schema.resizable ?? schema.resizableColumns` (measured at the + // `.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). + // One switch, two spellings; a grid authoring both silently ignored this one. + // + // Registered under 18 for the reason `ui/ObjectGridProps:defaultSort` is: the + // removal ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look. Tombstoned with `retiredKey()` in + // `ObjectGridPropsSchema` (the surface baseline line carries `[RETIRED]`); + // sources are rewritten by the D2 conversion + // `object-grid-resizable-columns-removed` (renamed to `resizable` when that is + // absent; a pure lossless delete when it is present, since the legacy key was + // never read then). + 'ui/ObjectGridProps:resizableColumns', // #17260 — ADR-0049 enforce-or-remove, executing the objectui#8285 // director-seat ruling (comment 5583979207, decision batch #91, 2026-09-08, // standing maintainer delegation): ruled option B — `quickAdd` is retired from From 29ad2aa0fc9760d6e0537e66062f395030aacdbf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:39:48 +0000 Subject: [PATCH 3/9] wip(spec): pins for the typed object-grid members and the resizableColumns tombstone, plus the tree-scoped absence pin Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- ...nent-object-grid-typed-members.pin.test.ts | 304 ++++++++++++++++++ ...-grid-resizable-columns-retirement.test.ts | 145 +++++++++ packages/spec/vitest.repo-tests.json | 1 + 3 files changed, 450 insertions(+) create mode 100644 packages/spec/src/ui/component-object-grid-typed-members.pin.test.ts create mode 100644 packages/spec/src/ui/object-grid-resizable-columns-retirement.test.ts diff --git a/packages/spec/src/ui/component-object-grid-typed-members.pin.test.ts b/packages/spec/src/ui/component-object-grid-typed-members.pin.test.ts new file mode 100644 index 00000000000..7f666cff9ec --- /dev/null +++ b/packages/spec/src/ui/component-object-grid-typed-members.pin.test.ts @@ -0,0 +1,304 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21445] `ComponentPropsMap['object-grid']` types the seven members the grid + * reads with a fixed shape, and retires `resizableColumns` to a tombstone + * naming `resizable`. + * + * ## The defect this file closes + * + * `rowHeight`, `aggregations`, `conditionalFormatting`, `rowColor`, + * `navigation` and `operations` were `z.unknown()`, and `bulkActionDefs` an + * array of it, while objectui's `ObjectGrid` reads each with one shape + * (measured at the `.objectui-sha` pin `89cad75d55`; the read points are in + * the row's docblock). So `rowHeight: 42` passed every door and rendered as a + * compact grid, and every other off-shape value was substituted or dropped + * with no report. `resizableColumns` was the second spelling of `resizable`, + * read only when `resizable` was absent. + * + * ## What is pinned, and why each half + * + * - §1 THE DECLARED SHAPES PARSE: a value of each member's shape parses, and + * parses to what the shared schema itself answers. A refusal pin with no lit + * control passes just as well when the door refuses everything. + * - §2 THE REFUSALS: an off-shape value of each member is refused with the + * code AND the path, so a refusal for the wrong reason reds. + * - §3 ONE SCHEMA: the five members a list view also declares hold the list + * view's own defs by identity, and the two declared here hold exactly the + * measured vocabulary. A later copy of a shape reds the identity half even + * while it still agrees on today's corpus. + * - §4 THE TOMBSTONE: `resizableColumns` is refused with its prescription at + * both channels (`tsc` and the parse), and `resizable` still parses. + * - §5 THE REGISTRATION: the conversion's two arms, and the ADR-0087 entries + * the upgrade path reads. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; + +import { ComponentPropsMap, ObjectGridPropsSchema, type ObjectGridProps } from './component.zod'; +import { ListViewSchema, NavigationConfigSchema, RowColorConfigSchema, RowHeightSchema } from './view.zod'; +import { BulkActionDefSchema } from './bulk-action.zod'; +import { AggregationFunction } from '../data/query.zod'; +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; + +const GRID = () => ComponentPropsMap['object-grid']; +const BASE = { objectName: 'account' } as const; + +/** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */ +function issues(result: z.ZodSafeParseResult): { code: string; path: string }[] { + if (result.success) return []; + return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); +} + +// ─────────────────────────────────────────────────────────────────────────── +// §1 the declared shapes parse +// ─────────────────────────────────────────────────────────────────────────── + +describe('§1 object-grid accepts a value of each declared shape', () => { + const BYTE_IDENTICAL: ReadonlyArray]> = [ + ['every rowHeight value', { rowHeight: 'extra_tall' }], + ['a rowColor block', { rowColor: { field: 'status', colors: { overdue: 'red', done: 'bg-green-200' } } }], + ['each aggregation function', { + aggregations: [ + { field: 'id', type: 'count' }, + { field: 'amount', type: 'sum' }, + { field: 'amount', type: 'avg' }, + { field: 'amount', type: 'min' }, + { field: 'amount', type: 'max' }, + { field: 'owner_id', type: 'count_distinct' }, + ], + }], + ['the four operations toggles', { operations: { create: true, update: true, delete: false, export: false } }], + ['an empty operations block (replaces the default: no row affordance)', { operations: {} }], + ['an update bulk-action def', { bulkActionDefs: [{ name: 'close_all', label: 'Close', operation: 'update', patch: { status: 'closed' } }] }], + ]; + + for (const [label, props] of BYTE_IDENTICAL) { + it(`parses ${label}, byte-identical`, () => { + const r = GRID().safeParse({ ...BASE, ...props }); + expect(issues(r)).toEqual([]); + expect(r.success && r.data).toStrictEqual({ ...BASE, ...props }); + }); + } + + for (const rowHeight of ['compact', 'short', 'medium', 'tall', 'extra_tall'] as const) { + it(`parses rowHeight: '${rowHeight}'`, () => { + expect(issues(GRID().safeParse({ ...BASE, rowHeight }))).toEqual([]); + }); + } + + it('parses a navigation block to exactly what NavigationConfigSchema answers (its defaults included)', () => { + const navigation = { mode: 'drawer', size: 'lg' }; + const r = GRID().safeParse({ ...BASE, navigation }); + expect(issues(r)).toEqual([]); + expect(r.success && r.data.navigation).toStrictEqual(NavigationConfigSchema.parse(navigation)); + }); + + it('parses a conditional formatting rule to exactly what the list view answers (the condition envelope included)', () => { + const conditionalFormatting = [{ condition: "record.status == 'overdue'", style: { backgroundColor: '#fee2e2' } }]; + const r = GRID().safeParse({ ...BASE, conditionalFormatting }); + expect(issues(r)).toEqual([]); + expect(r.success && r.data.conditionalFormatting) + .toStrictEqual(ListViewSchema.shape.conditionalFormatting.parse(conditionalFormatting)); + }); + + it('an absent member stays absent', () => { + const r = GRID().safeParse(BASE); + expect(issues(r)).toEqual([]); + expect(r.success && Object.keys(r.data)).toEqual(['objectName']); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §2 the refusals +// ─────────────────────────────────────────────────────────────────────────── + +describe('§2 object-grid refuses an off-shape value of each member', () => { + const REFUSED: ReadonlyArray, code: string, path: string]> = [ + ['a numeric rowHeight', { rowHeight: 42 }, 'invalid_value', 'rowHeight'], + ['an off-preset rowHeight', { rowHeight: 'huge' }, 'invalid_value', 'rowHeight'], + ['a bare-string rowColor', { rowColor: 'red' }, 'invalid_type', 'rowColor'], + ['a rowColor with no field', { rowColor: { colors: { overdue: 'red' } } }, 'invalid_type', 'rowColor.field'], + ['a bare-string navigation', { navigation: 'drawer' }, 'invalid_type', 'navigation'], + ['an unknown navigation mode', { navigation: { mode: 'tab' } }, 'invalid_value', 'navigation.mode'], + ['one formatting rule written as an object', { + conditionalFormatting: { condition: "record.status == 'overdue'", style: { color: 'red' } }, + }, 'invalid_type', 'conditionalFormatting'], + ['an aggregations object', { aggregations: { amount: 'sum' } }, 'invalid_type', 'aggregations'], + ['an unknown aggregation function', { aggregations: [{ field: 'amount', type: 'median' }] }, 'invalid_value', 'aggregations.0.type'], + ['an aggregation with no field', { aggregations: [{ type: 'sum' }] }, 'invalid_type', 'aggregations.0.field'], + ['an undeclared aggregation key', { aggregations: [{ field: 'amount', type: 'sum', label: 'Total' }] }, 'unrecognized_keys', 'aggregations.0'], + ['a boolean operations', { operations: false }, 'invalid_type', 'operations'], + ['a string operations toggle', { operations: { export: 'no' } }, 'invalid_type', 'operations.export'], + ['operations.read', { operations: { read: true } }, 'unrecognized_keys', 'operations'], + ['operations.import', { operations: { import: true } }, 'unrecognized_keys', 'operations'], + ['a bare action name in bulkActionDefs', { bulkActionDefs: ['close_all'] }, 'invalid_type', 'bulkActionDefs.0'], + ['a no-op custom bulk-action def', { bulkActionDefs: [{ name: 'notify_all', operation: 'custom' }] }, 'custom', 'bulkActionDefs.0.execution'], + ]; + + for (const [label, props, code, path] of REFUSED) { + it(`refuses ${label} — ${code} at ${path}`, () => { + const r = GRID().safeParse({ ...BASE, ...props }); + expect(r.success).toBe(false); + expect(issues(r)).toEqual([{ code, path }]); + }); + } + + it('refuses an objectui-native formatting rule, naming its keys at the rule', () => { + const r = GRID().safeParse({ + ...BASE, + conditionalFormatting: [{ field: 'status', operator: 'equals', value: 'overdue', backgroundColor: '#fee2e2' }], + }); + expect(r.success).toBe(false); + expect(issues(r)).toContainEqual({ code: 'unrecognized_keys', path: 'conditionalFormatting.0' }); + }); + + it('says why `operations.read` and `operations.import` are refused, not only that they are unknown', () => { + const read = GRID().safeParse({ ...BASE, operations: { read: true } }); + expect(read.success).toBe(false); + expect(read.success ? '' : read.error.issues[0]!.message).toMatch(/`operations\.read` has no reader on `object-grid`/); + const imp = GRID().safeParse({ ...BASE, operations: { import: true } }); + expect(imp.success).toBe(false); + expect(imp.success ? '' : imp.error.issues[0]!.message).toMatch(/`operations\.import` has no reader on `object-grid`/); + }); + + it('LIT CONTROL — an unknown top-level key is still refused at the row itself', () => { + const r = GRID().safeParse({ ...BASE, rowHeight: 'tall', notAGridKey: 1 }); + expect(issues(r)).toEqual([{ code: 'unrecognized_keys', path: '' }]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §3 one schema, not a copy of its shape +// ─────────────────────────────────────────────────────────────────────────── + +describe('§3 the members hold the list view\'s own schemas, and the measured vocabulary', () => { + const shape = () => ObjectGridPropsSchema.shape; + + it('rowHeight unwraps to RowHeightSchema — the same def', () => { + expect(shape().rowHeight.unwrap()._zod.def).toBe(RowHeightSchema._zod.def); + }); + + it('rowColor unwraps to RowColorConfigSchema — the same def', () => { + expect(shape().rowColor.unwrap()._zod.def).toBe(RowColorConfigSchema._zod.def); + }); + + it('navigation unwraps to NavigationConfigSchema — the same def', () => { + expect(shape().navigation.unwrap()._zod.def).toBe(NavigationConfigSchema._zod.def); + }); + + it('conditionalFormatting is the list view\'s own member — the same array def', () => { + expect(shape().conditionalFormatting.unwrap()._zod.def) + .toBe(ListViewSchema.shape.conditionalFormatting.unwrap()._zod.def); + }); + + it('bulkActionDefs holds BulkActionDefSchema — the element the list view holds', () => { + expect(shape().bulkActionDefs.unwrap().element._zod.def).toBe(BulkActionDefSchema._zod.def); + expect(ListViewSchema.shape.bulkActionDefs.unwrap().element._zod.def).toBe(BulkActionDefSchema._zod.def); + }); + + it('aggregations[].type is AggregationFunction, whose members are exactly the six functions the grid computes', () => { + const type = shape().aggregations.unwrap().element.shape.type; + expect(type._zod.def).toBe(AggregationFunction._zod.def); + // The grid's own vocabulary at the pin (`AggregationType` in + // `plugin-grid/src/useGroupedData.ts`). A member added to the query AST's + // enum widens this door past the grid's reader; this line reds first. + expect([...AggregationFunction.options].sort()).toEqual(['avg', 'count', 'count_distinct', 'max', 'min', 'sum']); + }); + + it('operations declares exactly the four toggles a grid read point names', () => { + expect(Object.keys(shape().operations.unwrap().shape).sort()).toEqual(['create', 'delete', 'export', 'update']); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §4 the tombstone +// ─────────────────────────────────────────────────────────────────────────── + +const RESIZABLE_COLUMNS_PRESCRIPTION = + /^`object-grid` property `resizableColumns` was removed in @objectstack\/spec 17\.7\.0 \(ADR-0049\) — it was the legacy second spelling of `resizable`.*Use `resizable`\. Rename the key; the value \(a boolean\) is unchanged\./s; + +describe('§4 `resizableColumns` is retired to a tombstone naming `resizable`', () => { + for (const value of [true, false]) { + it(`refuses resizableColumns: ${value} with the prescription, at the key`, () => { + const r = GRID().safeParse({ ...BASE, resizableColumns: value }); + expect(r.success).toBe(false); + expect(issues(r)).toEqual([{ code: 'invalid_type', path: 'resizableColumns' }]); + expect(r.success ? '' : r.error.issues[0]!.message).toMatch(RESIZABLE_COLUMNS_PRESCRIPTION); + }); + } + + it('refuses it beside `resizable` too — no second spelling, whatever the first says', () => { + expect(issues(GRID().safeParse({ ...BASE, resizable: true, resizableColumns: true }))) + .toEqual([{ code: 'invalid_type', path: 'resizableColumns' }]); + }); + + for (const resizable of [true, false]) { + it(`keeps resizable: ${resizable}, the canonical spelling, byte-identical`, () => { + const r = GRID().safeParse({ ...BASE, resizable }); + expect(issues(r)).toEqual([]); + expect(r.success && r.data).toStrictEqual({ ...BASE, resizable }); + }); + } + + it('does not materialize the retired key on a clean parse', () => { + expect(GRID().parse(BASE)).not.toHaveProperty('resizableColumns'); + }); + + it('is refused by tsc as well (the input type is never)', () => { + // @ts-expect-error — `resizableColumns` is a retiredKey tombstone: its input type is `never`. + const props: ObjectGridProps = { objectName: 'account', resizableColumns: true }; + expect(GRID().safeParse(props).success).toBe(false); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §5 the registration +// ─────────────────────────────────────────────────────────────────────────── + +describe('§5 the conversion and the ADR-0087 entries', () => { + const CONVERSION_ID = 'object-grid-resizable-columns-removed'; + const grid = (properties: Record) => ({ + pages: [{ name: 'desk', regions: [{ name: 'main', components: [{ type: 'object-grid', id: 'g', properties }] }] }], + }); + const propsOf = (stack: Record) => + ((stack.pages as Array<{ regions: Array<{ components: Array<{ properties: unknown }> }> }>)[0]! + .regions[0]!.components[0]!.properties); + const convert = (properties: Record) => { + const { stack, notices } = collectConversionNotices(grid(properties), { includeRetired: true }); + return { properties: propsOf(stack), notices: notices.filter((n) => n.conversionId === CONVERSION_ID) }; + }; + + it('moves the value to `resizable` when `resizable` is absent — it WAS the setting', () => { + const { properties, notices } = convert({ objectName: 'account', resizableColumns: false }); + expect(properties).toStrictEqual({ objectName: 'account', resizable: false }); + expect(notices).toHaveLength(1); + }); + + it('deletes it when `resizable` holds a value — it was never read then', () => { + const { properties, notices } = convert({ objectName: 'account', resizable: true, resizableColumns: false }); + expect(properties).toStrictEqual({ objectName: 'account', resizable: true }); + expect(notices).toHaveLength(1); + }); + + it('leaves a grid without the key untouched', () => { + const { properties, notices } = convert({ objectName: 'account', resizable: false }); + expect(properties).toStrictEqual({ objectName: 'account', resizable: false }); + expect(notices).toEqual([]); + }); + + it('is retired from the load path, and wired into step 18 beside both D3 entries and the retired-key row', () => { + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion?.toMajor).toBe(18); + expect(conversion?.retiredFromLoadPath).toBe(true); + const step = MIGRATIONS_BY_MAJOR[18]!; + expect(step.conversionIds).toContain(CONVERSION_ID); + const semanticIds = step.semantic.map((s) => s.id); + expect(semanticIds).toContain('object-grid-resizable-columns-retired'); + expect(semanticIds).toContain('ui-object-grid-row-members-typed'); + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ui/ObjectGridProps:resizableColumns'); + }); +}); diff --git a/packages/spec/src/ui/object-grid-resizable-columns-retirement.test.ts b/packages/spec/src/ui/object-grid-resizable-columns-retirement.test.ts new file mode 100644 index 00000000000..3d034819c9b --- /dev/null +++ b/packages/spec/src/ui/object-grid-resizable-columns-retirement.test.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `object-grid`'s `resizableColumns` RETIRED (#21445) — ADR-0049 + * enforce-or-remove through the ADR-0087 D2 route; objectui#6152 ruling A + * (`resizable` is canonical). It was the legacy second spelling of + * `resizable`, read only as `schema.resizable ?? schema.resizableColumns` + * (measured at the `.objectui-sha` pin `89cad75d55`, + * `plugin-grid/src/ObjectGrid.tsx:5361`), and nothing in either repository + * wrote it. + * + * This file is the TREE-SCOPED half of the retirement — it reads outside the + * package, so it runs in the `repo` project (`vitest.repo-tests.json`). The + * tombstone's refusal and prescription, the conversion's two arms and the + * ADR-0087 registration are pinned in `component-object-grid-typed-members.pin.test.ts`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps only TYPED authoring sites, and a page component's `properties` +// is an open bag, so `tsc` does not reach a grid authored through +// `definePage`/`defineStack` at all. This walk covers every text file under the +// five repo roots `scripts/cross-package-test-inputs.mjs` declares for +// `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example apps' +// own `src/` trees. +// +// The matcher judges the AUTHORING SHAPE, never a mention: `resizableColumns` +// in key position with a boolean value (TS / JS / JSON, and YAML). Prose +// mentions are spelled in inline code in this repo, and inline code is stripped +// before judging. The bound, stated: a value that is not a boolean literal, a +// shorthand property, and `docs/**`, `.claude/**`, `.github/**` and the +// repo-root files are outside what this walk sees. objectui's `data-table` +// component declares a `resizableColumns` of its own; it has no source in this +// repository, and a schema here that ever declares one would trip this walk: +// narrow the matcher to `object-grid` then, never exclude the new file. +describe('tree-scoped absence: nothing inside the declared radius still authors an object-grid resizableColumns', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const AUTHORING = /(^|[^\w.$])["']?resizableColumns["']?[ \t]*:[ \t]*(true|false)\b/m; + + /** Inline code spans are prose; newline-bounded, so a fenced example is still judged. */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => AUTHORING.exec(stripInlineCode(text)); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the key in its anti-vacuity cases. + THIS_FILE, + // The row's pin authors the key to assert its refusal and its conversion. + 'packages/spec/src/ui/component-object-grid-typed-members.pin.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement grid on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `component.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention and the kit (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("{ type: 'object-grid', properties: { objectName: 'account', resizableColumns: true } }")).not.toBeNull(); + expect(judge(" properties: {\n resizableColumns: false,\n },")).not.toBeNull(); + expect(judge('{ "type": "object-grid", "properties": { "resizableColumns": true } }')).not.toBeNull(); + expect(judge(' properties:\n resizableColumns: false\n')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\ndefinePage({ regions: [{ components: [{ properties: { resizableColumns: true } }] }] });\n```\n")).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge('a grid that said `resizableColumns: false` keeps its value under `resizable`')).toBeNull(); + expect(judge('"ui/ObjectGridProps:resizableColumns [RETIRED]",')).toBeNull(); + expect(judge('resizableColumns: retiredKey(PRESCRIPTION),')).toBeNull(); + expect(judge('resizable: true,')).toBeNull(); + expect(judge("const flag = schema.resizable ?? schema.resizableColumns ?? true;")).toBeNull(); + }); + + it('no object-grid resizableColumns authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree and the example apps' sources. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'an object-grid resizableColumns authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 40a5d0f92eb..95d6e519c0d 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -45,6 +45,7 @@ "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", "src/ui/form-field-public-picker-retirement.test.ts", + "src/ui/object-grid-resizable-columns-retirement.test.ts", "src/ui/page-header-breadcrumb-retirement.test.ts", "src/ui/view-item-owner-hidden-retirement.test.ts", "src/ui/view-list-tabs-retirement.test.ts" From e7f208c4c78c262aab82ad20dd7fd8d924a22d66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:40:47 +0000 Subject: [PATCH 4/9] wip(spec): dropped-refinements ledger and authorable surface follow the typed bulkActionDefs and the tombstone Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- packages/spec/authorable-surface/ui.json | 2 +- packages/spec/dropped-refinements.baseline.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 6f308fb3b70..bcee998a338 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -885,7 +885,7 @@ "ui/ObjectGridProps:pagination", "ui/ObjectGridProps:reorderableColumns", "ui/ObjectGridProps:resizable", - "ui/ObjectGridProps:resizableColumns", + "ui/ObjectGridProps:resizableColumns [RETIRED]", "ui/ObjectGridProps:rowActions", "ui/ObjectGridProps:rowColor", "ui/ObjectGridProps:rowHeight", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 623be0035d3..8b331234f2f 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 217, - "droppedRefinementSites": 652, + "droppedRefinementSites": 653, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -1372,6 +1372,7 @@ }, "ui/ObjectGridProps": { "sites": [ + "bulkActionDefs.element", "filter.element", "grouping.fields.element.field" ] From 6a30591929c38f896092c269e670142e0128c8f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:51:19 +0000 Subject: [PATCH 5/9] wip(spec): regenerate the component reference page and the ui strictness-ledger counts Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 84 ++++++++++++++++--- .../ui.md | 10 +-- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 8ffc008cb22..d6e0a544082 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -704,28 +704,28 @@ Sort field and direction pair | **showPagination** | `boolean` | optional | Show the pager (read only when `pagination` is absent) | | **searchableFields** | `string[]` | optional | Fields the toolbar search queries; a non-empty list enables search | | **showSearch** | `boolean` | optional | Show the search box (read only when `searchableFields` is absent) | -| **rowHeight** | `any` | optional | Row density mode (e.g. compact / comfortable) | +| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height — one of `compact`, `short`, `medium`, `tall`, `extra_tall`; the same values a list view's `rowHeight` takes | | **grouping** | `{ fields: object[] }` | optional | Row grouping config | -| **aggregations** | `any` | optional | Group aggregation config (sum/avg/… per column) | -| **conditionalFormatting** | `any` | optional | Conditional row/cell formatting rules | -| **rowColor** | `any` | optional | Row color rules | +| **aggregations** | `{ field: string; type: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'> }[]` | optional | Per-group aggregations drawn in a grouped grid's group headers — `[{ field, type }]`, `type` one of `count`, `sum`, `avg`, `min`, `max`, `count_distinct` (`count` is the group's row count, whatever `field` names) | +| **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Conditional formatting rules — `[{ condition, style }]`, the same rules a list view declares: the first rule whose CEL `condition` holds applies its CSS `style` map to the row | +| **rowColor** | `{ field: string; colors?: Record }` | optional | Row colour by field value — `{ field, colors }`, the same block a list view's `rowColor` declares | | **selection** | `any` | optional | Selection config (`{ type: none \| single \| multiple }`) | | **selectable** | `any` | optional | Legacy selection shorthand, read only when `selection` is absent. Prefer `selection` | | **rowActions** | `any[]` | optional | Per-row action names | | **bulkActions** | `any[]` | optional | Bulk action names shown on selection | | **batchActions** | `any[]` | optional | Alternate spelling the renderer reads FIRST (`batchActions ?? bulkActions`) | -| **bulkActionDefs** | `any[]` | optional | Inline bulk-action definitions (full defs, not names) | -| **navigation** | `any` | optional | Row-click navigation config (`{ mode: page \| drawer \| modal \| split \| popover \| new_window \| none }`) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Inline bulk-action definitions (full defs, not names) — the same `BulkActionDef` entries a list view's `bulkActionDefs` declares | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Row-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`) | | **editable** | `boolean` | optional | Enable inline cell editing | | **singleClickEdit** | `boolean` | optional | Enter cell edit on single click (default true when editable) | | **keyboardNavigation** | `boolean` | optional | [EXPERIMENTAL — not enforced] Arrow-key cell navigation on the WAI-ARIA grid pattern. Defaults to on when `editable` is set; a read-only grid keeps its Tab behaviour unless this is `true`. No renderer reads it yet: it is declared ahead of the grid's keyboard-navigation build, so authoring it changes nothing today | -| **resizable** | `boolean` | optional | Allow column resize (read before `resizableColumns`) | -| **resizableColumns** | `boolean` | optional | Alternate spelling of `resizable` (the renderer reads `resizable ?? resizableColumns`) | +| **resizable** | `boolean` | optional | Allow column resize (the renderer default is on) | +| **resizableColumns** | `never` | optional | [REMOVED] `object-grid` property `resizableColumns` was removed in @objectstack/spec 17.7.0 (ADR-0049) — it was the legacy second spelling of `resizable`, read only when `resizable` was absent, so one switch had two spellings and a grid authoring both silently ignored this one. Use `resizable`. Rename the key; the value (a boolean) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **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** | `{ 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, … }`) | +| **operations** | `{ create?: boolean; update?: boolean; delete?: boolean; export?: boolean }` | optional | Built-in affordance toggles `{ create?, update?, delete?, export? }` (booleans). A declared block replaces the grid's default: `update` / `delete` not named are withheld from the row menu, `create` enables the add-row affordance, and `export: false` hides the export menu | | **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: [...] }` | @@ -770,7 +770,58 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **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` | +| **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.aggregations[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field whose values the function aggregates within each group (ignored by `count`) | +| **type** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | Aggregation function — `count` (the group's row count), `sum`, `avg`, `min`, `max` or `count_distinct` | + +### Nested Shape: `ObjectGridProps.conditionalFormatting[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL) to evaluate. | +| **style** | `Record` | ✅ | CSS styles to apply when condition is true | + +### Nested Shape: `ObjectGridProps.rowColor` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field whose value is looked up in the `colors` map below to pick a row colour (typically a select/status field). The map is what does the colouring — with no `colors`, no row is ever coloured, whatever this field holds. Author-time diagnostic `view/row-color-without-colors` reports that combination. | +| **colors** | `Record` | optional | Map of field value to row colour. The spellings that actually paint a row are not free-form: objectui `plugin-grid`'s `useRowColor` hands a value already written as a complete Tailwind background class (`bg-red-200`) straight through, otherwise lower-cases and trims it and resolves it through its own closed vocabulary of colour NAMES (`red`, `blue`, `slate`, … each mapping to `bg-NAME-100`), and returns undefined for anything else. A hex, an `rgb()` or a CSS variable parses here, publishes, and colours no row — Tailwind v4 has no runtime, so no class can be fabricated from one. Author-time diagnostic `view/row-color-unresolvable-value` reports a value that cannot resolve. | + +### Nested Shape: `ObjectGridProps.bulkActionDefs[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Stable identifier — the audit-log action key, and (for an aggregate def) the name of the object action to dispatch. | +| **label** | `string` | optional | Button + dialog-header text. Plain string: an authored def is not i18n-resolved (declare a real action and name it in `bulkActions` to get localization). | +| **icon** | `string` | optional | Lucide icon name (e.g. "user-check", "trash"). | +| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>` | optional | Visual treatment of the button. | +| **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). | +| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | +| **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | +| **params** | `{ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … }[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | +| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | +| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | +| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | +| **maxRecords** | `integer` | optional | Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request. | +| **batchSize** | `integer` | optional | Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition. | + +### Nested Shape: `ObjectGridProps.navigation` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **mode** | `Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>` | optional (default: `"page"`) | | +| **view** | `never` | optional | [REMOVED] `view.list.navigation.view` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's `onNavigate`, where anything other than `edit` or `view` matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a `record` page to the object and let `isDefault` pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (`mode`, `size`). | +| **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely | +| **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) | +| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. | +| **width** | `string \| number` | optional | [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. | ### Nested Shape: `ObjectGridProps.exportOptions` @@ -782,6 +833,15 @@ Sort field and direction pair | **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.operations` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **create** | `boolean` | optional | Show the add-row affordance (also gated by the user's create permission) | +| **update** | `boolean` | optional | Offer the row menu's generic Edit entry | +| **delete** | `boolean` | optional | Offer the row menu's generic Delete entry | +| **export** | `boolean` | optional | `false` hides the export menu that `exportOptions` enables | + ### Nested Shape: `ObjectGridProps.data[provider='object']` | Property | Type | Required | Description | @@ -794,8 +854,8 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | +| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | +| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | ### Nested Shape: `ObjectGridProps.data[provider='value']` 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 c7e4c9902b9..0846a3167bd 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/` | 188 | 178 | 3 | 0 | 7 | +| `ui/` | 190 | 180 | 3 | 0 | 7 | ## `ui/` — sites @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `app.zod.ts` | 19 | | `bulk-action.zod.ts` | 4 | | `chart.zod.ts` | 8 | -| `component.zod.ts` | 58 | +| `component.zod.ts` | 60 | | `dashboard.zod.ts` | 11 | | `dataset.zod.ts` | 4 | | `i18n.zod.ts` | 1 | @@ -46,7 +46,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `sharing.zod.ts` | 1 | | `view.zod.ts` | 60 | | `widget.zod.ts` | 1 | -| **total** | **188** | +| **total** | **190** | ## `ui/` — open @@ -54,7 +54,7 @@ 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 188**, in 4 file(s). +**7 strip of 190**, in 4 file(s). | File | Strip | Sites | |---|---|---| @@ -62,7 +62,7 @@ over it is here. | `app.zod.ts` | 1 | 19 | | `view.zod.ts` | 4 | 60 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **188** | +| **total** | **7** | **190** | | Bucket | Sites | |---|---| From 8fec2c155e409db5abb7f39c958a219325aadf9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:52:51 +0000 Subject: [PATCH 6/9] wip: changeset for the object-grid typed members and the resizableColumns retirement Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- .changeset/21445-object-grid-typed-members.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .changeset/21445-object-grid-typed-members.md diff --git a/.changeset/21445-object-grid-typed-members.md b/.changeset/21445-object-grid-typed-members.md new file mode 100644 index 00000000000..3e7f3b19e71 --- /dev/null +++ b/.changeset/21445-object-grid-typed-members.md @@ -0,0 +1,52 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an `object-grid` page block's props type the seven members the grid reads with a fixed shape, and the legacy `resizableColumns` spelling is retired in favour of `resizable` (#21445) + +Clause-②: no (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`** + +- **Seven members of `ComponentPropsMap['object-grid']` are typed.** Each was `z.unknown()` (`bulkActionDefs` an array of it), although the console's `ObjectGrid` reads each with one shape. Any value passed, and the grid answered an off-shape one with a silent default: `rowHeight: 42` rendered as a compact grid, and an aggregation with an unknown function drew no number. Each member now takes the shape the grid reads: + - `rowHeight` is the list view's `RowHeightSchema`: `compact`, `short`, `medium`, `tall` or `extra_tall`. These are exactly the five values the grid admits. + - `rowColor` is the list view's `RowColorConfigSchema`, `{ field, colors }`. + - `navigation` is the list view's `NavigationConfigSchema`, the same carrier `object-kanban`, `object-calendar` and `object-timeline` take. + - `conditionalFormatting` is the list view's own member, `[{ condition, style }]`, with a CEL `condition` and a CSS `style` map. + - `bulkActionDefs` is an array of the list view's `BulkActionDefSchema`. + - `aggregations` is `[{ field, type }]`, with `type` drawn from the query AST's aggregation functions (`count`, `sum`, `avg`, `min`, `max`, `count_distinct`). No list-view schema declares this member, so the shape is the one the grid's grouping reads. + - `operations` is `{ create?, update?, delete?, export? }`, the four booleans a grid read point names. `read` and `import` are refused with the reason: no grid read point reads either. +- **`resizableColumns` is retired.** It was the legacy second spelling of `resizable`, read only when `resizable` was absent, so a grid authoring both silently ignored it. It is now a `retiredKey()` tombstone: writing it fails `tsc` (the input type is `never`) and fails the parse with a prescription naming `resizable`. Nothing in either repository wrote it. +- **`ObjectGridProps`** (and `ObjectGridPropsParsed`) carry those types instead of `unknown`, and `resizableColumns` is `never`. + +## FROM → TO + +| you wrote on an `object-grid` | write instead | +|:--|:--| +| `resizableColumns: false` | `resizable: false` — the same boolean | +| `resizableColumns: true` beside `resizable: false` | `resizable: false` — the grid has always followed `resizable` | +| `rowHeight: 42`, `rowHeight: 'comfortable'` | `rowHeight: 'medium'`, or another of `compact` / `short` / `tall` / `extra_tall` | +| `rowColor: 'red'` | `rowColor: { field: 'status', colors: { overdue: 'red' } }` | +| `navigation: 'drawer'` | `navigation: { mode: 'drawer' }` | +| `conditionalFormatting: [{ field: 'status', operator: 'equals', value: 'late', backgroundColor: '#fee2e2' }]` | `conditionalFormatting: [{ condition: "record.status == 'late'", style: { backgroundColor: '#fee2e2' } }]` | +| `aggregations: [{ field: 'amount', type: 'median' }]` | a function the grid computes: `count`, `sum`, `avg`, `min`, `max` or `count_distinct` | +| `operations: { create: true, read: true, import: false }` | `operations: { create: true }` — delete `read` and `import`; nothing reads them | + +The one-line fix: rename `resizableColumns` to `resizable`, and write each of the seven members in the shape the list view declares for the same key (`aggregations` as `[{ field, type }]`, `operations` as four booleans). `os migrate meta --from 17` lists the mechanical `resizableColumns` edits for existing sources. + +## The retirement kit + +- **Tombstone.** `resizableColumns` is a `retiredKey()` on `ObjectGridPropsSchema`; its authorable-surface line carries `[RETIRED]`. +- **Conversion.** `object-grid-resizable-columns-removed` (protocol 18, retired from the load path) follows the renderer's own precedence. It moves the value to `resizable` when `resizable` is absent, and deletes the key as a lossless strip when `resizable` holds a value. Its D3 record is the semantic entry `object-grid-resizable-columns-retired`, which carries the judgment for a grid that authored both keys with different values. +- **Registration.** `RETIRED_KEYS_BY_MAJOR[18]` carries `ui/ObjectGridProps:resizableColumns`. +- **The typed members** have the D3 entry `ui-object-grid-row-members-typed` and no conversion. Nothing on the load path refuses their shapes, and an off-shape value has no rewrite that keeps what the grid shows while honouring what the author wrote. + +## Who is affected, measured + +- **objectstack.** Measured on `origin/main` `53fd35e3e3`: zero `object-grid` blocks author any of the seven members or `resizableColumns` in the examples, `@objectstack/platform-objects`, the spec tests, the documentation and the published skills. The control: the same census finds the two showcase grids' `columns`. +- **objectui.** Measured at the `.objectui-sha` pin, over 76 `object-grid` property bags in its sources, tests and documentation (23 of them in parsed JSON documents). One documentation example, the repository README's data grid, authors `operations.read: true`, which this row now refuses. No other bag authors a refused shape. The control: the same census finds `columns` in 46 bags. +- **Deployed metadata** was not measured. From 06e9c388ce10ca65683e8414f19193c9c93af8a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:53:26 +0000 Subject: [PATCH 7/9] =?UTF-8?q?wip:=20changeset=20declares=20the=20measure?= =?UTF-8?q?d=20Clause-=E2=91=A1=20arm=20(yes,=20narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- .changeset/21445-object-grid-typed-members.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/21445-object-grid-typed-members.md b/.changeset/21445-object-grid-typed-members.md index 3e7f3b19e71..cde5a058ab4 100644 --- a/.changeset/21445-object-grid-typed-members.md +++ b/.changeset/21445-object-grid-typed-members.md @@ -4,7 +4,7 @@ feat(spec)!: an `object-grid` page block's props type the seven members the grid reads with a fixed shape, and the legacy `resizableColumns` spelling is retired in favour of `resizable` (#21445) -Clause-②: no (narrowing) +Clause-②: yes (narrowing) From 45e0f6cc31e3c200824890b5f1d8a0f4d275e144 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:21:56 +0000 Subject: [PATCH 8/9] wip(spec): keep the step-18 fragment's pin citation on one line for check:objectui-pin-citations Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 7cc0f849e4e..3652c68d936 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5765,8 +5765,9 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ 'It also retires `object-grid`\'s `resizableColumns` (#21445, ADR-0049 enforce-or-remove; ' + 'objectui#6152 ruling A, `resizable` is canonical, under the startup rule of immediate ' + 'retirement): the legacy second spelling of `resizable`, read only as ' - + '`schema.resizable ?? schema.resizableColumns` (measured at the `.objectui-sha` pin ' - + '`89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two spellings, and zero ' + + '`schema.resizable ?? schema.resizableColumns` (measured at the ' + + '`.objectui-sha` pin `89cad75d55`, `plugin-grid/src/ObjectGrid.tsx:5361`). One switch, two ' + + 'spellings, and zero ' + 'writers in either repository, so there is no window. A retiredKey tombstone on ' + '`ObjectGridPropsSchema` with one D2 conversion that follows the renderer\'s precedence: the ' + 'value moves to `resizable` when that is absent, and strips as a lossless delete when it is ' From 52c4c42d72858f679b3348f8caede1da7e7e4b45 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:52:34 +0000 Subject: [PATCH 9/9] =?UTF-8?q?wip(spec):=20say=20what=20an=20off-vocabula?= =?UTF-8?q?ry=20aggregation=20drew=20=E2=80=94=20a=20zero=20nothing=20comp?= =?UTF-8?q?uted,=20or=20no=20number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude --- .changeset/21445-object-grid-typed-members.md | 2 +- .../entries/semantic/18.ui-object-grid-row-members-typed.ts | 2 +- packages/spec/src/migrations/registry.ts | 2 +- packages/spec/src/ui/component.zod.ts | 5 +++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.changeset/21445-object-grid-typed-members.md b/.changeset/21445-object-grid-typed-members.md index cde5a058ab4..f6b19aded8a 100644 --- a/.changeset/21445-object-grid-typed-members.md +++ b/.changeset/21445-object-grid-typed-members.md @@ -12,7 +12,7 @@ Clause-②: yes (narrowing) **`@objectstack/spec`** -- **Seven members of `ComponentPropsMap['object-grid']` are typed.** Each was `z.unknown()` (`bulkActionDefs` an array of it), although the console's `ObjectGrid` reads each with one shape. Any value passed, and the grid answered an off-shape one with a silent default: `rowHeight: 42` rendered as a compact grid, and an aggregation with an unknown function drew no number. Each member now takes the shape the grid reads: +- **Seven members of `ComponentPropsMap['object-grid']` are typed.** Each was `z.unknown()` (`bulkActionDefs` an array of it), although the console's `ObjectGrid` reads each with one shape. Any value passed, and the grid answered an off-shape one with a silent default: `rowHeight: 42` rendered as a compact grid, and an aggregation with an unknown function drew a zero nothing computed, or no number at all. Each member now takes the shape the grid reads: - `rowHeight` is the list view's `RowHeightSchema`: `compact`, `short`, `medium`, `tall` or `extra_tall`. These are exactly the five values the grid admits. - `rowColor` is the list view's `RowColorConfigSchema`, `{ field, colors }`. - `navigation` is the list view's `NavigationConfigSchema`, the same carrier `object-kanban`, `object-calendar` and `object-timeline` take. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-row-members-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-row-members-typed.ts index 64079d1b79c..ee11d9d070e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-row-members-typed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-row-members-typed.ts @@ -30,7 +30,7 @@ export const entry: SemanticMigration = { + 'answered an off-shape one with a silent default: an off-preset `rowHeight` such as `42` ' + 'rendered as `compact`, a `rowColor` of the wrong shape coloured no row, a `navigation` written ' + 'as a bare mode string opened the record page whatever it named, an aggregation with an ' - + 'unknown function drew no number, and an `operations` ' + + 'unknown function drew a zero nothing computed or no number at all, and an `operations` ' + 'toggle nothing reads toggled nothing. The row now takes the list view\'s own schemas for the ' + 'five members a list view declares, and the measured shape for `aggregations` and ' + '`operations`, so one value is judged the same way on both doors. It is read where every page ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3652c68d936..b2ccf5daad7 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -19313,7 +19313,7 @@ const step18: MigrationStep = { + 'answered an off-shape one with a silent default: an off-preset `rowHeight` such as `42` ' + 'rendered as `compact`, a `rowColor` of the wrong shape coloured no row, a `navigation` written ' + 'as a bare mode string opened the record page whatever it named, an aggregation with an ' - + 'unknown function drew no number, and an `operations` ' + + 'unknown function drew a zero nothing computed or no number at all, and an `operations` ' + 'toggle nothing reads toggled nothing. The row now takes the list view\'s own schemas for the ' + 'five members a list view declares, and the measured shape for `aggregations` and ' + '`operations`, so one value is judged the same way on both doors. It is read where every page ' diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 6bb33aa532b..9b6bb1dd97d 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3800,7 +3800,7 @@ const GridAggregationSchema = lazySchema(() => strictObject({ surface: 'this `object-grid` aggregation', history: 'Until this shape was declared, `aggregations` was `z.unknown()`: an unknown function, a ' - + 'missing `field` or a mis-spelled key passed, and the group header drew no number.', + + 'missing `field` or a mis-spelled key passed, and the group header drew a `0` nothing computed, or no number at all.', }, { field: z.string().describe('Field whose values the function aggregates within each group (ignored by `count`)'), type: AggregationFunction.describe('Aggregation function — `count` (the group\'s row count), `sum`, `avg`, `min`, `max` or `count_distinct`'), @@ -4144,7 +4144,8 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * `count_distinct`, the query AST's own {@link AggregationFunction} * vocabulary, by reference. `count` is the group's row count whatever * `field` names. An object, an unknown function or a missing `field` used to - * pass here and draw no number. + * pass here and draw a `0` nothing computed (client-side grouping) or no + * number at all (server-side grouping). */ aggregations: z.array(GridAggregationSchema).optional() .describe('Per-group aggregations drawn in a grouped grid\'s group headers — `[{ field, type }]`, `type` one of `count`, `sum`, `avg`, `min`, `max`, `count_distinct` (`count` is the group\'s row count, whatever `field` names)'),