From 58cdefbd086e791cfa57c5b6c693aaf7636c117a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:46:14 +0000 Subject: [PATCH 1/5] feat(types)!: the widget slot's metric-card arm declares MetricCard's registered inputs, and the widget `component` slot takes the arm (objectui#11467) `DashboardWidgetSlotComponentSchema` declares `title`, `value`, `icon`, `trend` and `trendValue` (the sixth input, `description`, is the base's member) with the types of the props `MetricCard` reads, on the TypeScript face and on the zod twin. `value` is required, as the registration marks it. The widget's `component` slot takes the arm first, then any other node, on both faces. The objectui#11022 side table of input names is retired with its walker arm: every name it held is now a member. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- ...rdRegisteredInputsStrictFace-11022.test.ts | 96 +++++-- ...ard-widget-slot-component-arm-7952.test.ts | 122 ++++++++- ...idget-slot-registered-inputs-11022.test.ts | 179 +++++++------ packages/types/src/complex.ts | 94 +++++-- packages/types/src/strict-authoring-face.ts | 41 +-- packages/types/src/zod/complex.zod.ts | 240 +++++++++--------- packages/types/src/zod/node-derivation.ts | 60 ----- 7 files changed, 484 insertions(+), 348 deletions(-) diff --git a/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts index 6afc83b0ac..024c5be9ff 100644 --- a/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts +++ b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts @@ -1,24 +1,28 @@ /** * objectui#11022 — the strict authoring face admits what the widget slot's - * REGISTRATIONS declare, measured against the live `ComponentRegistry`. + * REGISTRATIONS declare, measured against the live `ComponentRegistry`; and + * objectui#11467 — the slot arm declares them as MEMBERS, typed as the props + * `MetricCard` reads. * - * `@object-ui/types` records, on its private widget-slot arm, the input names - * of each component type in the closed `DASHBOARD_COMPONENT_WIDGET_TYPES`, and - * the strict walker admits exactly those (see `strict-authoring-face.ts`, - * "Registered inputs"). That package depends on no registry, so the names are + * `@object-ui/types` declares, on its private widget-slot arm (and on the + * TypeScript twin, `DashboardWidgetSlotComponentSchema`), the inputs of each + * component type in the closed `DASHBOARD_COMPONENT_WIDGET_TYPES`. That package + * depends on no registry and cannot import `MetricCard`, so the members are * transcribed there — and a transcription is only as good as the check that - * re-reads its source. The source is here: this package's barrel runs the - * registration. So this file holds the record to the registration in BOTH - * directions, and holds no copy of either list: + * re-reads its source. The sources are here: this package's barrel runs the + * registration, and `MetricCard` is the component it renders. So this file + * holds the arm to both, and holds no copy of either: * * 1. UNDER-admission — every input a widget-slot type's registration * declares parses on `StrictAnyComponentSchema` in the widget slot, with * no `unrecognized_keys` naming it. The population is every member of the * closed set, read from the set, so a member added later is walked too. - * 2. OVER-admission — every key the strict slot arm admits beyond the - * tolerant arm's own members is a key some widget-slot registration - * declares. Read off the derived arm's shape, so a name added to the - * record that no registration declares turns this red. + * 2. DECLARED, both directions — every registered input is a member of the + * slot arm on both faces, and every member the arm adds to `BaseSchema` + * besides `layout` is a registered input. Read off each face's arm, so a + * member no registration declares turns this red. Each member is typed as + * the registration declares it (`value` required, `trend` its enum), and + * the TypeScript arm's members are the types of `MetricCardProps`. * * The instrument's controls: a key no registration declares is refused by * name (so walk 1 can fail), and the population is non-empty and holds the @@ -43,6 +47,7 @@ import { } from '@object-ui/types'; import { AnyComponentSchema, + BaseSchema, DashboardComponentSchema, deriveStrictAuthoringSchema, StrictAnyComponentSchema, @@ -51,6 +56,7 @@ import { // calls, `metric-card`'s among them. import '../index'; import { mergeLayoutIntoSchema } from '../DashboardGridLayout'; +import type { MetricCardProps } from '../MetricCard'; type Issue = { code: string; path: PropertyKey[]; keys?: string[]; errors?: Issue[][] }; type Def = { @@ -59,8 +65,11 @@ type Def = { innerType?: unknown; element?: unknown; options?: unknown[]; + entries?: Record; }; type Input = { name: string; type?: unknown; enum?: Array<{ value: unknown } | string> }; +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; const defOf = (schema: unknown): Def => (schema as { _zod: { def: Def } })._zod.def; @@ -133,24 +142,55 @@ describe('objectui#11022 — widget-slot registrations vs the strict authoring f }); }); - it('OVER-admission: every key the strict slot arm admits beyond the tolerant arm is a registered input', () => { - const tolerantKeys = new Set(Object.keys(defOf(slotArm(DashboardComponentSchema)).shape ?? {})); - const strictKeys = Object.keys(defOf(slotArm(deriveStrictAuthoringSchema(DashboardComponentSchema))).shape ?? {}); - const admitted = strictKeys.filter((key) => !tolerantKeys.has(key)).sort(); + it('DECLARED, both directions, on both faces: every registered input is a member of the slot arm, and every member it adds beyond the base and `layout` is a registered input', () => { + // objectui#11467: the inputs are the arm's MEMBERS, so the strict face admits + // them by its ordinary rule (a member is declared) and both faces judge each + // value by its member. Until then the arm declared none of them and the + // strict face admitted their names from a side table in `@object-ui/types`. + const baseKeys = new Set(Object.keys(defOf(BaseSchema).shape ?? {})); const registered = new Set( DASHBOARD_COMPONENT_WIDGET_TYPES.flatMap((type) => registeredInputs(type).map((input) => input.name)), ); - // Non-vacuity: the derived arm does admit something the tolerant arm does not declare. - expect(admitted).toContain('value'); - expect( - admitted.filter((key) => !registered.has(key)), - 'the strict slot arm admits a key no widget-slot registration declares — remove it from ' - + '`DASHBOARD_WIDGET_SLOT_REGISTERED_INPUTS` in `@object-ui/types`, or register it first', - ).toEqual([]); - // And the other half, read the same way: nothing registered is left out. - const missing = [...registered].filter((key) => !tolerantKeys.has(key) && !admitted.includes(key)); - expect(missing).toEqual([]); + for (const face of [DashboardComponentSchema, deriveStrictAuthoringSchema(DashboardComponentSchema)]) { + const armKeys = Object.keys(defOf(slotArm(face)).shape ?? {}); + // UNDER: nothing registered is left to the passthrough (the base's own + // `description` counts — it is a member the arm inherits). + expect([...registered].filter((name) => !armKeys.includes(name))).toEqual([]); + // OVER: what the arm adds to the base is the slot's `layout` and registered inputs only. + const added = armKeys.filter((key) => !baseKeys.has(key) && key !== 'layout').sort(); + // Non-vacuity: the arm does declare an input the base lacks. + expect(added).toContain('value'); + expect( + added.filter((key) => !registered.has(key)), + 'the slot arm declares a member no widget-slot registration declares — remove it from ' + + '`DashboardWidgetSlotComponentSchema` in `@object-ui/types`, or register it first', + ).toEqual([]); + } + }); + + it('each member is typed as the registration declares it: `value` required, and `trend` exactly the registered enum', () => { + const shape = defOf(slotArm(DashboardComponentSchema)).shape ?? {}; + const value = registeredInputs('metric-card').find((input) => input.name === 'value') as Input & { required?: boolean }; + expect(value.required).toBe(true); + expect(defOf(shape.value).type, '`value` is required on the arm, as on the registration').not.toBe('optional'); + const trend = registeredInputs('metric-card').find((input) => input.name === 'trend'); + const enumValues = (trend?.enum ?? []).map((entry) => (typeof entry === 'string' ? entry : entry.value)).sort(); + // Non-vacuity: the registration does declare an enum. + expect(enumValues.length).toBeGreaterThan(0); + let member = shape.trend; + while (defOf(member).innerType) member = defOf(member).innerType; + expect(Object.values(defOf(member).entries ?? {}).sort()).toEqual(enumValues); }); + + it('the TypeScript arm declares each input as the type of the prop `MetricCard` reads it as', () => { + // `MetricCard` is what `plugin-dashboard:metric-card` renders. The arm in + // `@object-ui/types` cannot import it, so the parity is pinned here, where + // both are visible; `tsc -p tsconfig.test.json` is the reader of this line. + type Inputs = 'title' | 'value' | 'icon' | 'trend' | 'trendValue' | 'description'; + const sameTypes: Equal< Pick, Pick > = true; + expect(sameTypes).toBe(true); + }); +}); }); /** Every issue in the refusal, union arms flattened, each path made absolute to the widget. */ @@ -241,9 +281,9 @@ describe('objectui#11070 round 11 — the slot\'s `layout`, as Save Layout write }); it('the TypeScript face: the arm types `layout` by the spec\'s shape, not the index signature\'s `any`', () => { - const placed: DashboardWidgetSlotComponentSchema = { type: 'metric-card', layout: { x: 0, y: 0, w: 3, h: 2 } }; + const placed: DashboardWidgetSlotComponentSchema = { type: 'metric-card', value: '1', layout: { x: 0, y: 0, w: 3, h: 2 } }; // @ts-expect-error -- `x` is a number in the spec's widget `layout`. - const misplaced: DashboardWidgetSlotComponentSchema = { type: 'metric-card', layout: { x: 'left', y: 0, w: 3, h: 2 } }; + const misplaced: DashboardWidgetSlotComponentSchema = { type: 'metric-card', value: '1', layout: { x: 'left', y: 0, w: 3, h: 2 } }; expect([placed.layout?.w, misplaced.type]).toEqual([3, 'metric-card']); }); }); diff --git a/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts b/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts index 2639b19d22..8e96c914df 100644 --- a/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts +++ b/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts @@ -43,7 +43,14 @@ * 5. shape identity: the slot's element type IS the two-arm union, the arm's * `type` IS `DashboardComponentWidgetType`, and the arm is assignable to * `DashboardWidgetSchema` — which is why every `(w: DashboardWidgetSchema)` - * callback in `plugin-dashboard` compiled unchanged. + * callback in `plugin-dashboard` compiled unchanged; + * 6. objectui#11467: the arm DECLARES `MetricCard`'s registered inputs, so the + * README's `metric-card` literals compile against members rather than + * against `BaseSchema`'s index signature (which objectui#8347 removes); + * the Zod twin declares the same members; and the widget's `component` + * slot takes the arm first, on both faces. The members' parity with the + * live registration and `MetricCardProps` is pinned in + * `@object-ui/plugin-dashboard` (`metricCardRegisteredInputsStrictFace-11022.test.ts`). * * Type-level lines are erased at runtime and enforced because * `packages/types/tsconfig.test.json` is chained from this package's @@ -53,6 +60,9 @@ */ import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import type { I18nLabel } from '@objectstack/spec/ui'; +import type { SchemaNode } from '../base.js'; import type { DashboardComponentSchema, DashboardComponentWidgetType, @@ -61,6 +71,7 @@ import type { } from '../complex.js'; import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js'; import { DashboardComponentSchema as DashboardComponentZod } from '../zod/complex.zod.js'; +import { 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; @@ -124,7 +135,7 @@ describe('the forbidden repair did not happen — DashboardWidgetSchema is not w it('the arm\'s `type` is closed', () => { // @ts-expect-error — TS2322: a spec family is not a component type. - const open: DashboardWidgetSlotComponentSchema = { type: 'bar' }; + const open: DashboardWidgetSlotComponentSchema = { type: 'bar', value: '1' }; expect(open.type).toBe('bar'); }); }); @@ -162,7 +173,8 @@ describe('NOT A HATCH — what the union still refuses, on both faces', () => { it('a component node with an undeclared key is kept whole — that is the passthrough, by ruling', () => { const doc: DashboardComponentSchema = { type: 'dashboard', - widgets: [{ type: 'metric-card', title: 'x', someProp: 1 }], + // `value` is the card's required input since objectui#11467; `someProp` is the undeclared key. + widgets: [{ type: 'metric-card', title: 'x', value: '1', someProp: 1 }], }; const result = DashboardComponentZod.safeParse(doc); expect(result.success).toBe(true); @@ -199,3 +211,107 @@ describe('MEASURED LIMIT of a TypeScript union with a passthrough arm — record expect(DashboardComponentZod.safeParse(envelope).success).toBe(true); }); }); + +/* ── objectui#11467 — the arm declares MetricCard's registered inputs ─────── */ + +type MetricCardInput = 'title' | 'value' | 'icon' | 'trend' | 'trendValue'; + +/** The zod arm is private; its input type is read off the slot's element union. */ +type ZodSlotArm = Extract< z.input< typeof DashboardComponentZod >['widgets'][number], { type: 'metric-card' } >; + +/** + * `plugin-dashboard/README.md`'s "TypeScript Support" block, the two + * `metric-card` literals, byte-for-byte, with their annotations: the card in a + * widget's `component` slot, and the card directly in `widgets[]`. Read off the + * page by `check:doc-snippets`; copied here because a type-level pin cannot + * read a file. Both compiled before objectui#11467 only through `BaseSchema`'s + * index signature (the objectui#8347 census: `value`, three times in this file). + */ +const custom: DashboardWidgetSchema = { + id: 'kpi_custom', + component: { + type: 'metric-card', + title: 'Revenue', + value: '$123,456', + trend: 'up', + trendValue: '+12%', + }, + layout: { x: 0, y: 0, w: 3, h: 2 }, +}; + +const kpi: DashboardWidgetSlotComponentSchema = { + type: 'metric-card', + title: 'Revenue', + value: '$123,456', + trend: 'up', + trendValue: '+12%', +}; + +describe('objectui#11467 — the component arm declares `MetricCard`\'s registered inputs, on both faces', () => { + it('each input is a DECLARED member with `MetricCard`\'s prop type — not the index signature\'s `any`', () => { + // Through the index signature each of these was `any`, and `Equal< any, X >` is `false`. + const title: Equal< DashboardWidgetSlotComponentSchema['title'], string | I18nLabel | undefined > = true; + const value: Equal< DashboardWidgetSlotComponentSchema['value'], string | number > = true; + const icon: Equal< DashboardWidgetSlotComponentSchema['icon'], string | undefined > = true; + const trend: Equal< DashboardWidgetSlotComponentSchema['trend'], 'up' | 'down' | 'neutral' | undefined > = true; + const trendValue: Equal< DashboardWidgetSlotComponentSchema['trendValue'], string | undefined > = true; + // `description`, the sixth input, is the base's member, not restated. + const description: Equal< DashboardWidgetSlotComponentSchema['description'], string | I18nLabel | undefined > = true; + expect([title, value, icon, trend, trendValue, description]).toEqual([true, true, true, true, true, true]); + }); + + it('the Zod twin declares the same five members with the same types', () => { + const twins: Equal< Required< Pick< ZodSlotArm, MetricCardInput > >, Required< Pick< DashboardWidgetSlotComponentSchema, MetricCardInput > > > = true; + expect(twins).toBe(true); + }); + + it('the TypeScript face judges each value by its member, while the index signature still stands', () => { + // @ts-expect-error — TS2322: `trend` is the registration's enum. + const sideways: DashboardWidgetSlotComponentSchema = { type: 'metric-card', value: '1', trend: 'sideways' }; + // @ts-expect-error — TS2741: `value` is the card's one required input. + const valueless: DashboardWidgetSlotComponentSchema = { type: 'metric-card', title: 'Revenue', icon: 'users' }; + const doc: DashboardComponentSchema = { + type: 'dashboard', + // @ts-expect-error — TS2322: no arm admits it: the component arm judges `trend`, and `value` is no widget key. + widgets: [{ type: 'metric-card', value: '1', trend: 'sideways' }], + }; + expect([sideways.trend, valueless.type, doc.type]).toEqual(['sideways', 'metric-card', 'dashboard']); + }); + + it('the widget `component` slot is typed with the arm first, then any other component node', () => { + const slot: Equal< NonNullable< DashboardWidgetSchema['component'] >, DashboardWidgetSlotComponentSchema | NonNullable< SchemaNode > > = true; + expect(slot).toBe(true); + }); + + it('the README\'s two `metric-card` literals parse on both faces, every key kept', () => { + const doc = { type: 'dashboard', widgets: [custom, kpi] }; + const result = DashboardComponentZod.safeParse(doc); + expect(result.success).toBe(true); + if (!result.success) return; + const [parsedCustom, parsedKpi] = (result.data as { widgets: Record>[] }).widgets; + for (const key of Object.keys(custom.component as object)) expect(parsedCustom.component).toHaveProperty(key); + for (const key of Object.keys(kpi)) expect(parsedKpi).toHaveProperty(key); + // The strict authoring face: before objectui#11467 it refused the `component` + // card's five inputs as unrecognized (`BaseSchema` alone in that slot). + expect(StrictAnyComponentSchema.safeParse(doc).success).toBe(true); + }); + + it('in the `component` slot the strict face judges a card by the arm, and still admits any other node', () => { + const issues = (doc: unknown): string => { + const result = StrictAnyComponentSchema.safeParse(doc); + return result.success ? '' : JSON.stringify(result.error.issues); + }; + const envelope = (component: Record) => ({ type: 'dashboard', widgets: [{ id: 'w', component }] }); + const sideways = issues(envelope({ type: 'metric-card', value: '1', trend: 'sideways' })); + expect(sideways).toContain('"trend"'); + // MEASURED LIMIT, recorded rather than ruled: the tolerant face keeps its + // `BaseSchema` fallback for any component node a `custom` widget carries, so + // a card that fails the arm still parses through it. The TypeScript union + // has the same corner (the fallback arm has no closed `type` to exclude it). + // Directly in `widgets[]` there is no such fallback, and both faces refuse. + expect(DashboardComponentZod.safeParse(envelope({ type: 'metric-card', value: '1', trend: 'sideways' })).success).toBe(true); + // CONTROL — a non-card node in the slot is judged by `BaseSchema`, unchanged. + expect(issues(envelope({ type: 'chart' }))).toBe(''); + expect(DashboardComponentZod.safeParse(envelope({ type: 'chart' })).success).toBe(true); + }); +}); 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 5d42756729..4077d352b6 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 @@ -8,28 +8,35 @@ /** * The strict authoring face admits the widget-slot `metric-card`'s REGISTERED - * inputs, and still refuses a key no registration declares (objectui#11022). + * inputs, and still refuses a key no registration declares (objectui#11022) — + * since objectui#11467, because the slot arm DECLARES them. * * ## The defect * * `metric-card` sits in a dashboard's widget slot as a component node whose * props are its registry `inputs` (`title`, `value`, `icon`, `trend`, - * `trendValue`, `description`), admitted on the tolerant face by `BaseSchema`'s - * `.passthrough()` catchall, by ruling (objectstack#8593) — the private slot arm - * in `../zod/complex.zod.ts` declares none of them. The strict walker closes - * every object's catchall, so `StrictAnyComponentSchema` refused `value` on - * both arms of the slot's union, as `unrecognized_keys`: every correctly - * authored `metric-card` that carried its required input. + * `trendValue`, `description`). The strict walker closes every object's + * catchall, so while the slot arm admitted those props through `BaseSchema`'s + * `.passthrough()` alone, `StrictAnyComponentSchema` refused `value` on both + * arms of the slot's union, as `unrecognized_keys`: every correctly authored + * `metric-card` that carried its required input. * - * ## The repair, and what each block below holds + * ## Two repairs, in order * - * The arm records its registration's input names (`declareRegisteredInputs`), - * and the walker admits exactly those, judged by the arm's own catchall, before - * closing the object. The blocks pin, in order: the grade's two pins; the - * content channels objectui#9256 refused staying refused; the admitted keys - * being judged as the tolerant face judges them and no narrower; the tolerant - * face not moving; and the derivation itself on a hand-built node, so the rule - * is pinned apart from the one card that motivated it. + * objectui#11022 recorded the registration's input NAMES in a side table on the + * arm, and the walker admitted exactly those, each judged by the arm's catchall + * — so the strict face admitted the keys and judged no value. objectui#11467 + * declared the five inputs `BaseSchema` lacks as MEMBERS of the arm, typed as + * `MetricCard`'s props (`description` is already a `BaseSchema` member), on + * both faces and on the TypeScript twin. Every name the side table held became + * a member, so the side table and the walker's arm for it were retired. + * + * ## What each block below holds + * + * The grade's pins; the content channels objectui#9256 refused staying + * refused; each input judged by its member on BOTH faces; and the arm's shape + * declaring the inputs, with the passthrough still keeping any other key on + * the tolerant face. * * ⚠️ Where the grade's pin sits. `metric-card` is deliberately NOT an arm of * `AnyComponentSchema` (the ruling keeps it a property of the widget slot), so a @@ -37,9 +44,9 @@ * both faces, before and after this change — the control below says so. The * grade's widget therefore sits in a dashboard's `widgets` slot here. * - * The parity of the recorded names with the LIVE registration, both - * directions, is measured by the registering package, which holds the - * registry: `metricCardRegisteredInputsStrictFace-11022.test.ts` in + * The parity of the declared members with the LIVE registration and with + * `MetricCardProps` is measured by the registering package, which holds both: + * `metricCardRegisteredInputsStrictFace-11022.test.ts` in * `@object-ui/plugin-dashboard`'s `__tests__`. */ @@ -50,10 +57,9 @@ import { AnyComponentSchema, BaseSchema, DashboardComponentSchema, - deriveStrictAuthoringSchema, StrictAnyComponentSchema, } from '../zod/index.zod.js'; -import { declareRegisteredInputs, internals, registeredInputsOf } from '../zod/node-derivation.js'; +import { internals } from '../zod/node-derivation.js'; /* ── Reading a refusal ───────────────────────────────────────────────────── */ @@ -73,8 +79,8 @@ const issuesOf = (schema: z.ZodType, doc: unknown): Issue[] | null => { const dashboard = (widget: Record) => ({ type: 'dashboard', widgets: [widget] }); /** The ONE issue a widget refusal surfaces as: an `invalid_union` at the widget. */ -const widgetUnion = (doc: unknown): Issue | undefined => - issuesOf(StrictAnyComponentSchema, doc)?.find((i) => i.path.join('.') === 'widgets.0'); +const widgetUnion = (schema: z.ZodType, doc: unknown): Issue | undefined => + issuesOf(schema, doc)?.find((i) => i.path.join('.') === 'widgets.0'); /* ── 1. the grade's pins ─────────────────────────────────────────────────── */ @@ -82,18 +88,19 @@ describe('objectui#11022 — the grade\'s pins, in the widget slot', () => { it('`{ type: \'metric-card\', value: 42 }` parses under StrictAnyComponentSchema', () => { const doc = dashboard({ type: 'metric-card', value: 42 }); expect(issuesOf(StrictAnyComponentSchema, doc)).toBeNull(); - // Control: the tolerant face always accepted it — the two faces now agree. + // Control: the tolerant face always accepted it — the two faces agree. expect(issuesOf(AnyComponentSchema, doc)).toBeNull(); }); - it('`{ type: \'metric-card\', bogus: 1 }` is still refused, by name, on the slot arm', () => { - const issue = widgetUnion(dashboard({ type: 'metric-card', bogus: 1 })); + it('`{ type: \'metric-card\', value: 42, bogus: 1 }` is still refused, by name, on the slot arm', () => { + const issue = widgetUnion(StrictAnyComponentSchema, dashboard({ type: 'metric-card', value: 42, bogus: 1 })); expect(issue?.code).toBe('invalid_union'); // Arm 1 is the component-node arm; its path is relative to the widget. const [slotArm, widgetArm] = issue?.errors ?? []; expect(slotArm).toEqual([expect.objectContaining({ code: 'unrecognized_keys', path: [], keys: ['bogus'] })]); - // The strict widget schema, which the union falls through to, refuses it too. - expect(widgetArm).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'] })]); }); it('CONTROL — `metric-card` is not a ROOT arm: refused at `type` on both faces, unchanged', () => { @@ -108,7 +115,7 @@ describe('objectui#11022 — the grade\'s pins, in the widget slot', () => { describe('objectui#11022 — the content channels objectui#9256 refused stay refused on the strict face', () => { it.each(['children', 'body'])('`%s` on `metric-card` is refused with the by-name message', (key) => { - const issue = widgetUnion(dashboard({ type: 'metric-card', value: 42, [key]: [] })); + const issue = widgetUnion(StrictAnyComponentSchema, dashboard({ type: 'metric-card', value: 42, [key]: [] })); expect(issue?.code).toBe('invalid_union'); const refusal = (issue?.errors ?? []).flat().find((i) => i.path.join('.') === key && i.code === 'invalid_type'); expect(refusal?.message).toContain('`metric-card` reads NEITHER content channel'); @@ -116,9 +123,9 @@ describe('objectui#11022 — the content channels objectui#9256 refused stay ref }); }); -/* ── 3. admitted by key, judged as the tolerant face judges ──────────────── */ +/* ── 3. each input is judged by its member, on both faces ────────────────── */ -describe('objectui#11022 — the registered inputs are admitted by KEY; the strict face judges no value the tolerant face does not', () => { +describe('objectui#11467 — the registered inputs are MEMBERS: each value is judged by its declared type on both faces', () => { const FULL = { type: 'metric-card', title: 'Revenue', @@ -134,27 +141,60 @@ describe('objectui#11022 — the registered inputs are admitted by KEY; the stri expect(issuesOf(AnyComponentSchema, dashboard(FULL))).toBeNull(); }); - it('a value the tolerant face admits unjudged is admitted unjudged — the strict face is the tolerant one minus undeclared KEYS', () => { - // `trend` outside the registration's enum and a numeric `title`: the - // catchall judges both on the tolerant face, so the strict face does too. - const loose = { type: 'metric-card', value: 42, trend: 'sideways', title: 7 }; - expect(issuesOf(AnyComponentSchema, dashboard(loose))).toBeNull(); - expect(issuesOf(StrictAnyComponentSchema, dashboard(loose))).toBeNull(); + it('the members admit what `MetricCard` renders: a numeric `value`, an inline locale-map `title`, each `trend`', () => { + for (const card of [ + { type: 'metric-card', value: 12480 }, + { type: 'metric-card', value: '1', title: { en: 'Revenue', 'zh-CN': '收入' } }, + ...(['up', 'down', 'neutral'] as const).map((trend) => ({ type: 'metric-card', value: '1', trend, trendValue: '1%' })), + ]) { + expect(issuesOf(StrictAnyComponentSchema, dashboard(card))).toBeNull(); + expect(issuesOf(AnyComponentSchema, dashboard(card))).toBeNull(); + } + }); + + // Each value is outside its member. Until objectui#11467 BOTH faces accepted + // every row: the tolerant face through the catchall, the strict face through + // the side table, which judged the value by that same catchall. + it.each([ + ['trend', { trend: 'sideways' }], + ['title', { title: 7 }], + ['icon', { icon: 5 }], + ['trendValue', { trendValue: 3 }], + ['value', { value: { amount: 1 } }], + ] as const)('`%s` with a value outside its member is refused on both faces, at that key', (key, extra) => { + const doc = dashboard({ type: 'metric-card', value: 42, ...extra }); + for (const face of [AnyComponentSchema, StrictAnyComponentSchema]) { + const issue = widgetUnion(face, doc); + expect(issue?.code).toBe('invalid_union'); + // The COMPONENT arm's own verdict — the union's first arm. + const [slotArm] = issue?.errors ?? []; + expect(slotArm?.map((i) => i.path.join('.'))).toContain(key); + } + }); + + it('`value` is required — the registration\'s one `required` input — on both faces', () => { + const doc = dashboard({ type: 'metric-card', title: 'Revenue', icon: 'users' }); + for (const face of [AnyComponentSchema, StrictAnyComponentSchema]) { + const [slotArm] = widgetUnion(face, doc)?.errors ?? []; + expect(slotArm).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['value'] })]), + ); + } }); it('a registered input the base already declares keeps the base member\'s judgment', () => { // `description` is a `BaseSchema` member (a string or an inline locale map); - // its declared type still refuses a number on BOTH faces, so the record did - // not replace the member with the catchall. + // its declared type refuses a number on BOTH faces, and the arm does not + // restate it. const bad = dashboard({ type: 'metric-card', value: 1, description: 42 }); expect(issuesOf(AnyComponentSchema, bad)).not.toBeNull(); expect(issuesOf(StrictAnyComponentSchema, bad)).not.toBeNull(); }); }); -/* ── 4. the tolerant face does not move ──────────────────────────────────── */ +/* ── 4. the arm's shape ──────────────────────────────────────────────────── */ -describe('objectui#11022 — the tolerant slot arm is untouched by the record', () => { +describe('objectui#11467 — the inputs are members of the tolerant slot arm, which is still a passthrough', () => { /** The slot's component-node arm, read off the exported tolerant schema. */ const tolerantSlotArm = (): z.ZodType => { const widgets = internals(DashboardComponentSchema)._zod.def.shape?.widgets; @@ -164,64 +204,23 @@ describe('objectui#11022 — the tolerant slot arm is untouched by the record', return (internals(element)._zod.def.options ?? [])[0] as z.ZodType; }; - it('its shape declares none of the registered inputs the base does not — the record is a side table', () => { + it('its shape is the base\'s, plus `layout` and the five registered inputs the base lacks', () => { const arm = tolerantSlotArm(); // The arm overrides `type`, `body` and `children`, all three base members, - // and adds ONE member the base lacks: `layout`, the spec's widget position - // (objectui#11070 round 11), a widget key and not a registered input. So its - // key set IS the base's plus `layout`: no registered input became a member. + // and adds `layout` — the spec's widget position (objectui#11070 round 11), a + // widget key and not a registered input — and the five inputs. + const INPUTS = ['title', 'value', 'icon', 'trend', 'trendValue']; const baseKeys = Object.keys(internals(BaseSchema)._zod.def.shape ?? {}).sort(); const armKeys = Object.keys(internals(arm)._zod.def.shape ?? {}).sort(); - expect(armKeys).toEqual([...baseKeys, 'layout'].sort()); - for (const input of ['title', 'value', 'icon', 'trend', 'trendValue']) expect(armKeys).not.toContain(input); - // Non-vacuity: this IS the arm that carries the record. - expect(registeredInputsOf(arm)).toContain('value'); + for (const input of INPUTS) expect(baseKeys).not.toContain(input); + expect(armKeys).toEqual([...baseKeys, 'layout', ...INPUTS].sort()); + // The sixth input is the base's member. + expect(baseKeys).toContain('description'); }); it('its catchall is still the passthrough, so an undeclared key is still accepted there', () => { const arm = tolerantSlotArm(); expect(internals(internals(arm)._zod.def.catchall as z.ZodType)._zod.def.type).toBe('unknown'); - expect(issuesOf(AnyComponentSchema, dashboard({ type: 'metric-card', bogus: 1 }))).toBeNull(); - }); -}); - -/* ── 5. the derivation, apart from the card ──────────────────────────────── */ - -describe('objectui#11022 — the rule lives in the derivation: a hand-built passthrough node', () => { - const node = () => z.object({ type: z.literal('probe'), n: z.number().optional() }).passthrough(); - - it('WITHOUT a record, the strict twin refuses the would-be input — the pre-repair behaviour', () => { - const strict = deriveStrictAuthoringSchema(node()); - expect(issuesOf(strict, { type: 'probe', a: 1 })).toEqual([ - expect.objectContaining({ code: 'unrecognized_keys', keys: ['a'] }), - ]); - }); - - it('WITH a record, the named key is admitted with any value the catchall admits, and any other key is refused by name', () => { - const strict = deriveStrictAuthoringSchema(declareRegisteredInputs(node(), ['a'])); - expect(issuesOf(strict, { type: 'probe', a: { anything: true } })).toBeNull(); - expect(issuesOf(strict, { type: 'probe' })).toBeNull(); - expect(issuesOf(strict, { type: 'probe', a: 1, b: 2 })).toEqual([ - expect.objectContaining({ code: 'unrecognized_keys', keys: ['b'] }), - ]); - }); - - it('a recorded name the shape already declares keeps its declared member', () => { - const strict = deriveStrictAuthoringSchema(declareRegisteredInputs(node(), ['n'])); - expect(issuesOf(strict, { type: 'probe', n: 'not a number' })).toEqual([ - expect.objectContaining({ code: 'invalid_type', path: ['n'] }), - ]); - }); - - it('the source node is left as it was — the record moves the twin, not the tolerant node', () => { - const source = declareRegisteredInputs(node(), ['a']); - deriveStrictAuthoringSchema(source); - expect(Object.keys(internals(source)._zod.def.shape ?? {}).sort()).toEqual(['n', 'type']); - expect(issuesOf(source, { type: 'probe', a: 1, b: 2 })).toBeNull(); - }); - - it('⛔ a node with no catchall cannot carry a record — there is no tolerant judgment to copy', () => { - expect(() => declareRegisteredInputs(z.object({ type: z.literal('probe') }), ['a'])).toThrow(TypeError); - expect(() => declareRegisteredInputs(z.string(), ['a'])).toThrow(TypeError); + expect(issuesOf(AnyComponentSchema, dashboard({ type: 'metric-card', value: 42, bogus: 1 }))).toBeNull(); }); }); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 169f3fc692..677d036102 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -19,6 +19,7 @@ import type { DashboardWidget as SpecDashboardWidget, GlobalFilter as SpecGlobalFilter, Dashboard as SpecDashboard, + I18nLabel, ViewFilterOperator as SpecViewFilterOperator, } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; @@ -2130,10 +2131,11 @@ export interface FloatingChatbotConfig { * `trendValue` for `metric-card`, declared as registry `inputs` at * `plugin-dashboard/src/index.tsx`). Those props are NOT widget keys and must * not be added to {@link DashboardWidgetSchema}; a member of this list is - * validated as a component node against objectui's own passthrough - * `BaseSchema`, which is what keeps them. On the TypeScript face that node is - * {@link DashboardWidgetSlotComponentSchema}, the component arm — first, as - * in the Zod twin — of `DashboardComponentSchema.widgets` (objectui#7952). + * validated as a component node against objectui's own component schema — + * `BaseSchema` plus the registered inputs it declares (objectui#11467). On the + * TypeScript face that node is {@link DashboardWidgetSlotComponentSchema}, the + * component arm — first, as in the Zod twin — of + * `DashboardComponentSchema.widgets` (objectui#7952). * * ⛔ CLOSED on purpose. The ruling's triage block named an open * "extension allowed" hatch as the thing to avoid: an open hatch re-creates @@ -2142,10 +2144,11 @@ export interface FloatingChatbotConfig { * with a registration behind it — `examples/schema-catalog/test/ * plugin-dashboard-component-schema.test.ts` is the standing gate, and * `__tests__/report-chart-query-spec-parity.test.ts` pins the closure. The - * member also needs its row of registered input names in the zod slot arm's - * `DASHBOARD_WIDGET_SLOT_REGISTERED_INPUTS` (`zod/complex.zod.ts`), or the - * strict authoring face refuses its props (objectui#11022); `tsc` refuses the - * build without the row. + * member also needs an arm that declares ITS registered inputs: the one + * component arm declares `metric-card`'s (objectui#11467), so a second member + * would inherit that card's props. The Zod arm's `type` is checked against + * this exact one-member list (`zod/complex.zod.ts`), so `tsc` refuses the + * build until the arm is split per member. */ export const DASHBOARD_COMPONENT_WIDGET_TYPES = ['metric-card'] as const; @@ -2253,8 +2256,19 @@ export interface DashboardWidgetSchema // breakpoint `columns`, and `ResponsiveGrid` since objectui#11441 retired its // `responsive-grid` node key) — not this key. // Pinned by `__tests__/report-chart-query-spec-parity.test.ts`. - /** Component schema (legacy format) — objectui-only, no spec counterpart. */ - component?: SchemaNode; + /** + * Component schema (legacy format) — objectui-only, no spec counterpart. + * + * Either arm of this union is a node the dashboard renders through the + * registry (`toDashboardNodeType`, then `SchemaRenderer`). The first arm is + * the widget slot's component arm, {@link DashboardWidgetSlotComponentSchema} + * (objectui#11467): a `metric-card` written here is the same node the slot + * holds directly, so its registered inputs are typed by the same members. + * The second is any other registered component node, which a `custom` + * widget's `component` carries. The Zod twin (`zod/complex.zod.ts` + * `DashboardWidgetSchema.component`) is the same two arms. + */ + component?: DashboardWidgetSlotComponentSchema | SchemaNode; layout?: DashboardWidgetLayout; /** * Widget visualization type (spec shorthand format), or an objectui component @@ -2329,14 +2343,37 @@ export interface DashboardWidgetSchema * the TypeScript face. Twin of `zod/complex.zod.ts` * `DashboardWidgetSlotComponentSchema`, spelled the same way that arm is: * `BaseSchema` plus a `type` narrowed to the CLOSED component set - * ({@link DASHBOARD_COMPONENT_WIDGET_TYPES}) and the spec's widget `layout`. - * - * `BaseSchema`'s `[key: string]: any` is the passthrough. `value` / `icon` / - * `trend` / `trendValue` are `MetricCard`'s registry `inputs`, not widget keys: - * they reach the compiler through the index signature here and MUST NOT be - * declared on {@link DashboardWidgetSchema} — the compiler's own TS2561 - * suggestion ("Did you mean to write 'values'?") points at exactly that - * forbidden repair. + * ({@link DASHBOARD_COMPONENT_WIDGET_TYPES}), the spec's widget `layout`, and + * the inputs the `metric-card` registration declares. + * + * ## The registered inputs are declared members (objectui#11467) + * + * `title`, `value`, `icon`, `trend` and `trendValue` are `MetricCard`'s + * registry `inputs` (`plugin-dashboard:metric-card`), and `MetricCard` reads + * every one of them — measured twice: a type-checker census of the props + * `MetricCard` destructures and renders, and a runtime probe that removed one + * key at a time from a widget a real `DashboardRenderer` drew. The sixth + * input, `description`, is the `BaseSchema` member of the same type, so it is + * inherited, not restated. Until objectui#11467 the five reached the compiler + * only through `BaseSchema`'s `[key: string]: any`, so each was `any` here and + * the README's `metric-card` literals compiled on nothing but that signature, + * which objectui#8347 removes. + * + * Each member's type is the prop `MetricCard` declares for it, and `trend`'s + * vocabulary is the registration's enum. This package depends on neither, so + * both are held to these members by the registering package's own test, + * against `MetricCardProps` and the live registration: + * `metricCardRegisteredInputsStrictFace-11022.test.ts` in + * `@object-ui/plugin-dashboard`'s `__tests__`. + * + * They are component props, not widget keys, and MUST NOT be declared on + * {@link DashboardWidgetSchema} — the compiler's own TS2561 suggestion ("Did + * you mean to write 'values'?") points at exactly that forbidden repair. + * `title` is the one name both arms declare: the widget's heading there, the + * card's heading here. + * + * Every other key still reaches this arm through `BaseSchema`'s index + * signature while it stands; the Zod twin keeps the same `.passthrough()`. * * Until objectui#7952 this arm existed on the Zod face only: * `DashboardComponentSchema.widgets` was `DashboardWidgetSchema[]`, so the @@ -2377,6 +2414,27 @@ export interface DashboardWidgetSlotComponentSchema extends BaseSchema { * (`zod/complex.zod.ts`) declares the same spec member. */ layout?: SpecDashboardWidget['layout']; + /** + * The card's heading: a plain string or the spec's inline per-locale map + * (`I18nLabel`), which `MetricCard` resolves for the active language. + */ + title?: string | I18nLabel; + /** + * 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. + */ + value: string | number; + /** A Lucide icon name, drawn beside the heading. */ + icon?: string; + /** + * The trend's direction, drawn as the arrow and colour in front of + * `trendValue` — the registration's enum. Drawn only together with + * `trendValue`. + */ + trend?: 'up' | 'down' | 'neutral'; + /** The trend's text, such as `+12%`. Drawn only together with `trend`. */ + trendValue?: string; /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `metric-card` reads NEITHER * content channel; see `children` below for the measurement. diff --git a/packages/types/src/strict-authoring-face.ts b/packages/types/src/strict-authoring-face.ts index 21824b85d5..5b02aeba30 100644 --- a/packages/types/src/strict-authoring-face.ts +++ b/packages/types/src/strict-authoring-face.ts @@ -80,19 +80,14 @@ * (`rebindNestedJudgments`), so a nested component is judged strictly here and * tolerantly there, like every other node. * - * ## Registered inputs: where "declared" is the registration, not the shape + * ## "Declared" means the shape declares it — for every node * - * One node on the face spells its props with its CATCHALL rather than its - * shape, by ruling: the widget-slot component node (`metric-card`, - * objectstack#8593) is `BaseSchema` plus a closed `type`, and its props are the - * component's registered `inputs`, admitted by `.passthrough()`. Closing that - * catchall alone refused the node's own inputs — every `metric-card` carrying - * `value` (objectui#11022). Such a node records its registered input names - * through `declareRegisteredInputs` (`./zod/node-derivation.ts`), and the - * `object` arm below admits exactly those names, each judged by the node's own - * catchall, before closing the object. So on this face a key is "declared" when - * the shape declares it OR the node's registration does; any other key is - * refused by name, as everywhere else. The tolerant node is not touched. + * objectui#11022 once made one exception: the widget-slot `metric-card` node + * spelled its props with its catchall, so this face admitted the input names + * its registration declared from a side table, each judged by the catchall. + * objectui#11467 declared those inputs as members of the node, so the side + * table and its arm below are gone: a key is declared when the shape declares + * it, and any other key is refused by name. * * ## The recursion point, and why this card waited for it * @@ -123,7 +118,6 @@ import { cloneWithDef, internals, isZodType, - registeredInputsOf, type WalkableDef, } from './zod/node-derivation.js'; import { isNestedComponentJudgment, rebindNestedComponentJudgment } from './zod/nested-component-walk.js'; @@ -259,19 +253,6 @@ function createStrictWalker(options: DeriveStrictAuthoringOptions = {}): An SDUI dashboard COMPONENT node validates against objectui's OWN + * > component schema; [...] `metric-card` joins objectui's own CLOSED + * > component enum as an explicitly allowed objectui extension. + * + * Its body is `BaseSchema` (passthrough) plus the component's registry + * `inputs`, declared below: `title` / `value` / `icon` / `trend` / + * `trendValue` are the component's props, not widget keys, and a `.strict()` + * {@link DashboardWidgetSchema} must never see them. The `type` override is + * what makes this arm reachable ONLY for component nodes: for + * 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 + * exported: the routing is an internal property of the widget slot, not new + * authoring surface. + * + * `children` and `body` are refused by name (objectui#9256): `MetricCard` + * reads neither content channel, as its TypeScript twin's `?: never` pair + * 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 + * with each arm's issues under `errors` — this arm's message among them, which + * `objectui validate` prints as one arm of two. + * + * ## The registered inputs are MEMBERS (objectui#11467) + * + * `metric-card`'s registration (`plugin-dashboard:metric-card`) declares six + * `inputs`, and `MetricCard` reads all six. `description` is the `BaseSchema` + * member of the same type; the other five are declared here, each with the + * type of the prop `MetricCard` declares for it (`trend` takes the + * registration's enum), as on the TypeScript twin. Until objectui#11467 the + * five were admitted by the catchall and judged by nothing on this face; the + * 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 + * 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 + * test, `metricCardRegisteredInputsStrictFace-11022.test.ts` in + * `@object-ui/plugin-dashboard`'s `__tests__`. Any other key is still kept by + * the passthrough here and refused by name on the strict face. + * + * `type` checks the closed set against its one member, so a second member is + * a compile error until this arm is split per component: a second component + * type has its own inputs, and this arm declares `metric-card`'s. + * + * `layout` is the one WIDGET key this arm declares (objectui#11070 round 11), + * and it is the spec's member BY REFERENCE: `@objectstack/spec`'s + * `DashboardWidgetSchema.shape.layout`, the same member the strict widget arm + * below carries. It is not a registry input — no registration declares it and + * `MetricCard` never reads it — it is the slot's position, which the dashboard + * reads off every entry of `widgets[]`: `DashboardGridLayout` places each entry + * by it, and its Save Layout (`mergeLayoutIntoSchema`) writes it back onto + * EVERY entry, a component node included. Left to the passthrough, the strict + * face closed it out (`unrecognized_keys: ['layout']`), so a dashboard the + * grid had saved was refused there. Declared, both faces judge it by the + * spec's shape — four numbers, no other key — so a malformed one is refused + * on the tolerant face too, as it already was on the widget arm. + */ +const DashboardWidgetSlotComponentSchema = BaseSchema.extend({ + type: z.enum(DASHBOARD_COMPONENT_WIDGET_TYPES satisfies readonly ['metric-card']) + .describe('objectui component type legal in a widget slot (closed set)'), + layout: stripImportedDefaults(SpecDashboardWidgetSchema).shape.layout, + // objectui#11467: `metric-card`'s registered inputs, typed as `MetricCard`'s props. + title: stripImportedDefaults(I18nLabelSchema).optional().describe('Card heading (plain string or inline locale map)'), + value: z.union([z.string(), z.number()]).describe('The figure the card shows'), + icon: z.string().optional().describe('Lucide icon name'), + trend: z.enum(['up', 'down', 'neutral']).optional().describe('Trend direction, drawn with trendValue'), + trendValue: z.string().optional().describe('Trend text, drawn with trend'), + // objectui#9256: `MetricCard` reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER, as on the TypeScript twin. + body: retirementTombstone(METRIC_CARD_NEITHER_CHANNEL), + children: retirementTombstone(METRIC_CARD_NEITHER_CHANNEL), +}); + /** * Dashboard Widget Schema — DERIVED from `@objectstack/spec/ui` * (objectstack#4115): every spec key flows in **by reference** via @@ -1251,10 +1348,11 @@ export const DashboardWidgetTypeSchema = z.enum([ * removal messages rather than degrading to a generic unknown-key error. * * ⚠️ This schema owns spec-family widgets only. A component node in the widget - * slot (`type: 'metric-card'`) is routed to passthrough `BaseSchema` by - * {@link DashboardWidgetSlotComponentSchema} 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). + * slot (`type: 'metric-card'`) is routed to + * {@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). * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ @@ -1285,7 +1383,16 @@ export const DashboardWidgetSchema = specFieldsExcept(stripImportedDefaults(Spec // in this slot (`component: "text"`) was accepted through `SchemaNodeSchema` and is // refused now. No corpus document, fixture or pin writes one, and the key is // declared "Widget Component (legacy format)" — a node, never a scalar. - component: BaseSchema.optional().describe('Widget Component (legacy format)'), + // + // objectui#11467: the slot's component arm comes FIRST, as on the TypeScript twin + // (`component?: DashboardWidgetSlotComponentSchema | SchemaNode`). A `metric-card` + // here is the same node the slot holds directly, and the README's legacy-envelope + // example writes one with its registered inputs. With `BaseSchema` alone the strict + // authoring face refused all five as unrecognized (`component.value` and the rest, + // measured at `d93e53f5`); the arm admits them, judged by their members. Every + // other component node (a `custom` widget's) still parses through `BaseSchema`. + component: z.union([DashboardWidgetSlotComponentSchema, BaseSchema]).optional() + .describe('Widget Component (legacy format)'), }).strict() // ⭐ THE SPEC'S OBJECT-LEVEL CHECKS, re-attached (objectui#7715, ruling B1; objectui#11073). // @@ -1303,108 +1410,6 @@ export const DashboardWidgetSchema = specFieldsExcept(stripImportedDefaults(Spec .superRefine(checkDashboardWidgetStageOrder) .superRefine(checkDashboardWidgetMetricMeasureArity); -/** - * objectui#9256 (public-block slice): ONE refusal string for both content channels of the - * widget-slot `metric-card` node. Its own string rather than `neitherContentChannelGuidance`, because - * that builder says the parser tier's `not-a-container` warning noticed the key, and in a widget slot - * it does not: that tier walks `children`, never `widgets`. - */ -const METRIC_CARD_NEITHER_CHANNEL = - 'REFUSED (objectui#9256, ADR-0049) — `metric-card` reads NEITHER content channel: measured with the ' - + 'TypeScript type checker over one program per workspace package on a BUILT tree, no renderer read consumes ' - + '`body` or `children` for this node — `DashboardRenderer` hands a `metric-card` widget to `SchemaRenderer` ' - + 'as the widget\'s own keys, and the registration (`plugin-dashboard:metric-card`) renders `MetricCard`, ' - + 'which reads named props and forwards the rest to its `Card` as DOM attributes — and `SchemaRenderer` ' - + 'strips both out of the props bag it spreads. An ' - + 'authored value therefore rendered NOTHING — no render-time error or warning and no element — and in a ' - + 'widget slot nothing else noticed it: the parser tier\'s `not-a-container` warning (objectui#9910) walks ' - + '`children`, never `widgets`. What it renders instead: one KPI card — `title`, `value`, `icon`, ' - + '`trend` / `trendValue` and `description`.'; - -/** - * objectui#11022: the keys each widget-slot component type's REGISTRATION - * declares as `inputs` — the props {@link DashboardWidgetSlotComponentSchema}'s - * passthrough admits on this face, recorded so the strict authoring face admits - * them too instead of closing them out with the catchall. - * - * One row per member of the closed `DASHBOARD_COMPONENT_WIDGET_TYPES`, and the - * `satisfies` makes that a compile error rather than a convention: a member - * added without its row does not build. Names only — the strict face judges - * each one by the arm's catchall, exactly as the tolerant face does, so a row - * moves which keys are admitted and never how a value is judged. - * - * ⚠️ Transcribed from the registration, which lives in the registering package - * (`@object-ui/plugin-dashboard`; this package depends on no registry). Its - * parity is MEASURED there, against the live `ComponentRegistry`, in both - * directions — every registered input admitted, and no admitted key that no - * registration declares: `metricCardRegisteredInputsStrictFace-11022.test.ts` - * in that package's `__tests__`. ⛔ Do not add a key here that the - * registration does not declare, and do not hand-edit this row without the - * registration moving first. - */ -const DASHBOARD_WIDGET_SLOT_REGISTERED_INPUTS = { - 'metric-card': ['title', 'value', 'icon', 'trend', 'trendValue', 'description'], -} as const satisfies Record; - -/** - * A COMPONENT node sitting directly in a dashboard's widget slot — the - * `metric-card` extension the 2026-08-14 ruling (objectstack#8593) admits: - * - * > An SDUI dashboard COMPONENT node validates against objectui's OWN - * > component schema; [...] `metric-card` joins objectui's own CLOSED - * > component enum as an explicitly allowed objectui extension. - * - * Its body is `BaseSchema` (passthrough): `value` / `icon` / `trend` / - * `trendValue` are the component's registry `inputs`, not widget keys, and a - * `.strict()` {@link DashboardWidgetSchema} must never see them. The `type` - * override is what makes this arm reachable ONLY for component nodes: for - * 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 - * exported: the routing is an internal property of the widget slot, not new - * authoring surface. - * - * `children` and `body` are refused by name (objectui#9256): `MetricCard` - * reads neither content channel, as its TypeScript twin's `?: never` pair - * 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 - * with each arm's issues under `errors` — this arm's message among them, which - * `objectui validate` prints as one arm of two. - * - * The strict authoring face (objectui#11022): the passthrough that admits the - * registry `inputs` here is exactly what that face closes, so this arm RECORDS - * the input names ({@link DASHBOARD_WIDGET_SLOT_REGISTERED_INPUTS}, through - * `declareRegisteredInputs`) and the strict walker admits them — each judged by - * the catchall, as on this face — while still refusing any key no registration - * declares. The record is a side table keyed by this node: this arm's shape, - * catchall and accept set are what they were. - * - * `layout` is the one WIDGET key this arm declares (objectui#11070 round 11), - * and it is the spec's member BY REFERENCE: `@objectstack/spec`'s - * `DashboardWidgetSchema.shape.layout`, the same member the strict widget arm - * above carries. It is not a registry input — no registration declares it and - * `MetricCard` never reads it — it is the slot's position, which the dashboard - * reads off every entry of `widgets[]`: `DashboardGridLayout` places each entry - * by it, and its Save Layout (`mergeLayoutIntoSchema`) writes it back onto - * EVERY entry, a component node included. Left to the passthrough, the strict - * face closed it out (`unrecognized_keys: ['layout']`), so a dashboard the - * grid had saved was refused there. Declared, both faces judge it by the - * spec's shape — four numbers, no other key — so a malformed one is refused - * on the tolerant face too, as it already was on the widget arm. - */ -const DashboardWidgetSlotComponentSchema = declareRegisteredInputs(BaseSchema.extend({ - type: z.enum(DASHBOARD_COMPONENT_WIDGET_TYPES) - .describe('objectui component type legal in a widget slot (closed set)'), - layout: stripImportedDefaults(SpecDashboardWidgetSchema).shape.layout, - // objectui#9256: `MetricCard` reads NEITHER content channel, so both are refused by name, each - // kept a MEMBER, as on the TypeScript twin. - body: retirementTombstone(METRIC_CARD_NEITHER_CHANNEL), - children: retirementTombstone(METRIC_CARD_NEITHER_CHANNEL), -}), Object.values(DASHBOARD_WIDGET_SLOT_REGISTERED_INPUTS).flat()); - /** * Global Filter Schema — a dashboard-level filter definition: `@objectstack/spec/ui`'s * `GlobalFilterSchema` BY REFERENCE, whole (objectui#7759 group A). Every key, @@ -1515,8 +1520,9 @@ export const DashboardComponentSchema = BaseSchema.extend(SpecDashboardFields.sh columns: z.number().optional().describe('Number of columns'), gap: z.number().optional().describe('Grid gap'), // Routed slot (objectui#6002): a component node (`metric-card`) is owned by - // passthrough BaseSchema per the 2026-08-14 ruling; every other widget is - // the `.strict()` spec-derived schema. Component arm first — it matches + // objectui's own component schema per the 2026-08-14 ruling — passthrough + // 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. // objectui#11073: the strict spec-derived arm is closed where it meets this union, so a diff --git a/packages/types/src/zod/node-derivation.ts b/packages/types/src/zod/node-derivation.ts index 5724c7a448..fc65a48357 100644 --- a/packages/types/src/zod/node-derivation.ts +++ b/packages/types/src/zod/node-derivation.ts @@ -275,66 +275,6 @@ export const cloneWithDef = (schema: z.ZodType, patch: Partial): z. return carryRegistryMeta(schema, new Ctor({ ...internals(schema)._zod.def, ...patch })); }; -/** - * REGISTERED INPUTS — the keys a component's REGISTRATION declares as `inputs`, - * recorded on the passthrough object that carries them (objectui#11022). - * - * A node whose props the ruling routes to `BaseSchema`'s `.passthrough()` - * rather than to members of its own — the widget-slot component node, - * `metric-card` (objectstack#8593) — has an accept set the tolerant face spells - * with its catchall, not with its shape: `value` is admitted because the - * catchall admits every key. The strict authoring face closes that catchall, so - * without this record it refused the node's own registered inputs as - * unrecognized — every correctly authored `metric-card` that carried `value`. - * - * A record here is what the strict walker reads instead: it admits each named - * key the shape does not already declare, judged by the object's OWN catchall - * (the judgment the tolerant face gave it), and still closes the object, so a - * key no registration declares is refused by name. The tolerant object is not - * touched — not its shape, not its catchall, not its registry metadata — so the - * rendering face's accept set, its output and its inferred type do not move. - * - * ⛔ A plain `WeakMap` keyed by NODE IDENTITY, and ⛔ not `.meta()` / - * `z.globalRegistry`: a registry entry is published by `z.toJSONSchema` and - * inherited down zod's `clone()` parent chain, and this record is neither a - * description of the node nor something a copy of it should claim. - * - * ⚠️ The names are declared where the node is (`@object-ui/types` has no - * dependency on the registry that holds the registration). Their parity with - * the registration is measured by the registering package's own test, which - * reads the live `ComponentRegistry` — see the arm that records them. - */ -const REGISTERED_INPUTS = new WeakMap(); - -/** - * Record the registered input names of a PASSTHROUGH object node, for the - * strict authoring face to admit (see {@link REGISTERED_INPUTS}). Returns the - * node unchanged, so it can wrap a declaration in place. - * - * ⛔ Throws on a node that is not an object with a catchall: on a stripping - * object the tolerant face DROPS an undeclared key, so there is no tolerant - * judgment for the strict face to copy, and admitting the key there would make - * the strict face keep what the tolerant one throws away. - */ -export const declareRegisteredInputs = (schema: T, names: readonly string[]): T => { - const def = internals(schema)._zod.def; - if (def.type !== 'object' || def.catchall === undefined) { - throw new TypeError( - `declareRegisteredInputs: registered inputs can only be recorded on a passthrough object node (got \`${def.type}\`` - + `${def.type === 'object' ? ' with no catchall' : ''}). The strict face admits them with the catchall's own judgment, ` - + 'and a node without one has no judgment to copy.', - ); - } - REGISTERED_INPUTS.set(schema, Object.freeze([...names])); - return schema; -}; - -/** - * The registered input names recorded on this exact node, or `undefined` when - * none are. Identity-keyed: a clone or a wrapper of the node carries none. - */ -export const registeredInputsOf = (schema: z.ZodType): readonly string[] | undefined => REGISTERED_INPUTS.get(schema); - /** * TERMINAL UNKNOWN-KEY REFUSAL — where a strict object meets a union * (objectui#11073, the seat's Q3 ruling: 「恢复不变量」, never report a registered From 5c7fef6ee312cc9a708a73d4a0de10659b4d539b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:18:07 +0000 Subject: [PATCH 2/5] test(types,plugin-dashboard): measure the arm's declared inputs on a valued card; `value` is refused at its key when absent (objectui#11467) Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- ...CardRegisteredInputsStrictFace-11022.test.ts | 17 ++++++++++++----- ...-widget-slot-registered-inputs-11022.test.ts | 6 +++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts index 024c5be9ff..9263f3d9ef 100644 --- a/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts +++ b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts @@ -126,7 +126,9 @@ describe('objectui#11022 — widget-slot registrations vs the strict authoring f it.each(registeredInputs(type).map((input) => [input.name, input] as const))( 'UNDER-admission: its registered input `%s` parses in the widget slot on the strict face', (name, input) => { - const result = StrictAnyComponentSchema.safeParse(dashboard({ type, [name]: sampleFor(input) })); + // On a node that carries the REQUIRED inputs (`value`, since objectui#11467), so + // each optional input is measured on a card the arm otherwise accepts. + const result = StrictAnyComponentSchema.safeParse(dashboard({ ...nodeOf(type), [name]: sampleFor(input) })); const issues = result.success ? [] : (result.error.issues as unknown as Issue[]); expect(unrecognized(issues), `the strict face refuses the registered input \`${name}\``).not.toContain(name); expect(result.success).toBe(true); @@ -191,7 +193,6 @@ describe('objectui#11022 — widget-slot registrations vs the strict authoring f expect(sameTypes).toBe(true); }); }); -}); /** Every issue in the refusal, union arms flattened, each path made absolute to the widget. */ const flatIssues = (issues: readonly Issue[], prefix: PropertyKey[] = []): Issue[] => @@ -200,15 +201,21 @@ const flatIssues = (issues: readonly Issue[], prefix: PropertyKey[] = []): Issue ...(issue.errors ?? []).flatMap((group) => flatIssues(group, [...prefix, ...issue.path])), ]); -/** A node of `type` carrying each REQUIRED registered input, so only `layout` is under test. */ +/** + * A node of `type` carrying each REQUIRED registered input, so only `layout` is under test. + * The inputs are read from the live registration, which the compiler cannot see, so the + * cast names what they supply: the arm's required `value` (objectui#11467), which the + * population test above pins as the registration's `required` input. Both faces then + * judge the node at runtime. + */ const nodeOf = (type: string): DashboardWidgetSlotComponentSchema => ({ type: type as DashboardWidgetSlotComponentSchema['type'], id: `kpi-${type}`, - ...Object.fromEntries( + ...(Object.fromEntries( registeredInputs(type) .filter((input) => (input as Input & { required?: boolean }).required) .map((input) => [input.name, sampleFor(input)]), - ), + ) as Pick), }); /** What Save Layout persists: the producer's merge of grid coordinates into the dashboard. */ 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 4077d352b6..a4f03e1238 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 @@ -176,9 +176,9 @@ describe('objectui#11467 — the registered inputs are MEMBERS: each value is ju const doc = dashboard({ type: 'metric-card', title: 'Revenue', icon: 'users' }); for (const face of [AnyComponentSchema, StrictAnyComponentSchema]) { const [slotArm] = widgetUnion(face, doc)?.errors ?? []; - expect(slotArm).toEqual( - expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['value'] })]), - ); + // The member is `string | number` — the same spelling as the statistic's + // `value` — so an absent one is refused at `value` by that union. + expect(slotArm?.map((i) => i.path.join('.'))).toEqual(['value']); } }); From 35a89136798bac58c580bf35ec5a78012f2b9d39 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:24:12 +0000 Subject: [PATCH 3/5] docs(types,plugin-dashboard): the metric-card arm's declared inputs in both READMEs and the mdx twin; changeset (objectui#11467) Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11467-metric-card-arm-inputs.md | 17 +++++++++ content/docs/plugins/plugin-dashboard.mdx | 26 ++++++++------ packages/plugin-dashboard/README.md | 36 +++++++++++++------ packages/types/README.md | 22 +++++++----- ...ard-widget-slot-component-arm-7952.test.ts | 6 ++++ 5 files changed, 77 insertions(+), 30 deletions(-) create mode 100644 .changeset/11467-metric-card-arm-inputs.md diff --git a/.changeset/11467-metric-card-arm-inputs.md b/.changeset/11467-metric-card-arm-inputs.md new file mode 100644 index 0000000000..395056cf03 --- /dev/null +++ b/.changeset/11467-metric-card-arm-inputs.md @@ -0,0 +1,17 @@ +--- +'@object-ui/types': minor +--- + +A `metric-card` in a dashboard's widget slot is checked against the props `MetricCard` renders, on the TypeScript face and in the validator (objectui#11467). This narrows a published accept set on both faces and widens the strict authoring face in one place. Both changes are stated below. `minor`, per this repository's version alignment; the narrowing is the breaking part. + +`DashboardWidgetSlotComponentSchema` is the widget slot's component arm. It used to type the card's props only through `BaseSchema`'s `[key: string]: any`, and its zod twin admitted them through `.passthrough()` and judged nothing. The strict face (`StrictAnyComponentSchema`) admitted their names from a side table and judged their values by that same catchall. + +- **What changed.** The arm declares `metric-card`'s registered inputs as members, with the types of the props `MetricCard` reads: `title` (a string or an inline per-locale map, `I18nLabel`), `value` (`string | number`, required, as the registration marks it), `icon` (`string`), `trend` (`'up' | 'down' | 'neutral'`, the registration's enum) and `trendValue` (`string`). The sixth input, `description`, was already a `BaseSchema` member. The zod twin declares the same members, so every face judges each value by its member. The widget's legacy `component` slot now takes the arm first and then any other node, on both faces: `component?: DashboardWidgetSlotComponentSchema | SchemaNode`. The zod twin is `z.union([arm, BaseSchema])`. +- **What now refuses that did not.** Directly in `widgets[]`, `AnyComponentSchema` / `DashboardComponentSchema` and `StrictAnyComponentSchema` now refuse a card with any of these: a `trend` outside the three; a `title`, `icon` or `trendValue` that is not a string (`title` may also be a per-locale map); a `value` that is neither a string nor a number; or `icon`, `trend` or `trendValue` with no `value`. On the TypeScript face each of these literals is now a compile error, and so is a card annotated `DashboardWidgetSlotComponentSchema` with no `value`. All of them used to pass through the index signature and the passthrough. Every `metric-card` in the schema catalog, the docs fences and the apps' authored documents still parses on both faces. This was re-measured with `scripts/measure-strict-authoring-face.mjs` at this change's base and on this change, and no document moved on the tolerant face. +- **What now parses that did not.** On the strict face, a `metric-card` in a widget's `component` slot carrying its inputs. `BaseSchema` alone in that slot refused them as unrecognized, including in the plugin-dashboard README's own legacy-envelope example. +- **What does not move.** Any key the arm does not declare is still kept by the tolerant face's passthrough and refused by name on the strict face. The widget arm, `DashboardWidgetSchema`, still declares none of `value`, `icon`, `trend` and `trendValue`. Any other component node in the `component` slot is judged by `BaseSchema` as before. +- **Read types.** `title` now reads as `string | I18nLabel` straight off a `widgets[]` entry instead of `any`, because both arms declare it. + +**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. diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 0cd060ed31..b17eb55239 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -76,17 +76,18 @@ A `metric-card` is a dashboard widget-slot entry: it sits directly in a and the dashboard renders it as the `plugin-dashboard:metric-card` node. The bare `metric-card` NODE key is retired (objectui#10859), so a `metric-card` outside a dashboard's `widgets[]` renders nothing but the renderer's -"Unknown component type" panel. Its keys: +"Unknown component type" panel. Its keys, as `DashboardWidgetSlotComponentSchema` +(`@object-ui/types`) declares them and `@object-ui/types/zod` judges them: ```plaintext { type: 'metric-card', - title: string, - value: string | number, + title?: string | I18nLabel, // plain string or inline per-locale map + value: string | number, // required icon?: string, // Lucide icon name - trend?: 'up' | 'down' | 'neutral', - trendValue?: string, - description?: string, + trend?: 'up' | 'down' | 'neutral', // drawn with trendValue + trendValue?: string, // drawn with trend + description?: string | I18nLabel, className?: string } ``` @@ -463,7 +464,8 @@ const dashboard: DashboardComponentSchema = { An entry of `widgets[]` is either a widget (`DashboardWidgetSchema`) or a component node placed directly in the slot (`DashboardWidgetSlotComponentSchema`). -The widget keys (`title`, `colorVariant`, `filter`, …) are declared on the widget +The component arm declares the card's registered inputs — the keys above. The +widget keys (`colorVariant`, `filter`, `dataset`, …) are declared on the widget arm, which takes them from the spec's `DashboardWidget` row, and on no member of the component arm. To read one off `widgets[]`, read it through `DashboardWidgetSchema`. The component arm is assignable to that type, so the @@ -479,10 +481,12 @@ const widgets: DashboardWidgetSchema[] = dashboard.widgets; const accents = widgets.map((w) => w.colorVariant ?? 'default'); ``` -`layout` is the exception: both arms declare it, the component arm by reference -to the spec's widget `layout`, because the editable grid's Save Layout writes it -onto every entry, a `metric-card` node included. It reads with the spec's type -off any `widgets[]` entry, the strict authoring face accepts it on a component +`title` and `layout` are the exceptions: both arms declare them. `title` is the +card's heading on the component arm, typed as `MetricCard` reads it. The +component arm declares `layout` by reference to the spec's widget `layout`, +because the editable grid's Save Layout writes it onto every entry, a +`metric-card` node included. `layout` reads with the spec's type off any +`widgets[]` entry, the strict authoring face accepts it on a component node, and a malformed one (not four numbers, or a key besides `x`, `y`, `w` and `h`) is refused on either arm. diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index c0aec6ee75..fad026afe5 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -563,7 +563,7 @@ The authored shape is typed by `@object-ui/types`: | --- | --- | | `DashboardComponentSchema` | the whole `type: 'dashboard'` node — `columns`, `gap`, `widgets`, `header`, `globalFilters`, `dateRange`, `refreshIntervalSeconds`, … | | `DashboardWidgetSchema` | one entry of `widgets[]` — the spec's `DashboardWidget` keys, plus objectui's own (`component`, `layout`, `options`, …) | -| `DashboardWidgetSlotComponentSchema` | the other kind of `widgets[]` entry — a component node placed directly in the slot, `type` one of the closed component set (`metric-card`); every other key is that component's own prop, except the spec's widget `layout`, which places the node | +| `DashboardWidgetSlotComponentSchema` | the other kind of `widgets[]` entry — a component node placed directly in the slot, `type` one of the closed component set (`metric-card`); its other keys are that component's own props, which it declares (`title`, `value`, `icon`, `trend`, `trendValue`, and `description` from `BaseSchema`), except the spec's widget `layout`, which places the node | | `DashboardWidgetLayout` | a widget's `{ x, y, w, h }` grid box | ```typescript @@ -592,7 +592,8 @@ const users: DashboardWidgetSchema = { }; // Component format: a registered component node in the widget's `component` -// slot. Its keys are that COMPONENT's props, not widget keys. +// slot. Its keys are that COMPONENT's props, not widget keys; a `metric-card` +// here is typed by the same arm as `kpi` below. const custom: DashboardWidgetSchema = { id: 'kpi_custom', component: { @@ -607,7 +608,8 @@ const custom: DashboardWidgetSchema = { // Component node directly in the slot — the shape every `metric-card` example // above uses. `type` is one of the closed component set; the other keys are -// the component's own props, carried by `BaseSchema`'s index signature. +// the component's own props, which `DashboardWidgetSlotComponentSchema` +// declares. const kpi: DashboardWidgetSlotComponentSchema = { type: 'metric-card', title: 'Revenue', @@ -633,13 +635,25 @@ package's export surface either. A `metric-card` node is typed as a COMPONENT node in both places it can appear: in a widget's `component` slot (`custom` above) and directly in `widgets[]` (`kpi` above, `DashboardWidgetSlotComponentSchema` — the component arm of `DashboardComponentSchema['widgets']`, first in the -declaration as in the zod schema's two-arm slot). Its keys are checked as -`BaseSchema` keys either way, -never as widget keys. +declaration as in the zod schema's two-arm slot). Either way its keys are +checked against that arm, which declares the card's registered inputs, never +as widget keys: + +| Key | Type | +| --- | --- | +| `value` | `string \| number` — required | +| `title` | `string` or an inline per-locale map (`I18nLabel`) | +| `icon` | `string` — a Lucide icon name | +| `trend` | `'up' \| 'down' \| 'neutral'` — drawn with `trendValue` | +| `trendValue` | `string` — drawn with `trend` | +| `description` | `string` or `I18nLabel`, from `BaseSchema` | + +`@object-ui/types/zod` judges the same members, so `objectui validate` refuses +a `trend` outside the three, or a `value` that is neither a string nor a number. ### Reading a widget key off `widgets[]` -The widget keys (`title`, `colorVariant`, `filter`, …) are declared on the +The widget keys (`colorVariant`, `filter`, `dataset`, …) are declared on the widget arm, `DashboardWidgetSchema`, which takes them from the spec's `DashboardWidget` row. The component arm declares none of them. Read straight off a `widgets[]` entry, such a key is typed `any`, supplied by the component @@ -657,9 +671,11 @@ const widgets: DashboardWidgetSchema[] = dashboard.widgets; const accents = widgets.map((w) => w.colorVariant ?? 'default'); ``` -`layout` is the one widget key both arms declare. The component arm takes the -spec's widget `layout` by reference, because Save Layout writes it onto every -entry of `widgets[]`, a `metric-card` node included (see +`title` and `layout` are the two widget keys both arms declare. `title` is the +card's heading on the component arm, typed as `MetricCard` reads it, so it reads +as a string or an `I18nLabel` straight off a `widgets[]` entry. The component +arm takes the spec's widget `layout` by reference, because Save Layout writes it +onto every entry of `widgets[]`, a `metric-card` node included (see [DashboardGridLayout](#dashboardgridlayout--persisting-drag--resize-edits)). So `layout` reads with the spec's type straight off a `widgets[]` entry, and the strict authoring face accepts it on a component node. A malformed one (not diff --git a/packages/types/README.md b/packages/types/README.md index 0e5e689132..3b40957640 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -198,15 +198,18 @@ Opaque `custom` / `function` / `transform` validators have no shape to close; `deriveStrictAuthoringSchema` reports each one it meets through the optional `onOpaqueShape` callback. -One node spells its props through the passthrough by design: a `metric-card` -sitting directly in a dashboard's `widgets` slot, whose props are its -registration's `inputs`. The strict face admits exactly the input names that -registration declares on that node, each judged as the tolerant face judges it, -and still refuses any other key by name (objectui#11022; the names are held to -the live registration by a test in `@object-ui/plugin-dashboard`). 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): +A `metric-card` sitting directly in a dashboard's `widgets` slot is a +component node whose props are its registration's `inputs`. Its node declares +them — `title`, `value` (required), `icon`, `trend` and `trendValue`, with +`description` from `BaseSchema` — so both faces judge each value by its member, +and the strict face still refuses any other key by name (objectui#11022, +objectui#11467; the members are held to the live registration and to +`MetricCard`'s props by a test in `@object-ui/plugin-dashboard`). A widget's +`component` slot takes the same node first; the strict face judges a card there +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): ```typescript import { StrictAnyComponentSchema } from '@object-ui/types/zod'; @@ -216,6 +219,7 @@ 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, 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 ``` ### Writing a widget's `layout` diff --git a/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts b/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts index 8e96c914df..c428306351 100644 --- a/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts +++ b/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts @@ -278,6 +278,12 @@ describe('objectui#11467 — the component arm declares `MetricCard`\'s register expect([sideways.trend, valueless.type, doc.type]).toEqual(['sideways', 'metric-card', 'dashboard']); }); + it('`title`, declared on both arms, reads with its declared type straight off a `widgets[]` entry — not `any`', () => { + // The plugin-dashboard README says so ("Reading a widget key off `widgets[]`"). + const read: Equal< DashboardComponentSchema['widgets'][number]['title'], string | I18nLabel | undefined > = true; + expect(read).toBe(true); + }); + it('the widget `component` slot is typed with the arm first, then any other component node', () => { const slot: Equal< NonNullable< DashboardWidgetSchema['component'] >, DashboardWidgetSlotComponentSchema | NonNullable< SchemaNode > > = true; expect(slot).toBe(true); From 911239e52ef05b54c9cebf01a85db2d24c0661d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:13:08 +0000 Subject: [PATCH 4/5] docs(changeset): dated notes retire the 11022 and 11348 readings objectui#11467 falsifies Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11022-strict-widget-slot-registered-inputs.md | 2 ++ .changeset/11348-dashboard-widget-reads.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.changeset/11022-strict-widget-slot-registered-inputs.md b/.changeset/11022-strict-widget-slot-registered-inputs.md index 5000bf19fe..957036a979 100644 --- a/.changeset/11022-strict-widget-slot-registered-inputs.md +++ b/.changeset/11022-strict-widget-slot-registered-inputs.md @@ -13,3 +13,5 @@ The strict authoring face accepts a correctly authored `metric-card` in a dashbo No migration: a document that parsed on either face still parses there. ⚠️ **Dated note, 2026-10-01 — the slot arm declares `layout` — objectui#11070.** "the slot arm's shape and catchall are what they were" and "a document that parsed on either face still parses there" above held when this change landed. Later in this same release, round 11 of objectui#11070 added `layout` to the slot arm's shape, by reference to the spec's widget `layout`. So the tolerant face now refuses a malformed `layout` on a `metric-card` in the widget slot. `.changeset/11070-dashboard-keys-round11.md` states what ships; the text above is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-02 — the slot arm declares `metric-card`'s registered inputs — objectui#11467.** Three readings above held when this change landed. Later in this same release, objectui#11467 made all three false. "The slot's component-node arm now records the input names the `metric-card` registration declares" and "`deriveStrictAuthoringSchema` admits those names on that node before closing it": the arm now declares `title`, `value`, `icon`, `trend` and `trendValue` as members on both faces. The recorded names and the walker step that admitted them are retired, so both faces judge each value by its member. "its `component` is still judged as a plain `BaseSchema` node on both faces": that slot now takes the arm first, then `BaseSchema`. "No migration: a document that parsed on either face still parses there": both faces now refuse a `metric-card` in the widget slot whose `trend`, `title`, `icon`, `trendValue` or `value` falls outside its member, or that carries `icon`, `trend` or `trendValue` without `value`. `.changeset/11467-metric-card-arm-inputs.md` states what ships; the text above is kept as the reading of this change. diff --git a/.changeset/11348-dashboard-widget-reads.md b/.changeset/11348-dashboard-widget-reads.md index a228f96bb8..1ba8def033 100644 --- a/.changeset/11348-dashboard-widget-reads.md +++ b/.changeset/11348-dashboard-widget-reads.md @@ -28,3 +28,5 @@ a declaration on `ObjectDataTableSchema` with its own changeset; the drawer's literal is unchanged. ⚠️ **Dated note, 2026-10-01 — the component arm now declares `layout` — objectui#11070.** "the component arm has no spec row and declares none of them" above held when this change landed. Later in this same release, round 11 of objectui#11070 declared `layout` on the component arm, by reference to the spec's widget `layout`, because Save Layout writes it onto every `widgets[]` entry. `title` and `colorVariant` are still declared on the widget arm only. `.changeset/11070-dashboard-keys-round11.md` states what ships; the text above is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-02 — the component arm now declares `title` — objectui#11467.** "`title` and `colorVariant` are still declared on the widget arm only" in the note above held when that note was written. Later in this same release, objectui#11467 declared `metric-card`'s registered inputs on the component arm, `title` among them as the card's heading, typed as `MetricCard` reads it. So `title` is declared on both arms, and it reads as `string | I18nLabel` straight off a `widgets[]` entry. `colorVariant` is still declared on the widget arm only. `.changeset/11467-metric-card-arm-inputs.md` states what ships; the text above is kept as the reading of this change. From 6919d61a3785b71a483e91ecf388e4d569edb315 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:30:02 +0000 Subject: [PATCH 5/5] docs(changeset,plugin-dashboard): retire the 11070 round-11 and 11022 accept-set readings objectui#11467 falsifies; scope the README's validate clause to a card in widgets[] Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11022-strict-widget-slot-registered-inputs.md | 2 ++ .changeset/11070-dashboard-keys-round11.md | 2 ++ packages/plugin-dashboard/README.md | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.changeset/11022-strict-widget-slot-registered-inputs.md b/.changeset/11022-strict-widget-slot-registered-inputs.md index 957036a979..5be1b87a9e 100644 --- a/.changeset/11022-strict-widget-slot-registered-inputs.md +++ b/.changeset/11022-strict-widget-slot-registered-inputs.md @@ -15,3 +15,5 @@ No migration: a document that parsed on either face still parses there. ⚠️ **Dated note, 2026-10-01 — the slot arm declares `layout` — objectui#11070.** "the slot arm's shape and catchall are what they were" and "a document that parsed on either face still parses there" above held when this change landed. Later in this same release, round 11 of objectui#11070 added `layout` to the slot arm's shape, by reference to the spec's widget `layout`. So the tolerant face now refuses a malformed `layout` on a `metric-card` in the widget slot. `.changeset/11070-dashboard-keys-round11.md` states what ships; the text above is kept as the reading of this change. ⚠️ **Dated note, 2026-10-02 — the slot arm declares `metric-card`'s registered inputs — objectui#11467.** Three readings above held when this change landed. Later in this same release, objectui#11467 made all three false. "The slot's component-node arm now records the input names the `metric-card` registration declares" and "`deriveStrictAuthoringSchema` admits those names on that node before closing it": the arm now declares `title`, `value`, `icon`, `trend` and `trendValue` as members on both faces. The recorded names and the walker step that admitted them are retired, so both faces judge each value by its member. "its `component` is still judged as a plain `BaseSchema` node on both faces": that slot now takes the arm first, then `BaseSchema`. "No migration: a document that parsed on either face still parses there": both faces now refuse a `metric-card` in the widget slot whose `trend`, `title`, `icon`, `trendValue` or `value` falls outside its member, or that carries `icon`, `trend` or `trendValue` without `value`. `.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 tolerant face's arm moves too — objectui#11467.** "The tolerant face (`AnyComponentSchema`, `DashboardComponentSchema` and every other mirror): its accept set, output and inferred types are unchanged" above held when this change landed. objectui#11467 changed the slot arm's accept set and inferred type on the tolerant face, as the note above states for what it now refuses. `.changeset/11467-metric-card-arm-inputs.md` states what ships; the text above 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 3e17430e20..5e36ba4b42 100644 --- a/.changeset/11070-dashboard-keys-round11.md +++ b/.changeset/11070-dashboard-keys-round11.md @@ -13,3 +13,5 @@ The strict authoring face accepts the `layout` that the editable dashboard grid' **Fix:** write `layout` as the four numbers Save Layout writes, or leave it out. `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. diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index fad026afe5..85d64663a0 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -649,7 +649,8 @@ as widget keys: | `description` | `string` or `I18nLabel`, from `BaseSchema` | `@object-ui/types/zod` judges the same members, so `objectui validate` refuses -a `trend` outside the three, or a `value` that is neither a string nor a number. +a card directly in `widgets[]` whose `trend` is outside the three, or whose +`value` is neither a string nor a number. ### Reading a widget key off `widgets[]`