From 9efb7a34bd8a868513a4cfa9dab56475ef6be00e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:14:30 +0000 Subject: [PATCH 1/3] feat(types): the widget slot's component arm declares the spec's widget `layout` (objectui#11070 round 11) `DashboardGridLayout`'s Save Layout (`mergeLayoutIntoSchema`) writes `layout` onto every `widgets[]` entry, a `metric-card` component node included, and the grid positions each entry by it. The strict authoring face refused it on the component arm as `unrecognized_keys: ['layout']`. - `DashboardWidgetSlotComponentSchema` (zod and TypeScript) declares `layout` by reference to `@objectstack/spec`'s `DashboardWidgetSchema.shape.layout`, the member the strict widget arm already carries. - The 11022 pin parses Save Layout's own output on the strict face, with a malformed-`layout` control refused on both faces. - Read-site comments in plugin-dashboard now say what is true: `layout` is declared on both arms; `options.description` is read and served-path written but not declared by the spec (an open decision). Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- .../src/DashboardGridLayout.tsx | 15 +-- .../src/DashboardRenderer.tsx | 9 +- .../src/DashboardWithConfig.tsx | 14 ++- .../plugin-dashboard/src/DatasetWidget.tsx | 23 +++- ...rdRegisteredInputsStrictFace-11022.test.ts | 109 +++++++++++++++++- .../plugin-dashboard/src/widgetSubCaption.ts | 13 ++- packages/types/src/complex.ts | 15 ++- packages/types/src/zod/complex.zod.ts | 14 +++ 8 files changed, 184 insertions(+), 28 deletions(-) diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index cde9c7468e..5745ed5f6c 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -190,13 +190,14 @@ export const DashboardGridLayout: React.FC = ({ // // `w` is annotated `DashboardWidgetSchema`, as `buildDefaultLayouts` above // already is (objectui#11348). A `widgets[]` entry is either arm of a union, - // and `layout` is declared on the widget arm only: the spec's - // `DashboardWidget` row declares it, and the component arm - // (`DashboardWidgetSlotComponentSchema`) has no spec row and declares none of - // the widget keys. That arm is assignable to `DashboardWidgetSchema` (pinned - // by `@object-ui/types`' `dashboard-widget-slot-component-arm-7952.test.ts`), - // so the annotation is checked by the compiler rather than asserted, and the - // read no longer rides `BaseSchema`'s index signature on the other arm. + // and both arms declare `layout` as the spec's `DashboardWidget` member: the + // widget arm through the spec row, the component arm + // (`DashboardWidgetSlotComponentSchema`) by reference to it, because + // `mergeLayoutIntoSchema` below writes it onto every entry, a component node + // included (objectui#11070 round 11). That arm is assignable to + // `DashboardWidgetSchema` (pinned by `@object-ui/types`' + // `dashboard-widget-slot-component-arm-7952.test.ts`), so the annotation is + // checked by the compiler rather than asserted. const widgetsSignature = React.useMemo( () => JSON.stringify(schema.widgets?.map((w: DashboardWidgetSchema, i: number) => ({ i: w.id || `widget-${i}`, diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 53188fc240..b35250510e 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -323,10 +323,11 @@ const DashboardRendererInner = forwardRef { if (schema.columns != null) return schema.columns; // Typed `DashboardWidgetSchema[]`, the type `renderWidget` below already - // takes (objectui#11348): `layout` is declared on the widget arm of the - // `widgets[]` union only — the spec's `DashboardWidget` row declares it — - // and the component arm (`DashboardWidgetSlotComponentSchema`) is - // assignable to that arm, so this is a checked widening, not a cast. + // takes (objectui#11348). Both arms of the `widgets[]` union declare + // `layout` as the spec's `DashboardWidget` member — the component arm + // (`DashboardWidgetSlotComponentSchema`) by reference since objectui#11070 + // round 11 — and that arm is assignable to the widget arm, so this is a + // checked widening, not a cast. const widgets: DashboardWidgetSchema[] = schema.widgets ?? []; let maxSpan = 0; for (const w of widgets) { diff --git a/packages/plugin-dashboard/src/DashboardWithConfig.tsx b/packages/plugin-dashboard/src/DashboardWithConfig.tsx index 439aaf58bf..7d0276d657 100644 --- a/packages/plugin-dashboard/src/DashboardWithConfig.tsx +++ b/packages/plugin-dashboard/src/DashboardWithConfig.tsx @@ -94,11 +94,13 @@ export function DashboardWithConfig({ // field change. This prevents useConfigDraft from resetting the draft. const selectedWidgetConfig = React.useMemo(() => { if (!selectedWidgetId || !liveSchema.widgets) return null; - // Read through `DashboardWidgetSchema` (objectui#11348). `title`, - // `colorVariant` and `layout` below are declared on the widget arm of the - // `widgets[]` union only — the spec's `DashboardWidget` row declares all - // three — and the component arm (`DashboardWidgetSlotComponentSchema`) is - // assignable to that arm (pinned by `@object-ui/types`' + // Read through `DashboardWidgetSchema` (objectui#11348). `title` and + // `colorVariant` below are declared on the widget arm of the `widgets[]` + // union only — the spec's `DashboardWidget` row declares both — and + // `layout` on both arms (the component arm takes the spec's member by + // reference, objectui#11070 round 11). The component arm + // (`DashboardWidgetSlotComponentSchema`) is assignable to the widget arm + // (pinned by `@object-ui/types`' // `dashboard-widget-slot-component-arm-7952.test.ts`), so the annotation is // checked by the compiler rather than asserted. const widgets: DashboardWidgetSchema[] = liveSchema.widgets; @@ -161,7 +163,7 @@ export function DashboardWithConfig({ return { ...prev, // `DashboardWidgetSchema`, for the reason `selectedWidgetConfig` - // states: `title` and `layout` are widget-arm keys (objectui#11348). + // states: `title` is a widget-arm key (objectui#11348). widgets: prev.widgets.map((w: DashboardWidgetSchema) => { if ((w.id || w.title) !== selectedWidgetId) return w; if (field === 'layoutW') { diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index 4c1ae969bf..efa2631109 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -1071,12 +1071,23 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any; // declared the key stays byte-identical. const accentClass = metricAccentTextClass(widget?.colorVariant); // ── The declared sub-caption (objectui#7293) ─────────────────────────── - // `options.description` is the metric tile's SUB-CAPTION slot. It is - // declared end to end and reached nothing: it has its own translation key - // (`{ns}.dashboards.{dash}.widgets.{id}.subCaption`, objectui#4032 item 4 / - // objectstack#8056), the server's `translateDashboard` OVERLAYS that - // translation onto this very key, and `DashboardRenderer.tWidgetSubCaption` - // resolves it — but only onto the two INLINE arms of `getComponentSchema`. + // `options.description` is the metric tile's SUB-CAPTION slot. Before + // #7293 it was wired end to end and reached nothing here: it has its own + // translation key (`{ns}.dashboards.{dash}.widgets.{id}.subCaption`, + // objectui#4032 item 4 / objectstack#8056), the server's + // `translateDashboard` OVERLAYS that translation onto this very key, and + // `DashboardRenderer.tWidgetSubCaption` resolved it — but only onto the two + // INLINE arms of `getComponentSchema`. + // + // ⚠️ Wired, not DECLARED (objectui#11070 round 11). `@objectstack/spec`'s + // `DashboardWidgetOptionsSchema` has no `description` member; its open bag + // admits the key without judging it. `translateDashboard` writes it on the + // served path (objectstack's `check:widget-option-census` ledgers it as an + // undeclared resolver output). objectui's strict authoring face refuses an + // authored one with the inline-dialect keys (objectui#11228 ruling C). + // Which way it goes — a spec declaration, a declared home for the overlay, + // or retiring both ends — is an open decision on objectui#11070; this read + // stays meanwhile, because its writer is live. // `dataset` is REQUIRED on `DashboardWidgetSchema` (verified against the // published @objectstack/spec@17.4.0: required keys are id/dataset/values), // so every spec-legal widget renders HERE instead, and every author who diff --git a/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts index 5d323b37de..6e87e52f27 100644 --- a/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts +++ b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts @@ -23,11 +23,26 @@ * 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 * card that motivated the repair. + * + * 3. THE SLOT'S `layout` (objectui#11070 round 11) — not a registered input + * but a widget key, the spec's widget `layout`, which `DashboardGridLayout`'s + * Save Layout (`mergeLayoutIntoSchema`) writes onto every `widgets[]` + * entry, a component node included. The producer's own output is parsed + * here, so the pin measures what Save Layout writes rather than a + * hand-copied shape of it. The slot arm declares the spec's member by + * reference, so a malformed `layout` is the control: refused, at + * `layout`, on both faces. */ import { describe, expect, it } from 'vitest'; +import { DashboardWidgetSchema as SpecDashboardWidgetSchema } from '@objectstack/spec/ui'; import { ComponentRegistry } from '@object-ui/core'; -import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '@object-ui/types'; import { + DASHBOARD_COMPONENT_WIDGET_TYPES, + type DashboardComponentSchema as DashboardComponentNode, + type DashboardWidgetSlotComponentSchema, +} from '@object-ui/types'; +import { + AnyComponentSchema, DashboardComponentSchema, deriveStrictAuthoringSchema, StrictAnyComponentSchema, @@ -35,6 +50,7 @@ import { // Side-effect import: the package barrel runs the `ComponentRegistry.register` // calls, `metric-card`'s among them. import '../index'; +import { mergeLayoutIntoSchema } from '../DashboardGridLayout'; type Issue = { code: string; path: PropertyKey[]; keys?: string[]; errors?: Issue[][] }; type Def = { @@ -133,3 +149,94 @@ describe('objectui#11022 — widget-slot registrations vs the strict authoring f expect(missing).toEqual([]); }); }); + +/** Every issue in the refusal, union arms flattened, each path made absolute to the widget. */ +const flatIssues = (issues: readonly Issue[], prefix: PropertyKey[] = []): Issue[] => + issues.flatMap((issue) => [ + { ...issue, path: [...prefix, ...issue.path] }, + ...(issue.errors ?? []).flatMap((group) => flatIssues(group, [...prefix, ...issue.path])), + ]); + +/** A node of `type` carrying each REQUIRED registered input, so only `layout` is under test. */ +const nodeOf = (type: string): DashboardWidgetSlotComponentSchema => ({ + type: type as DashboardWidgetSlotComponentSchema['type'], + id: `kpi-${type}`, + ...Object.fromEntries( + registeredInputs(type) + .filter((input) => (input as Input & { required?: boolean }).required) + .map((input) => [input.name, sampleFor(input)]), + ), +}); + +/** What Save Layout persists: the producer's merge of grid coordinates into the dashboard. */ +const savedLayout = (node: DashboardWidgetSlotComponentSchema): DashboardComponentNode => + mergeLayoutIntoSchema({ type: 'dashboard', widgets: [node] }, [ + { i: node.id ?? '', x: 0, y: 0, w: 3, h: 2 }, + ]); + +describe('objectui#11070 round 11 — the slot\'s `layout`, as Save Layout writes it, on the strict face', () => { + describe.each([...DASHBOARD_COMPONENT_WIDGET_TYPES])('`%s`', (type) => { + it('the producer writes `layout` onto the component node — the population is real', () => { + const saved = savedLayout(nodeOf(type)); + expect(saved.widgets[0]).toMatchObject({ type, layout: { x: 0, y: 0, w: 3, h: 2 } }); + }); + + it('the saved dashboard parses on the strict face, and `layout` is not named', () => { + const result = StrictAnyComponentSchema.safeParse(savedLayout(nodeOf(type))); + const issues = result.success ? [] : (result.error.issues as unknown as Issue[]); + expect(unrecognized(issues), 'the strict face refuses the `layout` Save Layout wrote').not.toContain('layout'); + expect(result.success).toBe(true); + // Control: the tolerant face accepts the same document — the two faces agree. + expect(AnyComponentSchema.safeParse(savedLayout(nodeOf(type))).success).toBe(true); + }); + + it.each([ + ['a non-number coordinate', { x: 'left', y: 0, w: 3, h: 2 }, ['layout', 'x']], + ['a missing coordinate', { x: 0, y: 0, w: 3 }, ['layout', 'h']], + ['a key the spec does not declare', { x: 0, y: 0, w: 3, h: 2, z: 1 }, ['layout']], + ] as const)('CONTROL — %s in `layout` is refused at `layout`, on both faces', (_label, layout, path) => { + const doc = { type: 'dashboard', widgets: [{ ...nodeOf(type), layout }] }; + for (const face of [StrictAnyComponentSchema, AnyComponentSchema]) { + const result = face.safeParse(doc); + expect(result.success).toBe(false); + const issues = flatIssues(result.success ? [] : (result.error.issues as unknown as Issue[])); + // The slot arm's own verdict, read at the widget: `widgets.0.`. + expect(issues.map((issue) => issue.path.slice(2).join('.'))).toContain(path.join('.')); + } + }); + }); + + it('both arms carry the spec\'s widget `layout` — one shape, read off the spec, not restated', () => { + let widgets = defOf(DashboardComponentSchema).shape?.widgets; + while (defOf(widgets).innerType) widgets = defOf(widgets).innerType; + const arms = defOf(defOf(widgets).element).options ?? []; + type Member = { safeParse: (value: unknown) => { success: boolean } }; + // Each probe value, judged by the spec's member and by each arm's: the verdicts must agree. + const probes: unknown[] = [ + undefined, + { x: 0, y: 0, w: 3, h: 2 }, + { x: 0, y: 0, w: 3 }, + { x: 'left', y: 0, w: 3, h: 2 }, + { x: 0, y: 0, w: 3, h: 2, z: 1 }, + 'top', + ]; + const verdicts = (member: Member) => probes.map((value) => member.safeParse(value).success); + const spec = verdicts(SpecDashboardWidgetSchema.shape.layout as unknown as Member); + // Non-vacuity: the spec's member accepts and refuses something in the probe set. + expect(spec).toContain(true); + expect(spec).toContain(false); + expect(arms).toHaveLength(2); + for (const arm of arms) { + const layout = defOf(arm).shape?.layout as Member | undefined; + expect(layout, 'a widget-slot arm declares no `layout`').toBeDefined(); + expect(verdicts(layout as Member)).toEqual(spec); + } + }); + + 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 } }; + // @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 } }; + expect([placed.layout?.w, misplaced.type]).toEqual([3, 'metric-card']); + }); +}); diff --git a/packages/plugin-dashboard/src/widgetSubCaption.ts b/packages/plugin-dashboard/src/widgetSubCaption.ts index 806b6162b4..a7e66d3080 100644 --- a/packages/plugin-dashboard/src/widgetSubCaption.ts +++ b/packages/plugin-dashboard/src/widgetSubCaption.ts @@ -15,9 +15,16 @@ * The sub-caption has two channels, and this hook is the only thing in the repo * that composes them: * - * 1. the AUTHORED value, `widget.options.description`, which the spec admits - * as a plain string or as an inline per-locale map — collapsed to the - * active UI language through the objectui#4208 `pickLocalized` seam; + * 1. the AUTHORED value, `widget.options.description`, read as a plain + * string or as an inline per-locale map — collapsed to the active UI + * language through the objectui#4208 `pickLocalized` seam. ⚠️ The spec + * does not DECLARE it: `DashboardWidgetOptionsSchema` has no + * `description` member and its open bag admits the key unjudged, the + * strict authoring face refuses an authored one (objectui#11228 ruling + * C), and the server's `translateDashboard` writes it on the served + * path — objectstack's `check:widget-option-census` ledgers it as an + * undeclared resolver output. Its status is an open decision on + * objectui#11070; * 2. the client i18n BUNDLE entry, * `{ns}.dashboards.{dash}.widgets.{id}.subCaption` (objectui#4032 item 4 / * objectstack#8056, shipped in `@objectstack/spec@17.0.0`), which is diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 69107fcf21..25776d998a 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -2327,7 +2327,7 @@ 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}). + * ({@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: @@ -2362,6 +2362,19 @@ export interface DashboardWidgetSchema export interface DashboardWidgetSlotComponentSchema extends BaseSchema { /** An objectui component type legal in a widget slot — the CLOSED set. */ type: DashboardComponentWidgetType; + /** + * The slot's grid position — the spec's widget `layout`, BY REFERENCE + * (`SpecDashboardWidget['layout']`), so this arm and the spec agree on the + * four numbers with no restated shape (objectui#11070 round 11). + * + * The one widget key this arm declares. It is not a `MetricCard` prop: the + * dashboard reads it off every `widgets[]` entry, and `DashboardGridLayout`'s + * Save Layout (`mergeLayoutIntoSchema`) writes it onto every entry, a + * component node included. Through the index signature it was `any` here + * and refused as undeclared by the strict authoring face. The Zod twin + * (`zod/complex.zod.ts`) declares the same spec member. + */ + layout?: SpecDashboardWidget['layout']; /** * 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/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index a07a733d5d..239db04662 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1381,10 +1381,24 @@ const DASHBOARD_WIDGET_SLOT_REGISTERED_INPUTS = { * 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), From 3178fcbbf5763b616438ca78b43d4a6d1a1ca3d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:20:12 +0000 Subject: [PATCH 2/3] docs(types,plugin-dashboard): `layout` is declared on both widget-slot arms; changeset and dated notes (objectui#11070 round 11) - The 11022 types pin reads the slot arm's key set as the base's plus `layout`. - plugin-dashboard README, `plugin-dashboard.mdx`, `schema-reference.md` and the types README say that both arms declare `layout`; the "read a widget key through `DashboardWidgetSchema`" example reads `colorVariant` instead. - `.changeset/11070-dashboard-keys-round11.md` prices the widening as minor and states the tolerant-face narrowing for a malformed `layout`. - Dated notes on the 11348 and 11022 changesets, whose arm descriptions this round makes false. Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- ...11022-strict-widget-slot-registered-inputs.md | 2 ++ .changeset/11070-dashboard-keys-round11.md | 15 +++++++++++++++ .changeset/11348-dashboard-widget-reads.md | 2 ++ content/docs/api/schema-reference.md | 2 +- content/docs/plugins/plugin-dashboard.mdx | 15 +++++++++++---- packages/plugin-dashboard/README.md | 16 ++++++++++++---- packages/types/README.md | 6 +++++- ...t-widget-slot-registered-inputs-11022.test.ts | 8 +++++--- 8 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 .changeset/11070-dashboard-keys-round11.md diff --git a/.changeset/11022-strict-widget-slot-registered-inputs.md b/.changeset/11022-strict-widget-slot-registered-inputs.md index 45e3ce6cf7..5000bf19fe 100644 --- a/.changeset/11022-strict-widget-slot-registered-inputs.md +++ b/.changeset/11022-strict-widget-slot-registered-inputs.md @@ -11,3 +11,5 @@ The strict authoring face accepts a correctly authored `metric-card` in a dashbo - **What does not move.** The tolerant face (`AnyComponentSchema`, `DashboardComponentSchema` and every other mirror): its accept set, output and inferred types are unchanged, and the slot arm's shape and catchall are what they were. The legacy `{ id, component, layout }` widget envelope is untouched, and its `component` is still judged as a plain `BaseSchema` node on both faces. `metric-card` is still not a root-level arm of `AnyComponentSchema`, so a root `{ type: 'metric-card' }` document is refused at `type` on both faces, as before. 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. diff --git a/.changeset/11070-dashboard-keys-round11.md b/.changeset/11070-dashboard-keys-round11.md new file mode 100644 index 0000000000..3e17430e20 --- /dev/null +++ b/.changeset/11070-dashboard-keys-round11.md @@ -0,0 +1,15 @@ +--- +'@object-ui/types': minor +--- + +The strict authoring face accepts the `layout` that the editable dashboard grid's Save Layout writes onto a `metric-card` in a dashboard's widget slot (objectui#11070, round 11). This widens a published accept set, and it narrows the tolerant face on one corner, stated below. + +`DashboardGridLayout`'s Save Layout (`mergeLayoutIntoSchema`) writes `layout: { x, y, w, h }` onto every entry of `widgets[]`, a `metric-card` component node included, and the grid places each entry by it. The widget slot's component-node arm declared no `layout`. The tolerant face kept it through `BaseSchema`'s passthrough, and `StrictAnyComponentSchema` refused it as `unrecognized_keys: ['layout']`, so a dashboard the grid had saved failed on the strict face. + +- **What changed.** `DashboardWidgetSlotComponentSchema` declares `layout` on both faces, by reference to `@objectstack/spec`'s widget `layout`, the member the widget arm already carries: the four numbers `x`, `y`, `w` and `h`, and no other key. On the TypeScript face the member is typed `SpecDashboardWidget['layout']`, where it used to be the index signature's `any`. +- **What now refuses that did not.** A malformed `layout` on a component node in the widget slot (a coordinate that is not a number, a missing coordinate, or a key besides the four) was kept unjudged by the tolerant face (`AnyComponentSchema`, `DashboardComponentSchema`). It is now refused there, as it already was on the widget arm. On the TypeScript face such a literal is a compile error. `scripts/measure-strict-authoring-face.mjs`, run over the schema catalog, the docs fences and the apps' authored documents at this change's base and on this change, found no document that writes `layout` on a component node, so no corpus verdict moved. +- **What does not move.** The widget arm, the component node's other keys and the registered-input record objectui#11022 added. + +**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. diff --git a/.changeset/11348-dashboard-widget-reads.md b/.changeset/11348-dashboard-widget-reads.md index 084e2b8b7a..a228f96bb8 100644 --- a/.changeset/11348-dashboard-widget-reads.md +++ b/.changeset/11348-dashboard-widget-reads.md @@ -26,3 +26,5 @@ compiles with the signature present. The drill-down drawer's `pageSize` is settled in `@object-ui/types` instead, by 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. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 70dbfdf4e1..55e02411ea 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -1055,7 +1055,7 @@ A widget-based dashboard with configurable grid layout and auto-refresh. |----------|------|-------------| | `columns` | `number` | Number of grid columns. | | `gap` | `number` | Gap between widgets (Tailwind spacing scale). | -| `widgets` | `(DashboardWidgetSlotComponentSchema \| DashboardWidgetSchema)[]` | **Required.** Each entry is a widget or a component node. A widget (`DashboardWidgetSchema`) names itself with `id`, `title` and `description`, sizes itself with `layout: { x, y, w, h }`, and holds its content either as a family named in `type` bound to a `dataset` (with that family's settings under `options`), or as a registered component node in `component`; its full key set is the spec's `DashboardWidget` plus objectui's own. A component node (`DashboardWidgetSlotComponentSchema`) sits in the slot directly: its `type` is a member of the closed `DASHBOARD_COMPONENT_WIDGET_TYPES` set, such as `metric-card`, and its other keys are that component's own props. | +| `widgets` | `(DashboardWidgetSlotComponentSchema \| DashboardWidgetSchema)[]` | **Required.** Each entry is a widget or a component node. A widget (`DashboardWidgetSchema`) names itself with `id`, `title` and `description`, sizes itself with `layout: { x, y, w, h }`, and holds its content either as a family named in `type` bound to a `dataset` (with that family's settings under `options`), or as a registered component node in `component`; its full key set is the spec's `DashboardWidget` plus objectui's own. A component node (`DashboardWidgetSlotComponentSchema`) sits in the slot directly: its `type` is a member of the closed `DASHBOARD_COMPONENT_WIDGET_TYPES` set, such as `metric-card`, its other keys are that component's own props, and it is placed by the same `layout` a widget is, which the editable grid's Save Layout writes onto every entry. | | `refreshIntervalSeconds` | `number` | Auto-refresh interval in **seconds** — the renderer multiplies by 1000. Renamed from `refreshInterval`, which this table documented as milliseconds and which it never was (objectui#7783). | A widget's size is its `layout`: `w` and `h` are the grid columns and rows it spans, and `x` and `y` are its position on the editable `dashboard-grid`. `layout` takes all four numbers or is left out. `colSpan`, `rowSpan` and `body` are **not** widget keys: `DashboardWidgetSchema` is strict (objectui#6002) and refuses all three by name. The size is `layout.w` / `layout.h`, and the content is `type` + `dataset` (with `options`) or `component`. diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 18b003ca7e..8d26193e8e 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -399,9 +399,9 @@ 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 (`layout`, `title`, `colorVariant`, `filter`, …) 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 +The widget keys (`title`, `colorVariant`, `filter`, …) 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 annotation is checked rather than asserted, and the key gets its declared type instead of the `any` the component arm's passthrough supplies: @@ -412,9 +412,16 @@ import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui declare const dashboard: DashboardComponentSchema; const widgets: DashboardWidgetSchema[] = dashboard.widgets; -const widestSpan = Math.max(0, ...widgets.map((w) => w.layout?.w ?? 0)); +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 +node, and a malformed one (not four numbers, or a key besides `x`, `y`, `w` and +`h`) is refused on either arm. + ## Type-aware list/table widget cells `type: 'table'` widgets bound to an `objectName` infer the renderer for diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index 165090aed9..bd62505954 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -499,7 +499,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 | +| `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 | | `DashboardWidgetLayout` | a widget's `{ x, y, w, h }` grid box | ```typescript @@ -575,8 +575,8 @@ never as widget keys. ### Reading a widget key off `widgets[]` -The widget keys (`layout`, `title`, `colorVariant`, `filter`, …) are declared -on the widget arm, `DashboardWidgetSchema`, which takes them from the spec's +The widget keys (`title`, `colorVariant`, `filter`, …) 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 arm's passthrough. Read it through `DashboardWidgetSchema` instead, which is how @@ -590,9 +590,17 @@ import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui declare const dashboard: DashboardComponentSchema; const widgets: DashboardWidgetSchema[] = dashboard.widgets; -const widestSpan = Math.max(0, ...widgets.map((w) => w.layout?.w ?? 0)); +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 +[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 +four numbers, or a key besides `x`, `y`, `w` and `h`) is refused on either arm. + ## Customization All components support Tailwind CSS classes: diff --git a/packages/types/README.md b/packages/types/README.md index da55f5ff2f..8bb823e082 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -164,7 +164,10 @@ 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 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): ```typescript import { StrictAnyComponentSchema } from '@object-ui/types/zod'; @@ -173,6 +176,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 ``` ## Type Categories 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 62df682b1d..5d42756729 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 @@ -154,7 +154,7 @@ describe('objectui#11022 — the registered inputs are admitted by KEY; the stri /* ── 4. the tolerant face does not move ──────────────────────────────────── */ -describe('objectui#11022 — the tolerant slot arm is untouched', () => { +describe('objectui#11022 — the tolerant slot arm is untouched by the record', () => { /** The slot's component-node arm, read off the exported tolerant schema. */ const tolerantSlotArm = (): z.ZodType => { const widgets = internals(DashboardComponentSchema)._zod.def.shape?.widgets; @@ -167,10 +167,12 @@ describe('objectui#11022 — the tolerant slot arm is untouched', () => { it('its shape declares none of the registered inputs the base does not — the record is a side table', () => { const arm = tolerantSlotArm(); // The arm overrides `type`, `body` and `children`, all three base members, - // so its key set IS the base's: no registered input became a member. + // 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. const baseKeys = Object.keys(internals(BaseSchema)._zod.def.shape ?? {}).sort(); const armKeys = Object.keys(internals(arm)._zod.def.shape ?? {}).sort(); - expect(armKeys).toEqual(baseKeys); + 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'); From dbdaa454725625fc8c7acecc83437d3db4739b0b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:34:56 +0000 Subject: [PATCH 3/3] test(plugin-dashboard): the malformed-`layout` control reads the component arm's own verdict (objectui#11070 round 11) The widget arm has always judged `layout`, so a path check over the whole union could be answered by it. The control now reads the union's first arm, the component node's, alone. Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- .../metricCardRegisteredInputsStrictFace-11022.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts index 6e87e52f27..656ffcb2e0 100644 --- a/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts +++ b/packages/plugin-dashboard/src/__tests__/metricCardRegisteredInputsStrictFace-11022.test.ts @@ -199,9 +199,13 @@ describe('objectui#11070 round 11 — the slot\'s `layout`, as Save Layout write for (const face of [StrictAnyComponentSchema, AnyComponentSchema]) { const result = face.safeParse(doc); expect(result.success).toBe(false); - const issues = flatIssues(result.success ? [] : (result.error.issues as unknown as Issue[])); - // The slot arm's own verdict, read at the widget: `widgets.0.`. - expect(issues.map((issue) => issue.path.slice(2).join('.'))).toContain(path.join('.')); + const union = (result.success ? [] : (result.error.issues as unknown as Issue[])).find( + (issue) => issue.code === 'invalid_union' && issue.path.join('.') === 'widgets.0', + ); + // The COMPONENT arm's own verdict — the union's first arm — not the widget + // arm's, which has always judged `layout` and would answer for it otherwise. + const componentArm = flatIssues(union?.errors?.[0] ?? []); + expect(componentArm.map((issue) => issue.path.join('.'))).toContain(path.join('.')); } }); });