From cfa6e02fd4ea548d83ab4709683503804f070c79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:48:57 +0000 Subject: [PATCH 1/3] =?UTF-8?q?wip(spec):=20S-final=20stage=20=E2=80=94=20?= =?UTF-8?q?type=20drillDown.report,=20timeline=20items=20and=20the=20actio?= =?UTF-8?q?n=20container=20members=20(#21464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- packages/spec/src/ui/component.zod.ts | 543 +++++++++++++++++++++----- 1 file changed, 454 insertions(+), 89 deletions(-) diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 373d03336dd..b4dcaf34d2f 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -68,6 +68,11 @@ 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'; +// [#21464] `object-metric.drillDown.report` is the report contract itself, by +// reference (decision card #21704, fork 1, letter B): a joined report refuses a +// block that binds no dataset, so a report this schema admits is one the drill +// drawer draws. +import { ReportSchema } from './report.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; import { lazySchema } from '../shared/lazy-schema'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; @@ -3524,44 +3529,205 @@ export type ActionIconProps = z.input; export type ActionIconPropsParsed = z.infer; /** - * The member list `action:group` and `action:menu` both read — a LIST, as the - * renderers read it (`schema.actions || []`, then `.filter` / `.map`). Both - * registrations published the input as `type: 'object'` through `db11afd49`; - * since objectui#11168 slice 1 both publish `type: 'array', of: 'object'`, the - * shape declared here. + * [#21464] The members of `action:group` / `action:menu` — the S-final stage, + * per the maintainer's ruling on the decision card #21704, fork 5, letter A + * (record 5979239990): the measured read set, `action:button`'s keys by `type`, + * with the rows' prescriptions; `outcomeMessages`, a member `className` and + * `properties.params` refused; `outcomeMessages` undeclared on all four action + * blocks alike. Until this stage each member was an open record + * (`z.record(z.string(), z.unknown())`), so a misspelled key, a node-style + * `actionType` or an `endpoint` passed and the container drew and ran the + * member without it. + * + * The list itself is what both renderers read (`schema.actions || []`, then + * `.filter` / `.map`); both registrations publish it as `type: 'array', of: + * 'object'` since objectui#11168 slice 1. A bare string is refused: an action + * NAME list is `record:quick_actions`' `actionNames`, and a string member would + * draw an unlabeled button that runs nothing. + * + * ## The read set, measured (not transcribed from `UIActionSchema`) * * Each member is an action object the container draws and runs ITSELF, never - * through `SchemaRenderer`, so a member is not a page component and this row - * does not judge its keys: the members' value contract is the runner's. What - * the containers read off a member, at the pin: `visible` / `disabled` / - * `enabled`, `icon`, `variant`, `className`, `label` (falling back to `name`), - * `tags` (a `separator-before` tag draws a divider), `name` (the React key) and - * the runner forward — which hands the runner the member's own `type`, not - * `actionType` (a member is an action entry, and an action entry's executor is - * `type`), its `objectName`, and its static values off the member's OWN - * `properties.params`, evaluated by the container - * (`readMemberStaticParamValues`, `static-params.ts:142-148`). A bare string - * is refused here: an action NAME list is `record:quick_actions`' - * `actionNames`, and a string member would render as an unlabeled button that - * runs nothing. + * through `SchemaRenderer`, so it is not a page component. Read at the + * `.objectui-sha` pin `2e818d0b51ec` (`components/src/renderers/action/`; every + * file byte-identical from `ab1879721595`, where the fork was measured, and at + * objectui `main` `2abec3a96`): + * + * - **drawn**, in `action-group.tsx` — the inline button (`InlineActionButton`, + * `:91-174`) and the dropdown item (`DropdownActionItem`, `:188-247`) — and in + * `action-menu.tsx`'s item (`ActionMenuItem`, `:92-150`): `visible` (the + * shared fail-closed gate, group `:81-86`, menu `:78-85`), `disabled` + * (group `:119` + `:159-165`, `:208` + `:219-223`; menu `:108` + `:132-136`), + * `icon` (group `:122`, `:213`; menu `:111-115`), `label` falling back to + * `name` (group `:171`, `:243`; menu `:147`), `variant` (group inline `:123`, + * `primary` drawn as `default`; the dropdown and menu items `:236` / `:142`, + * where `destructive` draws the item red) and `tags` (`:224` / `:408`: a + * `separator-before` tag draws a divider above the item); + * - **`size`**, on an `action:group` member only: its inline button reads it, + * `md` drawn as `default` (`:124`). An `action:menu` item reads no `size`, + * so that member declares none — the `action:icon` precedent, measured per + * block; + * - **placed**: `locations`, through `actionRendersAt` on the group (`:304`), + * and forwarded by both; + * - **forwarded** to the runner (group `:329-382`, menu `:264-328`), each + * value as `action:button` declares it: the member's own `type` (the + * executor — a member is an action entry, whose executor is `type`, where a + * node spells it `actionType`), `name`, `label`, `description`, `target`, + * `openIn`, `method`, `params` (an array is the input list; an object is + * the request payload of a `type: 'api'` member only, `static-params.ts:172-183`), + * `bodyExtra`, `bodyShape`, `operation`, `patch`, `confirmText`, + * `successMessage`, `errorMessage`, `refreshAfter`, `locations`, `toast`, + * `resultDialog`, `onSuccess` and `objectName`. + * + * `undoable` and `recordIdField`, which `action:button` forwards, are not + * forwarded by either container, so a member declares neither. + * + * ## Read, and refused anyway — by the ruling, or with the rows' prescription + * + * - `outcomeMessages` (forwarded since objectui#11344, group `:363`, menu + * `:307`): undeclared on all four action blocks, as one decision, until an + * inline writer appears; + * - a member `className` (group `:145`, `:237`; menu `:143`) and the member's + * own `properties.params` bag of static values (`readMemberStaticParamValues`, + * `static-params.ts:142-148`, from group `:329` and menu `:264`); + * - `endpoint` (forwarded, group `:346`, menu `:290`): read by no console `api` + * handler, refused onto `target` through the rows' alias table; + * - `enabled` (group `:120`, `:209`; menu `:109`), the legacy fallback beside + * `disabled`, and `autoTrigger` (menu `:189`, through `useAutoTriggerOnce`; + * the group never reads it), a host transport flag — each with the rows' + * prescription. + * + * A code-composed `onClick` (group `:314`, menu `:249`) is a function, which + * metadata cannot carry. + * + * The two members differ only in `size`, so one shape builder serves both + * ({@link actionContainerMemberShape}); each container's member is built once. + * A factory the rows call, not a {@link lazySchema}, for the reason + * {@link objectGanttMarker} gives. */ +function actionContainerMemberShape() { + return { + name: z.string().optional() + .describe('Action name, forwarded to the action runner, and the item\'s text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`'), + label: z.string().optional() + .describe('The item\'s text. A literal string, placed as-is — localize through the translation bundle entry for this component id'), + icon: z.string().optional() + .describe('Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon)'), + type: z.string().optional() + .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`)'), + variant: z.enum([...BUTTON_PRIMITIVE_VARIANTS, 'primary']).optional() + .describe('Item variant — the Button primitive\'s vocabulary, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group\'s `variant`); a dropdown or `action:menu` item draws `destructive` red and every other variant plainly'), + visible: actionCondition().optional() + .describe('Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible'), + disabled: actionCondition().optional() + .describe('Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the item is drawn but cannot be pressed while it is TRUE, and a predicate that fails to evaluate disables it. Omit for never-disabled'), + tags: z.array(z.enum(['separator-before'])).optional() + .describe('Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn'), + params: z.unknown().optional() + .describe('Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the request payload of a `type: \'api\'` member only (use `bodyExtra` for that)'), + description: z.string().optional() + .describe('Action description, forwarded to the runner — the parameter dialog shows it under its title'), + target: z.string().optional() + .describe('Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`'), + openIn: z.enum(['self', 'new-tab']).optional() + .describe('For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab'), + method: z.string().optional().describe('HTTP method for an `api` action, forwarded to the runner'), + bodyExtra: z.unknown().optional().describe('Static request-body fields for an `api` action, forwarded to the runner'), + bodyShape: z.unknown().optional().describe('How an `api` action shapes its request body, forwarded to the runner'), + operation: z.unknown().optional().describe('Declarative single-record write, forwarded to the runner together with `patch`'), + patch: z.unknown().optional().describe('Field values the declarative `operation` writes, forwarded to the runner'), + confirmText: z.string().optional().describe('Confirmation question asked before the action runs'), + successMessage: z.string().optional().describe('Toast shown when the action succeeds'), + errorMessage: z.string().optional().describe('Toast shown when the action fails, in place of the raw error'), + refreshAfter: z.boolean().optional().describe('Refresh the surrounding data after the action runs'), + locations: z.array(ActionLocationSchema).optional() + .describe('Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one'), + toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), + resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), + onSuccess: z.unknown().optional().describe('Declared post-success navigation, forwarded to the runner'), + objectName: z.string().optional() + .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), + }; +} + /** - * [#21464] The members of `action:group` / `action:menu`: HELD as open - * records, in the enumeration pin's ledger as a fork the S-objectui-held stage - * reported. Each member is objectui's `UIActionSchema` - * (`types/src/ui-action.ts:571` at the `.objectui-sha` pin `ab1879721595`), - * drawn and run by the container itself (`action-group.tsx:91-249`, - * `:303-382`; `action-menu.tsx:80-147`, `:264-328`, `:408`). Measured from - * those reads, the key set this section's method gives is mostly - * `action:button`'s, keyed by `type` rather than `actionType` (a member is not - * a node) — but it also takes keys whose declaration the rows - * above leave undecided: `outcomeMessages` (forwarded, and recorded on - * `action:button` / `action:icon` as "a contract decision, not a pin - * re-measure"), a member `className` (a node key on the rows), the member's own - * `properties.params` bag of static values (`static-params.ts:142-160`) and - * `endpoint` (refused on the rows since #21005). + * The prescriptions a container member answers an undeclared key with. The + * `enabled` one is the rows' own; the rest name what a member writes instead. */ -const actionMemberList = () => z.array(z.record(z.string(), z.unknown())); +const actionContainerMemberGuidance = (container: 'action:group' | 'action:menu') => ({ + enabled: ACTION_NODE_GUIDANCE.enabled, + autoTrigger: container === 'action:menu' + ? ACTION_NODE_GUIDANCE.autoTrigger + : '`autoTrigger` is a host transport flag, not metadata: a host sets it on a schema it composes at runtime ' + + 'to run an action once on mount. An `action:group` member does not read it at all, so the action never ' + + 'runs on mount. Remove it.', + outcomeMessages: + '`outcomeMessages` is not a key an inline action declares: per-outcome success copy is declared on an ' + + 'object\'s registered action (`actions[]`), and on none of the four action blocks yet. Write the success ' + + 'toast as `successMessage`.', + className: + 'A member is drawn by its container, not as a page component, and carries no `className`. Style it with its ' + + '`variant` (`destructive` draws it red), or style the whole container with the node\'s own `className`.', + properties: + 'A member carries no `properties` bag: its static parameter values (`properties.params`) are not part of the ' + + 'inline action vocabulary. For a `type: \'api\'` member\'s request body write `bodyExtra`; to run an action ' + + 'with static parameter values, author it as its own `action:button` node, whose `params` object carries them.', + undoable: + '`undoable` reaches the runner only from an `action:button` node: a container member does not forward it. ' + + 'Author the action as its own `action:button`, or remove the key.', + recordIdField: + '`recordIdField` reaches the runner only from an `action:button` node: a container member does not forward ' + + 'it. Author the action as its own `action:button`, or remove the key.', +}); + +/** What a container member's undeclared keys used to cost. */ +const actionContainerMemberHistory = (container: string) => + `Until this shape was declared, each \`${container}\` member was an open record: a misspelled key passed, ` + + 'and the container drew and ran the member without it.'; + +/** The aliases a container member answers: the rows' table, with the executor key turned round. */ +const ACTION_CONTAINER_MEMBER_ALIASES = { + actionType: 'type', + visibleWhen: 'visible', + visibility: 'visible', + ...ACTION_TARGET_ALIASES, +} as const; + +/** [#21464] One `action:group` member: {@link actionContainerMemberShape}, plus the inline button's `size`. */ +function buildActionGroupMember() { + return strictObject({ + surface: 'this `action:group` member', + history: actionContainerMemberHistory('action:group'), + aliases: ACTION_CONTAINER_MEMBER_ALIASES, + guidance: actionContainerMemberGuidance('action:group'), + }, { + ...actionContainerMemberShape(), + size: z.enum([...BUTTON_PRIMITIVE_SIZES, 'md']).optional() + .describe('Inline button size — the Button primitive\'s vocabulary, plus `md` (drawn as `default`), falling back to the group\'s `size`. A dropdown item reads no size'), + }); +} +let actionGroupMemberOnce: ReturnType | undefined; +/** The one {@link buildActionGroupMember} instance. */ +const actionGroupMember = () => (actionGroupMemberOnce ??= buildActionGroupMember()); + +/** [#21464] One `action:menu` member: {@link actionContainerMemberShape}; a menu item reads no `size`. */ +function buildActionMenuMember() { + return strictObject({ + surface: 'this `action:menu` member', + history: actionContainerMemberHistory('action:menu'), + aliases: ACTION_CONTAINER_MEMBER_ALIASES, + guidance: { + ...actionContainerMemberGuidance('action:menu'), + size: + 'An `action:menu` item reads no `size`: each member is drawn as a menu item, and only the trigger is a sized ' + + 'button (the menu\'s own `size`). Remove it, or put the action in an `action:group`, whose inline buttons ' + + 'read a member\'s `size`.', + }, + }, actionContainerMemberShape()); +} +let actionMenuMemberOnce: ReturnType | undefined; +/** The one {@link buildActionMenuMember} instance. */ +const actionMenuMember = () => (actionMenuMemberOnce ??= buildActionMenuMember()); /** * `action:group` — a row or dropdown of actions @@ -3619,8 +3785,8 @@ export const ActionGroupPropsSchema = lazySchema(() => strictObject({ + 'Each member action\'s own `name` is what identifies it. Remove the key.', }, }, { - actions: actionMemberList().optional() - .describe('The actions in this group, in order — each an action object the group draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, …); a member\'s executor is its `type`'), + actions: z.array(actionGroupMember()).optional() + .describe('The actions in this group, in order — each an action object the group draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, `size`, …); a member\'s executor is its `type`'), display: z.enum(['inline', 'dropdown']).optional() .describe('Display mode: `inline` renders every action as a button row; `dropdown` renders one trigger button and lists the actions in its menu (renderer default: `inline`)'), location: ActionLocationSchema.optional() @@ -3677,7 +3843,7 @@ export const ActionMenuPropsSchema = lazySchema(() => strictObject({ guidanceSets: [COMPONENT_NODE_KEYS_GUIDANCE], aliases: { visibleWhen: 'visible', visibility: 'visible' }, }, { - actions: actionMemberList().optional() + actions: z.array(actionMenuMember()).optional() .describe('The menu\'s actions, in order — each an action object the menu draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, `tags`, …); a member\'s executor is its `type`'), label: z.string().optional() .describe('Trigger text and accessible label; omit for an icon-only trigger labelled "More actions". A literal string — localize through the translation bundle entry for this component id'), @@ -4713,7 +4879,8 @@ const ObjectMetricTrendSchema = lazySchema(() => strictObject({ * 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. + * - `report` is this package's {@link ReportSchema}, by reference — a member + * the chart's drill-down does not declare (see the member). */ const ObjectMetricDrillDownSchema = lazySchema(() => strictObject({ surface: 'this `object-metric` drill-down', @@ -4753,39 +4920,48 @@ const ObjectMetricDrillDownSchema = lazySchema(() => strictObject({ 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. It waited for - * the `objectui-held` stage, which reported it as a fork (the last paragraph - * below); the enumeration pin's ledger records it under `fork`. + * [#21464] The report the drill opens instead of the record list — this + * package's {@link ReportSchema}, BY REFERENCE: the maintainer's ruling on + * the decision card #21704, fork 1, letter B (record 5978663135). Until the + * S-final stage the member was `z.unknown()`, so a report missing its + * `dataset`, a misspelled report key or a bare report name passed the + * component-props gate, and the drawer quietly listed the records instead. * - * Read at the `.objectui-sha` pin `ab1879721595`: the tile hands `report` - * to the shared drawer (`ObjectMetricWidget.tsx:742`), and + * Read at the `.objectui-sha` pin `2e818d0b51ec` (both files byte-identical + * from `ab1879721595`, where the fork was measured, and at objectui `main` + * `2abec3a96`): the tile hands `report` to the shared drawer verbatim + * (`plugin-dashboard/src/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. + * metric's filter into the report's own `runtimeFilter` (`:150-153`); any + * other value lists the records. objectui types the member as this package's + * `ReportSchema` author input (`types/src/data-display.ts`, + * `SpecReportInput`), so the reference is the declaration both sides already + * name. + * + * ## Admitted implies drawn + * + * The fork existed because `ReportSchema` admitted a `joined` report none of + * whose blocks binds a `dataset`, which the drawer does not draw. Since #21702 + * its joined arm refuses every block with no `dataset`, at + * `blocks[i].dataset`; every other type needs a `dataset` and `values`. So + * every report this member admits satisfies `isDatasetBoundReport`, and is + * drawn as a report. * - * The S-objectui-held stage measured the by-reference candidate - * (`ReportSchema`, `report.zod.ts`) against that predicate and kept the hold, reporting - * a fork: every drawn report the census found parses, but the two do not - * agree. `ReportSchema` admits a `joined` report none of whose blocks binds a - * `dataset` (a block's `dataset` is optional there), which - * `isDatasetBoundReport` does not draw — the drawer lists the records - * instead, the silent fallback this card closes — and it refuses a - * dataset-bound report with no `name`, `label` or `values`, which the drawer - * does draw. + * The two still differ in one direction, which no measured writer reaches: + * the drawer also draws a dataset-bound report this schema refuses for its + * own reasons — one with no `name` or `label`, a non-joined one with no + * `values`, a joined one with a container `dataset`, or one whose other + * blocks bind none. Those are incomplete reports, and the census found none + * written on this member. + * + * The parsed member carries `ReportSchema`'s defaults (`type: 'tabular'`, + * `drilldown: true`); a page component's `properties` is not parsed on the + * way to the renderer, so the drawer still reads the report as written. */ - 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)'), + report: ReportSchema.optional() + .describe('Drill into a report instead of the record list — a report definition (the same shape as `reports[]`): `{ name, label, dataset, values, … }`, or a `joined` report whose every block binds a `dataset`. The drawer draws it as a report, with the metric\'s filter joined into its `runtimeFilter`'), })); /** @@ -4998,10 +5174,10 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ * [#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. + * `report` is {@link ReportSchema}, by reference. */ 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'), + .describe('Click-through drill config `{ enabled?, title?, target?, columns?, maxRows?, report? }` — opens the records behind the number, scoped by the metric\'s own `filter` — or draws the report `report` defines; a present block is on unless `enabled: false`. `filter` and `mode` are refused: a metric tile has no click context and no row'), /** * [#21464] The period-over-period comparison — see * {@link ObjectMetricCompareToSchema}. `kind` is the dashboard widget @@ -5013,11 +5189,13 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectMetricProps = z.input; /** - * ADR-0122: the parsed state differs from the authored state on exactly one - * key — `filter` carries `z.array(ViewFilterRuleSchema)` (the ui#6206-B family + * ADR-0122: the parsed state differs from the authored state on two keys — + * `filter` carries `z.array(ViewFilterRuleSchema)` (the ui#6206-B family * convergence, #15449), whose own input ≠ infer (`operator` is normalized on - * parse). So `object-metric` leaves the type-alias convention pin's default-free - * family the way `object-grid` did, taking the `ObjectGridPropsParsed` route. + * parse), and `drillDown.report` carries {@link ReportSchema}, whose defaults + * (`type`, `drilldown`) materialize on a report the author wrote (#21464). So + * `object-metric` leaves the type-alias convention pin's default-free family + * the way `object-grid` did, taking the `ObjectGridPropsParsed` route. */ export type ObjectMetricPropsParsed = z.infer; @@ -7847,6 +8025,195 @@ const ObjectTimelineMappingSchema = lazySchema(() => strictObject({ .describe('Field whose value picks each entry\'s marker colour (renderer default `variant`) — the only spelling this binding has'), })); +// --------------------------------------------------------------------------- +// [#21464] `object-timeline` `items` — the authored entry, in the shape the +// maintainer ruled on the decision card #21704, fork 4, letter B (record +// 5979239990): both arms closed, as objectui#6356 ruled them +// (`TimelineFeedItem`, `TimelineGanttItem`); a feed entry's `content` an +// opaque member; a row refinement pairing each entry with the arm the row's +// `variant` selects; a gantt bar's dates a string or a number. Read at the +// `.objectui-sha` pin `2e818d0b51ec`; the arm declarations and every cited +// reader are unchanged at objectui `main` `2abec3a96` (comments only). +// --------------------------------------------------------------------------- + +/** + * The five colours an authored timeline element names — a feed entry's marker + * or a gantt bar: objectui's `TimelineItemVariant` + * (`types/src/data-display.ts`), the "Marker Variants" its timeline guide + * documents. The marker primitive paints three more (`todo`, `in-progress`, + * `done`), reached only by the entries the object-bound rail composes from + * records — not an authoring vocabulary, and no gantt bar paints them. + */ +const OBJECT_TIMELINE_ITEM_VARIANTS = ['default', 'success', 'warning', 'danger', 'info'] as const; + +/** What an authored timeline entry's undeclared keys used to cost. */ +const OBJECT_TIMELINE_ITEM_HISTORY = + 'Until this shape was declared, a timeline entry was `z.unknown()`: a misspelled key, or an entry of the ' + + 'wrong kind for the timeline\'s `variant`, passed, and the rail drew an empty, unlabelled entry for it.'; + +/** + * [#21464] One bar of a gantt row — objectui's `TimelineGanttItemBar`, closed. + * The gantt branch reads `startDate` / `endDate` (`renderer.tsx:1909`, the axis + * reduce at `:651` and `:686-688`), `variant` (`:1916`, default `default`) and + * `title` (`:1918`, `:1921`, inside the bar and in its tooltip). Every member + * is optional, as objectui#6356 left them: a bar with no usable dates is not an + * authoring error but the renderer's `timeline.gantt.unusableRange` diagnostic. + * A date is a string or a FINITE number (epoch milliseconds — `z.number()` + * refuses `Infinity` and `NaN`), the renderer's own date rule; its third arm, a + * `Date`, is one authored JSON cannot carry (the ruling: string or number). + */ +function buildObjectTimelineGanttBar() { + return strictObject({ + surface: 'this gantt bar', + history: OBJECT_TIMELINE_ITEM_HISTORY, + aliases: { label: 'title', name: 'title', start: 'startDate', end: 'endDate', color: 'variant' }, + }, { + title: z.string().optional().describe('Bar label, drawn inside the bar and in its tooltip'), + startDate: z.union([z.string(), z.number()]).optional() + .describe('Bar start — a date string (`YYYY-MM-DD`, or an ISO date-time) or epoch milliseconds'), + endDate: z.union([z.string(), z.number()]).optional() + .describe('Bar end — a date string or epoch milliseconds; a date-only `YYYY-MM-DD` end is drawn through the end of that day'), + variant: z.enum(OBJECT_TIMELINE_ITEM_VARIANTS).optional().describe('Bar colour (renderer default `default`)'), + }); +} + +/** + * The FEED arm's members (`variant` absent, `vertical` or `horizontal`) — + * objectui's `TimelineFeedItem`, the seven keys objectui#6356 declared, with + * `title` the arm's required key. Both feed branches read them: `time` + * (`renderer.tsx:1612-1614`, `:1703-1705`), `title` (`:1642`, `:1708`), + * `description` (`:1650`, `:1709`), `variant` (`:1627`, `:1699`), `icon` + * (`:1630`, `:1700`), `content` (`renderChildren`, `:1659`, `:1716`) and + * `className` (`:1623`, `:1697`). + * + * `content` is held OPAQUE (`z.unknown()`), by the ruling: it is child schema + * nodes the entry draws below its description (`SchemaNode | SchemaNode[]`), + * which this map would judge only as a slot position the page walks visit, + * and no measured writer fills it. It becomes a slot when a writer appears; + * the enumeration pin records it with that reason. + */ +const objectTimelineFeedItemShape = () => ({ + time: z.string().optional().describe('When it happened — an ISO 8601 date (or date-time) string, formatted by the row\'s `dateFormat`'), + title: z.string().optional().describe('The entry\'s heading — required on a feed entry (the arm a feed `variant` selects)'), + description: z.string().optional().describe('Secondary line under the title'), + variant: z.enum(OBJECT_TIMELINE_ITEM_VARIANTS).optional().describe('Marker colour (renderer default `default`)'), + icon: z.string().optional().describe('Emoji or short text drawn inside the marker'), + content: z.unknown().optional() + .describe('Child page components drawn below the description — a component node or a list of them. Held opaque: not judged on this row'), + className: z.string().optional().describe('Tailwind classes for the entry'), +}); + +/** + * The GANTT arm's members (`variant: 'gantt'`) — objectui's + * `TimelineGanttItem`: `label` (`renderer.tsx:1899-1900`, the row-label + * gutter), the arm's required key, and `items`, the row's bars + * (`classifyGanttRows`, `:617-621`; drawn at `:1908`), optional because a row + * with no bars yet is an ordinary empty state. + */ +const objectTimelineGanttRowShape = () => ({ + label: z.string().optional().describe('Row label, drawn in the row-label gutter — required on a gantt row (the arm `variant: \'gantt\'` selects)'), + items: z.array(buildObjectTimelineGanttBar()).optional() + .describe('The row\'s bars, each `{ title?, startDate?, endDate?, variant? }`'), +}); + +/** + * [#21464] One authored `object-timeline` entry: every member of BOTH arms, + * each optional, closed against anything else — objectui's own element shape + * (`types/src/zod/data-display.zod.ts`, `TimelineItemSchema`). Which arm an + * entry must be is decided by the ROW's `variant`, so it is judged where the + * row is visible: {@link objectTimelineItemsFitVariant}. Not a union of the two + * arms, for the reason objectui gives: when neither arm accepts an entry, a + * union reports one `invalid_union` at the entry and folds each arm's issues + * beneath it, so an off-shape bar or an unknown key would lose its own path. + * + * The keys the object-bound rail composes onto an entry it maps from a record + * (`color`, `group`, `meta`, `startDate`, `endDate`) are renderer-internal, + * not authorable — objectui#6356 refuses them on its strict face — and are + * refused here with what to write instead. + * + * A factory the row calls, not a {@link lazySchema}, for the reason + * {@link objectGanttMarker} gives. + */ +function buildObjectTimelineItem() { + return strictObject({ + surface: 'this timeline entry', + history: OBJECT_TIMELINE_ITEM_HISTORY, + aliases: { date: 'time', timestamp: 'time', heading: 'title' }, + guidance: { + color: + 'A timeline entry\'s colour is `variant` (`default`, `success`, `warning`, `danger`, `info`): `color` is a ' + + 'key the record-bound rail composes onto the entries it maps, not one an authored entry carries. Write ' + + 'it as `variant`.', + startDate: + '`startDate` is a gantt BAR\'s key, written inside a gantt row\'s `items`; a feed entry\'s date is `time`. ' + + 'On a feed timeline write `time`; on `variant: \'gantt\'` write `{ label, items: [{ startDate, endDate }] }`.', + endDate: + '`endDate` is a gantt BAR\'s key, written inside a gantt row\'s `items`; a feed entry has one date, `time`. ' + + 'On a feed timeline write `time`; on `variant: \'gantt\'` write `{ label, items: [{ startDate, endDate }] }`.', + group: + '`group` is a key the record-bound rail composes onto the entries it maps (the date bucket it groups them ' + + 'under), not one an authored entry carries. Remove it — authored entries are drawn in the order written.', + meta: + '`meta` is a key the record-bound rail composes onto the entries it maps, not one an authored entry ' + + 'carries. Put the text in the entry\'s `description`, or remove it.', + }, + }, { + ...objectTimelineFeedItemShape(), + ...objectTimelineGanttRowShape(), + }); +} +let objectTimelineItemOnce: ReturnType | undefined; +/** The one {@link buildObjectTimelineItem} instance. */ +const objectTimelineItem = () => (objectTimelineItemOnce ??= buildObjectTimelineItem()); + +/** + * [#21464] The row refinement that pairs each `items` entry with the arm the + * row's `variant` selects (absent ⇒ `vertical`) — objectui's node-level + * refinement (`timelineItemsFitVariant`, `types/src/zod/data-display.zod.ts`), + * restated for this row: each branch of the renderer reads only its own arm's + * keys, so a gantt row on a feed timeline, or a feed entry on a gantt one, drew + * an empty, unlabelled entry. + * + * Two refusals per entry, each at the key it names: the arm's REQUIRED key is + * absent (`title` on a feed entry, `label` on a gantt row), or a key only the + * OTHER arm declares is present. An undeclared key is the entry shape's own + * refusal. The arm key lists are read off the two shapes above, never + * restated, so a key added to an arm is judged the moment it is declared. + */ +function objectTimelineItemsFitVariant() { + const feedKeys = Object.keys(objectTimelineFeedItemShape()); + const ganttKeys = Object.keys(objectTimelineGanttRowShape()); + const ARMS = { + feed: { name: 'feed entry', plural: 'feed entries', variants: "`variant: 'vertical'` (the default) or `'horizontal'`", required: 'title', keys: feedKeys }, + gantt: { name: 'gantt row', plural: 'gantt rows', variants: "`variant: 'gantt'`", required: 'label', keys: ganttKeys }, + } as const; + return (row: { variant?: string; items?: readonly unknown[] }, ctx: z.RefinementCtx): void => { + if (!Array.isArray(row.items)) return; + const variant = row.variant ?? 'vertical'; + const [arm, other] = variant === 'gantt' ? [ARMS.gantt, ARMS.feed] : [ARMS.feed, ARMS.gantt]; + const draws = `\`variant: '${variant}'\`${row.variant === undefined ? ' (the default)' : ''} draws ${arm.plural} \`{ ${arm.keys.join(', ')} }\``; + row.items.forEach((entry, index) => { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return; + const item = entry as Record; + if (item[arm.required] === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['items', index, arm.required], + message: `\`${arm.required}\` is required on a ${arm.name}: ${draws}, and an entry without it is drawn empty and unlabelled.`, + }); + } + for (const key of other.keys) { + if (arm.keys.includes(key) || item[key] === undefined) continue; + ctx.addIssue({ + code: 'custom', + path: ['items', index, key], + message: `\`${key}\` is a ${other.name} key, and ${draws}; author ${other.plural} under ${other.variants}.`, + }); + } + }); + }; +} + /** * `object-timeline` (objectui `plugin-timeline/src/ObjectTimeline.tsx`, the * presentational `plugin-timeline/src/renderer.tsx` it composes into, and the @@ -7992,9 +8359,10 @@ const ObjectTimelineMappingSchema = lazySchema(() => strictObject({ * optional field names), written here first and then taken; it was * `z.unknown()` while that contract lived only in objectui * (`TimelineMappingSchema`), the `object-calendar.calendar` posture this - * section's header prescribes for exactly that case. `items` stays - * `z.array(z.unknown())`: that stage found two viable spec shapes for the - * authored entry (see the member). `navigation` takes + * section's header prescribes for exactly that case. `items` takes + * {@link buildObjectTimelineItem} since #21464's S-final stage: objectui's two + * ruled arms, closed, with the row's {@link objectTimelineItemsFitVariant} + * pairing each entry with the arm `variant` selects. `navigation` takes * {@link NavigationConfigSchema}, by reference, for the reason the ruling gives. * * ⚠️ `variant: 'gantt'` is declared because the registration declares it @@ -8118,22 +8486,19 @@ export const ObjectTimelinePropsSchema = lazySchema(() => strictObject({ data: z.array(z.unknown()).optional() .describe("Pre-fetched records — read FIRST as the rail's row source, ahead of the data-scope binding and the fetch, and composed into entries through the same `timeline` field bindings a fetched row takes; authoring it suppresses the object query entirely. Distinct from `items`, which is the already-composed entry shape and wins over this key when both are written"), /** - * [#21464] HELD at `z.unknown()` elements, in the enumeration pin's ledger as - * a fork the S-objectui-held stage reported. Each element is objectui's - * declared authored timeline entry (`types/src/data-display.ts` at the - * `.objectui-sha` pin `ab1879721595`: `TimelineFeedItem`, `:2973`, or - * `TimelineGanttItem`, `:3042`, ruled on objectui#6356), handed to the rail - * verbatim (`ObjectTimeline.tsx:587`). Writing that contract here needs two - * decisions no ruling has made: a feed entry's `content` is child schema nodes - * (`SchemaNode | SchemaNode[]`), which this map either declares as a slot - * position the page walks judge or keeps as an opaque member; and the arm an - * entry must match is chosen by the PARENT's `variant`, which objectui judges - * in a node-level refinement and a spec row would either repeat or replace - * with a plain union of the two arms. (A gantt bar's dates also take a `Date` - * there, which authored JSON cannot carry.) + * [#21464] The authored entries — typed in the S-final stage, per the + * maintainer's ruling on the decision card #21704, fork 4, letter B (record + * 5979239990). Each element is objectui's declared authored timeline entry + * (`types/src/data-display.ts` at the `.objectui-sha` pin `2e818d0b51ec`: + * `TimelineFeedItem` or `TimelineGanttItem`, ruled on objectui#6356), handed + * to the rail verbatim (`ObjectTimeline.tsx:587`) — see + * {@link buildObjectTimelineItem}. The arm an entry must be is the one this + * row's `variant` selects, judged by the row's refinement + * ({@link objectTimelineItemsFitVariant}). A feed entry's `content` is held + * opaque; a gantt bar's dates are a string or a number. */ - items: z.array(z.unknown()).optional() - .describe("Static inline entries — read ahead of every record source, `data` above included, and bypasses the object query entirely (the renderer becomes a pass-through). Each element is objectui's declared timeline element, `@object-ui/types`'s `TimelineFeedItem` (`variant` absent / `vertical` / `horizontal`) or `TimelineGanttItem` (`variant: 'gantt'`), the arm this node's `variant` selects"), + items: z.array(objectTimelineItem()).optional() + .describe("Static inline entries — read ahead of every record source, `data` above included, and bypasses the object query entirely (the renderer becomes a pass-through). Each entry is the kind this node's `variant` selects: a feed entry `{ time?, title, description?, variant?, icon?, content?, className? }` (`variant` absent / `vertical` / `horizontal`), or a gantt row `{ label, items? }` (`variant: 'gantt'`) whose bars are `{ title?, startDate?, endDate?, variant? }`, each date a string or epoch milliseconds"), variant: z.enum(['vertical', 'horizontal', 'gantt']).optional() .describe("Rail layout (renderer default `vertical`). ⚠️ `gantt` needs authored `items`: the object-bound path composes flat feed entries, which the gantt branch cannot draw, and refuses that combination with a named diagnostic instead of drawing an empty chart"), dateFormat: z.enum(['short', 'long', 'iso']).optional() @@ -8158,7 +8523,7 @@ export const ObjectTimelinePropsSchema = lazySchema(() => strictObject({ */ navigation: NavigationConfigSchema.optional() .describe("Entry-click navigation config — the same block `ListViewSchema.navigation` declares ({ mode, size, openNewTab, preventNavigation })"), -})); +}).superRefine(objectTimelineItemsFitVariant())); /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectTimelineProps = z.input; /** From 22d8635ca8852ba401dc12ce5d6f2f7b10e8d97b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 16:05:45 +0000 Subject: [PATCH 2/3] wip(spec): S-final pins, D3 entries, rationale fragments, conformance rows (#21464) Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- .../test/expression-conformance.ledger.ts | 28 + .../spec/dropped-refinements.baseline.json | 8 +- .../18.ui-action-group-menu-members-typed.ts | 44 ++ ...i-object-metric-drill-down-report-typed.ts | 37 ++ .../18.ui-object-timeline-items-typed.ts | 40 ++ packages/spec/src/migrations/registry.ts | 149 +++++ ...omponent-action-element-rows-20371.test.ts | 2 +- ...nt-metric-family-typed-members.pin.test.ts | 27 +- ...omponent-props-unknown-members.pin.test.ts | 172 +++--- ...ort-items-action-members-typed.pin.test.ts | 510 ++++++++++++++++++ 10 files changed, 939 insertions(+), 78 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-members-typed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-metric-drill-down-report-typed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-timeline-items-typed.ts create mode 100644 packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 5b4d576b642..92447a86c24 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -444,6 +444,34 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ ], note: 'Deliberately NOT `fail-soft-log` like `cel-action-disabled`: the renderers measured here answer a faulting `disabled` with `true`, and on an un-negated enablement leg that `true` greys the control out — SchemaRenderer.tsx:1258-1265 states exactly that asymmetry ("on the negated visibility legs that means SHOWN, here it means GREYED OUT"). The objectui fix `cel-action-disabled` cites is about an EMPTY `disabled: \'\'`, which `hasDeclaredVisibilityGate` / `hasDeclaredPredicate` now treat as no gate, not about a faulting one. ⚠️ Scope consequence worth knowing: the node-gate leg evaluates at PAGE scope, so a row-scoped `record.*` predicate that does not resolve there faults and greys the button out whatever the row says. Same bare-string limit as `cel-action-block-visible-closed`. ⛔ NOT MEASURED HERE: the renderers were read at the pin, not run.', }, + // The S-final stage of #21464: each `action:group` / `action:menu` MEMBER took + // a closed shape that carries the block rows' own `visible` / `disabled` + // predicate (`actionContainerMemberShape`). A member is drawn and gated by its + // container ITSELF, never through `SchemaRenderer`, so each surface below has + // ONE evaluation leg — the container's — and no node gate. Read (not run) at + // the `.objectui-sha` pin `2e818d0b51ec`; the renderers + // `components/src/renderers/action/action-{group,menu}.tsx`, + // `react/src/hooks/useExpression.ts` and + // `core/src/evaluator/ExpressionEvaluator.ts` are byte-identical at objectui + // `main` `2abec3a96`. + { + id: 'cel-action-member-visible', + summary: '`action:group` / `action:menu` member visibility (a member\'s `visible`) — the member is not drawn when the predicate is FALSE', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-closed', + enforcement: + 'BUILD-TIME GATE, measured here: lint/validate-component-props.ts parses `ComponentPropsMap["action:group" | "action:menu"]`, whose `actions` element declares the slot. EVALUATOR, one leg: the container\'s own `useCondition(toPredicateInput(action.visible), recordData, { throwOnError: true, label })` against the host-bound row — action-group.tsx:81-86 (`useMemberVisible`, read by both display modes, :114 and :205) and action-menu.tsx:78-85 (`useMenuActionVisible`, read by the item :104 and by the auto-trigger :187) — where a fault returns `false` and warns once per label and predicate (useExpression.ts:216-238), and the member returns null (action-group.tsx:138, :212; action-menu.tsx:121): fail-CLOSED. No node-gate leg: a member is drawn by its container, never through SchemaRenderer', + covers: ['ui/component.zod.ts:actionContainerMemberShape.visible'], + note: 'Separate from `cel-action-block-visible-closed` / `cel-action-block-visible-soft`, which classify a BLOCK\'s own `visible` (two legs, the node gate among them), and from `cel-action-visible`, a registered object action drawn by `ActionEngine`. Same bare-string limit as `cel-action-block-visible-closed`: the member rides the opaque `properties` bag verbatim, so a bare string reaches the LEGACY evaluator and only a `{dialect:"cel"}` envelope routes to CEL. ⛔ NOT MEASURED HERE: the renderers were read at the pin, not run.', + }, + { + id: 'cel-action-member-disabled', + summary: '`action:group` / `action:menu` member disabling (a member\'s `disabled`) — the member stays on screen and cannot be pressed while the predicate is TRUE', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-closed', + enforcement: + 'BUILD-TIME GATE, measured here: lint/validate-component-props.ts parses `ComponentPropsMap["action:group" | "action:menu"]`, whose `actions` element declares the slot. EVALUATOR, one leg, un-negated: the container\'s own `useCondition(toPredicateInput(action.disabled), recordData)` WITHOUT `throwOnError` — action-group.tsx:119 + :159-165 (an inline button) and :208 + :219-223 (a dropdown item), action-menu.tsx:108 + :132-136 — where a fault answers `evaluateCondition`\'s fail-soft `true` (ExpressionEvaluator.ts:416-437), which on this leg means DISABLED: the member is drawn and refuses the press, fail-CLOSED. A faulting bare string is silent on this leg (no `onFault` is passed); a faulting `{dialect:"cel"}` envelope is reported through `evalFieldPredicate`', + covers: ['ui/component.zod.ts:actionContainerMemberShape.disabled'], + note: 'The `cel-action-block-disabled` face on a container member, without its node-gate leg, and for the same reason not `fail-soft-log` like `cel-action-disabled`: a `true` on an un-negated enablement leg greys the control out. The legacy `enabled` fallback the containers also read is a NEGATED leg the member shape refuses by name, so it classifies nothing here. Same bare-string limit as `cel-action-member-visible`. ⛔ NOT MEASURED HERE: the renderers were read at the pin, not run.', + }, // The S-forms stage of #21464: the `object-form` / `object-master-detail-form` // page blocks took a closed inline form field (`customFields`, and a // section's inline entry) with its own option, and a section shape of their diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 12b947e94c4..c91e76755ed 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 218, - "droppedRefinementSites": 670, + "droppedRefinementSites": 676, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -1427,11 +1427,17 @@ "ui/ObjectMetricProps": { "sites": [ "aggregate", + "drillDown.report", + "drillDown.report.blocks.element", + "drillDown.report.blocks.element.runtimeFilter", + "drillDown.report.runtimeFilter", + "drillDown.report.runtimeFilter.lazy", "filter.element" ] }, "ui/ObjectTimelineProps": { "sites": [ + "", "filter.element", "timeline.groupByField" ] diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-members-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-members-typed.ts new file mode 100644 index 00000000000..d893f3a4e86 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-members-typed.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21464 — each member of the `action:group` / `action:menu` page blocks' +// `actions` was an open record: the container draws and runs the member itself, +// and the spec declared none of its keys. The maintainer ruled on #21704 (fork +// 5, letter A): the measured read set, `action:button`'s keys keyed by `type`, +// with the rows' prescriptions; `outcomeMessages`, a member `className` and +// `properties.params` refused; `outcomeMessages` undeclared on all four action +// blocks. D3 only: page-component `properties` is not parsed on the metadata +// save or load path, so a stored page is never refused; and the authored census +// found no working member to respell — the refused values are objectui's probes +// of the very reads the ruling refuses (a member `className`, `outcomeMessages`, +// `properties.params`) and of the host's `autoTrigger` flag. +export const entry: SemanticMigration = { + id: 'ui-action-group-menu-members-typed', + surface: 'page `action:group` and `action:menu` components — each member of `properties.actions` (whose keys ' + + 'used to pass unjudged)', + replacement: 'an inline action with `action:button`\'s keys, its executor spelled `type`: `{ name?, label?, ' + + 'icon?, type?, variant?, visible?, disabled?, tags?, params?, description?, target?, openIn?, method?, ' + + 'bodyExtra?, bodyShape?, operation?, patch?, confirmText?, successMessage?, errorMessage?, refreshAfter?, ' + + 'locations?, toast?, resultDialog?, onSuccess?, objectName? }`, plus `size?` on an `action:group` member. ' + + 'Write `actionType` as `type`, `endpoint` (and `url` / `path` / `href`) as `target`, `enabled` as `disabled` ' + + 'with the condition inverted, and `outcomeMessages` as one `successMessage`; drop a member `className`, ' + + '`properties`, `autoTrigger`, `undoable`, `recordIdField` and an `action:menu` member\'s `size`.', + reason: 'An `action:group` or `action:menu` draws and runs each member itself: it draws `label` (or `name`), ' + + '`icon`, `variant`, `tags` and, on a group\'s inline buttons, `size`; gates the member on `visible` and ' + + '`disabled`; places it by `locations`; and forwards its `type` and the rest of `action:button`\'s keys to the ' + + 'action runner. The page-component rows declared each member an open record, so a misspelled key, a ' + + 'node-style `actionType` or an `endpoint` no `api` handler reads passed the component-props gate, and the ' + + 'container drew and ran the member without it. The rows now take a closed member: `action:button`\'s keys ' + + 'by `type`, with the rows\' prescriptions; the keys the rows leave undecided — `outcomeMessages`, a member ' + + '`className`, a member `properties.params` — are refused, and `outcomeMessages` stays undeclared on all four ' + + 'action blocks as one decision. 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 the authored census found no ' + + 'working member to respell. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `action:group` and `action:menu` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.actions`. Each member ' + + 'is drawn with its label, icon and variant, and runs the executor its `type` names.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-metric-drill-down-report-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-metric-drill-down-report-typed.ts new file mode 100644 index 00000000000..e1ce2ddb386 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-metric-drill-down-report-typed.ts @@ -0,0 +1,37 @@ +// 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.report` was `z.unknown()`: +// the tile hands it to the shared drill drawer, which draws a dataset-bound +// report and lists the records for any other value, and no spec drill shape +// declared it. The maintainer ruled on #21704 (fork 1, letter B) that it is +// `ReportSchema` by reference, once a joined report refuses a block that binds +// no dataset (#21702), so a report the member admits is one the drawer draws. +// D3 only: page-component `properties` is not parsed on the metadata save or +// load path, so a stored page is never refused; and the authored census found +// no drawn report to respell — the refused values are objectui's probes of the +// values the drawer does NOT draw. +export const entry: SemanticMigration = { + id: 'ui-object-metric-drill-down-report-typed', + surface: 'page `object-metric` components — `properties.drillDown.report` (which used to accept any value)', + replacement: 'a report definition, the same shape as `reports[]` (`ReportSchema`): `{ name, label, dataset, ' + + 'values, … }`, or a `joined` report whose every block binds a `dataset`. Write a bare report name, a ' + + '`{ name }` reference or the retired `objectName` / `columns` form as the dataset-bound report itself.', + reason: 'The metric tile hands `drillDown.report` to the shared drill drawer, which draws it as a report — with ' + + 'the metric\'s filter joined into the report\'s own `runtimeFilter` — when it is dataset-bound (a non-empty ' + + '`dataset`, or a `joined` report with a block that binds one), and lists the records for any other value. ' + + 'The page-component row declared it `z.unknown()`, so a report with no `dataset`, a misspelled report key, a ' + + 'bare report name or a `{ name }` reference passed the component-props gate, and the drawer quietly listed ' + + 'the records instead. The row now takes `ReportSchema` by reference — the declaration objectui already ' + + 'names for the member — and, since a joined report refuses a block that binds no `dataset`, every report it ' + + 'admits is one the drawer draws. 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 the authored census found no drawn report to ' + + 'respell. 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.report`. Each ' + + 'tile whose drill names a report opens that report, scoped by the metric\'s filter, instead of the record list.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-timeline-items-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-timeline-items-typed.ts new file mode 100644 index 00000000000..c94182dfdf1 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-timeline-items-typed.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21464 — the `object-timeline` page block's `items` was `z.array(z.unknown())`: +// each entry is objectui's authored timeline element (`TimelineFeedItem` / +// `TimelineGanttItem`, ruled on objectui#6356), which the spec did not declare, +// and the arm an entry must be is chosen by the row's `variant`. The maintainer +// ruled on #21704 (fork 4, letter B): both arms closed, a feed entry's `content` +// opaque, a row refinement pairing each entry with the arm `variant` selects, +// and a gantt bar's dates a string or a number. D3 only: page-component +// `properties` is not parsed on the metadata save or load path, so a stored page +// is never refused; and the authored census found no drawn entry to respell — +// the refused values are objectui's probes of its render-time gantt date +// diagnostic. +export const entry: SemanticMigration = { + id: 'ui-object-timeline-items-typed', + surface: 'page `object-timeline` components — `properties.items` (whose entries used to accept any value)', + replacement: 'the entry kind the block\'s `variant` selects: on `vertical` (the default) or `horizontal`, a feed ' + + 'entry `{ time?, title, description?, variant?, icon?, content?, className? }`; on `gantt`, a gantt row ' + + '`{ label, items? }` whose bars are `{ title?, startDate?, endDate?, variant? }`, each date a string or epoch ' + + 'milliseconds. Write a feed entry\'s `date` as `time` and its `color` as `variant` (`default`, `success`, ' + + '`warning`, `danger`, `info`); move a gantt row to `variant: \'gantt\'`, or a feed entry off it.', + reason: 'The timeline rail draws `items` as authored, ahead of every record source, and each branch of its ' + + 'renderer reads only its own kind of entry: the feed branches read `time`, `title`, `description`, `variant`, ' + + '`icon`, `content` and `className`; the gantt branch reads a row\'s `label` and its bars\' `title`, ' + + '`startDate`, `endDate` and `variant`. The page-component row declared each entry `z.unknown()`, so a ' + + 'misspelled key, a feed entry with no `title`, or a gantt row on a feed timeline passed the component-props ' + + 'gate, and the rail drew an empty, unlabelled entry. The row now takes objectui\'s two ruled kinds, closed, ' + + 'and pairs each entry with the kind its `variant` selects; a feed entry\'s `content` (child components) is ' + + 'held unjudged until a writer appears. 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 ' + + 'the authored census found no drawn entry to respell. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-timeline` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.items`. Each timeline ' + + 'with authored entries draws every entry with its title (or row label), date and colour.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 6413a91ee4e..ebd7062ca4e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6181,6 +6181,20 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'authors are refused at parse; its D3 record is the semantic entry ' + '`translation-widget-sub-caption-retired`.', }, + { + id: 'ui-action-group-menu-members-typed', + order: 82, + text: + 'And it types the members of the `action:group` and `action:menu` page blocks, the last of those forks ' + + '(the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so ' + + 'a misspelled key, a node-style `actionType` or an `endpoint` no `api` handler reads passed every door. A ' + + 'member now takes `action:button`\'s keys with its executor spelled `type`, measured from the containers\' ' + + 'reads — an `action:menu` item reads no `size` and declares none — with the rows\' prescriptions; ' + + '`outcomeMessages`, a member `className` and a member `properties.params` are refused, and ' + + '`outcomeMessages` stays undeclared on all four action blocks as one decision. 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-action-group-menu-members-typed`.', + }, { id: 'ui-ai-chat-window-retired', order: 65, @@ -6421,6 +6435,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '(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-report-typed', + order: 80, + text: + 'It then types the three members the stages above held open, as the maintainer ruled them on the ' + + 'decision card for those forks. The `object-metric` drill-down\'s `report` is `ReportSchema`, by ' + + 'reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, ' + + 'and since then every report the member admits is one the drill drawer draws — a report with no ' + + '`dataset`, a bare report name or a `{ name }` reference, which the drawer answered by listing the ' + + 'records, 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-metric-drill-down-report-typed`.', + }, { id: 'ui-object-metric-drill-down-typed', order: 72, @@ -6434,6 +6461,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '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-object-timeline-items-typed', + order: 81, + text: + 'It types the `object-timeline` page block\'s `items` (fork 4, letter B): each entry is one of objectui\'s ' + + 'two ruled kinds, closed — a feed entry `{ time, title, description, variant, icon, content, className }` ' + + 'or a gantt row `{ label, items }` of bars `{ title, startDate, endDate, variant }`, each date a string or ' + + 'epoch milliseconds — and a row refinement pairs each entry with the kind the block\'s `variant` selects, ' + + 'so a feed entry with no `title`, or a gantt row on a feed timeline, is refused instead of drawn empty. ' + + 'A feed entry\'s `content` (child components) is held unjudged until a writer appears. 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-timeline-items-typed`.', + }, { id: 'ui-object-timeline-mapping-typed', order: 76, @@ -19277,6 +19317,46 @@ const step18: MigrationStep = { + 'local file, and rewrite it to that spelling. Done when every turso datasource parses, the ' + 'driver builds from it, and a replica datasource reports a file: url beside its syncUrl.', }, + // #21464 — each member of the `action:group` / `action:menu` page blocks' + // `actions` was an open record: the container draws and runs the member itself, + // and the spec declared none of its keys. The maintainer ruled on #21704 (fork + // 5, letter A): the measured read set, `action:button`'s keys keyed by `type`, + // with the rows' prescriptions; `outcomeMessages`, a member `className` and + // `properties.params` refused; `outcomeMessages` undeclared on all four action + // blocks. D3 only: page-component `properties` is not parsed on the metadata + // save or load path, so a stored page is never refused; and the authored census + // found no working member to respell — the refused values are objectui's probes + // of the very reads the ruling refuses (a member `className`, `outcomeMessages`, + // `properties.params`) and of the host's `autoTrigger` flag. + { + id: 'ui-action-group-menu-members-typed', + surface: 'page `action:group` and `action:menu` components — each member of `properties.actions` (whose keys ' + + 'used to pass unjudged)', + replacement: 'an inline action with `action:button`\'s keys, its executor spelled `type`: `{ name?, label?, ' + + 'icon?, type?, variant?, visible?, disabled?, tags?, params?, description?, target?, openIn?, method?, ' + + 'bodyExtra?, bodyShape?, operation?, patch?, confirmText?, successMessage?, errorMessage?, refreshAfter?, ' + + 'locations?, toast?, resultDialog?, onSuccess?, objectName? }`, plus `size?` on an `action:group` member. ' + + 'Write `actionType` as `type`, `endpoint` (and `url` / `path` / `href`) as `target`, `enabled` as `disabled` ' + + 'with the condition inverted, and `outcomeMessages` as one `successMessage`; drop a member `className`, ' + + '`properties`, `autoTrigger`, `undoable`, `recordIdField` and an `action:menu` member\'s `size`.', + reason: 'An `action:group` or `action:menu` draws and runs each member itself: it draws `label` (or `name`), ' + + '`icon`, `variant`, `tags` and, on a group\'s inline buttons, `size`; gates the member on `visible` and ' + + '`disabled`; places it by `locations`; and forwards its `type` and the rest of `action:button`\'s keys to the ' + + 'action runner. The page-component rows declared each member an open record, so a misspelled key, a ' + + 'node-style `actionType` or an `endpoint` no `api` handler reads passed the component-props gate, and the ' + + 'container drew and ran the member without it. The rows now take a closed member: `action:button`\'s keys ' + + 'by `type`, with the rows\' prescriptions; the keys the rows leave undecided — `outcomeMessages`, a member ' + + '`className`, a member `properties.params` — are refused, and `outcomeMessages` stays undeclared on all four ' + + 'action blocks as one decision. 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 the authored census found no ' + + 'working member to respell. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `action:group` and `action:menu` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.actions`. Each member ' + + 'is drawn with its label, icon and variant, and runs the executor its `type` names.', + }, { id: 'ui-action-undoable-unfulfillable-refused', surface: '`action` documents declaring `undoable: true` on a shape no runtime fulfils — ' @@ -20562,6 +20642,39 @@ const step18: MigrationStep = { + '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.report` was `z.unknown()`: + // the tile hands it to the shared drill drawer, which draws a dataset-bound + // report and lists the records for any other value, and no spec drill shape + // declared it. The maintainer ruled on #21704 (fork 1, letter B) that it is + // `ReportSchema` by reference, once a joined report refuses a block that binds + // no dataset (#21702), so a report the member admits is one the drawer draws. + // D3 only: page-component `properties` is not parsed on the metadata save or + // load path, so a stored page is never refused; and the authored census found + // no drawn report to respell — the refused values are objectui's probes of the + // values the drawer does NOT draw. + { + id: 'ui-object-metric-drill-down-report-typed', + surface: 'page `object-metric` components — `properties.drillDown.report` (which used to accept any value)', + replacement: 'a report definition, the same shape as `reports[]` (`ReportSchema`): `{ name, label, dataset, ' + + 'values, … }`, or a `joined` report whose every block binds a `dataset`. Write a bare report name, a ' + + '`{ name }` reference or the retired `objectName` / `columns` form as the dataset-bound report itself.', + reason: 'The metric tile hands `drillDown.report` to the shared drill drawer, which draws it as a report — with ' + + 'the metric\'s filter joined into the report\'s own `runtimeFilter` — when it is dataset-bound (a non-empty ' + + '`dataset`, or a `joined` report with a block that binds one), and lists the records for any other value. ' + + 'The page-component row declared it `z.unknown()`, so a report with no `dataset`, a misspelled report key, a ' + + 'bare report name or a `{ name }` reference passed the component-props gate, and the drawer quietly listed ' + + 'the records instead. The row now takes `ReportSchema` by reference — the declaration objectui already ' + + 'names for the member — and, since a joined report refuses a block that binds no `dataset`, every report it ' + + 'admits is one the drawer draws. 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 the authored census found no drawn report to ' + + 'respell. 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.report`. Each ' + + 'tile whose drill names a report opens that report, scoped by the metric\'s filter, instead of the record list.', + }, // #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 @@ -20605,6 +20718,42 @@ const step18: MigrationStep = { + 'and the records behind the number, scoped by the metric\'s own `filter`, in the columns and page size ' + 'written.', }, + // #21464 — the `object-timeline` page block's `items` was `z.array(z.unknown())`: + // each entry is objectui's authored timeline element (`TimelineFeedItem` / + // `TimelineGanttItem`, ruled on objectui#6356), which the spec did not declare, + // and the arm an entry must be is chosen by the row's `variant`. The maintainer + // ruled on #21704 (fork 4, letter B): both arms closed, a feed entry's `content` + // opaque, a row refinement pairing each entry with the arm `variant` selects, + // and a gantt bar's dates a string or a number. D3 only: page-component + // `properties` is not parsed on the metadata save or load path, so a stored page + // is never refused; and the authored census found no drawn entry to respell — + // the refused values are objectui's probes of its render-time gantt date + // diagnostic. + { + id: 'ui-object-timeline-items-typed', + surface: 'page `object-timeline` components — `properties.items` (whose entries used to accept any value)', + replacement: 'the entry kind the block\'s `variant` selects: on `vertical` (the default) or `horizontal`, a feed ' + + 'entry `{ time?, title, description?, variant?, icon?, content?, className? }`; on `gantt`, a gantt row ' + + '`{ label, items? }` whose bars are `{ title?, startDate?, endDate?, variant? }`, each date a string or epoch ' + + 'milliseconds. Write a feed entry\'s `date` as `time` and its `color` as `variant` (`default`, `success`, ' + + '`warning`, `danger`, `info`); move a gantt row to `variant: \'gantt\'`, or a feed entry off it.', + reason: 'The timeline rail draws `items` as authored, ahead of every record source, and each branch of its ' + + 'renderer reads only its own kind of entry: the feed branches read `time`, `title`, `description`, `variant`, ' + + '`icon`, `content` and `className`; the gantt branch reads a row\'s `label` and its bars\' `title`, ' + + '`startDate`, `endDate` and `variant`. The page-component row declared each entry `z.unknown()`, so a ' + + 'misspelled key, a feed entry with no `title`, or a gantt row on a feed timeline passed the component-props ' + + 'gate, and the rail drew an empty, unlabelled entry. The row now takes objectui\'s two ruled kinds, closed, ' + + 'and pairs each entry with the kind its `variant` selects; a feed entry\'s `content` (child components) is ' + + 'held unjudged until a writer appears. 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 ' + + 'the authored census found no drawn entry to respell. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-timeline` node validates: `objectstack validate` reports no ' + + '`component-props-invalid` / `component-props-unknown-key` finding under `properties.items`. Each timeline ' + + 'with authored entries draws every entry with its title (or row label), date and colour.', + }, // #21464 — the `object-timeline` page block's `mapping` was `z.unknown()`: its // contract lived only in objectui, so a bare field name, a non-string binding // or a misspelled member (`titleField` inside `mapping`) passed the diff --git a/packages/spec/src/ui/component-action-element-rows-20371.test.ts b/packages/spec/src/ui/component-action-element-rows-20371.test.ts index 9004deffd45..2f926694b70 100644 --- a/packages/spec/src/ui/component-action-element-rows-20371.test.ts +++ b/packages/spec/src/ui/component-action-element-rows-20371.test.ts @@ -313,7 +313,7 @@ describe('what the measurement decided, pinned', () => { expect(schema.parse({ label: 'Close child', objectName: 'task' })).toEqual({ label: 'Close child', objectName: 'task' }); } // `action:group` / `action:menu` forward each MEMBER's `objectName`: it - // rides the member object, which this row does not judge ... + // rides the member object, which declares it (#21464, the S-final stage) ... for (const schema of [ActionGroupPropsSchema, ActionMenuPropsSchema]) { const member = { name: 'close', label: 'Close', type: 'script', objectName: 'task' }; expect(schema.safeParse({ actions: [member] }).success).toBe(true); 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 d9ef5840b10..fa5f8b6556d 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 @@ -7,8 +7,10 @@ * 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. + * by name. The drill-down's `report` was held in the enumeration pin's ledger + * until the maintainer ruled it (decision card #21704, fork 1, letter B): it is + * `ReportSchema`, by reference, since the S-final stage, pinned in + * `component-report-items-action-members-typed.pin.test.ts`. * * ## The defect this file closes * @@ -44,7 +46,8 @@ * * The enumeration pin (`component-props-unknown-members.pin.test.ts`) holds the * other half: these four left its ledger, so a member reverted to - * `z.unknown()` reds there, and the held `drillDown.report` is listed there. + * `z.unknown()` reds there — and so does `drillDown.report`, which left it in + * the S-final stage. */ import { describe, it, expect } from 'vitest'; @@ -53,6 +56,7 @@ import type { z } from 'zod'; import { ComponentPropsMap, ObjectMetricPropsSchema } from './component.zod'; import { ChartAggregateSchema, ChartAggregateFunctionSchema, ChartDrillDownSchema, ChartGroupBySchema } from './chart.zod'; import { DashboardWidgetSchema } from './dashboard.zod'; +import { ReportSchema } from './report.zod'; import { AggregationFunction } from '../data/query.zod'; import { I18nLabelSchema } from './i18n.zod'; import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; @@ -105,12 +109,6 @@ describe('§1 each member accepts every shape a measured writer authors', () => 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' } }], @@ -124,6 +122,17 @@ describe('§1 each member accepts every shape a measured writer authors', () => }); } + // objectui's drawn report drill (`objectMetricDrillDownMembers-8071.test.tsx`). + // Not byte-identical since the S-final stage: `report` is `ReportSchema`, by + // reference, whose `drilldown` default materializes on parse — so the drill + // parses to exactly the authored block with the report ReportSchema answers. + it('parses a drill into a dataset-bound report, its report exactly what ReportSchema answers', () => { + const report = { name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'deals_ds', rows: ['stage'], values: ['amount_sum'] }; + const r = parse({ drillDown: { enabled: true, report } }); + expect(issues(r)).toEqual([]); + expect(r.success && r.data).toStrictEqual({ ...BASE, drillDown: { enabled: true, report: ReportSchema.parse(report) } }); + }); + it('an absent member stays absent', () => { const r = parse({}); expect(issues(r)).toEqual([]); 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 94361ab908b..be318efc847 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 @@ -21,8 +21,8 @@ * is no longer `z.unknown()` fails, so typing a member deletes its line and * the ledger cannot outlive the debt it records. * - §2 EACH REASON IS CHECKED AGAINST THE SCHEMA, where it can be: a slot is a - * declared slot position, and a runner-forwarded member says so in its own - * `.describe()`. + * declared slot position, and a runner-forwarded member and a member held + * opaque each say so in their own `.describe()`. * - §3 THE WALK CAN FAIL: it finds a `z.unknown()` in every position it claims * to walk, so a green §1 is not a walk that saw nothing. * - §4 THE MEMBERS THIS CARD TYPES: `navigation` on `object-map`, @@ -32,6 +32,8 @@ * - §5 THE HOLD THIS FILE RECORDED, EXITED: `object-kanban` * `conditionalFormatting` is the list view's own member, by identity, the * same way (the S-kanban-cf stage). + * - §6 THE CLOSE-OUT: no stage is declared and no line is `staged` — the last + * three forks are typed (the S-final stage), with a firing control. * * Later stages pin the members they type in their own file, beside this one: * the list family (`object-grid` `columns` / `fields` / `selection` / @@ -47,31 +49,48 @@ * contracts the last stage could type (`object-gantt` `markers`, * `object-timeline` `mapping`, and the field-name `fields` of `object-form` * and `object-master-detail-form`) in - * `component-objectui-held-typed-members.pin.test.ts`; the rest of them are - * held below as forks — the drill-down's `report`, the timeline's `items` and - * the action containers' members. Two of that stage's forks were ruled and - * typed in the S-forms stage — the form's `customFields` and both forms' - * `sections` — and are pinned in - * `component-form-custom-fields-sections-typed.pin.test.ts`; the predicate ASTs - * and the roll-up filter inside them are lines here. The one member this ledger held for a ruling, + * `component-objectui-held-typed-members.pin.test.ts`; that stage held the + * other five as forks, and the maintainer ruled all five. Two were typed in + * the S-forms stage — the form's `customFields` and both forms' `sections` — + * and are pinned in `component-form-custom-fields-sections-typed.pin.test.ts`; + * the last three in the S-final stage — the drill-down's `report`, the + * timeline's `items` and the action containers' members — and are pinned in + * `component-report-items-action-members-typed.pin.test.ts`. The predicate + * ASTs, the runner-forwarded members, the filters and the timeline entry's + * opaque `content` inside those shapes are lines here, each with its reason. + * The one member this ledger held for a ruling, * `object-kanban` `conditionalFormatting`, exited its hold once objectui's * kanban declared the list view's rule as its only dialect, and is pinned * in §5 below, where its hold was recorded. * - * ## The STAGED reason is debt, not a verdict + * ## The STAGED reason is debt, not a verdict — and the debt is paid * - * A `staged` member IS read with a fixed shape at the `.objectui-sha` pin; its - * reader is cited on its line. Typing it is the next stage of #21464's + * A `staged` member WAS read with a fixed shape at the `.objectui-sha` pin, its + * reader cited on its line, and typing it was a later stage of #21464's * close-out (the census found more than one reviewable PR's worth), each stage - * preceded by its own census of authored writers. The ledger may only lose - * `staged` lines: a stage that types a member deletes its line here (§1's + * preceded by its own census of authored writers. The ledger could only lose + * `staged` lines: a stage that typed a member deleted its line here (§1's * second half enforces that), and ⛔ a NEW renderer-read member is typed, never * added as `staged`. * - * A `fork` line is a member the stage that owned it measured and did NOT type - * under the stop valve: its contract has two or more viable shapes that no - * ruling decides. The line names the shapes (§2 checks there are at least - * two), and the fork is on the card with its census, for a ruling. + * A `fork` line was a member the stage that owned it measured and did NOT type + * under the stop valve: its contract had two or more viable shapes that no + * ruling decided, so the line named the shapes (§2 checked there were at least + * two) and the fork went to the card with its census. All five were ruled + * (decision card #21704), and the S-final stage typed the last three. + * + * So {@link STAGES} is EMPTY, and §6 pins that: no member across the map is + * staged. The machinery stays, so a line that tried to come back as `staged` + * would have no stage to name and could not compile. + * + * ## The OPAQUE reason + * + * An `opaque` member is read with a fixed shape, and a RULING keeps it + * unjudged on its row until a writer appears — the timeline feed entry's + * `content` (child schema nodes), which the ruling on #21704's fork 4 holds + * opaque rather than making it a slot position the page walks visit. The line + * names the ruling record, and the member says so in its own `.describe()` + * (§2). */ import { describe, it, expect } from 'vitest'; @@ -149,9 +168,7 @@ function unknownMembers(schema: unknown): UnknownMember[] { * Each stage runs its own census of authored writers first; a narrowing that * would refuse a measured writer is reported, not shipped. */ -const STAGES = { - 'fork': 'element contracts whose declaration is still objectui\'s (the timeline items, and `UIActionSchema` — an objectui interface that borrows some members from the spec `Action`) and the metric drill-down\'s `report`: the S-objectui-held stage, the last of #21464, measured each and found two or more viable spec shapes that no ruling decides, so under the stop valve each is held and its fork reported on the card with its census; the member is typed once a ruling picks a shape (the runtime form field and the form sections were ruled, and typed in the S-forms stage)', -} as const; +const STAGES = {} as const satisfies Record; type Stage = keyof typeof STAGES; type Reason = @@ -167,6 +184,12 @@ type Reason = | { readonly kind: 'any-value'; readonly why: string } /** A deliberately open bag: the declared members are typed, the rest pass through. */ | { readonly kind: 'open-bag'; readonly why: string } + /** + * Read with a fixed shape, and held unjudged on its row by a ruling until a + * writer appears (`ruling` names the record); checked: the member's own + * `.describe()` says it is held opaque. + */ + | { readonly kind: 'opaque'; readonly ruling: string; readonly why: string } /** * Read with a fixed shape at the pin (`reader`); typing it is a named later * stage. A `fork` line also names the viable shapes no ruling has chosen @@ -199,15 +222,19 @@ const FILTER_CONDITION: Reason = { owner: 'data/filter.zod.ts `FilterConditionSchema`', why: 'a summary field\'s roll-up `filter` is a query `where` condition: each key names a field of the CHILD object and each value is its comparand, which the filter schema judges with its own refinement (`checkFilterConditionComparands`) and no page-component row can know', }; +const REPORT_RUNTIME_FILTER: Reason = { + kind: 'shared', + owner: 'ui/report.zod.ts `ReportSchema.runtimeFilter` / `JoinedReportBlockSchema.runtimeFilter` (`analyticsCarrierFilter`, over data/filter.zod.ts `FilterConditionSchema`)', + why: 'a report\'s render-time scope filter is a query `where` condition over its dataset: each key names a field and each value is its comparand, which the filter schema and the analytics carrier\'s own refinement judge and no page-component row can know', +}; const RECORDS: Reason = { kind: 'records' }; const SLOT: Reason = { kind: 'slot' }; const RUNNER: Reason = { kind: 'runner' }; -const fork = (reader: string, shapes: readonly string[]): Reason => ({ kind: 'staged', stage: 'fork', reader, shapes }); /** * `ObjectUI` source paths are at the `.objectui-sha` pin `89cad75d55`, except - * the `fork` lines, read at the `.objectui-sha` pin `ab1879721595` (each read - * point unchanged at objectui `main` `94985a92ba`). + * the S-final stage's line (the timeline entry's `content`), read at the + * `.objectui-sha` pin `2e818d0b51ec`. */ const LEDGER = new Map(); const on = (types: readonly string[], paths: readonly string[], reason: Reason): void => { @@ -225,6 +252,9 @@ on(['page:footer', 'page:sidebar', 'page:section'], ['children[]'], SLOT); // The engine AST beside every evaluated expression's `source`. on(['page:tabs'], ['items[].visibleWhen.ast'], EXPRESSION_AST); on(['record:alert', 'action:group', 'action:menu'], ['visible.ast'], EXPRESSION_AST); +// The S-final stage: each `action:group` / `action:menu` member carries the +// rows' own `visible` / `disabled` predicate. +on(['action:group', 'action:menu'], ['actions[].visible.ast', 'actions[].disabled.ast'], EXPRESSION_AST); on(['action:button', 'action:icon'], ['visible.ast', 'disabled.ast'], EXPRESSION_AST); on(['record:line_items'], ['columns[].readonlyWhen.ast', 'columns[].requiredWhen.ast'], EXPRESSION_AST); on(['object-master-detail-form'], ['details[].columns[].readonlyWhen.ast', 'details[].columns[].requiredWhen.ast'], EXPRESSION_AST); @@ -243,8 +273,11 @@ on(['object-form', 'object-master-detail-form'], [ 'sections[].visibleWhen.ast', ...FORM_FIELD_AST_PATHS.map((p) => `sections[].fields[].${p}`), ], EXPRESSION_AST); -// The action blocks' runner-forwarded members. +// The action blocks' runner-forwarded members — and, since the S-final stage, +// each `action:group` / `action:menu` member's, which the container forwards +// the same way (`buildActionGroupMember` / `buildActionMenuMember`). on(['action:button', 'action:icon'], ACTION_RUNNER_PATHS, RUNNER); +on(['action:group', 'action:menu'], ACTION_RUNNER_PATHS.map((p) => `actions[].${p}`), RUNNER); // The data-source binding every record-source block shares. on(VIEW_DATA_TYPES, ['data.read.params{}', 'data.read.body', 'data.write.params{}', 'data.write.body'], HTTP_REQUEST); @@ -263,6 +296,10 @@ on(['object-kanban'], ['columns[].cards[].*'], RECORDS); on(['object-grid'], ['bulkActionDefs[].params[].options[].*'], BULK_OPTION_ENTRY); on(['object-form'], ['customFields[].summaryOperations.filter{}'], FILTER_CONDITION); on(['object-form', 'object-master-detail-form'], ['sections[].fields[].summaryOperations.filter{}'], FILTER_CONDITION); +// The S-final stage: the drill-down's `report` is `ReportSchema`, by reference, +// and carries the report's own render-time filter on the report and on each +// joined block. +on(['object-metric'], ['drillDown.report.runtimeFilter{}', 'drillDown.report.blocks[].runtimeFilter{}'], REPORT_RUNTIME_FILTER); // The rest, one line each. on(['element:definition-list'], ['items[].description'], { @@ -274,48 +311,17 @@ on(['object-grid'], ['pagination.*'], { why: '`z.looseObject` on purpose: `pageSize` and `pageSizeOptions` are typed and are the only members a read point names; the member\'s own docblock records why the bag stays open', }); -// Read with a fixed shape at the pin — the forks the S-objectui-held stage -// reported. -// -// 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. Measured against it, the by-reference candidate admits a joined -// report with no dataset-bound block, which the drawer does not draw, and -// refuses a dataset-bound report with no name, label or values, which it does. -on(['object-metric'], ['drillDown.report'], fork( - 'plugin-dashboard/src/DrillDownDrawer.tsx:92 (`isDatasetBoundReport`), used at :115; handed over at ObjectMetricWidget.tsx:742', - [ - '`ReportSchema` by reference, as it stands: a joined report whose blocks bind no dataset is accepted and lists the records', - '`ReportSchema` once a joined report\'s blocks must each bind a dataset, as its own refinement comment says they do', - 'a drill-report shape of its own, the two arms the drawer draws', - ], -)); -// The authored timeline entry is objectui's (`TimelineFeedItem` / -// `TimelineGanttItem`): a feed entry's `content` is child schema nodes, and the -// arm an entry must match is chosen by the parent's `variant`. -on(['object-timeline'], ['items[]'], fork( - 'plugin-timeline/src/ObjectTimeline.tsx:587, into renderer.tsx (`TimelineFeedItem` / `TimelineGanttItem`, types/src/data-display.ts:2973, :3042)', - [ - 'the two arms with `content` a slot position the page walks judge, and the arm chosen by a row refinement on `variant`', - 'the two arms with `content` an opaque member and a plain union of the arms', - ], -)); -// Each member is objectui's `UIActionSchema`, drawn and run by the container. -on(['action:group'], ['actions[]{}'], fork( - 'components/src/renderers/action/action-group.tsx:303 (`UIActionSchema[]`), members at :91-249, run at :329-382', - [ - 'the read set, `action:button`\'s keys by `type`, without the keys the rows leave undecided', - 'the read set with `outcomeMessages`, a member `className` and the member `properties.params` bag declared', - ], -)); -on(['action:menu'], ['actions[]{}'], fork( - 'components/src/renderers/action/action-menu.tsx:342 (`UIActionSchema[]`), members at :80-147, :408, run at :264-328', - [ - 'the read set, `action:button`\'s keys by `type`, without the keys the rows leave undecided', - 'the read set with `outcomeMessages`, a member `className` and the member `properties.params` bag declared', - ], -)); +// Held opaque by a ruling. A timeline feed entry's `content` is child schema +// nodes the entry draws below its description (`plugin-timeline/src/renderer.tsx:1659`, +// `:1716`, `renderChildren`); the ruling on the decision card #21704's fork 4 +// (letter B) keeps it unjudged on this row rather than making it a slot +// position the page walks visit, until a writer appears — the census found +// none. +on(['object-timeline'], ['items[].content'], { + kind: 'opaque', + ruling: 'decision card #21704, fork 4, letter B (record 5979239990)', + why: 'child schema nodes no measured writer authors; it becomes a slot position the page walks judge once one does', +}); /** Every `z.unknown()` member of every row, keyed as the ledger keys it. */ function census(): Map { @@ -401,6 +407,17 @@ describe('§2 each recorded reason holds', () => { } }); + it('an `opaque` member says so in its own `.describe()`, and its line names the ruling that holds it', () => { + const opaque = entries.filter(([, reason]) => reason.kind === 'opaque'); + // LIT CONTROL: the timeline entry's `content` is the one opaque line, so + // the two checks below cannot pass over an empty set. + expect(opaque.map(([key]) => key)).toEqual(['object-timeline items[].content']); + for (const [key, reason] of opaque) { + expect(members.get(key)?.describe ?? '', key).toMatch(/Held opaque/); + expect(reason.kind === 'opaque' && reason.ruling, key).toMatch(/#21704/); + } + }); + it('a `shared` member really is the owner\'s: an `ast` beside a `source`, a request beside a `url`', () => { for (const [key, reason] of entries) { if (reason !== EXPRESSION_AST) continue; @@ -571,3 +588,24 @@ describe('§5 `conditionalFormatting` on object-kanban is the list view\'s own m expect(MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id)).toContain('ui-object-kanban-conditional-formatting-typed'); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +// §6 the close-out: no member is staged +// ─────────────────────────────────────────────────────────────────────────── + +describe('§6 #21464 is closed out — every renderer-read member is typed, or listed with a standing reason', () => { + it('no stage is declared, and no ledger line is `staged`', () => { + expect(Object.keys(STAGES)).toEqual([]); + expect([...LEDGER.entries()].filter(([, reason]) => reason.kind === 'staged').map(([key]) => key)).toEqual([]); + }); + + it('the members the last three forks held are typed: none of them is a `z.unknown()` any more', () => { + const members = census(); + for (const key of ['object-metric drillDown.report', 'object-timeline items[]', 'action:group actions[]{}', 'action:menu actions[]{}']) { + expect(members.has(key), key).toBe(false); + } + // FIRING CONTROL: the census does key a member this way — the timeline + // entry's opaque `content` is one. + expect(members.has('object-timeline items[].content')).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts b/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts new file mode 100644 index 00000000000..b76f367626c --- /dev/null +++ b/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts @@ -0,0 +1,510 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21464, the S-final stage] The last three members the close-out held as + * forks, typed in the shapes the maintainer ruled on the decision card #21704: + * + * - `object-metric` `drillDown.report` — fork 1, letter B (record + * 5978663135): this package's `ReportSchema`, by reference, now that a + * joined report refuses a block that binds no dataset. + * - `object-timeline` `items` — fork 4, letter B (record 5979239990): both of + * objectui#6356's arms closed, a feed entry's `content` opaque, a row + * refinement pairing each entry with the arm `variant` selects, a gantt + * bar's dates a string or a number. + * - the members of `action:group` / `action:menu` — fork 5, letter A (record + * 5979239990): the measured read set, `action:button`'s keys by `type`, with + * the rows' prescriptions; `outcomeMessages`, a member `className` and + * `properties.params` refused, `outcomeMessages` undeclared on all four + * action blocks. + * + * ## The defect this file closes + * + * Each member is read with a fixed shape (measured at the `.objectui-sha` pin + * `2e818d0b51ec`, unchanged at objectui `main` `2abec3a96`; the read points are + * in the schemas' docblocks), and the rows declared them `z.unknown()` / + * open records. So a drill report with no `dataset` or a bare report name, a + * gantt row on a feed timeline (or a feed entry with no `title`), and an + * action member keyed `actionType`, `endpoint` or a misspelling all passed the + * component-props gate, and the drawer listed the records instead, the rail + * drew an empty, unlabelled entry, or the container ran the member without + * the key. + * + * ## What is pinned, and why each half + * + * - §1 THE DECLARED SHAPES PARSE: every shape a measured writer authors + * parses — the timeline entries and the action members byte-identical (no + * default, no transform on the values written), the drill report to exactly + * what `ReportSchema` answers (its defaults materialize). A refusal pin with + * no lit control passes just as well when the door refuses everything. + * - §2 THE REFUSALS: an off-shape value of each member is refused with the + * code AND the path, so a refusal for the wrong reason reds; the refused + * action keys carry their prescriptions. + * - §3 ONE VOCABULARY: the drill report IS `ReportSchema`; a timeline entry + * declares exactly objectui's two arms; a container member declares exactly + * `action:button`'s keys by `type` — less the two keys no container + * forwards, plus the `tags` the containers draw — and no action block or + * member declares `outcomeMessages`. + * - §4 ADMITTED IMPLIES DRAWN: every report the member admits is one the drill + * drawer draws (`isDatasetBoundReport`, restated from objectui below), over + * every report shape this file writes; the remaining difference runs one way + * only, and is pinned as such. + * - §5 THE REGISTRATION: the ADR-0087 D3 entries step 18 carries. + * + * The enumeration pin (`component-props-unknown-members.pin.test.ts`) holds the + * other half: the three fork lines left its ledger, so a member reverted to + * `z.unknown()` reds there, and its §6 pins that no member is staged. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; + +import { + ActionButtonPropsSchema, + ActionGroupPropsSchema, + ActionIconPropsSchema, + ActionMenuPropsSchema, + ComponentPropsMap, + ObjectMetricPropsSchema, + ObjectTimelinePropsSchema, +} from './component.zod'; +import { ReportSchema } from './report.zod'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; + +type Row = 'object-metric' | 'object-timeline' | 'action:group' | 'action:menu'; +const BASE: Record> = { + 'object-metric': { objectName: 'deal' }, + 'object-timeline': { objectName: 'event' }, + 'action:group': {}, + 'action:menu': {}, +}; +const parse = (row: Row, props: Record) => + ComponentPropsMap[row].safeParse({ ...BASE[row], ...props }); + +/** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */ +function issues(result: z.ZodSafeParseResult): { code: string; path: string }[] { + if (result.success) return []; + return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); +} +const firstMessage = (result: z.ZodSafeParseResult): string => + (result.success ? '' : result.error.issues[0]!.message); + +/** The element schema of an optional array member, or the member itself, unwrapped. */ +function inner(member: unknown): { shape?: Record; _zod: { def: unknown } } { + let s = member as { unwrap?: () => unknown; element?: unknown }; + while (typeof s.unwrap === 'function') s = s.unwrap() as typeof s; + if (s.element) s = s.element as typeof s; + while (typeof s.unwrap === 'function') s = s.unwrap() as typeof s; + return s as ReturnType; +} +const keysOf = (member: unknown): string[] => Object.keys(inner(member).shape ?? {}).sort(); + +/** The drawn report drill, as objectui's member pin mounts it (`objectMetricDrillDownMembers-8071.test.tsx:281`). */ +const SUMMARY_REPORT = { name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'deals_ds', rows: ['stage'], values: ['amount_sum'] }; +/** A matrix drill with its own scope filter (objectui `drill-down-config-mirror-7352.test.ts:108`). */ +const MATRIX_REPORT = { + name: 'pipeline', label: 'Pipeline', type: 'matrix', dataset: 'opportunity_ds', + rows: ['stage'], columns: ['owner'], values: ['amount_sum'], runtimeFilter: { region: 'emea' }, +}; +/** A joined drill, every block bound — the shape of the showcase's `TaskOverviewReport`. */ +const JOINED_REPORT = { + name: 'task_overview', label: 'Task overview', type: 'joined', + blocks: [ + { name: 'by_status', type: 'summary', dataset: 'task_ds', rows: ['status'], values: ['task_count'] }, + { name: 'by_owner', type: 'summary', dataset: 'task_ds', rows: ['owner'], values: ['task_count'] }, + ], +}; +/** A tabular drill with no `type` (`tabular` is `ReportSchema`'s default). */ +const TABULAR_REPORT = { name: 'won_deals', label: 'Won deals', dataset: 'deals_ds', values: ['amount_sum'] }; + +// ─────────────────────────────────────────────────────────────────────────── +// §1 the declared shapes parse +// ─────────────────────────────────────────────────────────────────────────── + +describe('§1 each member accepts every shape a measured writer authors', () => { + describe('object-metric drillDown.report — to exactly what ReportSchema answers', () => { + for (const [label, report] of [ + ['a summary report (objectui\'s drawn drill)', SUMMARY_REPORT], + ['a matrix report with its own runtime filter', MATRIX_REPORT], + ['a joined report, every block bound', JOINED_REPORT], + ['a tabular report with no `type`', TABULAR_REPORT], + ] as const) { + it(`parses ${label}`, () => { + const r = parse('object-metric', { drillDown: { enabled: true, report } }); + expect(issues(r)).toEqual([]); + expect(r.success && (r.data as { drillDown?: unknown }).drillDown) + .toStrictEqual({ enabled: true, report: ReportSchema.parse(report) }); + }); + } + }); + + const BYTE_IDENTICAL: ReadonlyArray]> = [ + // This package's navigation test (`component-element-navigation-17987.test.ts:213`). + ['a feed entry', 'object-timeline', { variant: 'vertical', items: [{ title: 'Kickoff', time: '2026-01-15' }] }], + // objectui `plugin-timeline/src/ObjectTimeline.absentDateAxisRefusal-7459.test.tsx:216`, on the default variant. + ['feed entries with descriptions, on the default variant', 'object-timeline', { + items: [ + { time: '2024-01-15', title: 'Project Started', description: 'Kickoff' }, + { time: '2024-02-01', title: 'First Milestone', description: 'Design done' }, + ], + }], + // objectui `ObjectTimeline.fetchGate-7895.test.tsx:189`. + ['a date-time feed entry', 'object-timeline', { items: [{ title: 'Ship it', time: '2026-01-01T09:00:00Z' }] }], + ['a horizontal feed entry with every member', 'object-timeline', { + variant: 'horizontal', + items: [{ + time: '2026-02-01', title: 'Release', description: 'v2', variant: 'success', icon: '🚀', className: 'font-bold', + content: [{ type: 'element:text', properties: { content: 'Shipped' } }], + }], + }], + // objectui `plugin-timeline/src/__tests__/timeline-object-bound-gantt-refusal.test.tsx:219`. + ['gantt rows', 'object-timeline', { + variant: 'gantt', + items: [ + { + label: 'Backend Development', + items: [ + { title: 'API Design', startDate: '2024-01-01', endDate: '2024-01-31', variant: 'success' }, + { title: 'Implementation', startDate: '2024-02-01', endDate: '2024-03-31', variant: 'info' }, + ], + }, + { label: 'Frontend Development', items: [{ title: 'UI Design', startDate: '2024-01-15', endDate: '2024-02-15' }] }, + ], + }], + ['a gantt bar in epoch milliseconds', 'object-timeline', { + variant: 'gantt', items: [{ label: 'R', items: [{ title: 'T', startDate: 1704067200000, endDate: 1706659200000 }] }], + }], + // objectui `timeline-gantt-empty-items.test.tsx:107` and an empty row. + ['no gantt rows', 'object-timeline', { variant: 'gantt', items: [] }], + ['a gantt row with no bars yet', 'object-timeline', { variant: 'gantt', items: [{ label: 'Later' }] }], + // objectui `components/src/__tests__/action-group.test.tsx:33`, `action-bodyShape-forward.test.tsx:120`. + ['members placed by location', 'action:group', { + actions: [ + { name: 'here', label: 'Here', type: 'script', locations: ['list_toolbar'] }, + { name: 'elsewhere', label: 'Elsewhere', type: 'script', locations: ['record_header'] }, + ], + }], + ['an api member with a body shape', 'action:group', { + actions: [{ type: 'api', name: 'update_organization', label: 'Save organization', target: '/api/v1/auth/organization/update', bodyShape: { wrap: 'data' } }], + }], + // objectui `action-group-menu-inputs-11168.test.tsx:249`, `:273` — the member's own gates, variant and size. + ['members gated, styled and sized', 'action:group', { + size: 'sm', + actions: [ + { name: 'shown', label: 'SHOWN', type: 'run' }, + { name: 'hidden', label: 'HIDDEN', type: 'run', visible: false }, + { name: 'greyed', label: 'GREYED', type: 'run', disabled: true }, + { name: 'loud', label: 'LOUD', type: 'run', variant: 'destructive', size: 'lg' }, + { name: 'alpha', label: 'ALPHA', type: 'run', variant: 'default', size: 'md' }, + { name: 'promoted', label: 'PROMOTED', type: 'run', variant: 'primary', size: 'icon' }, + ], + }], + // objectui `components/src/__tests__/action-bodyExtra-forward.test.tsx:116`. + ['an api menu item with a request body', 'action:menu', { + actions: [{ type: 'api', name: 'close_order', label: 'Close order', target: '/api/v1/order/close', bodyExtra: { status: 'closed' } }], + }], + // objectui `action-group-menu-inputs-11168.test.tsx:368`; this package's row test (`component-action-element-rows-20371.test.ts:161`). + ['a destructive menu item below a separator', 'action:menu', { + actions: [ + { name: 'archive', label: 'Archive', type: 'script' }, + { name: 'nameonly', type: 'run', tags: ['separator-before'], variant: 'destructive' }, + ], + }], + // objectui `types/src/__tests__/held-public-block-arms-10872.test.ts:188` / `:189`. + ['a member acting on another object', 'action:menu', { actions: [{ name: 'log', objectName: 'task' }] }], + ['a member with every forwarded scalar', 'action:menu', { + actions: [{ + name: 'close_case', label: 'Close', icon: 'check', type: 'api', description: 'Close this case', + target: '/api/close', openIn: 'self', method: 'POST', confirmText: 'Close it?', successMessage: 'Closed', + errorMessage: 'Could not close', refreshAfter: true, locations: ['record_header'], objectName: 'case', + params: [{ name: 'reason', label: 'Reason', type: 'text' }], + }], + }], + ['no members', 'action:group', { actions: [] }], + ]; + for (const [label, row, props] of BYTE_IDENTICAL) { + it(`${row}: ${label} parses byte-identical`, () => { + const r = parse(row, props); + expect(issues(r)).toEqual([]); + for (const key of Object.keys(props)) { + expect(r.success && (r.data as Record)[key]).toStrictEqual(props[key]); + } + }); + } + + it('a member\'s bare CEL `visible` normalizes to the canonical envelope, as on the rows', () => { + const r = parse('action:group', { actions: [{ name: 'a', visible: "record.status == 'open'" }] }); + expect(issues(r)).toEqual([]); + expect(r.success && (r.data as { actions: Array<{ visible?: unknown }> }).actions[0]!.visible) + .toEqual({ dialect: 'cel', source: "record.status == 'open'" }); + }); + + it('an absent member stays absent on every row', () => { + for (const [row, path] of [ + ['object-metric', ['drillDown', 'report']], + ['object-timeline', ['items']], + ['action:group', ['actions']], + ['action:menu', ['actions']], + ] as const) { + const props = path.length === 2 ? { drillDown: {} } : {}; + const r = parse(row, props); + expect(issues(r), row).toEqual([]); + const owner = path.length === 2 ? (r.success && (r.data as { drillDown: object }).drillDown) : (r.success && r.data); + expect(owner, row).not.toHaveProperty(path[path.length - 1]!); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §2 the refusals +// ─────────────────────────────────────────────────────────────────────────── + +describe('§2 an off-shape value is refused with the code and the path', () => { + const REFUSED: ReadonlyArray, found: ReadonlyArray<{ code: string; path: string }>]> = [ + // The drill report. + ['a bare report name', 'object-metric', { drillDown: { report: 'pipeline' } }, [{ code: 'invalid_type', path: 'drillDown.report' }]], + ['a report with no dataset', 'object-metric', { drillDown: { report: { name: 'pipeline', label: 'Pipeline', values: ['amount_sum'] } } }, + [{ code: 'custom', path: 'drillDown.report.dataset' }]], + ['a joined report with an unbound block', 'object-metric', { + drillDown: { report: { ...JOINED_REPORT, blocks: [JOINED_REPORT.blocks[0], { name: 'notes', type: 'tabular' }] } }, + }, [{ code: 'custom', path: 'drillDown.report.blocks.1.dataset' }]], + // objectui `objectMetricDrillDownMembers-8071.test.tsx:305` — its two probes the drawer does not draw. + ['the retired `objectName` report', 'object-metric', { drillDown: { report: { name: 'pipeline', label: 'Pipeline', objectName: 'deal', columns: [] } } }, + [{ code: 'unrecognized_keys', path: 'drillDown.report' }]], + ['a value that is no report', 'object-metric', { drillDown: { report: { name: 'note', label: 'Note', note: 'not a report' } } }, + [{ code: 'unrecognized_keys', path: 'drillDown.report' }]], + // The timeline entries. + ['a number for items', 'object-timeline', { items: 42 }, [{ code: 'invalid_type', path: 'items' }]], + ['a bare string entry', 'object-timeline', { items: ['Kickoff'] }, [{ code: 'invalid_type', path: 'items.0' }]], + ['a null entry', 'object-timeline', { items: [null] }, [{ code: 'invalid_type', path: 'items.0' }]], + ['a feed entry with no title', 'object-timeline', { items: [{ time: '2026-01-15' }] }, [{ code: 'custom', path: 'items.0.title' }]], + ['a gantt row on a feed timeline', 'object-timeline', { items: [{ label: 'R', items: [] }] }, [ + { code: 'custom', path: 'items.0.title' }, + { code: 'custom', path: 'items.0.label' }, + { code: 'custom', path: 'items.0.items' }, + ]], + ['a feed entry on a gantt timeline', 'object-timeline', { variant: 'gantt', items: [{ title: 'Kickoff', time: '2026-01-15' }] }, [ + { code: 'custom', path: 'items.0.label' }, + { code: 'custom', path: 'items.0.time' }, + { code: 'custom', path: 'items.0.title' }, + ]], + ['a marker colour on a gantt row', 'object-timeline', { variant: 'gantt', items: [{ label: 'R', variant: 'info' }] }, + [{ code: 'custom', path: 'items.0.variant' }]], + ['a marker colour outside the five', 'object-timeline', { items: [{ title: 'A', variant: 'todo' }] }, + [{ code: 'invalid_value', path: 'items.0.variant' }]], + ['a record-composed `color`', 'object-timeline', { items: [{ title: 'A', color: 'red' }] }, [{ code: 'unrecognized_keys', path: 'items.0' }]], + ['a feed entry dated `startDate`', 'object-timeline', { items: [{ title: 'A', startDate: '2026-01-15' }] }, + [{ code: 'unrecognized_keys', path: 'items.0' }]], + ['a non-array set of bars', 'object-timeline', { variant: 'gantt', items: [{ label: 'R', items: { title: 'T' } }] }, + [{ code: 'invalid_type', path: 'items.0.items' }]], + ['a null bar', 'object-timeline', { variant: 'gantt', items: [{ label: 'R', items: [null] }] }, + [{ code: 'invalid_type', path: 'items.0.items.0' }]], + // objectui `timeline-gantt-date-spelling-6907.test.tsx:338`, `timeline-gantt-date-type-rule-6781.test.tsx:419`, + // `timeline-gantt-null-date-6770.test.tsx:220` — its render-time date diagnostic's probes. + ['an array bar date', 'object-timeline', { variant: 'gantt', items: [{ label: 'R', items: [{ startDate: '2024-01-01', endDate: ['2024-01-01'] }] }] }, + [{ code: 'invalid_union', path: 'items.0.items.0.endDate' }]], + ['a boolean bar date', 'object-timeline', { variant: 'gantt', items: [{ label: 'R', items: [{ startDate: '2024-01-01', endDate: false }] }] }, + [{ code: 'invalid_union', path: 'items.0.items.0.endDate' }]], + ['a null bar date', 'object-timeline', { variant: 'gantt', items: [{ label: 'R', items: [{ startDate: '2024-01-01', endDate: null }] }] }, + [{ code: 'invalid_union', path: 'items.0.items.0.endDate' }]], + ['an infinite bar date', 'object-timeline', { variant: 'gantt', items: [{ label: 'R', items: [{ startDate: Number.POSITIVE_INFINITY }] }] }, + [{ code: 'invalid_union', path: 'items.0.items.0.startDate' }]], + ['a bar `color`', 'object-timeline', { variant: 'gantt', items: [{ label: 'R', items: [{ title: 'T', color: 'red' }] }] }, + [{ code: 'unrecognized_keys', path: 'items.0.items.0' }]], + // The container members. + ['a node-style `actionType` on a member', 'action:group', { actions: [{ name: 'a', actionType: 'url' }] }, + [{ code: 'unrecognized_keys', path: 'actions.0' }]], + ['a member `endpoint`', 'action:group', { actions: [{ name: 'a', type: 'api', endpoint: '/api/a' }] }, + [{ code: 'unrecognized_keys', path: 'actions.0' }]], + // objectui `action-outcomeMessages-forward-11344.test.tsx:147` — the forward probe. + ['a member `outcomeMessages`', 'action:group', { actions: [{ name: 'a', type: 'script', outcomeMessages: { archived: 'Archived' } }] }, + [{ code: 'unrecognized_keys', path: 'actions.0' }]], + // objectui `action-group-menu-inputs-11168.test.tsx:249` — the member pin's `className`. + ['a member `className`', 'action:group', { actions: [{ name: 'a', className: 'member-class' }] }, + [{ code: 'unrecognized_keys', path: 'actions.0' }]], + // objectui `action-container-member-params-10290.test.tsx:182` — the static-values probe. + ['a member `properties.params`', 'action:menu', { actions: [{ name: 'edit', type: 'navigate_edit', properties: { params: { recordId: 'r1' } } }] }, + [{ code: 'unrecognized_keys', path: 'actions.0' }]], + ['a member `enabled`', 'action:menu', { actions: [{ name: 'a', enabled: false }] }, [{ code: 'unrecognized_keys', path: 'actions.0' }]], + // objectui `action-overflow-autotrigger.test.tsx:298` — the host's transport flag. + ['a member `autoTrigger`', 'action:menu', { actions: [{ name: 'a', type: 'api', autoTrigger: true }] }, + [{ code: 'unrecognized_keys', path: 'actions.0' }]], + ['an `action:menu` item `size`', 'action:menu', { actions: [{ name: 'a', size: 'sm' }] }, [{ code: 'unrecognized_keys', path: 'actions.0' }]], + ['an `undoable` member', 'action:group', { actions: [{ name: 'a', undoable: true }] }, [{ code: 'unrecognized_keys', path: 'actions.0' }]], + ['a member size outside the primitive\'s', 'action:group', { actions: [{ name: 'a', size: 'xl' }] }, [{ code: 'invalid_value', path: 'actions.0.size' }]], + ['a tag no container draws', 'action:menu', { actions: [{ name: 'a', tags: ['separator-after'] }] }, [{ code: 'invalid_value', path: 'actions.0.tags.0' }]], + ['a non-string executor', 'action:menu', { actions: [{ name: 'a', type: 5 }] }, [{ code: 'invalid_type', path: 'actions.0.type' }]], + ]; + for (const [label, row, props, found] of REFUSED) { + it(`${row}: refuses ${label}`, () => { + const r = parse(row, props); + expect(r.success).toBe(false); + expect(issues(r)).toEqual(found); + }); + } + + it('the pairing names the arm the row\'s `variant` selects, and the default', () => { + expect(firstMessage(parse('object-timeline', { items: [{ time: '2026-01-15' }] }))) + .toMatch(/`title` is required on a feed entry: `variant: 'vertical'` \(the default\) draws feed entries/); + expect(firstMessage(parse('object-timeline', { variant: 'gantt', items: [{ title: 'A' }] }))) + .toMatch(/`label` is required on a gantt row: `variant: 'gantt'` draws gantt rows `\{ label, items \}`/); + }); + + it('each refused member key carries what to write instead', () => { + const message = (row: Row, member: Record) => firstMessage(parse(row, { actions: [member] })); + expect(message('action:group', { name: 'a', actionType: 'url' })).toMatch(/`type`/); + expect(message('action:group', { name: 'a', endpoint: '/x' })).toMatch(/`target`/); + expect(message('action:menu', { name: 'a', outcomeMessages: {} })).toMatch(/Write the success toast as `successMessage`/); + expect(message('action:group', { name: 'a', className: 'x' })).toMatch(/Style it with its `variant`/); + expect(message('action:menu', { name: 'a', properties: {} })).toMatch(/write `bodyExtra`/); + expect(message('action:menu', { name: 'a', enabled: true })).toMatch(/write `disabled` instead/); + expect(message('action:menu', { name: 'a', autoTrigger: true })).toMatch(/run the action on every page load/); + // The group never reads `autoTrigger`, and its prescription says so. + expect(message('action:group', { name: 'a', autoTrigger: true })).toMatch(/does not read it at all/); + expect(message('action:menu', { name: 'a', size: 'sm' })).toMatch(/reads no `size`/); + // CONTROL: a member key with no prescription gets none of these. + expect(message('action:group', { name: 'a', bogus: 1 })).not.toMatch(/successMessage|bodyExtra|`variant`/); + }); + + it('a record-composed timeline key is told what an authored entry writes', () => { + expect(firstMessage(parse('object-timeline', { items: [{ title: 'A', color: 'red' }] }))).toMatch(/is `variant`/); + expect(firstMessage(parse('object-timeline', { items: [{ title: 'A', startDate: 'x' }] }))).toMatch(/a feed entry's date is `time`/); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §3 one vocabulary +// ─────────────────────────────────────────────────────────────────────────── + +describe('§3 each shape is the declaration the ruling names', () => { + it('the drill report IS ReportSchema — the same def, not a copy', () => { + const report = inner(ObjectMetricPropsSchema.shape.drillDown).shape!.report; + expect(inner(report)._zod.def).toBe(inner(ReportSchema)._zod.def); + }); + + it('a timeline entry declares exactly objectui\'s two arms, and a bar its four keys', () => { + const item = ObjectTimelinePropsSchema.shape.items; + // `TimelineFeedItem`'s seven and `TimelineGanttItem`'s two. + expect(keysOf(item)).toEqual(['className', 'content', 'description', 'icon', 'items', 'label', 'time', 'title', 'variant']); + expect(keysOf(inner(item).shape!.items)).toEqual(['endDate', 'startDate', 'title', 'variant']); + }); + + it('a container member declares `action:button`\'s keys by `type` — less the two no container forwards, plus `tags`', () => { + const button = Object.keys(ActionButtonPropsSchema.shape) + .map((key) => (key === 'actionType' ? 'type' : key)) + .filter((key) => key !== 'undoable' && key !== 'recordIdField'); + const group = [...button, 'tags'].sort(); + expect(keysOf(ActionGroupPropsSchema.shape.actions)).toEqual(group); + // An `action:menu` item reads no `size` — the one difference between the two. + expect(keysOf(ActionMenuPropsSchema.shape.actions)).toEqual(group.filter((key) => key !== 'size')); + }); + + it('no action block and no container member declares `outcomeMessages` — one decision, all four alike', () => { + for (const [label, keys] of [ + ['action:button', Object.keys(ActionButtonPropsSchema.shape)], + ['action:icon', Object.keys(ActionIconPropsSchema.shape)], + ['action:group', Object.keys(ActionGroupPropsSchema.shape)], + ['action:menu', Object.keys(ActionMenuPropsSchema.shape)], + ['an action:group member', keysOf(ActionGroupPropsSchema.shape.actions)], + ['an action:menu member', keysOf(ActionMenuPropsSchema.shape.actions)], + ] as const) { + expect(keys, label).not.toContain('outcomeMessages'); + } + }); + + it('a member\'s `visible` / `disabled` take the rows\' own condition — the same accept set', () => { + const member = inner(ActionGroupPropsSchema.shape.actions).shape!; + for (const key of ['visible', 'disabled'] as const) { + const row = ActionButtonPropsSchema.shape[key]; + for (const value of [true, false, "record.status == 'open'", { dialect: 'cel', source: 'x == 1' }, '', 5, { dialect: 'cel' }]) { + const a = (member[key] as z.ZodType).safeParse(value); + const b = row.safeParse(value); + expect(a.success, `${key} ${JSON.stringify(value)}`).toBe(b.success); + if (a.success && b.success) expect(a.data).toStrictEqual(b.data); + } + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §4 admitted implies drawn +// ─────────────────────────────────────────────────────────────────────────── + +/** + * objectui `plugin-dashboard/src/DrillDownDrawer.tsx:92-99` at the + * `.objectui-sha` pin `2e818d0b51ec`, restated: the drawer draws a drill report + * as a report exactly when it holds, and lists the records otherwise. The + * drawer reads the value as written — a page component's `properties` is + * never parsed on the way. + */ +function isDatasetBoundReport(report: unknown): boolean { + if (!report || typeof report !== 'object') return false; + const r = report as { dataset?: unknown; type?: unknown; blocks?: unknown }; + if (typeof r.dataset === 'string' && r.dataset.length > 0) return true; + return r.type === 'joined' + && Array.isArray(r.blocks) + && r.blocks.some((b) => typeof (b as { dataset?: unknown } | null)?.dataset === 'string'); +} + +describe('§4 every report the member admits is one the drill drawer draws', () => { + const block = (name: string, dataset?: string) => ({ name, type: 'summary', ...(dataset ? { dataset } : {}), rows: ['stage'], values: ['amount_sum'] }); + const CANDIDATES: ReadonlyArray = [ + ['a summary report', SUMMARY_REPORT], + ['a matrix report', MATRIX_REPORT], + ['a joined report, every block bound', JOINED_REPORT], + ['a tabular report with no `type`', TABULAR_REPORT], + ['a joined report, one block of two unbound', { name: 'two', label: 'Two', type: 'joined', blocks: [block('a', 'ds'), block('b')] }], + ['a joined report, no block bound', { name: 'none', label: 'None', type: 'joined', blocks: [block('a'), block('b')] }], + ['a joined report with no blocks', { name: 'empty', label: 'Empty', type: 'joined', blocks: [] }], + ['a joined report with a container dataset', { name: 'cont', label: 'Cont', type: 'joined', dataset: 'ds', blocks: [block('a', 'ds')] }], + ['a report with no name or label', { dataset: 'deals_ds', values: ['amount_sum'] }], + ['a summary report with no values', { name: 'nv', label: 'No values', type: 'summary', dataset: 'deals_ds', rows: ['stage'] }], + ['a report with no dataset', { name: 'nd', label: 'No dataset', values: ['amount_sum'] }], + ['the retired `objectName` report', { name: 'pipeline', label: 'Pipeline', objectName: 'deal', columns: [] }], + ['a bare report name', 'pipeline'], + ['a `{ name }` reference', { name: 'pipeline' }], + ]; + + it('admitted ⇒ drawn, over every candidate', () => { + const admittedNotDrawn = CANDIDATES + .filter(([, report]) => parse('object-metric', { drillDown: { report } }).success && !isDatasetBoundReport(report)) + .map(([label]) => label); + expect(admittedNotDrawn).toEqual([]); + }); + + it('LIT CONTROL: the member admits the four writer shapes, so the implication above is not vacuous', () => { + const admitted = CANDIDATES.filter(([, report]) => parse('object-metric', { drillDown: { report } }).success).map(([label]) => label); + expect(admitted).toEqual([ + 'a summary report', + 'a matrix report', + 'a joined report, every block bound', + 'a tabular report with no `type`', + ]); + }); + + it('the remaining difference runs one way: the drawer also draws incomplete reports the member refuses', () => { + const drawnNotAdmitted = CANDIDATES + .filter(([, report]) => isDatasetBoundReport(report) && !parse('object-metric', { drillDown: { report } }).success) + .map(([label]) => label); + expect(drawnNotAdmitted).toEqual([ + 'a joined report, one block of two unbound', + 'a joined report with a container dataset', + 'a report with no name or label', + 'a summary report with no values', + ]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §5 the registration +// ─────────────────────────────────────────────────────────────────────────── + +describe('§5 each narrowing is registered as the ADR-0087 D3 entry step 18 carries', () => { + const ids = MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id); + it.each([ + 'ui-object-metric-drill-down-report-typed', + 'ui-object-timeline-items-typed', + 'ui-action-group-menu-members-typed', + ])('%s', (id) => { + expect(ids).toContain(id); + }); +}); From 846a055627f6bad2ee31245fd3a730c742ff5c6e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 16:10:22 +0000 Subject: [PATCH 3/3] wip(spec): S-final changeset and regenerated docs and strictness counts (#21464) Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude --- ...props-report-items-action-members-typed.md | 48 ++++++++++ content/docs/references/ui/component.mdx | 87 +++++++++++++++++-- .../ui.md | 10 +-- 3 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 .changeset/21464-component-props-report-items-action-members-typed.md diff --git a/.changeset/21464-component-props-report-items-action-members-typed.md b/.changeset/21464-component-props-report-items-action-members-typed.md new file mode 100644 index 00000000000..edea6cb7230 --- /dev/null +++ b/.changeset/21464-component-props-report-items-action-members-typed.md @@ -0,0 +1,48 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an `object-metric` drill-down's `report` takes a report definition, an `object-timeline`'s `items` take the entry kind its `variant` selects, and each `action:group` / `action:menu` member takes a closed inline action, instead of any value (#21464) + +Clause-②: yes (narrowing) + + + +**BREAKING** — three accept-set narrowings 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` `drillDown.report` is a report definition — `ReportSchema`, by reference.** It was `z.unknown()`. The tile hands it to the shared drill drawer, which draws a dataset-bound report (with the metric's filter joined into the report's own `runtimeFilter`) and lists the records for any other value. A joined report already refuses a block that binds no `dataset`, so every report the member admits is one the drawer draws; a report with no `dataset`, a bare report name, a `{ name }` reference or the retired `objectName` / `columns` form is refused. The drawer still draws a few incomplete reports the member refuses (no `name` / `label`, a non-joined report with no `values`, a joined one with a container `dataset` or with only some blocks bound) — no measured writer authors one. +- **`object-timeline` `items` takes the entry kind the block's `variant` selects.** It was `z.array(z.unknown())`. On `vertical` (the default) or `horizontal` an entry is a feed entry `{ time?, title, description?, variant?, icon?, content?, className? }`; on `gantt` it is a gantt row `{ label, items? }` whose bars are `{ title?, startDate?, endDate?, variant? }`, each date a string or epoch milliseconds — objectui's two ruled element kinds, closed. A row refinement pairs each entry with its kind: a feed entry with no `title`, a gantt row with no `label`, and a key of the other kind are refused at the entry, by path. `variant` is one of `default`, `success`, `warning`, `danger`, `info`. A feed entry's `content` (child components) is not judged yet. The keys the record-bound rail composes onto its entries (`color`, `startDate`, `endDate`, `group`, `meta`) are refused on an authored entry with what to write instead. +- **Each `action:group` / `action:menu` member is a closed inline action.** It was an open record. A member takes `action:button`'s keys with its executor spelled `type` (a member is an action entry): `name`, `label`, `icon`, `type`, `variant`, `visible`, `disabled`, `tags`, `params`, `description`, `target`, `openIn`, `method`, `bodyExtra`, `bodyShape`, `operation`, `patch`, `confirmText`, `successMessage`, `errorMessage`, `refreshAfter`, `locations`, `toast`, `resultDialog`, `onSuccess`, `objectName` — and `size` on an `action:group` member, whose inline button reads it (an `action:menu` item reads none). `tags` takes `separator-before`, the one tag the containers draw. Refused, each with what to write instead: `actionType` (write `type`), `endpoint` / `url` / `path` / `href` (write `target`), `enabled` (write `disabled`, inverted), `autoTrigger`, `outcomeMessages` (write `successMessage`), a member `className`, a member `properties` bag, `undoable` and `recordIdField`. `outcomeMessages` stays undeclared on all four action blocks (`action:button`, `action:icon`, `action:group`, `action:menu`). +- **`ObjectMetricProps`, `ObjectTimelineProps`, `ActionGroupProps`, `ActionMenuProps`** and their parsed types carry these shapes instead of `unknown`; the member and entry shapes are module-private. `ObjectMetricPropsParsed` now also differs from the authored type on `drillDown.report`, whose `ReportSchema` defaults (`type`, `drilldown`) materialize on parse. No export is added or removed. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `drillDown: { report: 'pipeline' }` or `{ report: { name: 'pipeline' } }` | `drillDown: { report: { name: 'pipeline', label: 'Pipeline', dataset: 'deals_ds', values: ['amount_sum'] } }` | +| `drillDown: { report: { name, label, objectName: 'deal', columns: [ … ] } }` | the dataset-bound report: `{ name, label, dataset, rows, values }` | +| `drillDown: { report: { …, type: 'joined', blocks: [{ name: 'notes' }] } }` | bind every block: `blocks: [{ name: 'notes', dataset: 'notes_ds', values: [ … ] }]` | +| `items: [{ date: '2026-01-15', title: 'Kickoff' }]` | `items: [{ time: '2026-01-15', title: 'Kickoff' }]` | +| `items: [{ title: 'Kickoff', color: 'green' }]` | `items: [{ title: 'Kickoff', variant: 'success' }]` | +| `items: [{ label: 'Backend', items: [ … ] }]` with no `variant` | `variant: 'gantt', items: [{ label: 'Backend', items: [ … ] }]` | +| `variant: 'gantt', items: [{ title: 'Kickoff' }]` | `variant: 'gantt', items: [{ label: 'Kickoff', items: [{ startDate, endDate }] }]`, or drop `variant: 'gantt'` | +| `actions: [{ name: 'go', actionType: 'url', target: '/x' }]` | `actions: [{ name: 'go', type: 'url', target: '/x' }]` | +| `actions: [{ name: 'save', type: 'api', endpoint: '/api/save' }]` | `actions: [{ name: 'save', type: 'api', target: '/api/save' }]` | +| `actions: [{ name: 'del', outcomeMessages: { archived: 'Archived' } }]` | `actions: [{ name: 'del', successMessage: 'Archived' }]` | +| `actions: [{ name: 'del', className: 'text-red-600' }]` | `actions: [{ name: 'del', variant: 'destructive' }]` | +| `actions: [{ name: 'run', enabled: "record.status == 'open'" }]` | `actions: [{ name: 'run', disabled: "record.status != 'open'" }]` | +| `actions: [{ name: 'edit', properties: { params: { … } } }]` on `action:group` / `action:menu` | `bodyExtra` for a `type: 'api'` request body, or the action as its own `action:button` node with a `params` object | +| `action:menu` `actions: [{ name: 'a', size: 'sm' }]` | `actions: [{ name: 'a' }]` — the menu's own `size` sizes the trigger | + +The one-line fix: write a drill report as the report definition, each timeline entry as the kind the block's `variant` draws, and each container member with `action:button`'s keys and `type` as its executor. No conversion is registered: nothing on the load path refuses these shapes, and the census below found no working value to respell — the D3 entries `ui-object-metric-drill-down-report-typed`, `ui-object-timeline-items-typed` and `ui-action-group-menu-members-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 block, flat or in its `properties` bag, or with its `type` arriving through a spread constant), a literal annotated as one, a direct parse through the row, the block's React component (`schema={…}`, or its props), and the argument a local helper passes in that position at every same-file call site. Values resolve through same-file constants and spreads, a `.map` over a constant list, templates and same-file helper calls (`member('alpha', { size })`). Every static value was parsed through this branch's rows; each value with a non-static part, and each refusal, was read by hand. + +- **objectstack** at `1289925c0a`, this branch's merge base: one drill report (the metric pin's own), one timeline `items` (a spec test, a feed entry) and three `action:group` / `action:menu` members (a spec test) — all parse. No example, doc or skill writes any of the three. +- **objectui** at the `.objectui-sha` pin `2e818d0b51ec` and at `main` `2abec3a96` (every cited reader byte-identical between the two, and the same census at both): **drill report** — the drawn report drill (`objectMetricDrillDownMembers-8071.test.tsx:281`) parses; refused are only the probes of what the drawer does NOT draw (that file's two `it.each` values, and the `@object-ui/types` drill-mirror tests' `{ name }` / incomplete-report refusal probes). **Timeline `items`** — 18 values: 15 parse (feed entries and gantt rows); the 3 refused are the render-time gantt date diagnostic's own probes (an array, `false` and `null` bar date). **Members** — `action:group` 40 values and `action:menu` 19, all in tests but for one run-time hand-off: every static member parses except the probes of the very reads the ruling refuses — the member pin's `className` (`action-group-menu-inputs-11168.test.tsx:249`), the `outcomeMessages` forward tests, the `properties.params` static-value tests, and the host's `autoTrigger` flag in the overflow / forward tests. The hand-off is `action:bar`'s overflow menu (`action-bar.tsx:287`), which hands the bar's own members — a host's registered actions — to `action:menu` at run time, never through the component-props gate. +- **hotcrm** at `4054ec2680` and **cloud** at `2205b53010`: no writer of any of the three (hotcrm's four `object-metric` tiles declare no drill-down). +- **Deployed metadata** was not measured. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 14df916b251..e04cb57f366 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -66,7 +66,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **actions** | `Record[]` | optional | The actions in this group, in order — each an action object the group draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, …); a member's executor is its `type` | +| **actions** | `{ name?: string; label?: string; icon?: string; type?: string; … }[]` | optional | The actions in this group, in order — each an action object the group draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, `size`, …); a member's executor is its `type` | | **display** | `Enum<'inline' \| 'dropdown'>` | optional | Display mode: `inline` renders every action as a button row; `dropdown` renders one trigger button and lists the actions in its menu (renderer default: `inline`) | | **location** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>` | optional | Render only the members whose `locations` include this location. Omit to render every member | | **label** | `string` | optional | Dropdown trigger text (renderer default: `Actions`). Inline mode renders no group label. A literal string — localize through the translation bundle entry for this component id | @@ -75,6 +75,38 @@ const result = ActionButtonPropsSchema.parse(data); | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon'>` | optional | Button size for the dropdown trigger and for every inline member that sets none — the Button primitive's vocabulary | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate for the whole group — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds. Omit for always-visible | +### Nested Shape: `ActionGroupProps.actions[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` | +| **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | +| **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | +| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | +| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Item variant — the Button primitive's vocabulary, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group's `variant`); a dropdown or `action:menu` item draws `destructive` red and every other variant plainly | +| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | +| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the item is drawn but cannot be pressed while it is TRUE, and a predicate that fails to evaluate disables it. Omit for never-disabled | +| **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn | +| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the request payload of a `type: 'api'` member only (use `bodyExtra` for that) | +| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type` | +| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | +| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | +| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | +| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | +| **operation** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` | +| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner | +| **confirmText** | `string` | optional | Confirmation question asked before the action runs | +| **successMessage** | `string` | optional | Toast shown when the action succeeds | +| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | +| **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | +| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | +| **toast** | `any` | optional | Toast behaviour, forwarded to the runner | +| **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | +| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | +| **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon' \| 'md'>` | optional | Inline button size — the Button primitive's vocabulary, plus `md` (drawn as `default`), falling back to the group's `size`. A dropdown item reads no size | + --- @@ -119,13 +151,44 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **actions** | `Record[]` | optional | The menu's actions, in order — each an action object the menu draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, `tags`, …); a member's executor is its `type` | +| **actions** | `{ name?: string; label?: string; icon?: string; type?: string; … }[]` | optional | The menu's actions, in order — each an action object the menu draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, `tags`, …); a member's executor is its `type` | | **label** | `string` | optional | Trigger text and accessible label; omit for an icon-only trigger labelled "More actions". A literal string — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name on the trigger (renderer default: the horizontal ellipsis) | | **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'>` | optional | Trigger button variant — the Button primitive's vocabulary (renderer default: `ghost`) | | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon'>` | optional | Trigger button size — the Button primitive's vocabulary (renderer default: `icon`) | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate for the whole menu — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; a predicate that fails to evaluate hides it. Omit for always-visible | +### Nested Shape: `ActionMenuProps.actions[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` | +| **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | +| **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | +| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | +| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Item variant — the Button primitive's vocabulary, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group's `variant`); a dropdown or `action:menu` item draws `destructive` red and every other variant plainly | +| **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | +| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the item is drawn but cannot be pressed while it is TRUE, and a predicate that fails to evaluate disables it. Omit for never-disabled | +| **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn | +| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the request payload of a `type: 'api'` member only (use `bodyExtra` for that) | +| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title | +| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type` | +| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab | +| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner | +| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner | +| **bodyShape** | `any` | optional | How an `api` action shapes its request body, forwarded to the runner | +| **operation** | `any` | optional | Declarative single-record write, forwarded to the runner together with `patch` | +| **patch** | `any` | optional | Field values the declarative `operation` writes, forwarded to the runner | +| **confirmText** | `string` | optional | Confirmation question asked before the action runs | +| **successMessage** | `string` | optional | Toast shown when the action succeeds | +| **errorMessage** | `string` | optional | Toast shown when the action fails, in place of the raw error | +| **refreshAfter** | `boolean` | optional | Refresh the surrounding data after the action runs | +| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | +| **toast** | `any` | optional | Toast behaviour, forwarded to the runner | +| **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | +| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | + --- @@ -1218,7 +1281,7 @@ 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** | `{ 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 | +| **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` — or draws the report `report` defines; 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` @@ -1256,7 +1319,7 @@ View filter rule | **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) | +| **report** | `{ name: string; label: string \| Record; description?: string \| Record; type?: Enum<'tabular' \| 'summary' \| 'matrix' \| 'joined'>; … }` | optional | Drill into a report instead of the record list — a report definition (the same shape as `reports[]`): `{ name, label, dataset, values, … }`, or a `joined` report whose every block binds a `dataset`. The drawer draws it as a report, with the metric's filter joined into its `runtimeFilter` | ### Nested Shape: `ObjectMetricProps.compareTo` @@ -1279,7 +1342,7 @@ View filter rule | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched entries — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the rail (row cap); lowered to the query's top-level `$top` (renderer default 100). A timeline renders one rail with no pagination control, so this is the author's window rather than a page size | | **data** | `any[]` | optional | Pre-fetched records — read FIRST as the rail's row source, ahead of the data-scope binding and the fetch, and composed into entries through the same `timeline` field bindings a fetched row takes; authoring it suppresses the object query entirely. Distinct from `items`, which is the already-composed entry shape and wins over this key when both are written | -| **items** | `any[]` | optional | Static inline entries — read ahead of every record source, `data` above included, and bypasses the object query entirely (the renderer becomes a pass-through). Each element is objectui's declared timeline element, `@object-ui/types`'s `TimelineFeedItem` (`variant` absent / `vertical` / `horizontal`) or `TimelineGanttItem` (`variant: 'gantt'`), the arm this node's `variant` selects | +| **items** | `{ time?: string; title?: string; description?: string; variant?: Enum<'default' \| 'success' \| 'warning' \| 'danger' \| 'info'>; … }[]` | optional | Static inline entries — read ahead of every record source, `data` above included, and bypasses the object query entirely (the renderer becomes a pass-through). Each entry is the kind this node's `variant` selects: a feed entry `{ time?, title, description?, variant?, icon?, content?, className? }` (`variant` absent / `vertical` / `horizontal`), or a gantt row `{ label, items? }` (`variant: 'gantt'`) whose bars are `{ title?, startDate?, endDate?, variant? }`, each date a string or epoch milliseconds | | **variant** | `Enum<'vertical' \| 'horizontal' \| 'gantt'>` | optional | Rail layout (renderer default `vertical`). ⚠️ `gantt` needs authored `items`: the object-bound path composes flat feed entries, which the gantt branch cannot draw, and refuses that combination with a named diagnostic instead of drawing an empty chart | | **dateFormat** | `Enum<'short' \| 'long' \| 'iso'>` | optional | How each entry's date is rendered (renderer default `short`): `short` / `long` are locale-formatted, `iso` is the locale-free machine form | | **rowLabel** | `string` | optional | Header label for the gantt row column — read by the gantt branch only, which on this block needs authored `items` | @@ -1319,6 +1382,20 @@ Sort field and direction pair | **field** | `string` | ✅ | Field name to sort by | | **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction | +### Nested Shape: `ObjectTimelineProps.items[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **time** | `string` | optional | When it happened — an ISO 8601 date (or date-time) string, formatted by the row's `dateFormat` | +| **title** | `string` | optional | The entry's heading — required on a feed entry (the arm a feed `variant` selects) | +| **description** | `string` | optional | Secondary line under the title | +| **variant** | `Enum<'default' \| 'success' \| 'warning' \| 'danger' \| 'info'>` | optional | Marker colour (renderer default `default`) | +| **icon** | `string` | optional | Emoji or short text drawn inside the marker | +| **content** | `any` | optional | Child page components drawn below the description — a component node or a list of them. Held opaque: not judged on this row | +| **className** | `string` | optional | Tailwind classes for the entry | +| **label** | `string` | optional | Row label, drawn in the row-label gutter — required on a gantt row (the arm `variant: 'gantt'` selects) | +| **items** | `{ title?: string; startDate?: string \| number; endDate?: string \| number; variant?: Enum<'default' \| 'success' \| 'warning' \| 'danger' \| 'info'> }[]` | optional | The row's bars, each `{ title?, startDate?, endDate?, variant? }` | + ### Nested Shape: `ObjectTimelineProps.mapping` | Property | Type | Required | Description | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 699e00cc539..659ef8d5964 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/` | 204 | 193 | 4 | 0 | 7 | +| `ui/` | 208 | 197 | 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` | 74 | +| `component.zod.ts` | 78 | | `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** | **204** | +| **total** | **208** | ## `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 204**, in 4 file(s). +**7 strip of 208**, 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** | **204** | +| **total** | **7** | **208** | | Bucket | Sites | |---|---|