diff --git a/.changeset/10859-dashboard-node-keys.md b/.changeset/10859-dashboard-node-keys.md index 1e4dea8eaf..762b148718 100644 --- a/.changeset/10859-dashboard-node-keys.md +++ b/.changeset/10859-dashboard-node-keys.md @@ -20,3 +20,5 @@ Migration: - a selector `[data-obj-type="metric"]` / `[data-obj-type="metric-card"]` → `[data-obj-type="plugin-dashboard:metric"]` / `[data-obj-type="plugin-dashboard:metric-card"]`. **Clause-②: yes** — registrations leave the runtime (narrowing), released as `minor` with this banner. + +⚠️ **Dated note, 2026-10-02 — `metric-card` leaves the widget vocabulary — objectui#11483.** At this change, "The dashboard WIDGET vocabulary is unchanged" held, and that vocabulary named `metric-card`. Now the widget `type` vocabulary (`DashboardWidgetTypeSchema` / `DashboardWidgetTypeName`) names no component type. The `metric-card` slot entry is still authored as before when it carries its `value`; without one it is refused. `.changeset/11483-metric-card-needs-value.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/11070-dashboard-keys-round11.md b/.changeset/11070-dashboard-keys-round11.md index 5e36ba4b42..0458130b8e 100644 --- a/.changeset/11070-dashboard-keys-round11.md +++ b/.changeset/11070-dashboard-keys-round11.md @@ -15,3 +15,5 @@ The strict authoring face accepts the `layout` that the editable dashboard grid' `options.description` is not changed by this release. `@objectstack/spec` does not declare it on the widget's `options`, so the strict face keeps refusing an authored one (objectui#11228 ruling C), and its status is an open decision on objectui#11070. Comments in `@object-ui/plugin-dashboard` that called it declared now say so; that package's runtime behaviour is unchanged. ⚠️ **Dated note, 2026-10-02 — the slot arm declares `metric-card`'s registered inputs — objectui#11467.** "The widget arm, the component node's other keys and the registered-input record objectui#11022 added", under "What does not move" above, held when this change landed. Later in this same release, objectui#11467 moved all three. The widget arm's `component` member now takes the slot's component arm first, then `BaseSchema`. The component node declares `title`, `value`, `icon`, `trend` and `trendValue` as members on both faces, so each value is judged by its member. The registered-input record and the strict walker's step that admitted it are retired. `.changeset/11467-metric-card-arm-inputs.md` states what ships; the text above is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-02 — the widget arm's `type` vocabulary moves — objectui#11483.** At this change, "What does not move" above held for the widget arm, whose `type` vocabulary named `metric-card`; now that vocabulary names no component type, and the slot reads a `metric-card` through its component arm alone. `.changeset/11483-metric-card-needs-value.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/11467-metric-card-arm-inputs.md b/.changeset/11467-metric-card-arm-inputs.md index 395056cf03..ff94864b49 100644 --- a/.changeset/11467-metric-card-arm-inputs.md +++ b/.changeset/11467-metric-card-arm-inputs.md @@ -15,3 +15,5 @@ A `metric-card` in a dashboard's widget slot is checked against the props `Metri **Fix:** write `trend` as one of the three, give the card a `value`, and keep `title`, `icon` and `trendValue` as strings (`title` may also be a per-locale map). The internal `declareRegisteredInputs` side table (objectui#11022) and the strict walker's branch for it are removed. Every name it recorded is now a member of the arm. Neither was exported from the package. + +⚠️ **Dated note, 2026-10-02 — a `metric-card` with no `value` is refused, whatever else it carries — objectui#11483.** At this change, a card with no `value` was refused directly in `widgets[]` only when it carried `icon`, `trend` or `trendValue`, and the widget arm still named `metric-card` as a widget type. A card carrying only keys the widget arm also accepts (`title`, `description`, `id`, `layout`, a `dataset`, `options`) parsed there as a widget. Now `metric-card` is no widget type, so both faces refuse every card with no `value` in `widgets[]`. `.changeset/11483-metric-card-needs-value.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/11483-metric-card-needs-value.md b/.changeset/11483-metric-card-needs-value.md new file mode 100644 index 0000000000..49843a3e74 --- /dev/null +++ b/.changeset/11483-metric-card-needs-value.md @@ -0,0 +1,14 @@ +--- +'@object-ui/types': minor +--- + +A `metric-card` in a dashboard's widget slot parses only with its `value` (objectui#11483). This narrows a published accept set on both validator faces, and it narrows the widget `type` vocabulary the TypeScript face exports. `minor`, per this repository's version alignment; the narrowing is the breaking part. + +`DashboardWidgetTypeSchema` is the widget arm's `type` vocabulary. It named the component type `metric-card`, taken from `DASHBOARD_COMPONENT_WIDGET_TYPES`. So `{ type: 'metric-card', title: 'Revenue' }` in `widgets[]` parsed as a widget, because it carries only keys both arms accept. It parsed on `AnyComponentSchema` / `DashboardComponentSchema` and on `StrictAnyComponentSchema`. The card's required `value` (objectui#11467) governed only a card that also carried `icon`, `trend` or `trendValue`. The dashboard then drew the card with an empty figure. + +- **What changed.** `DashboardWidgetTypeSchema` is now the spec's families plus `list` and `custom`, so the slot reads a `metric-card` through its component arm alone. The TypeScript twin, `DashboardWidgetTypeName`, drops `DashboardComponentWidgetType` the same way. `DASHBOARD_COMPONENT_WIDGET_TYPES` still lists `metric-card`: it is the component arm's `type`, and only that. +- **What now refuses that did not.** Directly in `widgets[]`, both faces refuse a `metric-card` with no `value`. That holds whatever else the card carries: `title`, `description`, `id` and `layout`, a `dataset` with `values`, or its figure under `options` (`options: { value }`), which the tolerant face accepted. On its own, `DashboardWidgetSchema` (zod) refuses `type: 'metric-card'` at `type`. On the TypeScript face, `'metric-card'` is no longer a `DashboardWidgetTypeName`, so assigning it to that type is a compile error. +- **Why no widget key stands in for `value`.** This was measured through the real `DashboardRenderer` and `DashboardGridLayout`. A `dataset` makes the dashboard draw `DatasetWidget` in the card's place, by a rule that does not read the `type`; the card is never mounted. `options` is spread onto the node as literal props: a second spelling of `value`, not a binding. No other widget key reached the figure. +- **What does not move.** On the TypeScript face, `DashboardWidgetSchema['type']` still includes `'metric-card'`. That interface is also the read type of every `widgets[]` entry, and the component arm stays assignable to it. So a `metric-card` literal with no `value` still compiles directly in `widgets[]`, while the validator refuses it on both faces. A `metric-card` in a widget's legacy `component` envelope is judged as before: there, the tolerant face's `BaseSchema` fallback still accepts a card that fails the arm. Every `metric-card` in the schema catalog, the READMEs and the docs fences carries its `value`. The one docs example that did not, under "TypeScript Support" in `content/docs/plugins/plugin-dashboard.mdx`, is rewritten. + +**Fix:** give the card its `value`. For a figure queried from a dataset, write a `metric` widget (`{ type: 'metric', dataset, values: [measure] }`), which the dashboard draws as the same dataset tile. diff --git a/.changeset/dashboard-widget-type-closed-enum.md b/.changeset/dashboard-widget-type-closed-enum.md index 11f2c6bbf9..57d84e892c 100644 --- a/.changeset/dashboard-widget-type-closed-enum.md +++ b/.changeset/dashboard-widget-type-closed-enum.md @@ -11,3 +11,5 @@ Close the dashboard widget `type` vocabulary, and admit `metric-card` as objectu It is now the CLOSED `DashboardWidgetTypeName` / `DashboardWidgetTypeSchema`: the spec's own `ChartTypeSchema` families **by reference**, plus two named, closed objectui extension sets — `DASHBOARD_WIDGET_TYPE_EXTENSIONS` (`list`, `custom`: objectui-only widget families) and `DASHBOARD_COMPONENT_WIDGET_TYPES` (`metric-card`: an objectui SDUI **component** type the widget slot holds directly, per the maintainer ruling of 2026-08-14 — objectui's own component enum, explicitly not the spec widget enum). Three drifts the closure surfaced and this change fixes: the dashboard designer's palette offered `grid`, which is not a widget family in either contract and was refused at publish; the metadata-admin widget inspector and the designer both wrote an unvalidated `string` from their select boxes; and a `@object-ui/types` fixture pinned `bar-chart`, a `plugin-charts` component type, on a dataset-bound widget that could never render as one. + +⚠️ **Dated note, 2026-10-02 — `metric-card` is no widget type — objectui#11483.** At this change, `DashboardWidgetTypeName` / `DashboardWidgetTypeSchema` were the spec's families plus two objectui sets, `DASHBOARD_WIDGET_TYPE_EXTENSIONS` and `DASHBOARD_COMPONENT_WIDGET_TYPES`; now they are the spec's families plus `DASHBOARD_WIDGET_TYPE_EXTENSIONS` only. A `metric-card` in the widget slot is read by the slot's component arm alone, which requires its `value`. `DASHBOARD_COMPONENT_WIDGET_TYPES` still lists `metric-card` as that arm's `type`, and `DashboardWidgetSchema['type']` on the TypeScript face still reads it. `.changeset/11483-metric-card-needs-value.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index b17eb55239..bed75973c5 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -92,6 +92,13 @@ outside a dashboard's `widgets[]` renders nothing but the renderer's } ``` +A card with no `value` is refused by `@object-ui/types/zod`, on the tolerant +face and the strict one. `metric-card` is not a widget type, so a card is never +read through widget keys alone, and no widget key stands in for `value`: a +`dataset` makes the dashboard draw its dataset tile in the card's place, and +`options` is not a binding (objectui#11483). For a figure queried from a +dataset, write a `metric` widget with `dataset` and `values`. + ## Usage ### Auto-registration (Side-effect Import) @@ -446,19 +453,33 @@ preview or a design surface (objectui#11295). ## TypeScript Support ```ts -import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types'; +import type { + DashboardComponentSchema, + DashboardWidgetSchema, + DashboardWidgetSlotComponentSchema, +} from '@object-ui/types'; const widget: DashboardWidgetSchema = { id: 'revenue', title: 'Revenue', - type: 'metric-card', + type: 'metric', + dataset: 'invoices', + values: ['amount'], layout: { x: 0, y: 0, w: 1, h: 1 }, }; +const card: DashboardWidgetSlotComponentSchema = { + id: 'users', + type: 'metric-card', + title: 'Users', + value: '1,234', + layout: { x: 1, y: 0, w: 1, h: 1 }, +}; + const dashboard: DashboardComponentSchema = { type: 'dashboard', columns: 3, - widgets: [widget], + widgets: [widget, card], }; ``` diff --git a/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts b/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts index a0c00e4317..d689cdb60b 100644 --- a/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts +++ b/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts @@ -135,8 +135,11 @@ function auditWidget(widget: Widget, where: string): string[] { // toward `z.string()`, which is precisely the regression objectui#4600 // closed: the catalog would keep its own floor instead of going quietly // green with the contract. - const known = DashboardWidgetTypeSchema.safeParse(type); - if (!known.success) { + // A slot component type (`metric-card`) is no widget type since + // objectui#11483 — the slot reads it through its component arm alone — so + // the vocabulary is the widget types plus the slot's component set. + const known = COMPONENT_WIDGET_TYPES.has(type) || DashboardWidgetTypeSchema.safeParse(type).success; + if (!known) { problems.push(`${where}: \`type: '${type}'\` is outside the closed widget vocabulary`); } } diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index 4083e08907..775b3af69a 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -205,6 +205,14 @@ outside a dashboard's `widgets[]` renders the "Unknown component type" panel. `value` is the only required key. `title` and `description` take a plain string or the spec's inline per-locale map (`I18nLabel`). +A card with no `value` is refused by `@object-ui/types/zod`, on the tolerant +face and the strict one. `metric-card` is not a widget type, so a card is never +read through widget keys alone, and no widget key stands in for `value`: a +`dataset` makes the dashboard draw its dataset tile in the card's place, and +`options` is not a binding (objectui#11483). For a figure queried from a +dataset, write a `metric` widget with `dataset` and `values` (see "TypeScript +Support" below). + #### Percent `format` patterns (`'0%'`, `'0.00%'`) A numeral pattern ending in `%` is handed whole to `formatPercent` diff --git a/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts index 9263f3d9ef..6e6bdb98cf 100644 --- a/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts +++ b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts @@ -294,3 +294,46 @@ describe('objectui#11070 round 11 — the slot\'s `layout`, as Save Layout write expect([placed.layout?.w, misplaced.type]).toEqual([3, 'metric-card']); }); }); + +/** + * objectui#11483 — the registration's `required` and the schema agree IN THE SLOT. + * + * The arm declared `value` required since objectui#11467, and the walk above holds + * that member to the registration. The slot still disagreed: its widget arm's + * `type` vocabulary named `metric-card`, so a card carrying no card-only input + * parsed as a widget and the arm's requirement governed nothing. This walk reads + * every REQUIRED input off the live registration and drops it from an otherwise + * complete node, so it measures the slot's verdict, not the arm's member. + */ +describe('objectui#11483 — a widget-slot registration\'s required inputs are required in the slot', () => { + describe.each([...DASHBOARD_COMPONENT_WIDGET_TYPES])('`%s`', (type) => { + const required = (): Input[] => + registeredInputs(type).filter((input) => (input as Input & { required?: boolean }).required); + + it('the registration declares a required input — the walk below is not empty', () => { + expect(required().length).toBeGreaterThan(0); + }); + + it('CONTROL — the node carrying every required input parses in the slot on both faces', () => { + for (const face of [AnyComponentSchema, StrictAnyComponentSchema]) { + expect(face.safeParse(dashboard({ ...nodeOf(type) })).success).toBe(true); + } + }); + + it('a node missing any required input is refused in the slot on both faces, by the component arm at that input', () => { + for (const input of required()) { + const node: Record = { ...nodeOf(type), title: 'Revenue' }; + delete node[input.name]; + for (const face of [AnyComponentSchema, StrictAnyComponentSchema]) { + const result = face.safeParse(dashboard(node)); + expect(result.success, `\`${input.name}\` is required by the registration`).toBe(false); + const union = (result.success ? [] : (result.error.issues as unknown as Issue[])).find( + (issue) => issue.code === 'invalid_union' && issue.path.join('.') === 'widgets.0', + ); + const componentArm = flatIssues(union?.errors?.[0] ?? []); + expect(componentArm.map((issue) => issue.path.join('.'))).toContain(input.name); + } + } + }); + }); +}); diff --git a/packages/types/README.md b/packages/types/README.md index 5ba6b53eb5..cf76126f90 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -219,7 +219,9 @@ objectui#11467; the members are held to the live registration and to by it, while the tolerant face also keeps `BaseSchema` for any other component node. The node also declares one widget key, `layout`, the spec's widget position, which the editable grid's Save Layout writes onto every entry; it is -judged by the spec's shape (objectui#11070): +judged by the spec's shape (objectui#11070). `metric-card` is not a widget +`type`, so the slot reads such a card through this node alone, and a card with +no `value` is refused on both faces (objectui#11483): ```typescript import { StrictAnyComponentSchema } from '@object-ui/types/zod'; @@ -227,7 +229,8 @@ import { StrictAnyComponentSchema } from '@object-ui/types/zod'; const card = (widget: object) => ({ type: 'dashboard', widgets: [widget] }); StrictAnyComponentSchema.safeParse(card({ type: 'metric-card', value: 42 })).success; // true -StrictAnyComponentSchema.safeParse(card({ type: 'metric-card', bogus: 1 })).success; // false — `bogus` is named +StrictAnyComponentSchema.safeParse(card({ type: 'metric-card', value: 42, bogus: 1 })).success; // false — `bogus` is named +StrictAnyComponentSchema.safeParse(card({ type: 'metric-card', title: 'Revenue' })).success; // false — no `value` StrictAnyComponentSchema.safeParse(card({ type: 'metric-card', value: 42, layout: { x: 0, y: 0, w: 3, h: 2 } })).success; // true StrictAnyComponentSchema.safeParse(card({ type: 'metric-card', value: 42, trend: 'sideways' })).success; // false — not a trend ``` diff --git a/packages/types/src/__tests__/metric-card-needs-value-11483.test.ts b/packages/types/src/__tests__/metric-card-needs-value-11483.test.ts new file mode 100644 index 0000000000..c2e57f7e93 --- /dev/null +++ b/packages/types/src/__tests__/metric-card-needs-value-11483.test.ts @@ -0,0 +1,196 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11483 — a `metric-card` in a dashboard's widget slot parses only + * with the `value` it draws. + * + * ## The defect + * + * The widget arm's `type` vocabulary (`DashboardWidgetTypeSchema`) named the + * component type `metric-card`. So a card carrying only keys both arms accept, + * `{ type: 'metric-card', title: 'Revenue' }`, parsed as a WIDGET on the + * tolerant face (`DashboardComponentSchema` / `AnyComponentSchema`) and on the + * strict one (`StrictAnyComponentSchema`). The component arm's required + * `value` (objectui#11467) governed only a card that also carried a card-only + * input. The dashboard then drew `MetricCard` with an empty figure. + * + * ## The ruling, and the measurement that picked its arm + * + * Triage's ruling on objectui#11483: a `metric-card` parses only when it + * carries what the card draws from, which is its `value`, or the widget arm's + * own data binding if that arm declares one. With neither, it is refused. + * Measured through the real `DashboardRenderer` and `DashboardGridLayout`, no + * widget key binds the card's figure. A `dataset` makes the dashboard draw + * `DatasetWidget` in the card's place, by a rule that ignores the `type`. The + * `options` bag is spread onto the node as literal props, which is not a + * binding. So `metric-card` left the widget vocabulary, and the component + * arm's required `value` governs alone. The probe's readings are in that + * card's pull request; this file pins the contract, not the probe. + * + * ## What is pinned + * + * 1. the measured node is refused on every face, by BOTH arms of the slot: + * the component arm at `value`, the widget arm at `type`; + * 2. a card with its `value` parses on every face; + * 3. no widget key stands in for `value`: a dataset-bound card and a card + * whose figure sits in `options` are refused. CONTROL: the dataset-bound + * single figure's own spelling, a `metric` widget, parses; + * 4. the vocabulary on both faces: no component type is a widget type, while + * the TypeScript widget interface still READS one (it is the read type of + * every slot entry); + * 5. MEASURED LIMIT of the TypeScript face, recorded two-faced: a `metric-card` + * literal with no `value` still compiles through that read type. + * + * The registration's `required` against the slot is pinned where the live + * registration is: `metricCardRegisteredInputsStrictFace-11022.test.ts` in + * `@object-ui/plugin-dashboard`'s `__tests__`. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import type { + DashboardComponentSchema, + DashboardComponentWidgetType, + DashboardWidgetSchema, + DashboardWidgetTypeName, +} from '../complex.js'; +import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js'; +import { + AnyComponentSchema, + DashboardComponentSchema as DashboardComponentZod, + DashboardWidgetSchema as DashboardWidgetZod, + DashboardWidgetTypeSchema, + StrictAnyComponentSchema, +} from '../zod/index.zod.js'; + +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; + +type Issue = { code: string; path: PropertyKey[]; keys?: string[]; errors?: Issue[][] }; + +/** Every face a dashboard document is judged by. */ +const FACES: ReadonlyArray = [ + ['tolerant DashboardComponentSchema', DashboardComponentZod], + ['tolerant AnyComponentSchema', AnyComponentSchema], + ['strict StrictAnyComponentSchema', StrictAnyComponentSchema], +]; + +const dashboard = (widget: Record) => ({ type: 'dashboard', widgets: [widget] }); + +/** The slot's verdict on its one widget: the `invalid_union` at `widgets.0`, both arms' issues under it. */ +const slotRefusal = (face: z.ZodType, doc: unknown): Issue | undefined => { + const result = face.safeParse(doc); + if (result.success) return undefined; + return (result.error.issues as unknown as Issue[]).find((i) => i.path.join('.') === 'widgets.0'); +}; + +const paths = (issues: readonly Issue[] | undefined) => (issues ?? []).map((i) => `${i.code}@${i.path.join('.')}`); + +/* ── 1. the measured node ────────────────────────────────────────────────── */ + +describe('objectui#11483 — `{ type: \'metric-card\', title }` is refused on every face', () => { + it.each(FACES)('%s: refused at the widget, by the component arm at `value` and the widget arm at `type`', (_name, face) => { + const refusal = slotRefusal(face, dashboard({ type: 'metric-card', title: 'Revenue' })); + expect(refusal?.code).toBe('invalid_union'); + const [componentArm, widgetArm] = refusal?.errors ?? []; + // The component arm, first in the union: its required `value` is missing. + expect(paths(componentArm)).toEqual(['invalid_union@value']); + // The widget arm: `metric-card` is no widget type, so this arm is no second + // reading of the card. Before the change this arm ACCEPTED the node. + expect(paths(widgetArm)).toEqual(['invalid_value@type']); + }); + + it.each(FACES)('%s: so is the bare type, and a card carrying every other key both arms accept', (_name, face) => { + for (const card of [ + { type: 'metric-card' }, + { type: 'metric-card', id: 'kpi', title: 'Revenue', description: 'vs last month', layout: { x: 0, y: 0, w: 3, h: 2 } }, + ]) { + expect(paths(slotRefusal(face, dashboard(card))?.errors?.[0]), JSON.stringify(card)).toEqual(['invalid_union@value']); + } + }); +}); + +/* ── 2. a card with its value ────────────────────────────────────────────── */ + +describe('objectui#11483 — a card with its `value` passes on every face', () => { + it.each(FACES)('%s', (_name, face) => { + for (const value of ['$24k', 12480]) { + expect(face.safeParse(dashboard({ type: 'metric-card', title: 'Revenue', value })).success).toBe(true); + } + }); +}); + +/* ── 3. no widget key stands in for `value` ──────────────────────────────── */ + +describe('objectui#11483 — no widget key stands in for the card\'s `value`', () => { + it.each(FACES)('%s: a dataset-bound card is refused at `value`', (_name, face) => { + // The dashboard draws `DatasetWidget` for any widget with a `dataset`, in the + // card's place: the card is never mounted, so `dataset` is not its binding. + const refusal = slotRefusal(face, dashboard({ type: 'metric-card', title: 'Revenue', dataset: 'sales', values: ['revenue'] })); + expect(refusal?.code).toBe('invalid_union'); + expect(paths(refusal?.errors?.[0])).toContain('invalid_union@value'); + expect(paths(refusal?.errors?.[1])).toContain('invalid_value@type'); + }); + + it.each(FACES)('%s: a card whose figure sits in `options` is refused at `value`', (_name, face) => { + // The surfaces spread `options` onto the node, so `options.value` reached the + // card as its prop: a second spelling of `value`, which the tolerant face + // accepted until this change. + const refusal = slotRefusal(face, dashboard({ type: 'metric-card', title: 'Revenue', options: { value: '$24k' } })); + expect(refusal?.code).toBe('invalid_union'); + expect(paths(refusal?.errors?.[0])).toContain('invalid_union@value'); + }); + + it.each(FACES)('%s: CONTROL — the dataset-bound single figure\'s own spelling, a `metric` widget, parses', (_name, face) => { + expect(face.safeParse(dashboard({ type: 'metric', title: 'Revenue', dataset: 'sales', values: ['revenue'] })).success).toBe(true); + }); +}); + +/* ── 4. the vocabulary ───────────────────────────────────────────────────── */ + +describe('objectui#11483 — no component type is a widget type', () => { + it('the Zod vocabulary names no member of the component set, and the widget arm refuses each at `type`', () => { + // Non-vacuity: the set this walks is not empty. + expect(DASHBOARD_COMPONENT_WIDGET_TYPES.length).toBeGreaterThan(0); + const vocabulary = new Set(DashboardWidgetTypeSchema.options); + for (const type of DASHBOARD_COMPONENT_WIDGET_TYPES) { + expect(vocabulary.has(type), `\`${type}\` is a widget type`).toBe(false); + const result = DashboardWidgetZod.safeParse({ id: 'w', type }); + expect(result.success).toBe(false); + expect(paths(result.success ? [] : (result.error.issues as unknown as Issue[]))).toEqual(['invalid_value@type']); + } + }); + + it('the TypeScript twin agrees, while the widget interface still READS the component type', () => { + // `DashboardWidgetTypeName` is the twin of `DashboardWidgetTypeSchema`: the two are disjoint + // from the component set. An authoring surface typed by it cannot offer the card as a widget. + const disjoint: Equal< Extract< DashboardWidgetTypeName, DashboardComponentWidgetType >, never > = true; + // `DashboardWidgetSchema['type']` is the read type of every slot entry, the component arm's + // included: the renderers annotate slot entries with that interface. + const reads: Equal< NonNullable< DashboardWidgetSchema['type'] >, DashboardWidgetTypeName | DashboardComponentWidgetType > = true; + expect([disjoint, reads]).toEqual([true, true]); + }); +}); + +/* ── 5. the TypeScript face's measured limit ─────────────────────────────── */ + +describe('MEASURED LIMIT of the TypeScript face — recorded, not a contract', () => { + it('a `metric-card` literal with no `value` compiles through the widget read type, and both Zod faces refuse it', () => { + // It matches the widget arm, whose `type` must keep the component type because + // `DashboardWidgetSchema` is also the read type of every slot entry. If tsc ever + // refuses this literal, the corner has closed: delete this constant and the notes + // on `DashboardWidgetSchema.type` and `DashboardComponentSchema.widgets` in + // `complex.ts`. ⛔ Do not add an `@ts-expect-error` to keep the file green. + const valuelessCard: DashboardComponentSchema = { + type: 'dashboard', + widgets: [{ type: 'metric-card', title: 'Revenue' }], + }; + for (const [, face] of FACES) expect(face.safeParse(valuelessCard).success).toBe(false); + }); +}); diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index 7c22d15f98..da3f757cd7 100644 --- a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -573,13 +573,17 @@ describe('DashboardWidgetSchema pinned divergences', () => { } }); - it('admits `metric-card` as objectui\'s own COMPONENT extension (ruling 2026-08-14)', () => { + it('does NOT admit `metric-card`: it is objectui\'s own COMPONENT extension, read by the slot\'s component arm (ruling 2026-08-14, objectui#11483)', () => { // objectstack#8593, maintainer, verbatim: `metric-card` joins objectui's own // CLOSED component enum as an explicitly allowed objectui extension, NOT the - // spec widget enum. Both halves are asserted — the second is what keeps this - // an objectui-local extension instead of a quiet spec change. + // spec widget enum. That component enum is the slot's component arm's `type`; + // since objectui#11483 it is no widget type either, because a `metric-card` + // parsing here as a widget skipped the card's required `value`. Both halves + // are asserted: the spec refuses it too, so this stays an objectui-local + // extension instead of a quiet spec change. + expect(DASHBOARD_COMPONENT_WIDGET_TYPES.length).toBeGreaterThan(0); for (const type of DASHBOARD_COMPONENT_WIDGET_TYPES) { - expect(DashboardWidgetSchema.safeParse({ id: 'w', type }).success).toBe(true); + expect(DashboardWidgetSchema.safeParse({ id: 'w', type }).success).toBe(false); expect(SpecDashboardWidgetSchema.safeParse({ id: 'w', type }).success).toBe(false); } }); @@ -604,12 +608,12 @@ describe('DashboardWidgetSchema pinned divergences', () => { for (const type of SpecChartTypeSchema.options) { expect(DashboardWidgetSchema.safeParse({ id: 'w', type }).success, `spec family '${type}'`).toBe(true); } - // And the closed enum is EXACTLY the spec's families plus the two declared - // objectui sets — no third, undocumented member has crept in. + // And the closed enum is EXACTLY the spec's families plus the declared + // objectui extension set — no undocumented member has crept in, and the + // component set left it with objectui#11483. expect(new Set(DashboardWidgetTypeSchema.options)).toEqual(new Set([ ...SpecChartTypeSchema.options, ...DASHBOARD_WIDGET_TYPE_EXTENSIONS, - ...DASHBOARD_COMPONENT_WIDGET_TYPES, ])); }); diff --git a/packages/types/src/__tests__/strict-widget-slot-registered-inputs-11022.test.ts b/packages/types/src/__tests__/strict-widget-slot-registered-inputs-11022.test.ts index a4f03e1238..e12484c52c 100644 --- a/packages/types/src/__tests__/strict-widget-slot-registered-inputs-11022.test.ts +++ b/packages/types/src/__tests__/strict-widget-slot-registered-inputs-11022.test.ts @@ -100,7 +100,12 @@ describe('objectui#11022 — the grade\'s pins, in the widget slot', () => { expect(slotArm).toEqual([expect.objectContaining({ code: 'unrecognized_keys', path: [], keys: ['bogus'] })]); // The strict widget schema, which the union falls through to, refuses it too // — and `value` with it, which is a component input and never a widget key. - expect(widgetArm).toEqual([expect.objectContaining({ code: 'unrecognized_keys', path: [], keys: ['value', 'bogus'] })]); + // Since objectui#11483 it refuses the `type` as well: `metric-card` is no widget type. + expect(widgetArm).toHaveLength(2); + expect(widgetArm).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'unrecognized_keys', path: [], keys: ['value', 'bogus'] }), + expect.objectContaining({ code: 'invalid_value', path: ['type'] }), + ])); }); it('CONTROL — `metric-card` is not a ROOT arm: refused at `type` on both faces, unchanged', () => { diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 1751858475..88af7c8e5c 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,7 +154,14 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **49 entries** in `KnownDrift`, **86 keys** across them — 49 / 87 until + * - **49 entries** in `KnownDrift`, **87 keys** across them — 49 / 86 until + * objectui#11483 took the component type `metric-card` out of the mirror's + * widget vocabulary (`DashboardWidgetTypeSchema`), so a card in the slot is read + * by its component arm alone and its required `value` governs. The TS + * `DashboardWidgetSchema` keeps that type in its `type`, because it is also the + * read type of every `widgets[]` entry, so `type` JOINED + * `complex.zod.ts#DashboardWidgetSchema`'s existing entry: the key total rose by + * one and the entry count did not move. It was 49 / 87 until * objectui#11234 RETIRED `ObjectKanbanSchema.onQuickAdd` on BOTH faces * (`?: never` on the declaration, `handlerKeyRefusal(…, 'retired', …)` on the * mirror), completing ruling B of decision batch #91 on `object-kanban`. The @@ -2241,8 +2248,19 @@ interface KnownDrift { * INSIDE this file without editing it, and `tsc -p tsconfig.json` (the BUILD project) * excludes every `.test.ts` under `src` and stays green while `tsconfig.test.json` * reddens. + * + * `type` — joined with objectui#11483, and the TS side is the WIDER one, on purpose. + * The mirror's widget vocabulary (`DashboardWidgetTypeSchema`) dropped the component + * type `metric-card`, so the slot reads a card through its component arm alone and + * the card's required `value` governs. The TS interface keeps + * `DashboardComponentWidgetType` in `type` because it is ALSO the read type of every + * `widgets[]` entry: the component arm is assignable to it, and `plugin-dashboard`'s + * renderers annotate slot entries with it. Measured on that card's branch, dropping it + * on this face breaks those renderers under `tsc`. The literal this lets compile, a + * `metric-card` with no `value`, is pinned two-faced as a measured limit in + * `metric-card-needs-value-11483.test.ts`. */ - 'complex.zod.ts#DashboardWidgetSchema': 'component' | 'options'; + 'complex.zod.ts#DashboardWidgetSchema': 'component' | 'options' | 'type'; /** * `onChange` — RUNTIME SLOT (objectui#6124): the `filter-builder` renderer * calls it as `props.onChange` after `SchemaRenderer`'s spread. (`fields` LEFT diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 677d036102..05793fc85f 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -2137,6 +2137,18 @@ export interface FloatingChatbotConfig { * component arm — first, as in the Zod twin — of * `DashboardComponentSchema.widgets` (objectui#7952). * + * ⛔ Not a widget TYPE (objectui#11483). This list is the component arm's own + * `type` and nothing else: it is no member of {@link DashboardWidgetTypeName} + * or its Zod twin, so the slot reads a `metric-card` through the component arm + * alone, and that arm's required `value` is the one spelling of what the card + * draws from. While it was also a widget type, `{ type: 'metric-card', title }` + * parsed as a widget and `MetricCard` drew an empty figure. No widget key binds + * the card's figure: measured through the real `DashboardRenderer` and + * `DashboardGridLayout`, a `dataset` draws `DatasetWidget` in the card's place + * by a rule that ignores the `type`. The one place a member is still named + * beside the widget families is {@link DashboardWidgetSchema}'s `type`, which + * is the READ type of every slot entry; see that member. + * * ⛔ CLOSED on purpose. The ruling's triage block named an open * "extension allowed" hatch as the thing to avoid: an open hatch re-creates * exactly the hole the catalog gate exists to close, because a typo'd or @@ -2178,7 +2190,13 @@ export type DashboardWidgetTypeExtension = (typeof DASHBOARD_WIDGET_TYPE_EXTENSI * spec's `ChartTypeSchema`) rather than being restated, so a family the spec * adds or retires lands here with no edit — the same discipline * {@link DashboardWidgetSchema} already uses for its key set. objectui's own - * two extension sets are spelled out above, each with its own reason. + * extension set, {@link DASHBOARD_WIDGET_TYPE_EXTENSIONS}, is spelled out above + * with its reason. {@link DashboardComponentWidgetType} was a third member until + * objectui#11483: a component type is not a widget type, and the slot reads a + * `metric-card` through its component arm alone (see + * {@link DASHBOARD_COMPONENT_WIDGET_TYPES}). An authoring surface that offers + * widget types (a designer palette, an inspector's type picker) is typed by this + * name, so it cannot offer the card as a widget it would save without a `value`. * * This used to be bare `string`. That was the open hatch: `type: 'metrci-card'` * type-checked, validated, and rendered the registry's red OBJUI-001 panel — @@ -2189,8 +2207,7 @@ export type DashboardWidgetTypeExtension = (typeof DASHBOARD_WIDGET_TYPE_EXTENSI */ export type DashboardWidgetTypeName = | NonNullable - | DashboardWidgetTypeExtension - | DashboardComponentWidgetType; + | DashboardWidgetTypeExtension; /** * Dashboard Widget Layout @@ -2275,12 +2292,26 @@ export interface DashboardWidgetSchema * type the widget slot holds directly. * * CLOSED — see {@link DashboardWidgetTypeName}. The spec's families flow in by - * reference; objectui's additions are the two named, closed extension sets. + * reference; objectui's additions are the two named, closed sets. * It was `string` until objectui#4600, which is why a retired family, a typo, * or a component type nothing registers all type-checked here and only * surfaced as the renderer's red OBJUI-001 panel at runtime. - */ - type?: DashboardWidgetTypeName; + * + * ⚠️ WIDER than the Zod twin's `type`, on purpose (objectui#11483). The + * validator's widget arm no longer admits {@link DashboardComponentWidgetType}: + * a `metric-card` is read by the component arm alone. On this face, this + * interface is ALSO the read type of every `widgets[]` entry. The component + * arm is assignable to it, which the dashboard docs teach and + * `plugin-dashboard`'s renderers rely on: `DashboardRenderer` and + * `DashboardGridLayout` annotate slot entries with this interface. Measured on + * objectui#11483's branch, dropping the component type here breaks those + * renderers' slot-entry callbacks under `tsc`. So the type is kept here, and a + * `metric-card` literal with no `value` still compiles through this arm. That + * is a measured limit of this face, pinned two-faced in + * `__tests__/metric-card-needs-value-11483.test.ts`. The validator refuses + * the same literal on both of its faces. + */ + type?: DashboardWidgetTypeName | DashboardComponentWidgetType; /** Widget-specific configuration (spec shorthand format). Kept `unknown` — objectui * renderers pass widget-family-specific bags the spec's `options` object does not model. */ options?: unknown; @@ -2422,7 +2453,8 @@ export interface DashboardWidgetSlotComponentSchema extends BaseSchema { /** * The figure the card shows, drawn as given. The one REQUIRED input: the * registration marks it `required`, and a card without it draws an empty - * figure. + * figure. Since objectui#11483 the validator has no other reading of a + * `metric-card` in the slot, so a card without it is refused there. */ value: string | number; /** A Lucide icon name, drawn beside the heading. */ @@ -2555,7 +2587,12 @@ export interface DashboardComponentSchema extends BaseSchema, Omit; /** diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index e81708d979..73f5fd3b38 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1165,7 +1165,7 @@ export const DashboardWidgetLayoutSchema = z.object({ * `../complex.ts` {@link DashboardWidgetTypeName}, and the enforcement half of * the 2026-08-14 maintainer ruling on objectstack#8593. * - * Composed of three sets, each reached the way its own provenance demands: + * Composed of two sets, each reached the way its own provenance demands: * * - the spec's 20 visualization families, BY REFERENCE off * `ChartTypeSchema.options` — the same enum the spec's own @@ -1176,10 +1176,23 @@ export const DashboardWidgetLayoutSchema = z.object({ * - `DASHBOARD_WIDGET_TYPE_EXTENSIONS` — objectui-only widget FAMILIES * (`list`, `custom`). The pre-existing divergence, until objectui#4600 * carried only as the prose "widened to `z.string()`" and enforced nowhere. - * - `DASHBOARD_COMPONENT_WIDGET_TYPES` — objectui COMPONENT types the widget - * slot holds directly (`metric-card`). The ruling puts it HERE and - * explicitly not in the spec widget enum, which is a different repo and a - * different contract. + * + * `DASHBOARD_COMPONENT_WIDGET_TYPES` (`metric-card`) was a third set here + * until objectui#11483, and it is ⛔ not a widget type. The slot holds such a + * node directly, and the slot's component arm, + * {@link DashboardWidgetSlotComponentSchema}, reads it with the registered + * inputs it declares. While the type was also in this vocabulary, a + * `metric-card` carrying only widget keys (`{ type: 'metric-card', title }`) + * parsed as a WIDGET, so the arm's required `value` governed only when a + * card-only input happened to be present, and `MetricCard` drew an empty + * figure. objectui#11483 measured, through the real `DashboardRenderer` and + * `DashboardGridLayout`, that no widget key binds the card's figure. A + * `dataset` makes the dashboard draw `DatasetWidget` in the card's place, by a + * rule that ignores the `type`. The `options` bag is spread onto the node as + * literal props, which is not a binding. So the type left this vocabulary, and + * the component arm's required `value` is the one reading of a `metric-card` + * in the slot. A dataset-bound single figure is the `metric` widget + * (`type: 'metric'` with `dataset` and `values`). * * ⛔ Closed, not `z.string()`. The open form is what let the catalog ship * widgets naming types nothing registers: measured on this tree before the @@ -1188,7 +1201,7 @@ export const DashboardWidgetLayoutSchema = z.object({ * built on it would have passed by validating nothing. * * ⚠️ A member of `DASHBOARD_COMPONENT_WIDGET_TYPES` is a component node, and - * this schema is NOT the schema for its body: `metric-card`'s own props + * this schema is NOT the schema for its `type` or its body: `metric-card`'s own props * (`value`, `icon`, `trend`, `trendValue` — registry `inputs`, not widget * keys) belong to objectui's own component schema — passthrough `BaseSchema` * plus those inputs as members (objectui#11467) — which is the schema the @@ -1203,7 +1216,6 @@ export const DashboardWidgetLayoutSchema = z.object({ export const DashboardWidgetTypeSchema = z.enum([ ...stripImportedDefaults(SpecChartTypeSchema).options, ...DASHBOARD_WIDGET_TYPE_EXTENSIONS, - ...DASHBOARD_COMPONENT_WIDGET_TYPES, ]); /** @@ -1240,7 +1252,13 @@ const METRIC_CARD_NEITHER_CHANNEL = * every other widget (any spec family, `list`/`custom`, the legacy * `component` envelope with no `type` at all) the required closed enum fails * fast and the union falls through to the strict widget schema — so this arm - * cannot become a passthrough hatch around #6002's refusal. Deliberately NOT + * cannot become a passthrough hatch around #6002's refusal. The converse holds + * since objectui#11483: the widget schema's `type` vocabulary names no + * component type, so a `metric-card` in the slot is read by this arm ALONE, and + * a card this arm refuses, one with no `value` among them, has no other reading. + * Before that, a card carrying only keys both arms accept (`title`, + * `description`, `layout`, `id`) parsed as a widget, and the required `value` + * below governed nothing. Deliberately NOT * exported: the routing is an internal property of the widget slot, not new * authoring surface. * @@ -1249,7 +1267,8 @@ const METRIC_CARD_NEITHER_CHANNEL = * states. ⚠️ A refusal here does not surface on its own: this is the first arm * of the slot's `z.union`, so a document it refuses falls through to the * strict {@link DashboardWidgetSchema}, which refuses the same key as - * unrecognized, and the author gets one `invalid_union` at the widget's path + * unrecognized and the `metric-card` type as no widget type (objectui#11483), + * and the author gets one `invalid_union` at the widget's path * with each arm's issues under `errors` — this arm's message among them, which * `objectui validate` prints as one arm of two. * @@ -1264,7 +1283,8 @@ const METRIC_CARD_NEITHER_CHANNEL = * strict authoring face admitted their NAMES only, from a side table of the * registration's input names (objectui#11022), and judged each value by the * same catchall. Declared, both faces judge each value by its member, and - * `value` — the registration's one `required` input — is required. The side + * `value` — the registration's one `required` input — is required, and since + * objectui#11483 the slot has no other reading of the card to escape it. The side * table went with the repair, because every name it held is now a member. * This package depends on no registry, so the members are held to the live * registration and to `MetricCardProps` by the registering package's own @@ -1323,11 +1343,12 @@ const DashboardWidgetSlotComponentSchema = BaseSchema.extend({ * - `id` relaxed to optional — the spec requires it, but stored objectui * dashboards (and the legacy `component` format below) omit it. * - `type` re-pointed at {@link DashboardWidgetTypeSchema} — the spec's own - * enum plus objectui's two CLOSED extension sets. It was `z.string()` until - * objectui#4600; the widening was real (objectui renders `list` / `custom`, - * and the 2026-08-14 ruling admits the `metric-card` component type) but it - * was spent as an unbounded hatch rather than a named set, so every typo and - * every retired family validated too. + * enum plus objectui's CLOSED `list` / `custom` extension. It was `z.string()` until + * objectui#4600; the widening was real (objectui renders `list` / `custom`) + * but it was spent as an unbounded hatch rather than a named set, so every typo and + * every retired family validated too. The vocabulary also named the + * `metric-card` component type until objectui#11483, which made a card with + * no `value` parse here as a widget; see that schema's docblock. * - `component` — the legacy `{ id, component: , layout }` envelope, * which the spec has no room for. Migration to the shorthand form is deferred. * @@ -1352,7 +1373,8 @@ const DashboardWidgetSlotComponentSchema = BaseSchema.extend({ * {@link DashboardWidgetSlotComponentSchema} — passthrough `BaseSchema` plus * the component's registered inputs — before this schema is consulted: its * props are component inputs, not widget keys, and MUST NOT be refused here - * (2026-08-14 ruling, objectstack#8593). + * (2026-08-14 ruling, objectstack#8593). Since objectui#11483 this schema + * refuses that `type` too, so it is never a second reading of the card. * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ @@ -1362,7 +1384,7 @@ export const DashboardWidgetSchema = specFieldsExcept(stripImportedDefaults(Spec ] as const).extend({ id: z.string().optional().describe('Widget ID'), type: DashboardWidgetTypeSchema.optional() - .describe('Widget visualization type — the spec families plus objectui\'s closed `list`/`custom` and `metric-card` extensions'), + .describe('Widget visualization type — the spec families plus objectui\'s closed `list`/`custom` extension'), // ⚠️ `BaseSchema`, ⛔ NOT `SchemaNodeSchema` (objectui#8344). The two were the // same accept set until #8344 redirected the node recursion point at // `AnyComponentSchema`, and this slot is the one place in the package where they @@ -1524,7 +1546,9 @@ export const DashboardComponentSchema = BaseSchema.extend(SpecDashboardFields.sh // BaseSchema plus the component's registered inputs (objectui#11467); every // other widget is the `.strict()` spec-derived schema. Component arm first — it matches // exclusively on the closed component-type enum, so a spec-family widget - // can never be captured by it. + // can never be captured by it. The other way round, the widget arm's vocabulary names no + // component type (objectui#11483), so a `metric-card` is read by the component arm alone. + // That arm's required `value` is the one spelling of what the card draws from. // objectui#11073: the strict spec-derived arm is closed where it meets this union, so a // widget the component arm refuses by name is not answered by this arm's unknown keys alone // (see `closeStrictUnionArms` in `./node-derivation.ts`).