From c57afb409a5b3e2da2385489a0f4a4f370e2aaed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:26:42 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(spec)!:=20object-metric=20drillDown=20?= =?UTF-8?q?and=20compareTo=20take=20the=20shape=20the=20tile=20reads;=20ob?= =?UTF-8?q?ject-grid=20columns=20exits=20its=20hold=20(#21464,=20stage=205?= =?UTF-8?q?)=20=E2=80=94=20WIP=20schemas=20and=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- ...nent-list-family-typed-members.pin.test.ts | 77 ++++-- ...nt-metric-family-typed-members.pin.test.ts | 118 +++++++-- ...omponent-props-unknown-members.pin.test.ts | 53 ++-- packages/spec/src/ui/component.zod.ts | 231 ++++++++++++++---- 4 files changed, 368 insertions(+), 111 deletions(-) diff --git a/packages/spec/src/ui/component-list-family-typed-members.pin.test.ts b/packages/spec/src/ui/component-list-family-typed-members.pin.test.ts index 9d8fa6aef06..9e3f9f0b59f 100644 --- a/packages/spec/src/ui/component-list-family-typed-members.pin.test.ts +++ b/packages/spec/src/ui/component-list-family-typed-members.pin.test.ts @@ -1,22 +1,26 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#21464, stage 2] Eight of the list family's nine `z.unknown()` members are - * typed: `object-grid` `fields`, `selection`, `selectable`, `rowActions`, - * `bulkActions` and `batchActions`, `object-kanban` `columns`, and - * `object-calendar` `calendar`. The ninth, `object-grid` `columns`, is held in - * the enumeration pin's ledger: the grid draws a column's `options`, which the - * list view's column entry does not declare (objectstack-ai/objectui#11544). + * [#21464, stages 2 and 5] The list family's nine `z.unknown()` members are + * typed: `object-grid` `columns`, `fields`, `selection`, `selectable`, + * `rowActions`, `bulkActions` and `batchActions`, `object-kanban` `columns`, + * and `object-calendar` `calendar`. Stage 2 typed eight and held `object-grid` + * `columns`, because at the `.objectui-sha` pin `89cad75d55` the grid's group + * headers drew a column's `options`, which the list view's column entry does + * not declare. objectui `b0bf413` (objectstack-ai/objectui#11544) retired that + * read: at the `.objectui-sha` pin `ab1879721595` the group-header labels come + * from the object field's `options` only, so stage 5 types `columns` too. * * ## The defect this file closes * * Each renderer reads these members with one shape (measured at the * `.objectui-sha` pin `89cad75d55`; the read points are in the members' - * docblocks), and each row declared them `z.unknown()`. So an object entry in - * the grid's `fields`, a `{ name }` entry in `bulkActions`, a kanban lane list - * mixing objects and strings and a calendar block with no `startDateField` all - * passed the component-props gate, and the block drew no column, skipped the - * action, drew a blank lane or placed no event, with no report. + * docblocks), and each row declared them `z.unknown()`. So a grid column keyed + * `accessorKey`, an object entry in the grid's `fields`, a `{ name }` entry in + * `bulkActions`, a kanban lane list mixing objects and strings and a calendar + * block with no `startDateField` all passed the component-props gate, and the + * block drew no column, skipped the action, drew a blank lane or placed no + * event, with no report. * * ## What is pinned, and why each half * @@ -24,15 +28,15 @@ * 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 — and, for the kanban's two-array union, the issue - * inside the arm that should have taken it — so a refusal for the wrong reason reds. - * - §3 ONE SCHEMA: the four members a list view also declares hold the list + * code AND the path — and, for the two-array unions, the issue inside the + * arm that should have taken it — 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 (`batchActions` holds `bulkActions`'s), and the * shapes declared here hold exactly the measured vocabulary. * - §4 THE REGISTRATION: the ADR-0087 D3 entry step 18 carries. * * The enumeration pin (`component-props-unknown-members.pin.test.ts`) holds the - * other half: these eight left its ledger, so a member reverted to + * other half: these nine left its ledger, so a member reverted to * `z.unknown()` reds there. */ @@ -71,6 +75,15 @@ function armIssues(result: z.ZodSafeParseResult): string[][] { describe('§1 each member accepts a value of its declared shape', () => { const BYTE_IDENTICAL: ReadonlyArray]> = [ + // The showcase's grids (`examples/app-showcase/src/ui/pages/my-work.page.ts`, `command-center.page.ts`). + ['grid columns as field names', 'object-grid', { columns: ['title', 'project', 'status', 'priority', 'due_date'] }], + ['grid columns as column entries', 'object-grid', { + columns: [ + { field: 'name', label: 'Name', width: 240, sortable: true, link: true }, + { field: 'amount', align: 'right', summary: 'sum', wrap: true, pinned: 'left', type: 'currency' }, + { field: 'notes', hidden: true, resizable: false, action: 'open_note' }, + ], + }], ['grid fields', 'object-grid', { fields: ['name', 'amount'] }], ['each selection type', 'object-grid', { selection: { type: 'single' } }], ['grid rowActions', 'object-grid', { rowActions: ['edit', 'delete', 'approve'] }], @@ -111,6 +124,14 @@ describe('§1 each member accepts a value of its declared shape', () => { }); } + it('parses a column with a prefix to exactly what the list view\'s columns answer (its default included)', () => { + const columns = [{ field: 'name', prefix: { field: 'status' } }]; + const r = parse('object-grid', { columns }); + expect(issues(r)).toEqual([]); + expect(r.success && (r.data as { columns?: unknown }).columns) + .toStrictEqual(ListViewSchema.shape.columns.parse(columns)); + }); + it('parses an empty selection block to exactly what the list view\'s selection answers (its default included)', () => { const r = parse('object-grid', { selection: {} }); expect(issues(r)).toEqual([]); @@ -158,9 +179,26 @@ describe('§2 each member refuses an off-shape value', () => { }); } - // The kanban's two-array union answers `invalid_union` at the member; the arm that + // The two-array unions answer `invalid_union` at the member; the arm that // should have taken the value says why it did not. const UNION_REFUSED: ReadonlyArray, arms: string[][]]> = [ + ['a grid column list mixing strings and column objects', 'object-grid', + { columns: ['name', { field: 'amount' }] }, [['invalid_type@1'], ['invalid_type@0']]], + ['a grid column keyed accessorKey / header', 'object-grid', + { columns: [{ accessorKey: 'amount', header: 'Amount' }] }, [['invalid_type@0'], ['invalid_type@0.field', 'unrecognized_keys@0']]], + ['a grid column keyed name', 'object-grid', + { columns: [{ name: 'salary' }] }, [['invalid_type@0'], ['invalid_type@0.field', 'unrecognized_keys@0']]], + ['a grid column with no field', 'object-grid', + { columns: [{ field: 'name' }, { width: 80 }] }, [['invalid_type@0', 'invalid_type@1'], ['invalid_type@1.field']]], + ['a grid column key the grid never reads (editable)', 'object-grid', + { columns: [{ field: 'name', editable: false }] }, [['invalid_type@0'], ['unrecognized_keys@0']]], + ['a grid column `options` (the group headers read the object field\'s)', 'object-grid', + { columns: [{ field: 'stage', options: [{ value: 'won', label: 'Won' }] }] }, [['invalid_type@0'], ['unrecognized_keys@0']]], + ['a grid column `reference` (relational metadata is the object field\'s)', 'object-grid', + { columns: [{ field: 'account_id', type: 'lookup', reference: 'crm_account' }] }, [['invalid_type@0'], ['unrecognized_keys@0']]], + ['a grid column summary hint the footer reads off the field (precision)', 'object-grid', + { columns: [{ field: 'amount', summary: 'sum', precision: 0 }] }, [['invalid_type@0'], ['unrecognized_keys@0']]], + ['a numeric grid columns', 'object-grid', { columns: 42 }, [['invalid_type@'], ['invalid_type@']]], ['a kanban lane list mixing objects and strings', 'object-kanban', { columns: [{ id: 'done', title: 'Done' }, 'todo'] }, [['invalid_type@0'], ['invalid_type@1']]], ['a numeric lane id', 'object-kanban', { columns: [{ id: 1, title: 'One' }] }, [['invalid_type@0'], ['invalid_type@0.id']]], @@ -203,6 +241,10 @@ describe('§3 the members hold the list view\'s own defs, and the measured vocab const grid = () => ObjectGridPropsSchema.shape; const listView = () => ListViewSchema.shape; + it('object-grid columns is the list view\'s own columns member — the same union def', () => { + expect(grid().columns.unwrap()._zod.def).toBe(listView().columns._zod.def); + }); + it('object-grid selection, rowActions and bulkActions are the list view\'s own members — the same defs', () => { expect(grid().selection.unwrap()._zod.def).toBe(SelectionConfigSchema._zod.def); expect(grid().selection.unwrap()._zod.def).toBe(listView().selection.unwrap()._zod.def); @@ -238,9 +280,10 @@ describe('§3 the members hold the list view\'s own defs, and the measured vocab // ─────────────────────────────────────────────────────────────────────────── describe('§4 the ADR-0087 entry', () => { - it('is registered as the D3 entry step 18 carries, beside the stage-1 entry', () => { + it('is registered as the D3 entries step 18 carries — stage 2\'s and stage 5\'s — beside the stage-1 entry', () => { const ids = MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id); expect(ids).toContain('ui-object-grid-kanban-calendar-list-members-typed'); + expect(ids).toContain('ui-object-grid-columns-typed'); expect(ids).toContain('ui-object-map-gantt-tree-navigation-typed'); }); }); diff --git a/packages/spec/src/ui/component-metric-family-typed-members.pin.test.ts b/packages/spec/src/ui/component-metric-family-typed-members.pin.test.ts index 7f03e9f4401..d9ef5840b10 100644 --- a/packages/spec/src/ui/component-metric-family-typed-members.pin.test.ts +++ b/packages/spec/src/ui/component-metric-family-typed-members.pin.test.ts @@ -1,23 +1,27 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#21464, stage 4] Two of the metric tile's four `z.unknown()` members are - * typed: `object-metric` `aggregate` and `trend`. The other two stay in the - * enumeration pin's ledger, each waiting on a ruling: the by-reference - * candidate for `drillDown` (the chart's `ChartDrillDownSchema`) declares - * `filter`, which the tile never reads, and refuses `report`, which it draws; - * the candidate for `compareTo` (the dashboard widget's) declares `dimension`, - * which this path never reads. + * [#21464, stages 4 and 5] The metric tile's four `z.unknown()` members are + * typed: `object-metric` `aggregate` and `trend` (stage 4), and `drillDown` and + * `compareTo` (stage 5, each typed to the tile's read per the seat's ruling B): + * the drill-down's five list members are the chart drill-down's by reference, + * with `filter` and `mode` refused by name, and the comparison is `{ kind }`, + * with `kind` the dashboard comparison's by reference and `dimension` refused + * by name. The drill-down's `report` stays in the enumeration pin's ledger, + * held until the spec declares a drill report. * * ## The defect this file closes * - * Both members are read with one shape (measured at the `.objectui-sha` pin - * `89cad75d55`; the read points are in the schemas' docblocks), and the row - * declared them `z.unknown()`. So `aggregate: 'count'`, a function the engine - * does not have, `groupby` for `groupBy`, a bare `trend: 'up'` and a `trend` with no - * `value` all passed the component-props gate, and the tile asked the server - * for a measure it could not answer, drew one ungrouped number, or painted a - * lone `%` — with no report. + * Each member is read with one shape (measured at the `.objectui-sha` pin + * `89cad75d55` for `aggregate` / `trend`, and at the `.objectui-sha` pin + * `ab1879721595` for `drillDown` / `compareTo`; the read points are in the + * schemas' docblocks), and the row declared them `z.unknown()`. So + * `aggregate: 'count'`, a function the engine does not have, `groupby` for + * `groupBy`, a bare `trend: 'up'`, a `trend` with no `value`, a drill `filter` + * and a comparison `dimension` all passed the component-props gate, and the + * tile asked the server for a measure it could not answer, drew one ungrouped + * number, painted a lone `%`, or ignored the drill filter and the dimension — + * with no report. * * ## What is pinned, and why each half * @@ -32,19 +36,23 @@ * identity; the one rule restated here (a function other than `count` needs * a `field`) answers exactly as the chart aggregate's does, over the chart's * vocabulary, and `count_distinct` needs a field too. `trend` declares - * exactly the three members the badge draws. - * - §4 THE REGISTRATION: the ADR-0087 D3 entry step 18 carries. + * exactly the three members the badge draws. The drill-down's five list + * members are the chart drill-down's defs and `compareTo.kind` the dashboard + * comparison's, by identity, and each shape declares exactly the members the + * tile reads. + * - §4 THE REGISTRATION: the ADR-0087 D3 entries step 18 carries. * * The enumeration pin (`component-props-unknown-members.pin.test.ts`) holds the - * other half: these two left its ledger, so a member reverted to `z.unknown()` - * reds there. + * other half: these four left its ledger, so a member reverted to + * `z.unknown()` reds there, and the held `drillDown.report` is listed there. */ import { describe, it, expect } from 'vitest'; import type { z } from 'zod'; import { ComponentPropsMap, ObjectMetricPropsSchema } from './component.zod'; -import { ChartAggregateSchema, ChartAggregateFunctionSchema, ChartGroupBySchema } from './chart.zod'; +import { ChartAggregateSchema, ChartAggregateFunctionSchema, ChartDrillDownSchema, ChartGroupBySchema } from './chart.zod'; +import { DashboardWidgetSchema } from './dashboard.zod'; import { AggregationFunction } from '../data/query.zod'; import { I18nLabelSchema } from './i18n.zod'; import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; @@ -89,6 +97,23 @@ describe('§1 each member accepts every shape a measured writer authors', () => ['a trend with a locale-map caption', { trend: { value: 12, direction: 'up', label: { en: 'Revenue records', 'zh-CN': '收入记录' } }, }], + // objectui's drill-down member pins (`plugin-dashboard/src/__tests__/objectMetricDrillDownMembers-8071.test.tsx`, + // `ObjectMetricWidget.drillRoutedToSharedDrawer-8970.test.tsx`) at the `.objectui-sha` pin `ab1879721595`. + ['an empty drill-down block (on)', { drillDown: {} }], + ['a drill-down switched off', { drillDown: { enabled: false } }], + ['a drill-down with every list member', { + drillDown: { enabled: true, title: 'Won deals', target: 'dialog', columns: ['name', 'amount'], maxRows: 5 }, + }], + ['a drill-down that navigates', { drillDown: { enabled: true, target: 'navigate' } }], + ['a drill into a dataset-bound report (held open)', { + drillDown: { + enabled: true, + report: { name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'deals_ds', rows: ['stage'], values: ['amount_sum'] }, + }, + }], + // objectui's comparison pins (`ObjectMetricWidget.compareTo.test.tsx`, `objectMetricTrendMembers-8071.test.tsx`). + ['a comparison with the year before', { compareTo: { kind: 'previousYear' } }], + ['a comparison with the period before', { compareTo: { kind: 'previousPeriod' } }], ]; for (const [label, props] of BYTE_IDENTICAL) { @@ -128,6 +153,16 @@ describe('§2 each member refuses an off-shape value', () => { ['a pre-formatted trend value', { trend: { value: '12%' } }, 'invalid_type', 'trend.value'], ['a direction outside the three', { trend: { value: 12, direction: 'sideways' } }, 'invalid_value', 'trend.direction'], ['a trend member the badge does not draw', { trend: { value: 12, percent: 99, caption: 'Off-list caption' } }, 'unrecognized_keys', 'trend'], + ['a bare-boolean drill-down', { drillDown: true }, 'invalid_type', 'drillDown'], + ['a drill `filter`', { drillDown: { enabled: true, filter: { stage: 'won' } } }, 'unrecognized_keys', 'drillDown'], + ['a drill `mode`', { drillDown: { enabled: true, mode: 'record' } }, 'unrecognized_keys', 'drillDown'], + ['a drill target outside the three', { drillDown: { target: 'popover' } }, 'invalid_value', 'drillDown.target'], + ['a fractional drill page size', { drillDown: { maxRows: 2.5 } }, 'invalid_type', 'drillDown.maxRows'], + ['a drill column object', { drillDown: { columns: [{ field: 'name' }] } }, 'invalid_type', 'drillDown.columns.0'], + ['a bare-string comparison', { compareTo: 'previousYear' }, 'invalid_type', 'compareTo'], + ['a comparison kind outside the two', { compareTo: { kind: 'previousQuarter' } }, 'invalid_value', 'compareTo.kind'], + ['a comparison with no kind', { compareTo: {} }, 'invalid_value', 'compareTo.kind'], + ['a comparison `dimension`', { compareTo: { kind: 'previousYear', dimension: 'close_date' } }, 'unrecognized_keys', 'compareTo'], ]; for (const [label, props, code, path] of REFUSED) { @@ -138,6 +173,23 @@ describe('§2 each member refuses an off-shape value', () => { }); } + it('says a drill `filter` is the metric\'s own, one level up', () => { + const r = parse({ drillDown: { enabled: true, filter: { stage: 'won' } } }); + const message = r.success ? '' : r.error.issues[0]!.message; + expect(message).toMatch(/`filter` is not a member a metric drill-down reads/); + expect(message).toMatch(/scope the metric with its own `filter`/); + }); + + it('says a metric has no row for a drill `mode`', () => { + const r = parse({ drillDown: { enabled: true, mode: 'record' } }); + expect(r.success ? '' : r.error.issues[0]!.message).toMatch(/`mode` is not a member a metric drill-down reads/); + }); + + it('says the tile shifts its own filter\'s date macros, not a `dimension`', () => { + const r = parse({ compareTo: { kind: 'previousYear', dimension: 'close_date' } }); + expect(r.success ? '' : r.error.issues[0]!.message).toMatch(/`dimension` is not read on a metric tile/); + }); + it('says `dateGranularity` goes inside `groupBy`', () => { const r = parse({ aggregate: { field: 'closed_at', function: 'count', dateGranularity: 'month' } }); expect(r.success ? '' : r.error.issues[0]!.message).toMatch(/goes INSIDE `groupBy`/); @@ -190,13 +242,37 @@ describe('§3 `aggregate` holds the engine\'s functions and the chart\'s `groupB }); }); +describe('§3 `drillDown` holds the chart drill-down\'s list members, and `compareTo` the dashboard comparison\'s `kind`', () => { + const drillDown = () => ObjectMetricPropsSchema.shape.drillDown.unwrap(); + const compareTo = () => ObjectMetricPropsSchema.shape.compareTo.unwrap(); + + it('each drill list member is the chart drill-down\'s own — the same def', () => { + for (const member of ['enabled', 'title', 'target', 'columns', 'maxRows'] as const) { + expect(drillDown().shape[member]._zod.def, member).toBe(ChartDrillDownSchema.shape[member]._zod.def); + } + }); + + it('declares exactly the six members the tile reads — the chart\'s `filter` is not one', () => { + expect(Object.keys(drillDown().shape).sort()).toEqual(['columns', 'enabled', 'maxRows', 'report', 'target', 'title']); + expect(Object.keys(ChartDrillDownSchema.shape)).toContain('filter'); + }); + + it('`compareTo.kind` is the dashboard comparison\'s own — the same def — and `kind` is the one member', () => { + expect(compareTo().shape.kind._zod.def).toBe(DashboardWidgetSchema.shape.compareTo.unwrap().shape.kind._zod.def); + expect(Object.keys(compareTo().shape)).toEqual(['kind']); + expect(Object.keys(DashboardWidgetSchema.shape.compareTo.unwrap().shape)).toContain('dimension'); + }); +}); + // ─────────────────────────────────────────────────────────────────────────── // §4 the registration // ─────────────────────────────────────────────────────────────────────────── -describe('§4 the ADR-0087 entry', () => { - it('is registered as the D3 entry step 18 carries, beside the earlier stages\' entries', () => { +describe('§4 the ADR-0087 entries', () => { + it('are registered as the D3 entries step 18 carries, beside the earlier stages\' entries', () => { const ids = MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id); + expect(ids).toContain('ui-object-metric-drill-down-typed'); + expect(ids).toContain('ui-object-metric-compare-to-typed'); expect(ids).toContain('ui-object-metric-aggregate-trend-typed'); expect(ids).toContain('ui-object-form-members-typed'); expect(ids).toContain('ui-object-grid-kanban-calendar-list-members-typed'); diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index 8225481082a..051afbbab96 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -31,18 +31,20 @@ * the code AND the path; its ADR-0087 D3 entry is registered. * * Later stages pin the members they type in their own file, beside this one: - * the list family (`object-grid` `fields` / `selection` / `selectable` / - * `rowActions` / `bulkActions` / `batchActions`, `object-kanban` `columns`, - * `object-calendar` `calendar`) in - * `component-list-family-typed-members.pin.test.ts`. The family's ninth, - * `object-grid` `columns`, is held below. The form family (`object-form` - * `contentLayout` / `submitBehavior` / `navigateOnSuccess` / `mobile`) in + * the list family (`object-grid` `columns` / `fields` / `selection` / + * `selectable` / `rowActions` / `bulkActions` / `batchActions`, + * `object-kanban` `columns`, `object-calendar` `calendar`) in + * `component-list-family-typed-members.pin.test.ts` — `object-grid` `columns` + * since stage 5, which exited its hold once objectui retired the grid's read of + * a column `options`. The form family (`object-form` `contentLayout` / + * `submitBehavior` / `navigateOnSuccess` / `mobile`) in * `component-form-family-typed-members.pin.test.ts`; its `fields` and * `sections`, and the master-detail form's two, are held below, and its * `customFields` waits with the objectui-held contracts. The metric tile - * (`object-metric` `aggregate` / `trend`) in - * `component-metric-family-typed-members.pin.test.ts`; its `drillDown` and - * `compareTo` wait below on a ruling between the reference and the read. + * (`object-metric` `aggregate` / `trend` / `drillDown` / `compareTo`) in + * `component-metric-family-typed-members.pin.test.ts`; the drill-down's + * `report` waits below with the objectui-held contracts, until the spec + * declares a drill report. * * ## The STAGED reason is debt, not a verdict * @@ -130,8 +132,7 @@ function unknownMembers(schema: unknown): UnknownMember[] { * would refuse a measured writer is reported, not shipped. */ const STAGES = { - 'object-metric': 'the metric tile\'s `drillDown` and `compareTo`, each waiting on a fork: the by-reference candidate (the chart\'s `ChartDrillDownSchema`, the dashboard widget\'s `compareTo`) declares a key the tile never reads, and the chart\'s drill-down refuses one it draws, so the reference and the read are put to a ruling', - 'objectui-held': 'element contracts whose only declaration is still objectui\'s (`GanttMarker`, `TimelineMappingSchema`, the timeline items, `UIActionSchema` — an objectui interface that borrows some members from the spec `Action` — and the runtime form field `FormField`, identity key `name`); the spec declares each first, contract-first, then the row takes it', + 'objectui-held': 'element contracts the spec does not declare yet, whose declaration is still objectui\'s (`GanttMarker`, `TimelineMappingSchema`, the timeline items, `UIActionSchema` — an objectui interface that borrows some members from the spec `Action` — and the runtime form field `FormField`, identity key `name`), and the metric drill-down\'s `report`, which objectui types as the spec\'s own `ReportSchema` input while no spec drill shape declares a `report` member (the chart\'s drill-down refuses it); the spec declares each first, contract-first, then the row takes it', 'held-for-decision': 'a typed shape exists (by reference, or the renderer\'s own declared type), but measured writers author values it refuses that the renderer draws — the narrowing waits for a ruling', } as const; type Stage = keyof typeof STAGES; @@ -177,7 +178,11 @@ const SLOT: Reason = { kind: 'slot' }; const RUNNER: Reason = { kind: 'runner' }; const staged = (stage: Stage, reader: string): Reason => ({ kind: 'staged', stage, reader }); -/** `ObjectUI` source paths are at the `.objectui-sha` pin `89cad75d55`. */ +/** + * `ObjectUI` source paths are at the `.objectui-sha` pin `89cad75d55`, except + * the `object-metric` `drillDown.report` line, read at the `.objectui-sha` pin + * `ab1879721595`. + */ const LEDGER = new Map(); const on = (types: readonly string[], paths: readonly string[], reason: Reason): void => { for (const type of types) for (const path of paths) LEDGER.set(`${type} ${path}`, reason); @@ -229,15 +234,13 @@ on(['object-grid'], ['pagination.*'], { }); // Read with a fixed shape at the pin — the later stages. -// The metric tile's `aggregate` and `trend` are typed (stage 4). Its other two -// wait on a fork between the by-reference candidate and the read: the chart's -// drill-down declares `filter`, which the tile never reads, and refuses -// `report`, which the tile draws as a report body (`DrillDownDrawer.tsx:77-114`; -// objectui's `plugin-dashboard/src/__tests__/objectMetricDrillDownMembers-8071.test.tsx:277-294` -// authors one); the dashboard widget's comparison declares `dimension`, which -// this path never reads (`core/src/utils/compare-to.ts:28-33`). -on(['object-metric'], ['drillDown'], staged('object-metric', 'plugin-dashboard/src/ObjectMetricWidget.tsx:602-651 (`enabled` / `title` / `target` / `columns` / `maxRows` / `report`; `ObjectMetricDrillDownConfig`, :218, refuses `filter` and `mode`)')); -on(['object-metric'], ['compareTo'], staged('object-metric', 'plugin-dashboard/src/ObjectMetricWidget.tsx:468-469, :581 (`kind` alone; `CompareToConfig`, :241)')); +// The metric tile's four members are typed (stages 4 and 5); the drill-down's +// `report` is not. The tile hands it to the shared drawer, which draws a +// dataset-bound report (`isDatasetBoundReport`) and lists the records for any +// other value; objectui types it as the spec's own `ReportSchema` input, but no +// spec drill shape declares a `report` member — the chart's drill-down refuses +// it — so the spec declares that contract first. +on(['object-metric'], ['drillDown.report'], staged('objectui-held', 'plugin-dashboard/src/DrillDownDrawer.tsx:92 (`isDatasetBoundReport`), used at :115; handed over at ObjectMetricWidget.tsx:742')); // The form's inline members are objectui's runtime form field (`FormField`, // identity key `name`), merged over the generated set and drawn whole; the spec // declares no such field — its own form field is keyed by `field`, and the @@ -255,14 +258,6 @@ on(['action:menu'], ['actions[]{}'], staged('objectui-held', 'components/src/ren // `types/src/__tests__/kanban-conditional-formatting.test.ts:29-52`), so the // narrowing is reported for a ruling instead of shipped. on(['object-kanban'], ['conditionalFormatting'], staged('held-for-decision', 'plugin-kanban/src/KanbanBoardCore.tsx:114, evaluated at KanbanImpl.tsx:179 (`resolveConditionalFormatting`)')); -// The list view's own `columns` is the by-reference shape, and the draw path -// matches it — but the grid's group-header formatter also reads `options` off -// an authored column (`colOverride?.options || objectDefField?.options`, the -// column winning) and draws the group labels from it, which objectui pins as -// behaviour (`plugin-grid/src/__tests__/gridGroupingMembers-8071.test.tsx:260-301`). -// `ListColumn` declares no `options`, so the narrowing would refuse a value the -// grid draws: held until objectstack-ai/objectui#11544 is ruled. -on(['object-grid'], ['columns[]'], staged('held-for-decision', 'plugin-grid/src/ObjectGrid.tsx:2158 (`normalizeColumns`), `columns[].options` drawn by the group-header formatter at :2997-3001')); // The renderer's own declared type for the form's `fields` is field-name // strings (`ObjectFormSchema.fields: string[]`), but its read also draws a // `{ name }` entry by that name, and measured writers author one: objectui's diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 5881437da05..1da36f2c8b0 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -56,7 +56,12 @@ import { ACTION_TARGET_ALIASES } from './action-target-aliases'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; // [#21464] `object-metric.aggregate.groupBy` is the chart aggregate's own // `groupBy` union, by reference — the tile routes it exactly as the chart does. -import { ChartGroupBySchema } from './chart.zod'; +// `object-metric.drillDown`'s five list members are the chart drill-down's own +// members, by reference — the tile opens the same shared drawer. +import { ChartDrillDownSchema, ChartGroupBySchema } from './chart.zod'; +// [#21464] `object-metric.compareTo.kind` is the dashboard widget comparison's +// own `kind` vocabulary, by reference — the executor's two kinds. +import { DashboardWidgetSchema } from './dashboard.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; import { lazySchema } from '../shared/lazy-schema'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; @@ -4008,7 +4013,8 @@ const GridOperationsSchema = lazySchema(() => strictObject({ * three a list view also declares take the list view's own members by * reference; `fields` and `selectable` have no list-view counterpart and * declare the measured shape here; `batchActions` takes `bulkActions`'s def. - * `columns` stays `z.unknown()`, held for a ruling — see the member. + * `columns` was held at `z.unknown()` for a ruling, and takes the list view's + * own member by reference since stage 5 — see the member. */ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ surface: 'this `object-grid`', @@ -4063,25 +4069,41 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ emptyState: EmptyStateSchema.optional() .describe('What the grid draws instead of an empty table: `{ title, message, icon }` — the list view\'s own empty-state shape'), /** - * [#21464] HELD at `z.unknown()` for a ruling, not typed. The by-reference - * candidate is the list view's own `columns` member - * (`ListViewSchema.shape.columns`: all field-name strings, or all strict - * `ListColumn` entries), and the draw path matches it: `normalizeColumns` - * (`ObjectGrid.tsx:819` at the pin `89cad75d55`, read at `:2158`) decides - * by the FIRST entry, and only an entry with a non-empty string `field` - * draws a column. But the grid also reads `options` off an authored column: - * the group-header formatter (`:2997-3001`) takes the column whose `field` - * is the grouping field and draws the group-header labels from - * `colOverride?.options || objectDefField?.options`, the column's list - * winning — and objectui's own `gridGroupingMembers-8071` test pins that as - * behaviour. `ListColumn` declares no `options`, so the by-reference shape - * would refuse a value the grid draws. The renderer-side read is carded as - * objectstack-ai/objectui#11544; the member is typed once that is ruled. - * (A column `editable` key, by contrast, is read nowhere off an authored - * column.) + * [#21464] The list view's own `columns` member, by reference + * (`ListViewSchema.shape.columns` — the union of two array shapes the column + * entry is declared in): all field-name strings, or all strict `ListColumn` + * entries. Optional here, where the list view requires it: a grid with no + * `columns` derives them from `fields` or the object. + * + * Read at the `.objectui-sha` pin `ab1879721595` + * (`plugin-grid/src/ObjectGrid.tsx`): `normalizeColumns` (`:819`) decides + * between the two arms by the FIRST entry, and the draw path (`:3367`) keeps + * only an entry `resolvesToDataColumn` admits (`:3451` — a non-empty string + * `field`, not `hidden`), reading `ListColumn`'s own members off it. So a + * mixed array, a column keyed `accessorKey` / `header` / `name`, or a key the + * grid never reads off a column (`editable`, `reference`, `options`) drew no + * column or was ignored, and passed the component-props gate. + * + * Stage 2 HELD this member at `z.unknown()` on one read: at the pin + * `89cad75d55` the group-header formatter drew the header labels from + * `colOverride?.options || objectDefField?.options`, the authored column's + * list winning, and `ListColumn` declares no `options`. objectui `b0bf413` + * (objectstack-ai/objectui#11544) retired that read: at `ab1879721595` + * `groupValueFormatter` (`:2979`) looks the column up typed as `ListColumn` + * (`:3004-3007`) for its declared `type` alone, and takes the labels from + * `objectDefField?.options` only (`:3010`). The hold's exit condition is + * met, so the member takes the reference it was held from. + * + * ⚠️ One read at that pin still takes keys `ListColumn` does not declare off + * an authored column: the footer summary formats a column's total with the + * column's `currency`, `defaultCurrency`, `precision` and `scale` ahead of + * the field's (`plugin-grid/src/useColumnSummary.ts:558-568`). No measured + * writer authors one on an `object-grid` column, and a list view's columns — + * the same entry — refuse all four, so this row refuses them too; the read + * is reported to the objectui side, as the `options` read was. */ - columns: z.array(z.unknown()).optional() - .describe('Columns: field names or column definition objects'), + columns: ListViewSchema.shape.columns.optional() + .describe('Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view\'s `columns` declares. One spelling per list: an array mixing strings and column objects is refused'), /** * [#21464] Field NAMES. No list-view schema declares this member, so the * shape is the one the grid reads (`ObjectGrid.tsx:1946` at the pin @@ -4520,8 +4542,8 @@ export type ObjectGridProps = z.input; * type-alias convention pin's default-free family (the Iso839 line deleted * with this alias), on the `RecordAlertPropsParsed` route its comment * prescribes. The list view's own members taken by reference since carry - * their defaults too (#21445, #21464: `navigation`'s four and - * `selection.type`). + * their defaults too (#21445, #21464: `navigation`'s four, `selection.type` + * and a column's `prefix.type`). */ export type ObjectGridPropsParsed = z.infer; @@ -4620,6 +4642,138 @@ const ObjectMetricTrendSchema = lazySchema(() => strictObject({ direction: z.enum(['up', 'down', 'neutral']).optional().describe('Arrow beside the value; omit it for no arrow'), })); +/** + * [#21464] The `object-metric` tile's `drillDown` — the click-through to the + * records behind the number, as the tile reads it at the `.objectui-sha` pin + * `ab1879721595` (`plugin-dashboard/src/ObjectMetricWidget.tsx`): `enabled` + * decides whether the tile is clickable (`:696`, `isDrillEnabled` — a present + * block is on unless `enabled: false`), `title` heads the panel (`:708`, + * `resolveDrillTitle` against an EMPTY click event, falling back to the tile's + * `title`, then its `label`), and `target`, `columns` and `maxRows` reach the + * shared `DrillDownDrawer` the tile opens (`:732-744`; `maxRows` defaults to + * the tile's page size of 25). The drilled list is scoped by the metric's own + * resolved `filter`. + * + * Those five are the chart drill-down's own members, by reference + * ({@link ChartDrillDownSchema}`.shape` — the same defs; `title` carries a + * describe of its own, because a metric tile has no click context for the + * chart's `${event.*}` tokens to resolve against). The SHAPE is the tile's, + * not the chart's, in two places: + * + * - `filter` and `mode` are REFUSED BY NAME, with the prescriptions objectui's + * own type for this block carries (`ObjectMetricDrillDownConfig`, + * `types/src/data-display.ts:2628`): a drill `filter` is interpolated + * against a click event and the metric has none, and `mode` chooses + * drill-to-record for a clicked row and the metric has no row. The chart's + * drill-down declares `filter`, which is why the chart's shape is not taken + * whole. + * - `report` is HELD at `z.unknown()` — see the member. + */ +const ObjectMetricDrillDownSchema = lazySchema(() => strictObject({ + surface: 'this `object-metric` drill-down', + history: + 'Until this shape was declared, `drillDown` was `z.unknown()`: a drill `filter`, a `mode`, a misspelled ' + + 'member or a non-numeric `maxRows` passed, and the tile ignored each — the drilled list stayed scoped by ' + + 'the metric\'s own filter and a click always listed the records — with no report.', + // The chart drill-down's near-misses for the five shared members. Its + // `where` / `criteria` / `filters` → `filter` entries are NOT carried: this + // shape refuses `filter`, so an alias to it would send the author into a + // second refusal. + aliases: { + enable: 'enabled', on: 'enabled', active: 'enabled', + label: 'title', heading: 'title', drawerTitle: 'title', + display: 'target', open: 'target', openIn: 'target', presentation: 'target', + fields: 'columns', columnList: 'columns', select: 'columns', + limit: 'maxRows', pageSize: 'maxRows', rowLimit: 'maxRows', max: 'maxRows', + }, + guidance: { + filter: + '`filter` is not a member a metric drill-down reads: a drill filter is interpolated against a click event ' + + '(`${event.*}`), and a metric tile has no click context — no row, column, category or series. The ' + + 'drilled list is always scoped by the metric\'s own `filter`, which is what keeps the number and the ' + + 'records behind it in agreement. Delete the key, and scope the metric with its own `filter`, one level ' + + 'up beside `aggregate`. Drill filters apply on `object-chart` and `object-pivot`.', + mode: + '`mode` is not a member a metric drill-down reads: it chooses drill-to-record against drill-through for a ' + + 'clicked ROW, and a metric has no row — it always lists the records behind the number. Delete the key. ' + + '`mode` applies on `object-data-table`, whose row click reads it.', + }, +}, { + enabled: ChartDrillDownSchema.shape.enabled, + title: ChartDrillDownSchema.shape.title + .describe('Drill drawer/dialog heading; defaults to the tile\'s own `title`, then its `label`. A metric tile has no click context, so an `${event.*}` token here resolves to nothing'), + target: ChartDrillDownSchema.shape.target, + columns: ChartDrillDownSchema.shape.columns, + maxRows: ChartDrillDownSchema.shape.maxRows, + /** + * [#21464] HELD at `z.unknown()`, not typed: the spec declares no drill + * report yet, and the spec declares each such contract first (the + * `objectui-held` stage of the enumeration pin). + * + * Read at the `.objectui-sha` pin `ab1879721595`: the tile hands `report` + * to the shared drawer (`ObjectMetricWidget.tsx:742`), and + * `DrillDownDrawer.tsx` draws it as a `report` node when + * `isDatasetBoundReport` holds (`:92`, used at `:115`) — a non-empty + * `dataset`, or a `joined` report with a block that binds one — joining the + * metric's filter into the report's `runtimeFilter`; any other value lists + * the records instead. objectui#11506 (`8366acc`) put that predicate in place + * of the old "carries `columns` or `objectName`" one, and objectui#11517 + * (`9ed8d0f`) refuses the named `{ name }` arm on objectui's faces. objectui + * types the member as this spec's `ReportSchema` author input + * (`types/src/data-display.ts:2592`, `SpecReportInput`), but no spec drill + * shape declares a `report` member — the chart's drill-down refuses it — so + * the conclusion stage 4 recorded stands: the tile draws a value the + * by-reference drill shape refuses, and the member waits for the spec to + * declare it. + */ + report: z.unknown().optional() + .describe('Drill into a report instead of the record list — not typed on this row yet: the tile draws a dataset-bound report here, but no spec drill shape declares a `report` member yet (the chart\'s drill-down refuses it)'), +})); + +/** + * [#21464] The `object-metric` tile's `compareTo` — the period-over-period + * comparison, as the tile reads it at the `.objectui-sha` pin `ab1879721595`: + * `kind` ALONE. `ObjectMetricWidget.tsx` runs a second aggregate over + * `shiftFilterByCompareTo(filter, compareTo)` (`:522-523`) and labels the + * derived trend with `compareToTrendLabelKey(compareTo, filter)` (`:675`); + * both (objectui `core/src/utils/compare-to.ts:99`, `:124`) dispatch on + * `compareTo.kind === 'previousYear'` (`:104`, `:128`) and treat every other + * value as `previousPeriod`. The same file's header records `dimension` as + * "deliberately never read on this path" (`:28-33`): this inline tile shifts + * the date macros in its own `filter`, while a dashboard widget's dataset path + * hands `dimension` to the analytics executor, which shifts that time + * dimension's window. + * + * So `kind` is the dashboard widget comparison's own member, by reference + * (`DashboardWidgetSchema.shape.compareTo` — the executor's two kinds, the same + * def), and `dimension` is REFUSED BY NAME with that prescription, rather than + * accepted and ignored: an author who writes `dimension: 'close_date'` + * expecting that column's window to shift would get the filter's macro window + * instead, with no report. + */ +const ObjectMetricCompareToSchema = lazySchema(() => strictObject({ + surface: 'this `object-metric` comparison window', + history: + 'Until this shape was declared, `compareTo` was `z.unknown()`: a bare kind string, a kind outside the two ' + + 'or a `dimension` passed, and the tile compared against the previous period, or shifted its own filter\'s ' + + 'window rather than the dimension named, with no report.', + // The dashboard comparison's near-misses for `kind` (#5042 measured authors + // reaching for these words on this slot). Its `field` / `dateField` / + // `timeDimension` → `dimension` entries are NOT carried: this shape refuses + // `dimension`. + aliases: { type: 'kind', mode: 'kind' }, + guidance: { + dimension: + '`dimension` is not read on a metric tile: it names the dataset time dimension the analytics executor ' + + 'shifts on a dashboard widget\'s dataset path, while this tile runs its own aggregate and shifts the date ' + + 'macros in its own `filter` (`{current_quarter_start}` becomes `{last_quarter_start}` for ' + + '`previousPeriod`; the same window one year back for `previousYear`). Delete the key — the comparison ' + + 'window is the one the tile\'s `filter` resolves to, so state that window there with date macros.', + }, +}, { + kind: DashboardWidgetSchema.shape.compareTo.unwrap().shape.kind, +})); + /** * `object-metric` (objectui `plugin-dashboard/src/ObjectMetricWidget.tsx` @ * `eb7f586b`). The widget destructures every prop it reads @@ -4774,31 +4928,20 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ trend: ObjectMetricTrendSchema.optional() .describe('Static trend badge `{ value, label?, direction? }` — `value` painted as a percentage, `direction` up / down / neutral. A `compareTo`-derived trend replaces it'), /** - * [#21464] HELD at `z.unknown()` for a ruling, not typed. The tile reads - * `enabled` (`isDrillEnabled`), `title` (`resolveDrillTitle`), `target`, - * `columns`, `maxRows` and `report` (`ObjectMetricWidget.tsx:602-651` at the - * `.objectui-sha` pin `89cad75d55`; `report` is drawn as a report body when - * it carries `columns` or `objectName`, `DrillDownDrawer.tsx:77-114`), and - * objectui's own type for this block refuses `filter` and `mode` by name - * (`ObjectMetricDrillDownConfig`). The by-reference candidate, - * `ChartDrillDownSchema`, disagrees with that read twice: it declares - * `filter`, which the tile never reads, and it refuses `report`, which the - * tile draws (objectui's `objectMetricDrillDownMembers-8071` test authors one). - * So neither the reference nor a copy is shipped; the member is typed once - * the fork is ruled. + * [#21464] The click-through to the records behind the number — see + * {@link ObjectMetricDrillDownSchema}. Its five list members are the chart + * drill-down's by reference; `filter` and `mode` are refused by name; its + * `report` is held open until the spec declares a drill report. */ - drillDown: z.unknown().optional().describe('Click-through drill config — opens the underlying records'), + drillDown: ObjectMetricDrillDownSchema.optional() + .describe('Click-through drill config `{ enabled?, title?, target?, columns?, maxRows?, report? }` — opens the records behind the number, scoped by the metric\'s own `filter`; a present block is on unless `enabled: false`. `filter` and `mode` are refused: a metric tile has no click context and no row'), /** - * [#21464] HELD at `z.unknown()` for a ruling, not typed. The tile reads - * `kind` alone (`shiftFilterByCompareTo` and `compareToTrendLabelKey` in - * objectui's `core/src/utils/compare-to.ts`, from `ObjectMetricWidget.tsx:468-469` - * and `:581` at the `.objectui-sha` pin `89cad75d55`), and objectui records - * `dimension` as never read on this path. The by-reference candidate, the - * dashboard widget's `compareTo`, declares `dimension` beside `kind` — a key - * the tile would accept and ignore. So neither the reference nor a narrower - * copy is shipped; the member is typed once the fork is ruled. + * [#21464] The period-over-period comparison — see + * {@link ObjectMetricCompareToSchema}. `kind` is the dashboard widget + * comparison's by reference; `dimension` is refused by name. */ - compareTo: z.unknown().optional().describe("Period-over-period comparison ({ kind: 'previousPeriod' | 'previousYear' })"), + compareTo: ObjectMetricCompareToSchema.optional() + .describe("Period-over-period comparison `{ kind }` — `previousPeriod` or `previousYear`, shifting the date macros in the tile's own `filter`. `dimension` is refused: this tile never reads a dataset time dimension"), })); /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectMetricProps = z.input; From d69a9bb1601ae69ac30c77e14de56ebc138e175f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:33:28 +0000 Subject: [PATCH 2/4] feat(spec): the three stage-5 narrowings' ADR-0087 D3 entries, step-18 fragments and changeset (#21464, stage 5) Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- ...rops-metric-rest-and-grid-columns-typed.md | 47 +++++ .../18.ui-object-grid-columns-typed.ts | 48 +++++ .../18.ui-object-metric-compare-to-typed.ts | 43 +++++ .../18.ui-object-metric-drill-down-typed.ts | 47 +++++ packages/spec/src/migrations/registry.ts | 166 ++++++++++++++++++ 5 files changed, 351 insertions(+) create mode 100644 .changeset/21464-component-props-metric-rest-and-grid-columns-typed.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-grid-columns-typed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-metric-compare-to-typed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-metric-drill-down-typed.ts diff --git a/.changeset/21464-component-props-metric-rest-and-grid-columns-typed.md b/.changeset/21464-component-props-metric-rest-and-grid-columns-typed.md new file mode 100644 index 00000000000..ed5e49bee34 --- /dev/null +++ b/.changeset/21464-component-props-metric-rest-and-grid-columns-typed.md @@ -0,0 +1,47 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an `object-metric` page block's `drillDown` and `compareTo`, and an `object-grid` page block's `columns`, take the shape each block reads instead of any value (#21464) + +Clause-②: yes (narrowing) + + + +**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the rows: 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`** + +- **`object-metric` `compareTo` takes the tile's read, `{ kind }`.** It was `z.unknown()`, although the tile reads `kind` alone: a bare `'previousYear'` or a kind outside the two compared against the previous period, and a `dimension` was carried and never read. `kind` is the dashboard widget comparison's own vocabulary by reference (`previousPeriod`, `previousYear`). `dimension` is refused by name, with the prescription: this inline tile shifts the date macros in its own `filter` and never reads a dataset time dimension, so state the window on the tile's `filter`. +- **`object-metric` `drillDown` takes the tile's read.** It was `z.unknown()`: a drill `filter`, a `mode` or a misspelled member passed and was ignored. Its five list members — `enabled`, `title`, `target` (`drawer`, `dialog`, `navigate`), `columns` (field names) and `maxRows` (a positive whole number) — are the chart drill-down's own by reference. `filter` and `mode` are refused by name: a metric tile has no click event for a drill filter to resolve against (the drilled list is scoped by the metric's own `filter`), and no row for `mode` to open as a record. The chart's drill-down shape is not taken whole, because it declares `filter`. +- **The drill-down's `report` is not narrowed** and still accepts any value. The tile draws a dataset-bound report through the shared drill drawer, but no spec drill shape declares a `report` member yet; it is typed once the spec declares the drill report. +- **`object-grid` `columns` takes the list view's own `columns`**: all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, resizable?, wrap?, type?, pinned?, summary?, prefix?, link?, action? }`. It was an array of `z.unknown()`, held at the second stage because the grid's group headers drew a column's `options`, which the column entry does not declare. The renderer has since retired that read (the group-header labels come from the object field's `options` only), so the hold is lifted. A column keyed `accessorKey` / `header` or `name`, a column with no `field`, a list mixing strings and entries, or a column key the entry does not declare (`editable`, `options`, `reference`, or a footer number hint such as `currency` or `precision`) is refused. +- **`ObjectMetricProps` and `ObjectGridProps`** carry these types on the three members instead of `unknown`. A parsed column's `prefix.type` now carries the list view's `'text'` default. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `object-metric` `compareTo: 'previousYear'` | `compareTo: { kind: 'previousYear' }` | +| `object-metric` `compareTo: { kind: 'previousYear', dimension: 'close_date' }` | `compareTo: { kind: 'previousYear' }`, with the window stated on the tile's own `filter` (date macros such as `{current_quarter_start}`) | +| `object-metric` `compareTo: { kind: 'previousQuarter' }` | `previousPeriod` (the equal-length window before the one the filter resolves to) or `previousYear` | +| `object-metric` `drillDown: { filter: { stage: 'won' } }` | delete it, and scope the metric with its own `filter` (the drilled list follows it) | +| `object-metric` `drillDown: { mode: 'record' }` | delete it — a metric always lists the records behind its number | +| `object-metric` `drillDown: { limit: 50 }` | `drillDown: { maxRows: 50 }` | +| `object-grid` `columns: [{ accessorKey: 'amount', header: 'Amount' }]` | `columns: [{ field: 'amount', label: 'Amount' }]` | +| `object-grid` `columns: [{ name: 'salary' }]` | `columns: [{ field: 'salary' }]` | +| `object-grid` `columns: ['name', { field: 'amount', width: 120 }]` | all entries: `[{ field: 'name' }, { field: 'amount', width: 120 }]` | +| `object-grid` a column `editable`, `options`, `reference`, `currency` or `precision` | delete the key: inline editing is the grid's own `editable`, and option labels, relational metadata and number formats are the object field's | + +The one-line fix: write each member as the table above shows. No conversion is registered, because a refused value has no rewrite that both keeps what the block shows today and honours what the author wrote; the D3 entries `ui-object-metric-compare-to-typed`, `ui-object-metric-drill-down-typed` and `ui-object-grid-columns-typed` carry that judgment. + +## Who is affected, measured + +A writer is a value written on the block: a page-component node (an object literal naming the type, a literal annotated with the block's type, a direct parse through the row), the block's React component with the member as a prop or inside `schema={{…}}`, or the argument of a local test helper that mounts one (positional helper parameters resolved at every call site). Values resolve through same-file constants. Each static value was parsed through the row. + +- **objectstack** at `6ec54f00ba`, over `examples/`, `packages/` (with `packages/apps/`), `content/`, `skills/`, `apps/` and `docs/`: 5 `object-grid` `columns` values, all field-name strings (the showcase's `my-work.page.ts` and `command-center.page.ts` grids, and three test copies), all parse. No `object-metric` `drillDown` or `compareTo` is authored. +- **objectui** at the `.objectui-sha` pin `ab1879721595`, every one a test fixture or a run-time hand-off: + - `compareTo`: 5 values, 4 parse. The refused one is the test that asserts a `dimension` never touches the query (`ObjectMetricWidget.compareTo.test.tsx`). + - `drillDown`: 26 values, 25 static; 23 parse. The two refused are the compile-time refusal probes for `filter` and `mode` (`ObjectMetricWidget.drillDownRefusal-9002.test.tsx`). The one that is not static carries a report probe, which parses, since `report` stays open. + - `object-grid` `columns`: 362 values, 353 static (147 distinct); 312 parse. Each of the 41 refused is a test fixture whose refused key or entry the grid does not draw: 17 columns keyed `accessorKey` / `header` and 5 keyed `name` (the column-spelling diagnostic, identity and field-security tests); 14 columns carrying `editable: false` and 1 carrying `reference`, keys no read takes off an authored column; 2 carrying `options`, the tests asserting that the group headers no longer read them; 1 column with no `field`; and 1 numeric `columns` refused by objectui's own mirror. The 9 values that are not static are 3 run-time hand-offs (the object view and two designer grids) and 6 test lists built from `{ field, label, type }` entries, which parse. +- **Deployed metadata** was not measured. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-columns-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-columns-typed.ts new file mode 100644 index 00000000000..1f01615854d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-grid-columns-typed.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21464 — the `object-grid` page block's `columns` was `z.array(z.unknown())` +// although the grid reads it with one shape, so a column keyed `accessorKey` / +// `header` / `name`, a column with no `field`, a mixed list or a key the grid +// never reads off a column passed the component-props gate, and the grid drew no +// column or ignored the key, in silence. Stage 2 held it because the grid's group +// headers drew a column's `options`, which the list view's column entry does not +// declare; objectui retired that read (the group-header labels come from the +// object field's `options` only), so the member takes the list view's own +// `columns` by reference — the reference it was held from. D3 only: +// page-component `properties` is not parsed on the metadata save or load path, so +// a stored page is never refused; a refused column has no rewrite that both keeps +// what the grid draws today and honours what the author wrote; and the authored +// census found no authored value to respell — every refused value is a fixture +// whose refused key the grid does not draw. +export const entry: SemanticMigration = { + id: 'ui-object-grid-columns-typed', + surface: 'page `object-grid` components — `properties.columns` (whose entries used to accept any value)', + replacement: 'the list view\'s own `columns`: all field-name strings, or all column entries `{ field, label?, ' + + 'width?, align?, hidden?, sortable?, resizable?, wrap?, type?, pinned?, summary?, prefix?, link?, action? }`. ' + + 'Respell a column keyed `accessorKey` / `header` or `name` as `field` / `label`; write a list as all strings ' + + 'or all entries, never a mix; delete a column key the entry does not declare (`editable`, `options`, ' + + '`reference`, `currency`, `precision`, …) — inline editing is the grid\'s own `editable`, and option ' + + 'labels, relational metadata and number formats are the object field\'s.', + reason: 'The grid reads `columns` with one shape — all field-name strings or all column entries, decided by ' + + 'the first entry, drawing only an entry with a string `field` and reading the column entry\'s own members ' + + 'off it — and the page-component row declared it `z.array(z.unknown())`, so any entry passed the ' + + 'component-props gate and the grid answered an off-shape one in silence: a column keyed `accessorKey` / ' + + '`header` or `name`, or one with no `field`, drew no column, a mixed list lost every entry the first one ' + + 'did not match, and a key the grid never reads off a column (`editable`, `options`, `reference`) was ' + + 'ignored. The member was held while the grid\'s group headers drew a column\'s `options` ahead of the ' + + 'field\'s; the renderer has since retired that read and takes the labels from the object field only, so ' + + 'the row takes the list view\'s own `columns` by reference — the column entry a list view already ' + + 'refuses an undeclared key on. 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 a refused column has no ' + + 'rewrite that both keeps what the grid draws 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 `properties.columns`. Each grid ' + + 'draws every authored column: one per entry, headed by its `label` or the field\'s own, in the order ' + + 'written.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-metric-compare-to-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-metric-compare-to-typed.ts new file mode 100644 index 00000000000..ec7c633e91d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-metric-compare-to-typed.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21464 — the `object-metric` page block's `compareTo` was `z.unknown()` +// although the tile reads it with one shape, so a bare kind string, a kind +// outside the two or a `dimension` passed the component-props gate, and the +// tile compared against the previous period, or shifted its own filter's window +// rather than the dimension named, in silence. It now takes the tile's read: +// `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by +// reference, and `dimension` refused by name with a prescription (the inline +// tile shifts the date macros in its own `filter` and never reads a dataset +// time dimension). D3 only: page-component `properties` is not parsed on the +// metadata save or load path, so a stored page is never refused; a `dimension` +// has no rewrite that keeps the window the author meant; and the authored census +// found no authored value to respell — the one refused value is a fixture +// probing that the tile does not read `dimension`. +export const entry: SemanticMigration = { + id: 'ui-object-metric-compare-to-typed', + surface: 'page `object-metric` components — `properties.compareTo` (which used to accept any value)', + replacement: 'the shape the tile reads: `{ kind }`, with `kind` the dashboard widget comparison\'s own ' + + 'vocabulary, `previousPeriod` or `previousYear`. Write a bare kind string as an object ' + + '(`\'previousYear\'` → `{ kind: \'previousYear\' }`), and delete a `dimension`: the tile shifts the date ' + + 'macros in its own `filter`, so state the window there.', + reason: 'The tile reads `compareTo` with one shape — `kind` alone, dispatching on `previousYear` and treating ' + + 'every other value as `previousPeriod` — and the page-component row declared it `z.unknown()`, so any value ' + + 'passed the component-props gate and the tile answered an off-shape one in silence: a bare `\'previousYear\'` ' + + 'or a kind outside the two compared against the previous period, and a `dimension` was carried and never ' + + 'read, because this inline tile shifts the date macros in its own `filter` while only a dashboard widget\'s ' + + 'dataset path hands `dimension` to the analytics executor. The row now takes `{ kind }`, with `kind` the ' + + 'dashboard widget comparison\'s own member by reference, and refuses `dimension` by name with that ' + + 'prescription rather than accepting a key the tile ignores. 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 a `dimension` has no rewrite that keeps the window the author meant — which is the judgment this entry ' + + 'leaves to the upgrader. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-metric` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.compareTo`. Each tile ' + + 'that sets a comparison shows its trend labelled for the kind it names, over the window its own `filter` ' + + 'resolves to.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-metric-drill-down-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-metric-drill-down-typed.ts new file mode 100644 index 00000000000..b7e465858d6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-metric-drill-down-typed.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21464 — the `object-metric` page block's `drillDown` was `z.unknown()` +// although the tile reads it with one shape, so a drill `filter`, a `mode`, a +// misspelled member or a non-numeric page size passed the component-props gate +// and the tile ignored each, in silence. It now takes the tile's read: the five +// list members (`enabled`, `title`, `target`, `columns`, `maxRows`) are the chart +// drill-down's own by reference, and `filter` and `mode` are refused by name with +// the prescriptions the renderer's own type for this block carries (a metric has +// no click event for a drill filter to resolve against, and no row for `mode` to +// open). The drill `report` is held open, not typed: the tile draws a +// dataset-bound report, but the spec declares no drill report yet. D3 only: +// page-component `properties` is not parsed on the metadata save or load path, so +// a stored page is never refused; a drill `filter` has no rewrite that keeps the +// scope the author meant; and the authored census found no authored value to +// respell — the two refused values are fixtures probing the refusal. +export const entry: SemanticMigration = { + id: 'ui-object-metric-drill-down-typed', + surface: 'page `object-metric` components — `properties.drillDown` (which used to accept any value)', + replacement: 'the shape the tile reads: `{ enabled?, title?, target?, columns?, maxRows?, report? }`, the first ' + + 'five the chart drill-down\'s own members — `enabled` a boolean, `title` a string, `target` `drawer`, ' + + '`dialog` or `navigate`, `columns` field names, `maxRows` a positive whole number — and `report` still open. ' + + 'Delete a drill `filter` and scope the metric with its own `filter`, one level up; delete a `mode`, since a ' + + 'metric always lists the records behind its number.', + reason: 'The tile reads `drillDown` with one shape — `enabled`, `title`, `target`, `columns`, `maxRows` and ' + + '`report`, scoping the drilled list by the metric\'s own `filter` — and the page-component row declared it ' + + '`z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in ' + + 'silence: a drill `filter` or a `mode` was carried and never read, a misspelled member was simply not ' + + 'applied, and a non-numeric page size reached the drilled list. The row now takes the five list members ' + + 'the chart drill-down declares, by reference, and refuses `filter` and `mode` by name: a metric tile has no ' + + 'click event for a drill filter to resolve against, and no row for `mode` to open as a record. The chart\'s ' + + 'shape is not taken whole, because it declares `filter`. The drill `report` stays open: the tile draws a ' + + 'dataset-bound report through the shared drawer, but no spec drill shape declares a `report` member yet. ' + + '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 a drill `filter` has no rewrite that keeps the scope the ' + + 'author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-metric` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.drillDown`. Each tile ' + + 'that sets a drill-down opens it as written: the panel shape `target` names, the heading `title` names, ' + + 'and the records behind the number, scoped by the metric\'s own `filter`, in the columns and page size ' + + 'written.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 59a00370297..4fa479401b7 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6199,6 +6199,20 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ' + '`ui-object-form-members-typed`.', }, + { + id: 'ui-object-grid-columns-typed', + order: 73, + text: + 'It also types the `object-grid` page block\'s `columns` (#21464, the fifth stage of the ' + + '`ComponentPropsMap` `z.unknown()` close-out), the list member the second stage held: the grid\'s ' + + 'group headers drew a column\'s `options`, which the list view\'s column entry does not declare, and ' + + 'objectui has since retired that read and takes the labels from the object field only. So the member ' + + 'takes the list view\'s own `columns` by reference — all field names or all column entries — and a ' + + 'column keyed `accessorKey` / `header` / `name`, a mixed list or an undeclared column key (`editable`, ' + + '`options`, `reference`), which passed every door and drew no column or was ignored, is refused. 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-columns-typed`.', + }, { id: 'ui-object-grid-export-options-closed', order: 59, @@ -6296,6 +6310,32 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '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-metric-aggregate-trend-typed`.', }, + { + id: 'ui-object-metric-compare-to-typed', + order: 71, + text: + 'It also types the `object-metric` page block\'s `compareTo` (#21464, the fifth stage of the ' + + '`ComponentPropsMap` `z.unknown()` close-out) to the tile\'s read, per the ruling between the ' + + 'reference and the read: `{ kind }`, with `kind` the dashboard widget comparison\'s own vocabulary by ' + + 'reference, and `dimension` refused by name, because this inline tile shifts the date macros in its own ' + + '`filter` and never reads a dataset time dimension. A bare kind string, a kind outside the two and a ' + + '`dimension` passed every door and compared the wrong window. 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-metric-compare-to-typed`.', + }, + { + id: 'ui-object-metric-drill-down-typed', + order: 72, + text: + 'It also types the `object-metric` page block\'s `drillDown` to the tile\'s read (#21464, the same ' + + 'stage and ruling): its five list members — `enabled`, `title`, `target`, `columns`, `maxRows` — are ' + + 'the chart drill-down\'s own by reference, and `filter` and `mode` are refused by name, because a ' + + 'metric tile has no click event for a drill filter to resolve against and no row for `mode` to open; ' + + 'both passed every door and were ignored. The drill `report` stays open: the tile draws a ' + + 'dataset-bound report, but the spec declares no drill report yet, and declares that contract first. ' + + '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-metric-drill-down-typed`.', + }, { id: 'ui-record-line-items-props-closed', order: 57, @@ -19726,6 +19766,50 @@ const step18: MigrationStep = { + 'Each form that set one of them now shows it: the post-submit behaviour it names, the modal\'s tabbed ' + 'sections, the navigation after a save, and the phone presentation.', }, + // #21464 — the `object-grid` page block's `columns` was `z.array(z.unknown())` + // although the grid reads it with one shape, so a column keyed `accessorKey` / + // `header` / `name`, a column with no `field`, a mixed list or a key the grid + // never reads off a column passed the component-props gate, and the grid drew no + // column or ignored the key, in silence. Stage 2 held it because the grid's group + // headers drew a column's `options`, which the list view's column entry does not + // declare; objectui retired that read (the group-header labels come from the + // object field's `options` only), so the member takes the list view's own + // `columns` by reference — the reference it was held from. D3 only: + // page-component `properties` is not parsed on the metadata save or load path, so + // a stored page is never refused; a refused column has no rewrite that both keeps + // what the grid draws today and honours what the author wrote; and the authored + // census found no authored value to respell — every refused value is a fixture + // whose refused key the grid does not draw. + { + id: 'ui-object-grid-columns-typed', + surface: 'page `object-grid` components — `properties.columns` (whose entries used to accept any value)', + replacement: 'the list view\'s own `columns`: all field-name strings, or all column entries `{ field, label?, ' + + 'width?, align?, hidden?, sortable?, resizable?, wrap?, type?, pinned?, summary?, prefix?, link?, action? }`. ' + + 'Respell a column keyed `accessorKey` / `header` or `name` as `field` / `label`; write a list as all strings ' + + 'or all entries, never a mix; delete a column key the entry does not declare (`editable`, `options`, ' + + '`reference`, `currency`, `precision`, …) — inline editing is the grid\'s own `editable`, and option ' + + 'labels, relational metadata and number formats are the object field\'s.', + reason: 'The grid reads `columns` with one shape — all field-name strings or all column entries, decided by ' + + 'the first entry, drawing only an entry with a string `field` and reading the column entry\'s own members ' + + 'off it — and the page-component row declared it `z.array(z.unknown())`, so any entry passed the ' + + 'component-props gate and the grid answered an off-shape one in silence: a column keyed `accessorKey` / ' + + '`header` or `name`, or one with no `field`, drew no column, a mixed list lost every entry the first one ' + + 'did not match, and a key the grid never reads off a column (`editable`, `options`, `reference`) was ' + + 'ignored. The member was held while the grid\'s group headers drew a column\'s `options` ahead of the ' + + 'field\'s; the renderer has since retired that read and takes the labels from the object field only, so ' + + 'the row takes the list view\'s own `columns` by reference — the column entry a list view already ' + + 'refuses an undeclared key on. 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 a refused column has no ' + + 'rewrite that both keeps what the grid draws 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 `properties.columns`. Each grid ' + + 'draws every authored column: one per entry, headed by its `label` or the field\'s own, in the order ' + + 'written.', + }, // #21229 — an `object-grid` page block's `exportOptions` was `z.unknown()`, so a // bare format array (the list view's legacy spelling, which the list view lifts // to `{ formats }`) was accepted on the grid, whose renderer reads @@ -20068,6 +20152,88 @@ const step18: MigrationStep = { + 'tile that set one of them now shows it: the number its aggregate names, grouped or bucketed as written, ' + 'and the trend badge with its value, arrow and caption.', }, + // #21464 — the `object-metric` page block's `compareTo` was `z.unknown()` + // although the tile reads it with one shape, so a bare kind string, a kind + // outside the two or a `dimension` passed the component-props gate, and the + // tile compared against the previous period, or shifted its own filter's window + // rather than the dimension named, in silence. It now takes the tile's read: + // `{ kind }`, with `kind` the dashboard widget comparison's own vocabulary by + // reference, and `dimension` refused by name with a prescription (the inline + // tile shifts the date macros in its own `filter` and never reads a dataset + // time dimension). D3 only: page-component `properties` is not parsed on the + // metadata save or load path, so a stored page is never refused; a `dimension` + // has no rewrite that keeps the window the author meant; and the authored census + // found no authored value to respell — the one refused value is a fixture + // probing that the tile does not read `dimension`. + { + id: 'ui-object-metric-compare-to-typed', + surface: 'page `object-metric` components — `properties.compareTo` (which used to accept any value)', + replacement: 'the shape the tile reads: `{ kind }`, with `kind` the dashboard widget comparison\'s own ' + + 'vocabulary, `previousPeriod` or `previousYear`. Write a bare kind string as an object ' + + '(`\'previousYear\'` → `{ kind: \'previousYear\' }`), and delete a `dimension`: the tile shifts the date ' + + 'macros in its own `filter`, so state the window there.', + reason: 'The tile reads `compareTo` with one shape — `kind` alone, dispatching on `previousYear` and treating ' + + 'every other value as `previousPeriod` — and the page-component row declared it `z.unknown()`, so any value ' + + 'passed the component-props gate and the tile answered an off-shape one in silence: a bare `\'previousYear\'` ' + + 'or a kind outside the two compared against the previous period, and a `dimension` was carried and never ' + + 'read, because this inline tile shifts the date macros in its own `filter` while only a dashboard widget\'s ' + + 'dataset path hands `dimension` to the analytics executor. The row now takes `{ kind }`, with `kind` the ' + + 'dashboard widget comparison\'s own member by reference, and refuses `dimension` by name with that ' + + 'prescription rather than accepting a key the tile ignores. 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 a `dimension` has no rewrite that keeps the window the author meant — which is the judgment this entry ' + + 'leaves to the upgrader. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-metric` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.compareTo`. Each tile ' + + 'that sets a comparison shows its trend labelled for the kind it names, over the window its own `filter` ' + + 'resolves to.', + }, + // #21464 — the `object-metric` page block's `drillDown` was `z.unknown()` + // although the tile reads it with one shape, so a drill `filter`, a `mode`, a + // misspelled member or a non-numeric page size passed the component-props gate + // and the tile ignored each, in silence. It now takes the tile's read: the five + // list members (`enabled`, `title`, `target`, `columns`, `maxRows`) are the chart + // drill-down's own by reference, and `filter` and `mode` are refused by name with + // the prescriptions the renderer's own type for this block carries (a metric has + // no click event for a drill filter to resolve against, and no row for `mode` to + // open). The drill `report` is held open, not typed: the tile draws a + // dataset-bound report, but the spec declares no drill report yet. D3 only: + // page-component `properties` is not parsed on the metadata save or load path, so + // a stored page is never refused; a drill `filter` has no rewrite that keeps the + // scope the author meant; and the authored census found no authored value to + // respell — the two refused values are fixtures probing the refusal. + { + id: 'ui-object-metric-drill-down-typed', + surface: 'page `object-metric` components — `properties.drillDown` (which used to accept any value)', + replacement: 'the shape the tile reads: `{ enabled?, title?, target?, columns?, maxRows?, report? }`, the first ' + + 'five the chart drill-down\'s own members — `enabled` a boolean, `title` a string, `target` `drawer`, ' + + '`dialog` or `navigate`, `columns` field names, `maxRows` a positive whole number — and `report` still open. ' + + 'Delete a drill `filter` and scope the metric with its own `filter`, one level up; delete a `mode`, since a ' + + 'metric always lists the records behind its number.', + reason: 'The tile reads `drillDown` with one shape — `enabled`, `title`, `target`, `columns`, `maxRows` and ' + + '`report`, scoping the drilled list by the metric\'s own `filter` — and the page-component row declared it ' + + '`z.unknown()`, so any value passed the component-props gate and the tile answered an off-shape one in ' + + 'silence: a drill `filter` or a `mode` was carried and never read, a misspelled member was simply not ' + + 'applied, and a non-numeric page size reached the drilled list. The row now takes the five list members ' + + 'the chart drill-down declares, by reference, and refuses `filter` and `mode` by name: a metric tile has no ' + + 'click event for a drill filter to resolve against, and no row for `mode` to open as a record. The chart\'s ' + + 'shape is not taken whole, because it declares `filter`. The drill `report` stays open: the tile draws a ' + + 'dataset-bound report through the shared drawer, but no spec drill shape declares a `report` member yet. ' + + '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 a drill `filter` has no rewrite that keeps the scope the ' + + 'author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-metric` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.drillDown`. Each tile ' + + 'that sets a drill-down opens it as written: the panel shape `target` names, the heading `title` names, ' + + 'and the records behind the number, scoped by the metric\'s own `filter`, in the columns and page size ' + + 'written.', + }, { id: 'ui-react-list-view-binding-aliases-retired', surface: '`kind:\'react\'` page source — `` and `` ' From 23a7e570578362119f257c95520a8ec5ce34729c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:39:56 +0000 Subject: [PATCH 3/4] chore(spec): regenerate the component reference page and the strictness counts; the metric drill's enabled describe (#21464, stage 5) Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 42 +++++++++++++++++-- .../ui.md | 10 ++--- packages/spec/src/ui/component.zod.ts | 10 +++-- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 734d6365349..bdef9d2a758 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -710,7 +710,7 @@ Sort field and direction pair | **title** | `string \| Record` | optional | Fallback for `label` (the renderer reads `label \|\| title`) | | **description** | `string \| Record` | optional | One line of help text drawn above the grid's rows — a string, or an inline locale map resolved against the display locale | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | What the grid draws instead of an empty table: `{ title, message, icon }` — the list view's own empty-state shape | -| **columns** | `any[]` | optional | Columns: field names or column definition objects | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **defaultFilters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array` | @@ -754,6 +754,25 @@ Sort field and direction pair | **message** | `string \| Record` | optional | Line of text below the heading | | **icon** | `string` | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph | +### Nested Shape: `ObjectGridProps.columns[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name (snake_case) | +| **label** | `string \| Record` | optional | Display label override | +| **width** | `number` | optional | Column width in pixels | +| **align** | `Enum<'left' \| 'center' \| 'right'>` | optional | Text alignment | +| **hidden** | `boolean` | optional | Hide column by default | +| **sortable** | `boolean` | optional | Allow sorting by this column | +| **resizable** | `boolean` | optional | Allow resizing this column | +| **wrap** | `boolean` | optional | Allow text wrapping | +| **type** | `string` | optional | Renderer type override (e.g., "currency", "date") | +| **pinned** | `Enum<'left' \| 'right'>` | optional | Pin/freeze column to left or right side | +| **summary** | `Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …> \| { type: Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …>; field?: string }` | optional | Footer aggregation for this column — the function alone, or `{ type, field }` to aggregate another field | +| **prefix** | `{ field: string; type?: Enum<'badge' \| 'text'> }` | optional | Field rendered inline before this cell value | +| **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | +| **action** | `string` | optional | Registered Action ID to execute when clicked | + ### Nested Shape: `ObjectGridProps.filter[number]` View filter rule @@ -1116,8 +1135,8 @@ Sort field and direction pair | **variant** | `Enum<'card' \| 'bare'>` | optional | Layout variant | | **fallbackValue** | `string \| number` | optional | Static value shown when no data source is available | | **trend** | `{ value: number; label?: string \| Record; direction?: Enum<'up' \| 'down' \| 'neutral'> }` | optional | Static trend badge `{ value, label?, direction? }` — `value` painted as a percentage, `direction` up / down / neutral. A `compareTo`-derived trend replaces it | -| **drillDown** | `any` | optional | Click-through drill config — opens the underlying records | -| **compareTo** | `any` | optional | Period-over-period comparison (`{ kind: 'previousPeriod' \| 'previousYear' }`) | +| **drillDown** | `{ enabled?: boolean; title?: string; target?: Enum<'drawer' \| 'dialog' \| 'navigate'>; columns?: string[]; … }` | optional | Click-through drill config `{ enabled?, title?, target?, columns?, maxRows?, report? }` — opens the records behind the number, scoped by the metric's own `filter`; a present block is on unless `enabled: false`. `filter` and `mode` are refused: a metric tile has no click context and no row | +| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'> }` | optional | Period-over-period comparison `{ kind }` — `previousPeriod` or `previousYear`, shifting the date macros in the tile's own `filter`. `dimension` is refused: this tile never reads a dataset time dimension | ### Nested Shape: `ObjectMetricProps.aggregate` @@ -1145,6 +1164,23 @@ View filter rule | **label** | `string \| Record` | optional | Badge caption — a string or an inline locale map. The tile-level `description` outranks it in the one caption slot they share | | **direction** | `Enum<'up' \| 'down' \| 'neutral'>` | optional | Arrow beside the value; omit it for no arrow | +### Nested Shape: `ObjectMetricProps.drillDown` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **enabled** | `boolean` | optional | Turn the segment drill on/off; the block being present already means on, so this is only needed to force it off | +| **title** | `string` | optional | Drill drawer/dialog heading; defaults to the tile's own `title`, then its `label`. A metric tile has no click context, so an `${event.*}` token here resolves to nothing | +| **target** | `Enum<'drawer' \| 'dialog' \| 'navigate'>` | optional | Where the drilled list opens: 'drawer' (default, side sheet), 'dialog' (centered modal), or 'navigate' (skip the in-place view and open the object's full list page; needs host drill navigation, else falls back to 'drawer') | +| **columns** | `string[]` | optional | Field names to show as columns in the drilled list (default: the table's own columns) | +| **maxRows** | `integer` | optional | Rows per page in the drilled list | +| **report** | `any` | optional | Drill into a report instead of the record list — not typed on this row yet: the tile draws a dataset-bound report here, but no spec drill shape declares a `report` member yet (the chart's drill-down refuses it) | + +### Nested Shape: `ObjectMetricProps.compareTo` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kind** | `Enum<'previousPeriod' \| 'previousYear'>` | ✅ | Comparison window: previousPeriod (equal-length, immediately before) or previousYear (−1 calendar year) | + --- 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 f6ba20cf26c..2f859bbb156 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/` | 194 | 183 | 4 | 0 | 7 | +| `ui/` | 196 | 185 | 4 | 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` | 64 | +| `component.zod.ts` | 66 | | `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** | **194** | +| **total** | **196** | ## `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 194**, in 4 file(s). +**7 strip of 196**, 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** | **194** | +| **total** | **7** | **196** | | Bucket | Sites | |---|---| diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 1da36f2c8b0..b3bba43a354 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -4655,9 +4655,10 @@ const ObjectMetricTrendSchema = lazySchema(() => strictObject({ * resolved `filter`. * * Those five are the chart drill-down's own members, by reference - * ({@link ChartDrillDownSchema}`.shape` — the same defs; `title` carries a - * describe of its own, because a metric tile has no click context for the - * chart's `${event.*}` tokens to resolve against). The SHAPE is the tile's, + * ({@link ChartDrillDownSchema}`.shape` — the same defs; `enabled` and `title` + * carry describes of their own, because the chart's speak of a clicked segment + * and its `${event.*}` tokens, and a metric tile has no click context for them + * to resolve against). The SHAPE is the tile's, * not the chart's, in two places: * * - `filter` and `mode` are REFUSED BY NAME, with the prescriptions objectui's @@ -4699,7 +4700,8 @@ const ObjectMetricDrillDownSchema = lazySchema(() => strictObject({ + '`mode` applies on `object-data-table`, whose row click reads it.', }, }, { - enabled: ChartDrillDownSchema.shape.enabled, + enabled: ChartDrillDownSchema.shape.enabled + .describe('Turn the tile\'s drill on or off; the block being present already means on, so this is only needed to force it off'), title: ChartDrillDownSchema.shape.title .describe('Drill drawer/dialog heading; defaults to the tile\'s own `title`, then its `label`. A metric tile has no click context, so an `${event.*}` token here resolves to nothing'), target: ChartDrillDownSchema.shape.target, From a10f94d7c482d8759e4d687341f778682ac7846c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:43:06 +0000 Subject: [PATCH 4/4] chore(spec): regenerate the component reference page for the drill enabled describe (#21464, stage 5) Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index bdef9d2a758..3542156a09c 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1168,7 +1168,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | optional | Turn the segment drill on/off; the block being present already means on, so this is only needed to force it off | +| **enabled** | `boolean` | optional | Turn the tile's drill on or off; the block being present already means on, so this is only needed to force it off | | **title** | `string` | optional | Drill drawer/dialog heading; defaults to the tile's own `title`, then its `label`. A metric tile has no click context, so an `${event.*}` token here resolves to nothing | | **target** | `Enum<'drawer' \| 'dialog' \| 'navigate'>` | optional | Where the drilled list opens: 'drawer' (default, side sheet), 'dialog' (centered modal), or 'navigate' (skip the in-place view and open the object's full list page; needs host drill navigation, else falls back to 'drawer') | | **columns** | `string[]` | optional | Field names to show as columns in the drilled list (default: the table's own columns) |