From a13229954cbfe6369529ff5d896f88aaf395421e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:36:37 +0000 Subject: [PATCH 1/3] feat(types)!: an authored object-chart takes its props in the properties bag (objectui#11276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectChartBlockSchema` arms the authored `object-chart` node: `BaseSchema` + the literal + `NODE_ENVELOPE` + `properties` through `propsBag`, whose members are the flat `ObjectChartSchema` mirror's own members by reference (the spec has no `ComponentPropsMap['object-chart']` row, and none is invented). Each bag member written flat is refused by name with `Did you mean KEY → properties.KEY?`; the chart-family floor is read in the bag. The flat mirror leaves `ObjectQLComponentSchema` and stays published as the post-hoist reading; its TypeScript twin is re-declared, not retired. Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude --- packages/types/src/objectql.ts | 23 ++- packages/types/src/zod/index.zod.ts | 4 + packages/types/src/zod/objectql.zod.ts | 185 +++++++++++++++++++- packages/types/src/zod/public-blocks.zod.ts | 13 +- 4 files changed, 220 insertions(+), 5 deletions(-) diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index cc273eb01b..0eb683bbdc 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -5107,6 +5107,25 @@ export type KanbanConditionalFormattingRule = * objectui#10770's PR, and nothing re-derives it: no render, publish or * `objectui validate` path ran this schema on the wrapper's node. The defect * was on the contract face, not on a door. + * + * ## What this type describes (objectui#11276) + * + * The `object-chart` node as `ObjectChart` READS it: after `SchemaRenderer` + * has hoisted the node's `properties` bag onto it, or as code composes it — + * `ObjectView` and `ListView` building a chart view, the dashboard renderers + * building a widget, the react-page wrapper building the `` + * block, a host mounting ``. That is why its members + * sit flat on the node. + * + * It is NOT the shape of an AUTHORED `object-chart` document. An authored node + * takes its props in the `properties` bag: `{ type: 'object-chart', + * properties: { chartType, dataset, dimensions, values, … } }`, because + * `@objectstack/spec`'s page component refuses a prop written on the node + * itself (ADR-0089 D3a). `ObjectChartBlockSchema` (`./zod/objectql.zod.ts`) + * judges it and refuses a prop written flat on the node by name. The spec has + * no `ComponentPropsMap['object-chart']` row, so that bag's members are this + * type's own members, read off its zod mirror by reference. `SchemaRenderer` + * reads both spellings, so a node built in code keeps its flat keys. */ export interface ObjectChartSchema extends BaseSchema { type: 'object-chart'; @@ -5839,7 +5858,9 @@ export interface ObjectDataTableSchema extends BaseSchema { * and `ObjectFormSchema` here is the node as `ObjectForm` reads it. It carries * ten since batch 5, for the same reason: the authored `object-map` node is * armed by `ObjectMapBlockSchema`, and `ObjectMapSchema` here is the node as - * `ObjectMap` reads it. + * `ObjectMap` reads it. It carries nine since objectui#11276: the authored + * `object-chart` node is armed by `ObjectChartBlockSchema`, and + * `ObjectChartSchema` here is the node as `ObjectChart` reads it. */ export type ObjectQLComponentSchema = | ObjectGridSchema diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 108acf8703..f2b07a5f4d 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -331,11 +331,15 @@ export { // Batch 4's `ObjectFormBlockSchema` and batch 5's `ObjectMapBlockSchema` are // the authored `object-form` / `object-map` arms; `ObjectFormSchema` and // `ObjectMapSchema` above stay the nodes as their renderers read them. + // objectui#11276's `ObjectChartBlockSchema` is the authored `object-chart` + // arm, its bag the flat mirror's own members (the spec has no row for it); + // `ObjectChartSchema` above stays the node as `ObjectChart` reads it. ObjectMetricBlockSchema, ObjectMasterDetailFormBlockSchema, ObjectTimelineBlockSchema, ObjectFormBlockSchema, ObjectMapBlockSchema, + ObjectChartBlockSchema, ObjectQLPublicBlockComponentSchema, } from './objectql.zod.js'; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 55add962a8..ec019f610c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -3276,6 +3276,179 @@ export const ObjectChartSchema = BaseSchema.extend({ children: retirementTombstone(OBJECT_CHART_NEITHER_CHANNEL), }).superRefine(requireObjectChartFamily); +/* ── The authored `object-chart` node: its props in the `properties` bag ─── */ + +/** + * The node-level keys of `ObjectChartSchema` above: everything the node base + * declares (`BaseSchema`, `type` and the two content channels among them) and + * the node envelope (`NODE_ENVELOPE`). The chart's OWN members are the rest of + * the mirror's shape, and they are what the bag below holds (objectui#11276). + * Read off the two declarations, not transcribed, so a key either one gains + * stays at node level the day it lands. + */ +const OBJECT_CHART_NODE_LEVEL_KEYS = Object.fromEntries( + [...Object.keys(BaseSchema.shape), ...Object.keys(NODE_ENVELOPE)].map((key) => [key, true]), +) as { [K in keyof typeof BaseSchema.shape | keyof typeof NODE_ENVELOPE]: true }; + +/** + * The `object-chart` props bag (objectui#11276): the flat mirror's own members, + * BY REFERENCE. + * + * ⛔ `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row, and none + * is invented here. The spec's `PageComponentSchema` types every `properties` + * bag as an open record and judges a bag only through a row, so for this type + * the spec accepts any bag at all. The bag's members are therefore objectui's + * own: every member `ObjectChartSchema` declares beyond the node-level keys + * above, each the SAME schema object the mirror holds (its value checks, its + * spec bindings — `aggregate`, `series`, `drillDown`, `xAxis`, `yAxis` — and + * its objectui#10608 retirement tombstones). Nothing is restated, so the bag + * and the post-hoist mirror cannot drift apart. + * + * ⭐ Built from the mirror's SHAPE, not with `.omit()` on the mirror: zod + * refuses `.pick()` / `.omit()` on an object carrying a refinement, and the + * mirror carries the chart-family floor. That floor is a NODE rule, so the bag + * object deliberately carries no check of its own; the arm below re-applies the + * same floor one layer down (`requireObjectChartFamilyInBag`). + * + * The bag keeps the mirror's posture, `.passthrough()` (`BaseSchema`'s), so a + * key the chart does not declare is judged in the bag exactly as it was judged + * on the flat node: unjudged by the tolerant face, refused by name by the + * strict authoring face, which closes every object it walks. + */ +const ObjectChartPropsBag = z.looseObject(ObjectChartSchema.shape).omit(OBJECT_CHART_NODE_LEVEL_KEYS); + +/** + * The chart-family floor of {@link requireObjectChartFamily}, read in the bag + * (objectui#11276): an authored `object-chart` names its family as + * `properties.chartType` (or `properties.specType`). A node with no bag, or a + * bag naming neither, is refused at `properties.chartType`, as the flat mirror + * refuses a node naming neither key. + */ +function requireObjectChartFamilyInBag( + node: { properties?: unknown }, + ctx: z.core.$RefinementCtx, +): void { + const bag = node.properties; + const family = bag !== null && typeof bag === 'object' && !Array.isArray(bag) + ? (bag as { chartType?: unknown; specType?: unknown }) + : {}; + if (family.chartType !== undefined || family.specType !== undefined) return; + ctx.addIssue({ + code: 'custom', + path: ['properties', 'chartType'], + message: + '`object-chart` names no chart family: write `properties.chartType` — ' + + '`{ "type": "object-chart", "properties": { "chartType": "bar", … } }` (objectui#11276).', + }); +} + +/** + * The ONE refusal detail every `object-chart` prop written flat on the node + * gets (objectui#11276). `aliasKeyRefusal` puts the key and its bag member in + * front of it: "Did you mean `chartType` → `properties.chartType`?". + */ +const OBJECT_CHART_FLAT_PROP = + 'An `object-chart` node takes its props in its `properties` bag: write `{ "type": "object-chart", ' + + '"properties": { "chartType": "bar", "dataset": "…", "dimensions": ["…"], "values": ["…"] } }` ' + + '(objectui#11276). `@objectstack/spec`\'s own page component refuses a prop written on the node as ' + + 'mis-layered (ADR-0089 D3a), so this face and `os validate` agree. The spec has no ' + + '`ComponentPropsMap[\'object-chart\']` row, so the bag\'s members are `ObjectChartSchema`\'s own. Moving it ' + + 'changes nothing at render time: `SchemaRenderer` hoists every `properties` key onto the node before ' + + '`ObjectChart` reads it.'; + +/** A member of the `object-chart` bag — a TYPE position. */ +type ObjectChartBagKey = keyof typeof ObjectChartPropsBag.shape; + +/** + * One by-name refusal per member of the bag, keyed by the bag's own key set — + * read off the bag, not transcribed, so a member the mirror gains is refused + * flat the day it lands and the list cannot fall behind. + */ +const OBJECT_CHART_FLAT_PROP_REFUSALS = Object.fromEntries( + Object.keys(ObjectChartPropsBag.shape).map((key) => [ + key, + aliasKeyRefusal(key, `properties.${key}`, 'this `object-chart` node', OBJECT_CHART_FLAT_PROP), + ]), +) as { [K in ObjectChartBagKey]-?: ReturnType }; + +/** + * `object-chart` — the AUTHORED node: its props in the `properties` bag + * (objectui#11276, the `object-chart` batch of triage's routing call A). + * + * ## Why the arm moved to the bag + * + * `@objectstack/spec`'s strict `PageComponentSchema` refuses a prop written on + * a page component itself as mis-layered (ADR-0089 D3a), for every component + * type: `properties` is the only home of a component's own props. This union + * used to arm the node with the flat `ObjectChartSchema` above, so `objectui + * validate` refused the spec-shaped document — the objectstack showcase's + * command-center charts, `{ type: 'object-chart', responsiveStyles, + * properties: { dataset, dimensions, values, chartType, colors, yAxis } }` — + * and accepted the flat one `os validate` refuses. Triage's answer on + * objectui#11276 is this arm: "the `properties` bag is the contract on these + * three arms too", released for `object-chart` first. + * + * It is the construct `ObjectFormBlockSchema` and `ObjectMapBlockSchema` use + * (objectui#10859, batches 4 and 5) — `BaseSchema` + the `type` literal + + * `NODE_ENVELOPE` + `properties` through `propsBag` + one `aliasKeyRefusal` + * per bag member — with ONE difference, stated rather than hidden: ⛔ there is + * no spec row to read. `ComponentPropsMap` carries no `object-chart` row, so + * the bag is `ObjectChartPropsBag` above, the flat mirror's own members by + * reference, and its description says so instead of naming a row. + * + * ## The flat spelling is refused by name + * + * Every member of the bag written FLAT on the node is refused on both faces, + * with a message naming its bag member (`OBJECT_CHART_FLAT_PROP_REFUSALS` + * above). The three list-view spellings objectui#10608 retired + * (`xAxisField`, `yAxisFields`, `aggregation`) are the exception: written flat + * they keep the flat mirror's own retirement tombstones (the same objects, by + * reference), whose remedy is the spec spelling rather than a bag member that + * is itself retired. In the bag they are refused by the same tombstones. + * `BaseSchema`'s keys stay on the node, as on every arm. A key the chart does + * not declare is left as every arm leaves an undeclared key: unjudged by the + * tolerant face, refused by the strict one. + * + * ## The chart family, `dataSource` and the content channels + * + * The flat mirror's chart-family floor (objectui#10770) stays on the authored + * node, read in the bag (`requireObjectChartFamilyInBag`). `dataSource` stays + * undeclared, as on the flat mirror: objectui#11070 left `object-chart`'s + * binding refused by name on the strict face until it is declared, and this + * arm does not decide that. Neither content channel is read, so both are + * refused with the objectui#9256 string the flat mirror uses. + * + * ## What did not move + * + * The TypeScript `ObjectChartSchema` and its zod mirror above stay published: + * they are the node as `ObjectChart` reads it after `SchemaRenderer` hoists the + * bag, and as code composes it — `ObjectView`, `ListView`, the dashboard + * renderers and the react-page wrapper all build a flat `object-chart` node. + * Those keep working, because `SchemaRenderer` reads both spellings and no + * composed node passes through `safeValidateSchema`. + */ +export const ObjectChartBlockSchema = BaseSchema.extend({ + type: z.literal('object-chart'), + ...NODE_ENVELOPE, + properties: propsBag( + 'object-chart', + ObjectChartPropsBag, + 'The `object-chart` props bag — the members `ObjectChartSchema` declares beyond the node-level keys, ' + + 'by reference. `@objectstack/spec` has no `ComponentPropsMap[\'object-chart\']` row, so these are ' + + 'objectui\'s own members (objectui#11276). The chart family is required here: `chartType` (or `specType`).', + ), + ...OBJECT_CHART_FLAT_PROP_REFUSALS, + // objectui#10608: the three retired list-view spellings keep their retirement + // when written flat — the flat mirror's own tombstones, by reference. + xAxisField: ObjectChartSchema.shape.xAxisField, + yAxisFields: ObjectChartSchema.shape.yAxisFields, + aggregation: ObjectChartSchema.shape.aggregation, + // objectui#9256: the renderer reads NEITHER content channel, so both are + // refused by name — the flat mirror's own members, by reference. + body: ObjectChartSchema.shape.body, + children: ObjectChartSchema.shape.children, +}).superRefine(requireObjectChartFamilyInBag); + /** * ObjectGallery Schema (objectui#6576) * @@ -3484,6 +3657,11 @@ export const ObjectDataTableSchema = BaseSchema.extend({ * ⚠️ TEN since objectui#10859 batch 5: `ObjectMapSchema` left it the same way, * for the same reason. Its TypeScript twin is the node as `ObjectMap` reads it, * and the authored `object-map` node is armed by `ObjectMapBlockSchema` below. + * + * ⚠️ NINE since objectui#11276: `ObjectChartSchema` left it the same way. Its + * TypeScript twin is the node as `ObjectChart` reads it, and the authored + * `object-chart` node is armed by `ObjectChartBlockSchema` above, whose bag is + * the mirror's own members (the spec has no row for this type). */ export const ObjectQLComponentSchema = z.discriminatedUnion('type', [ ObjectGridSchema, @@ -3492,7 +3670,6 @@ export const ObjectQLComponentSchema = z.discriminatedUnion('type', [ ObjectGanttSchema, ObjectCalendarSchema, ObjectKanbanSchema, - ObjectChartSchema, ObjectGallerySchema, ObjectDataTableSchema, ListViewSchema, @@ -3939,6 +4116,11 @@ export const ObjectMapBlockSchema = BaseSchema.extend({ * node as its renderer reads it after the hoist, not the authored document * (batches 4 and 5). * + * `object-chart` joined in objectui#11276, the one arm here with NO spec row: + * `ComponentPropsMap` carries no `object-chart` entry, so its bag is the flat + * `ObjectChartSchema` mirror's own members by reference, and its TypeScript + * twin is likewise the node as `ObjectChart` reads it after the hoist. + * * Each arm also spreads `NODE_ENVELOPE` from `./public-blocks.zod.ts`, * the node-level `responsiveStyles` every public block declares by reference to * the spec's `PageComponentSchema` (objectui#10872 batch 8) — the same one @@ -3951,4 +4133,5 @@ export const ObjectQLPublicBlockComponentSchema = z.discriminatedUnion('type', [ ObjectTimelineBlockSchema, ObjectFormBlockSchema, ObjectMapBlockSchema, + ObjectChartBlockSchema, ]); diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index 311e4c1587..224b8ec18d 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -235,13 +235,20 @@ import { * The ONE copy: `./objectql.zod.ts`'s public-block arms import it rather than * restate it (objectui#10872). Internal to this package's zod modules — deliberately NOT * re-exported from `index.zod.ts`, like the helpers in `./tombstone.zod.ts`. + * + * `description` replaces the provenance text for the one bag that is NOT a spec + * row: `object-chart` (objectui#11276) has no `ComponentPropsMap` row, so its + * bag is the flat mirror's own members, and its description says that rather + * than naming a row that does not exist. Every other caller omits it, and its + * description is unchanged. */ -export function propsBag(type: string, row: T) { +export function propsBag(type: string, row: T, description?: string) { return row .optional() .describe( - `The \`${type}\` props bag — \`@objectstack/spec\` \`ComponentPropsMap['${type}']\`, by reference. ` - + 'Judged only when present, as the spec\'s props gate judges it.', + description + ?? `The \`${type}\` props bag — \`@objectstack/spec\` \`ComponentPropsMap['${type}']\`, by reference. ` + + 'Judged only when present, as the spec\'s props gate judges it.', ); } From f1cd6ab7086b1be6ce5dfef72f64904e0eb5fb95 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:42:14 +0000 Subject: [PATCH 2/3] test(types): pin the object-chart bag arm, and triage the fixtures the move forces (objectui#11276) New pin `object-chart-properties-bag-11276.test.ts`: the showcase's bag nodes pass both faces, the bag is the mirror's own members by reference, each flat member is refused by name with the bag prescription, the family floor is read in the bag, and the flat mirror stays the post-hoist reading. Census rows and fixture triage: the parity census gains one EXCLUSIONS row; the union-arm counts move (ObjectQL union 10 to 9, public-block union gains `object-chart`); door rows that judged a flat authored chart now write the bag, while the mirror rows keep the post-hoist spelling. Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude --- .../content-channel-e3-residual-9256.test.ts | 11 +- .../flat-arm-responsive-styles-10872.test.ts | 39 +- .../object-chart-axis-config-10518.test.ts | 25 +- ...art-legacy-axis-keys-retired-10608.test.ts | 22 +- .../object-chart-properties-bag-11276.test.ts | 420 ++++++++++++++++++ ...object-chart-react-tier-node-10770.test.ts | 17 +- .../objectql-union-arms-7363.test.ts | 7 +- ...blic-block-responsive-styles-10872.test.ts | 11 +- .../registered-type-arms-10859-b2.test.ts | 4 +- .../strict-face-read-keys-11070.test.ts | 4 +- .../src/__tests__/zod-mirror-parity.test.ts | 6 + 11 files changed, 526 insertions(+), 40 deletions(-) create mode 100644 packages/types/src/__tests__/object-chart-properties-bag-11276.test.ts diff --git a/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts b/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts index a653c62db7..66e33aadf7 100644 --- a/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts @@ -126,6 +126,8 @@ const ROWS: ReadonlyArray>> = { 'object-form': { properties: { objectName: 'account', mode: 'create' } }, 'object-map': { properties: { objectName: 'account' } }, + 'object-chart': { properties: { objectName: 'account', chartType: 'bar' } }, }; const CHANNELS = ['body', 'children'] as const; diff --git a/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts index 613dc4e5d7..749155dd65 100644 --- a/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts @@ -31,6 +31,13 @@ * * The three arms spread the ONE shared fragment the public-block arms spread, * `PAGE_COMPONENT_ENVELOPE`, so the member is the same object on all 33 arms. + * + * ⚠️ objectui#11276 moved `object-chart`'s AUTHORED arm into the public-block + * set (`ObjectChartBlockSchema`, its props in the `properties` bag), so the + * population below is the two arms the node union still selects a flat mirror + * for. The flat `ObjectChartSchema` keeps the shared member as the post-hoist + * reading, and the authored chart arm is pinned with the other public blocks in + * `./public-block-responsive-styles-10872.test.ts`. * The key is NOT declared on `BaseSchema` and NOT on any arm without a measured * producer: `grid`, `stack` and `container` below are the control, and the * whole arm list is read at run time so an arm that gains the member silently @@ -122,14 +129,16 @@ function armsByLiteral(): Map { } /** - * The three arms this batch widens: the arms outside the public-block set that - * a producer writes `responsiveStyles` on (objectui#10872's batch-9 pull). NAMED, - * not derived, because the population is a measurement of producers. + * The arms this batch widens that the node union still selects: the arms + * outside the public-block set that a producer writes `responsiveStyles` on + * (objectui#10872's batch-9 pull). NAMED, not derived, because the population + * is a measurement of producers. `object-chart` was the third until + * objectui#11276 moved its authored arm into the public-block set; its flat + * mirror keeps the member (the post-hoist row below). */ const FLAT_ARMS = [ ['flex', FlexSchema], ['object-grid', ObjectGridSchema], - ['object-chart', ObjectChartSchema], ] as const; /** Arms with no producer: the strict face still refuses the key on them. */ @@ -161,15 +170,6 @@ const VALID_NODES = { columns: ['title', 'status'], responsiveStyles: { large: { minWidth: '0', display: 'block' } }, }, - 'object-chart': { - type: 'object-chart', - id: 'cc_status_c', - dataset: 'showcase_task_metrics', - dimensions: ['status'], - values: ['task_count'], - chartType: 'bar', - responsiveStyles: { large: { width: '100%', minWidth: '0' }, xsmall: { display: 'none' } }, - }, } as const; /** Values the spec refuses: a breakpoint it does not have, a non-object, a non-CSS value. */ @@ -220,13 +220,20 @@ describe('the population (objectui#10872 batch 9)', () => { }); }); -describe('`responsiveStyles` is declared on the three arms, from the public blocks\' fragment (objectui#10872 batch 9)', () => { +describe('`responsiveStyles` is declared on the batch-9 arms, from the public blocks\' fragment (objectui#10872 batch 9)', () => { it.each(FLAT_ARMS)('%s carries the member, and it is the SAME object the public-block arms carry', (_type, arm) => { const shared = PageSectionBlockSchema.shape.responsiveStyles; expect(shared).toBeDefined(); expect(arm.shape.responsiveStyles).toBe(shared); }); + it('the flat `ObjectChartSchema` mirror keeps the SAME member as the post-hoist reading (objectui#11276)', () => { + // Its authored arm left the node union for the public-block set; the + // mirror is the node as `ObjectChart` reads it, and it still declares the key. + expect(ObjectChartSchema.shape.responsiveStyles).toBe(PageSectionBlockSchema.shape.responsiveStyles); + expect(armsByLiteral().get('object-chart')).not.toBe(ObjectChartSchema); + }); + it('the member is the spec\'s own map: the same breakpoints, each the spec\'s own value schema', () => { const member = (FlexSchema.shape.responsiveStyles as z.ZodOptional).unwrap() as z.ZodObject; const spec = SpecResponsiveStylesSchema as unknown as z.ZodObject; @@ -234,7 +241,7 @@ describe('`responsiveStyles` is declared on the three arms, from the public bloc for (const key of Object.keys(spec.shape)) expect(member.shape[key]).toBe(spec.shape[key]); }); - it('exactly the public-block arms and these three declare it: no other arm, and not `BaseSchema`', () => { + it('exactly the public-block arms and these flat arms declare it: no other arm, and not `BaseSchema`', () => { expect(Object.keys(BaseSchema.shape)).not.toContain('responsiveStyles'); const publicBlocks = [ ...optionsOf(PublicBlockComponentSchema), @@ -282,7 +289,7 @@ describe('an invalid `responsiveStyles` is refused on BOTH faces, with the spec\ }); }); -describe('the other five envelope keys stay undeclared on the three arms (objectui#10872 batch 9)', () => { +describe('the other five envelope keys stay undeclared on the batch-9 arms (objectui#10872 batch 9)', () => { it('no arm gained any of them: `dataSource` only where objectui#11070 declared it', () => { const declared = FLAT_ARMS.flatMap(([type, arm]) => UNDECLARED_ENVELOPE.filter((key) => key in arm.shape).map((key) => `${type}#${key}`), diff --git a/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts b/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts index 64c570cfa7..f7b075c34f 100644 --- a/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts +++ b/packages/types/src/__tests__/object-chart-axis-config-10518.test.ts @@ -73,6 +73,13 @@ import { safeValidateSchema } from '../zod/index.zod'; const CHART = { type: 'object-chart', chartType: 'bar' } as const; const chart = (extra: Record) => ({ ...CHART, ...extra }); +/** + * The AUTHORED spelling of `chart(extra)` (objectui#11276): an authored + * `object-chart` takes its props in the `properties` bag, and the public door + * judges that arm, so every `safeValidateSchema` row below hands it this. The + * mirror rows keep `chart(extra)`, the node as `ObjectChart` reads it. + */ +const authored = (extra: Record) => ({ type: 'object-chart', properties: { chartType: 'bar', ...extra } }); const parsed = (doc: unknown) => { const r = ObjectChartMirror.safeParse(doc); @@ -124,6 +131,12 @@ const SHOWCASE_NODE = { yAxis: [{ field: 'task_count', stepSize: 1 }], } as const; +/** The same producer node as the producer WRITES it — its props in the bag (objectui#11276). */ +const SHOWCASE_AUTHORED = (() => { + const { id, type, responsiveStyles, ...props } = SHOWCASE_NODE; + return { id, type, responsiveStyles, properties: props }; +})(); + type Shape = Record; const mirrorShape = (ObjectChartMirror as unknown as { shape: Shape }).shape; @@ -200,7 +213,7 @@ describe('objectui#10518 (b) — LIT CONTROL: the showcase producer\'s node pars }); it('through `safeValidateSchema`, the door `objectui validate` / `objectui check` run', () => { - expect(safeValidateSchema(SHOWCASE_NODE).success).toBe(true); + expect(safeValidateSchema(SHOWCASE_AUTHORED).success).toBe(true); }); }); @@ -220,14 +233,14 @@ describe('objectui#10518 (c) — a malformed `yAxis` entry is refused at its own }); it('the refusal reaches the public door — `safeValidateSchema` refuses the node', () => { - expect(safeValidateSchema(chart({ yAxis: [{ field: 'task_count', stepSize: 'big' }] })).success).toBe(false); + expect(safeValidateSchema(authored({ yAxis: [{ field: 'task_count', stepSize: 'big' }] })).success).toBe(false); }); /* CONTROL for every refusal above: the same documents minus the defect parse. */ it('CONTROL — the same entries, minus the defect, are accepted', () => { expect(() => parsed(chart({ yAxis: [{ field: 'task_count', stepSize: 2 }] }))).not.toThrow(); expect(() => parsed(chart({ yAxis: [{ field: 'task_count', position: 'right', logarithmic: true }] }))).not.toThrow(); - expect(safeValidateSchema(chart({ yAxis: [{ field: 'task_count', stepSize: 2 }] })).success).toBe(true); + expect(safeValidateSchema(authored({ yAxis: [{ field: 'task_count', stepSize: 2 }] })).success).toBe(true); }); }); @@ -343,7 +356,7 @@ describe('objectui#10518 (g) — LIT CONTROL: the showcase `renewals-pipeline` a }); it('through `safeValidateSchema`, the door `objectui validate` / `objectui check` run', () => { - expect(safeValidateSchema(chart(RENEWALS_AXES)).success).toBe(true); + expect(safeValidateSchema(authored(RENEWALS_AXES)).success).toBe(true); }); }); @@ -363,7 +376,7 @@ describe('objectui#10518 (h) — a malformed `xAxis` object is refused at `xAxis }); it('the refusal reaches the public door — `safeValidateSchema` refuses the node', () => { - expect(safeValidateSchema(chart({ xAxis: { field: 'status', min: 'zero' } })).success).toBe(false); + expect(safeValidateSchema(authored({ xAxis: { field: 'status', min: 'zero' } })).success).toBe(false); }); /* CONTROL for every refusal above: the same objects, minus the defect, parse. */ @@ -388,7 +401,7 @@ describe('objectui#10518 (i) — `xAxis` is the spec object, and only that', () }); it('the bare string is refused at the public door too', () => { - expect(safeValidateSchema(chart({ xAxis: 'status' })).success).toBe(false); + expect(safeValidateSchema(authored({ xAxis: 'status' })).success).toBe(false); }); it('CONTROL — the same column as the spec object parses, and a malformed OBJECT is not told the remedy', () => { diff --git a/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts b/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts index 0ba5fa7d55..b485159fce 100644 --- a/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts +++ b/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts @@ -65,6 +65,13 @@ import { ListViewSchema, safeValidateSchema } from '../zod/index.zod'; const CHART = { type: 'object-chart', chartType: 'bar' } as const; const chart = (extra: Record) => ({ ...CHART, ...extra }); +/** + * The AUTHORED node (objectui#11276): its props in the `properties` bag, the + * arm the public door judges. `authoredFlat` writes `extra` on that node + * itself. The mirror rows keep `chart(extra)`, the node as `ObjectChart` reads it. + */ +const authored = (extra: Record) => ({ type: 'object-chart', properties: { chartType: 'bar', ...extra } }); +const authoredFlat = (extra: Record) => ({ type: 'object-chart', properties: { chartType: 'bar' }, ...extra }); /** * The retired keys, each with the values an author would plausibly have written @@ -102,10 +109,17 @@ describe.each(RETIRED_KEYS)('objectui#10608 (a) — `%s` is RETIRED on `object-c it.each(values.map((v) => [JSON.stringify(v), v] as const))( 'refuses `%s` at the public door and on the mirror: ONE `invalid_type` issue at the key, naming the remedy', (_label, value) => { - for (const r of [safeValidateSchema(chart({ [key]: value })), ObjectChartMirror.safeParse(chart({ [key]: value }))]) { + // objectui#11276: at the door the key is retired in the authored node's + // bag AND written flat on it (the flat mirror's own tombstone, by + // reference, not a pointer at a bag member that is itself retired). + for (const [r, path] of [ + [safeValidateSchema(authored({ [key]: value })), ['properties', key]], + [safeValidateSchema(authoredFlat({ [key]: value })), [key]], + [ObjectChartMirror.safeParse(chart({ [key]: value })), [key]], + ] as const) { expect(r.success, `an authored \`${key}: ${JSON.stringify(value)}\` was ACCEPTED`).toBe(false); const issues = issuesOf(r); - expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'invalid_type', path: [key] }]); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'invalid_type', path }]); expect(issues[0]!.message).toContain(`\`${key}\``); expect(issues[0]!.message).toContain(remedy); } @@ -130,12 +144,12 @@ describe('objectui#10608 (b) — LIT CONTROL: each remedy parses on the same nod ['`yAxis: [{ field }]` for `yAxisFields`', { yAxis: [{ field: 'amount' }] }], ['`aggregate: { field, function, groupBy }` for `aggregation`', { objectName: 'task', aggregate: { field: 'amount', function: 'sum', groupBy: 'status' } }], ])('%s', (_label, extra) => { - expect(issuesOf(safeValidateSchema(chart(extra)))).toEqual([]); + expect(issuesOf(safeValidateSchema(authored(extra)))).toEqual([]); expect(issuesOf(ObjectChartMirror.safeParse(chart(extra)))).toEqual([]); }); it('all three remedies together, on one node', () => { - const node = chart({ + const node = authored({ objectName: 'task', aggregate: { field: 'amount', function: 'sum', groupBy: 'status' }, xAxis: { field: 'status' }, diff --git a/packages/types/src/__tests__/object-chart-properties-bag-11276.test.ts b/packages/types/src/__tests__/object-chart-properties-bag-11276.test.ts new file mode 100644 index 0000000000..f752d85091 --- /dev/null +++ b/packages/types/src/__tests__/object-chart-properties-bag-11276.test.ts @@ -0,0 +1,420 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `object-chart` takes its props in the `properties` bag, and the flat + * spelling retires from both authoring faces (objectui#11276, the + * `object-chart` batch of triage's routing call A). + * + * ## The defect this pins + * + * `@objectstack/spec`'s strict `PageComponentSchema` refuses a prop written on + * a page component itself as mis-layered (ADR-0089 D3a), for every component + * type, and accepts the same props in `properties`. objectui's arm was the flat + * mirror of the TypeScript twin instead, so the two validators disagreed in + * both directions: + * + * - the objectstack showcase's command-center charts, + * `{ type: 'object-chart', responsiveStyles, properties: { … } }`, were + * refused by `safeValidateSchema` (the flat mirror's chart-family floor + * found no family on the node) and by the strict face, which added + * `properties` as an unrecognized key; + * - the flat node `os validate` refuses parsed green on both faces. + * + * ## The one difference from batches 4 and 5: there is no spec row + * + * `ComponentPropsMap` carries no `object-chart` row, so the spec's page + * component takes ANY bag for this type, and no row is invented here. The bag's + * members are the flat `ObjectChartSchema` mirror's own members, BY REFERENCE — + * the same schema objects — and everything else is the construct batches 4 + * and 5 used: `propsBag`, one `aliasKeyRefusal` per member written flat, the + * mirror's tombstones and content-channel refusals. + * + * ## What the arm keeps that the spec does not say + * + * The flat mirror's chart-family floor (objectui#10770) stays, read in the bag. + * The spec has no such rule, so a node naming no family passes the spec's page + * component and is refused here, as the flat mirror refused it. + * + * ## What did NOT move + * + * The TypeScript `ObjectChartSchema` (`../objectql.ts`) and its zod mirror stay + * published: they are the node as `ObjectChart` reads it AFTER `SchemaRenderer` + * hoists `properties`, and as `ObjectView` / `ListView` / the dashboard + * renderers / the react-page wrapper compose it. The mirror is no longer an arm + * of `AnyComponentSchema`; `ObjectChartBlockSchema` is. + * + * ## Two instruments, and which half each one reads + * + * The `Expect` / `Equal` block below is TYPE-level: `tsc -p tsconfig.test.json` + * (the third leg of this package's `type-check` script) reads it, and vitest — + * which strips types — does not. The `describe` blocks are RUNTIME. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + ComponentPropsMap as SpecComponentPropsMap, + PageComponentSchema as SpecPageComponentSchema, +} from '@objectstack/spec/ui'; + +import type { ObjectChartSchema as TsObjectChartSchema, ObjectQLComponentSchema as TsObjectQLComponentSchema } from '../objectql'; +import { + BaseSchema, + ObjectChartBlockSchema, + ObjectChartSchema, + ObjectMapBlockSchema, + ObjectQLComponentSchema, + ObjectQLPublicBlockComponentSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; + +/* ── Type-level parity: the `tsc` channel ────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +type ShapeOf = M extends { shape: infer S } ? S : never; +/** What a shape entry ACCEPTS (input side, so `.optional()` shows). */ +type InputOf = T extends z.ZodType ? z.input : never; +type Arm = ShapeOf; +type Mirror = ShapeOf; +type Bag = ShapeOf>>; +/** The node-level keys: the node base's and the envelope's. */ +type NodeLevel = keyof ShapeOf | 'responsiveStyles'; + +/** + * The bag's key set is exactly the mirror's own members (its shape less the + * node-level keys), and each member IS the mirror's schema type. + */ +export type assertionBagIsTheMirrorsOwnMembers = [ + Expect>>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/** Every bag member written flat on the arm accepts nothing, and so do both content channels. */ +export type assertionEveryBagKeyIsRefusedFlat = [ + Expect }[keyof Bag], undefined>>, + Expect, undefined>>, + Expect, undefined>>, +]; + +/** + * The TypeScript twin is RE-DECLARED, not retired: still published, still the + * post-hoist reading with its flat members, still a member of the TypeScript + * ObjectQL union. A narrowing or a removal here reddens `tsc`. + */ +export type assertionTwinStaysPublished = [ + Expect>, + Expect>, + Expect>, + Expect, TsObjectChartSchema>>, +]; + +/** Non-vacuity: a key set narrower than the mirror's own is not Equal to it. */ +export type assertionInstrumentFires = [ + Expect>, false>>, +]; + +/* ── Runtime fixtures ────────────────────────────────────────────────────── */ + +const PALETTE = ['hsl(192 86% 46%)', 'hsl(256 72% 62%)']; + +/** + * The objectstack showcase command-center producer's `chart()` helper, as it + * WRITES the node — `integerYAxis` on — and the same helper without it. + */ +const SHOWCASE_BAR = { + id: 'cc_status_c', + type: 'object-chart', + responsiveStyles: { large: { width: '100%', minWidth: '0' } }, + properties: { + dataset: 'showcase_task_metrics', + dimensions: ['status'], + values: ['task_count'], + chartType: 'bar', + colors: PALETTE, + yAxis: [{ field: 'task_count', stepSize: 1 }], + }, +} as const; +const SHOWCASE_DONUT = { + id: 'cc_health_c', + type: 'object-chart', + responsiveStyles: { large: { width: '100%', minWidth: '0' } }, + properties: { dataset: 'showcase_project_metrics', dimensions: ['health'], values: ['project_count'], chartType: 'donut', colors: PALETTE }, +} as const; + +/** The inline object path: `objectName` + the spec's `aggregate`, a `filter` and a drill. */ +const INLINE = { + type: 'object-chart', + properties: { + objectName: 'opportunity', + chartType: 'bar', + aggregate: { field: 'amount', function: 'sum', groupBy: 'stage' }, + filter: [['stage', '!=', 'lost']], + xAxis: { field: 'stage' }, + drillDown: { target: 'drawer' }, + title: 'Pipeline by stage', + }, +} as const; + +/** The family named by `specType` alone, with the spec's `{ name }` series arm. */ +const SPEC_TYPE = { + type: 'object-chart', + properties: { objectName: 'invoice', specType: 'line', series: [{ name: 'total', label: 'Invoice value' }] }, +} as const; + +/** The flat spelling this batch retires. */ +const FLAT = { type: 'object-chart', objectName: 'opportunity', chartType: 'bar' } as const; + +type Issues = z.core.$ZodIssue[]; + +function issuesOf(result: { success: boolean; error?: { issues: Issues } }): Issues { + if (result.success) throw new Error('expected a refusal, the document parsed'); + return result.error!.issues; +} + +const FACES = [ + ['safeValidateSchema', (doc: unknown) => safeValidateSchema(doc)], + ['the strict authoring face', (doc: unknown) => StrictAnyComponentSchema.safeParse(doc)], +] as const; + +const bagOf = () => ObjectChartBlockSchema.shape.properties.unwrap(); +const bagKeys = () => Object.keys(bagOf().shape); +/** The three list-view spellings objectui#10608 retired — tombstones in the mirror. */ +const RETIRED = ['xAxisField', 'yAxisFields', 'aggregation'] as const; + +describe('object-chart validates in the `properties` bag (objectui#11276)', () => { + it.each([ + ['the showcase command-center bar chart (dataset-bound, integer y axis)', SHOWCASE_BAR], + ['the showcase command-center donut', SHOWCASE_DONUT], + ['an object-bound chart with its aggregate, filter, axis and drill', INLINE], + ['a chart naming its family by `specType`, with the spec series arm', SPEC_TYPE], + ] as const)('%s is accepted by safeValidateSchema and by the strict authoring face', (_label, doc) => { + const tolerant = safeValidateSchema(doc); + expect(tolerant.success, JSON.stringify(tolerant.success ? null : tolerant.error.issues)).toBe(true); + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success, JSON.stringify(strict.success ? null : strict.error.issues)).toBe(true); + }); + + it('the fixtures are spec-valid by the spec\'s own page component (lit control)', () => { + for (const doc of [SHOWCASE_BAR, SHOWCASE_DONUT, INLINE, SPEC_TYPE]) { + const r = SpecPageComponentSchema.safeParse(doc); + expect(r.success, JSON.stringify(r.success ? null : r.error.issues)).toBe(true); + } + }); + + it('the bag survives the parse unchanged on both faces', () => { + expect((safeValidateSchema(SHOWCASE_BAR).data as { properties?: unknown }).properties).toEqual(SHOWCASE_BAR.properties); + expect((StrictAnyComponentSchema.safeParse(SHOWCASE_BAR).data as { properties?: unknown }).properties).toEqual( + SHOWCASE_BAR.properties, + ); + }); + + it('reaches the arm at a child slot and at a page container\'s bag child list', () => { + expect(safeValidateSchema({ type: 'page', children: [SHOWCASE_BAR, INLINE] }).success).toBe(true); + const section = { type: 'page:section', properties: { children: [SHOWCASE_BAR, SHOWCASE_DONUT] } }; + for (const [, parse] of FACES) expect(parse(section).success).toBe(true); + // Control: the walk does judge a chart nested in the bag — a flat one is refused there. + const flatInSection = { type: 'page:section', properties: { children: [FLAT] } }; + const nested = issuesOf(safeValidateSchema(flatInSection)).find( + (i) => i.path.join('.') === 'properties.children.0.objectName', + ); + expect(nested?.message).toContain('`objectName` → `properties.objectName`'); + }); +}); + +describe('the spec has no `object-chart` row, and none is invented (objectui#11276)', () => { + it('`ComponentPropsMap` carries no `object-chart` row (lit control, read off the installed spec)', () => { + expect(Object.prototype.hasOwnProperty.call(SpecComponentPropsMap, 'object-chart')).toBe(false); + // Control: the read finds a row where one exists. + expect(Object.prototype.hasOwnProperty.call(SpecComponentPropsMap, 'object-map')).toBe(true); + }); + + it('so the spec\'s page component takes any bag on this type', () => { + const r = SpecPageComponentSchema.safeParse({ type: 'object-chart', properties: { inventedKey11276: 1, chartType: 7 } }); + expect(r.success).toBe(true); + }); + + it('the bag\'s description names the mirror, not a row; a row-backed arm\'s still names its row', () => { + const own = ObjectChartBlockSchema.shape.properties.description ?? ''; + expect(own).toContain('`ObjectChartSchema`'); + expect(own).toContain('no `ComponentPropsMap[\'object-chart\']` row'); + const control = ObjectMapBlockSchema.shape.properties.description ?? ''; + expect(control).toContain('`ComponentPropsMap[\'object-map\']`, by reference'); + }); +}); + +describe('the bag is the flat mirror\'s own members, by reference (objectui#11276)', () => { + it('its key set is the mirror\'s shape less the node-level keys, read on every run', () => { + const nodeLevel = new Set([...Object.keys(BaseSchema.shape), 'responsiveStyles']); + const expected = Object.keys(ObjectChartSchema.shape).filter((key) => !nodeLevel.has(key)).sort(); + expect(bagKeys().sort()).toEqual(expected); + // Non-vacuity: the chart's own vocabulary is in it. + expect(expected.length).toBeGreaterThan(10); + expect(expected).toEqual(expect.arrayContaining(['chartType', 'dataset', 'objectName', 'aggregate', 'xAxis', 'yAxis'])); + }); + + it('each member IS the mirror\'s schema object, not a copy', () => { + const mirror = ObjectChartSchema.shape as Record; + for (const [key, member] of Object.entries(bagOf().shape)) expect(member, key).toBe(mirror[key]); + }); + + it.each([ + ['a chart family outside the vocabulary', { chartType: 'radar' }, ['properties', 'chartType'], 'invalid_value'], + ['a bare column name as `xAxis` (objectui#10518)', { chartType: 'bar', xAxis: 'stage' }, ['properties', 'xAxis'], 'invalid_union'], + ['a single axis object as `yAxis` (objectui#10518)', { chartType: 'bar', yAxis: { field: 'n' } }, ['properties', 'yAxis'], 'invalid_type'], + ['an `aggregate` with no `groupBy` (the spec\'s strict schema)', { chartType: 'bar', aggregate: { function: 'count' } }, ['properties', 'aggregate', 'groupBy'], 'invalid_union'], + ['a series entry naming no column (objectui#10770)', { chartType: 'bar', series: [{ label: 'x' }] }, ['properties', 'series', 0], 'invalid_union'], + ] as const)('%s is refused at that member on both faces', (_label, props, path, code) => { + for (const [, parse] of FACES) { + const issue = issuesOf(parse({ type: 'object-chart', properties: props })).find((i) => i.code === code); + expect(issue?.path).toEqual(path); + } + }); + + it.each(RETIRED.map((key) => [key] as const))('the retired `%s` is refused in the bag with its retirement (objectui#10608)', (key) => { + const [issue] = issuesOf(safeValidateSchema({ type: 'object-chart', properties: { chartType: 'bar', [key]: 'x' } })); + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['properties', key]); + expect(issue.message).toContain('RETIRED (objectui#10608'); + }); + + it('a key the chart does not declare, in the bag, stays unjudged by the tolerant face and is refused by the strict face', () => { + // The mirror's own posture (`BaseSchema`'s `.passthrough()`), kept one layer down. + const doc = { type: 'object-chart', properties: { chartType: 'bar', inventedKey11276: 1 } }; + expect(safeValidateSchema(doc).success).toBe(true); + const issue = issuesOf(StrictAnyComponentSchema.safeParse(doc)).find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path).toEqual(['properties']); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['inventedKey11276']); + }); +}); + +describe('the flat spelling is refused by name, with the bag member as the remedy (objectui#11276)', () => { + it('the spec\'s own page component refuses the flat node (lit control: the reason holds for this type)', () => { + const issues = issuesOf(SpecPageComponentSchema.safeParse(FLAT)); + expect(issues.map((i) => i.code)).toEqual(['unrecognized_keys']); + expect((issues[0] as { keys?: string[] }).keys).toEqual(['objectName', 'chartType']); + }); + + it.each(FACES)('%s refuses the flat node at each flat key, naming `properties.KEY`', (_face, parse) => { + const issues = issuesOf(parse(FLAT)); + const byPath = new Map(issues.map((i) => [i.path.join('.'), i.message])); + expect([...byPath.keys()].sort()).toEqual(['chartType', 'objectName']); + expect(byPath.get('chartType')).toContain('`chartType` → `properties.chartType`'); + expect(byPath.get('objectName')).toContain('`objectName` → `properties.objectName`'); + // The prescription is the whole document shape, not only the path. + expect(byPath.get('chartType')).toContain('"properties": {'); + }); + + it('the bag is non-trivial, so the per-key rows below are not vacuous', () => { + expect(bagKeys().length).toBeGreaterThan(10); + }); + + // Every member of the bag but the three retired ones, read off the arm on + // each run — not a transcribed list that could drift from it. + const LIVE_KEYS = Object.keys(ObjectChartBlockSchema.shape.properties.unwrap().shape).filter( + (key) => !(RETIRED as readonly string[]).includes(key), + ); + + it.each(LIVE_KEYS.map((key) => [key] as const))('a flat `%s` is refused on the tolerant face, by name', (key) => { + const issues = issuesOf(safeValidateSchema({ ...SHOWCASE_BAR, [key]: true })); + const issue = issues.find((i) => i.path.join('.') === key); + expect(issue, JSON.stringify(issues)).toBeDefined(); + expect(issue!.message).toContain(`\`${key}\` → \`properties.${key}\``); + }); + + it.each(RETIRED.map((key) => [key] as const))( + 'a flat `%s` keeps its retirement, not a pointer at a retired bag member', + (key) => { + for (const [, parse] of FACES) { + const issue = issuesOf(parse({ ...SHOWCASE_BAR, [key]: 'x' })).find((i) => i.path.join('.') === key); + expect(issue?.message).toContain('RETIRED (objectui#10608'); + expect(issue?.message).not.toContain('Did you mean'); + } + }, + ); + + it.each([ + ['an invented key', 'inventedKey11276'], + ['the node-level `dataSource` this arm leaves undeclared (objectui#11070)', 'dataSource'], + ] as const)('%s written flat stays unjudged on the tolerant face and is refused on the strict face', (_label, key) => { + const doc = { ...SHOWCASE_BAR, [key]: { object: 'x' } }; + expect(safeValidateSchema(doc).success).toBe(true); + const issue = issuesOf(StrictAnyComponentSchema.safeParse(doc)).find((i) => i.code === 'unrecognized_keys'); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual([key]); + }); + + it('a `BaseSchema` key stays on the node, as on every arm (control)', () => { + for (const [, parse] of FACES) expect(parse({ ...SHOWCASE_BAR, className: 'min-h-0' }).success).toBe(true); + }); + + it.each(['body', 'children'] as const)('refuses the `%s` content channel by name (objectui#9256)', (key) => { + const issue = issuesOf(safeValidateSchema({ ...SHOWCASE_BAR, [key]: [{ type: 'text', content: 'x' }] }))[0]; + expect(issue.path).toEqual([key]); + expect(issue.message).toContain('objectui#9256'); + expect(issue.message).toContain('`object-chart`'); + }); +}); + +describe('the chart family is required, read in the bag (objectui#11276)', () => { + it.each([ + ['the bare node', { type: 'object-chart' }], + ['an empty bag', { type: 'object-chart', properties: {} }], + ['a bag with a dataset and no family', { type: 'object-chart', properties: { dataset: 'm', values: ['n'] } }], + ] as const)('%s is refused on both faces at `properties.chartType`', (_label, doc) => { + for (const [, parse] of FACES) { + const found = issuesOf(parse(doc)).filter((i) => i.code === 'custom'); + expect(found.map((i) => i.path)).toEqual([['properties', 'chartType']]); + expect(found[0].message).toContain('`properties.chartType`'); + } + }); + + it.each([ + ['`properties.chartType`', { chartType: 'pie' }], + ['`properties.specType`', { specType: 'pie' }], + ] as const)('%s alone names the family', (_label, props) => { + expect(safeValidateSchema({ type: 'object-chart', properties: props }).success).toBe(true); + }); + + it('a family written flat is not a family: it is refused by name and pointed at the bag', () => { + const issue = issuesOf(safeValidateSchema({ type: 'object-chart', chartType: 'bar' })).find( + (i) => i.path.join('.') === 'chartType', + ); + expect(issue?.message).toContain('`chartType` → `properties.chartType`'); + }); + + it('the spec has no such rule: its page component accepts the bare node (the recorded divergence)', () => { + expect(SpecPageComponentSchema.safeParse({ type: 'object-chart' }).success).toBe(true); + }); +}); + +describe('the arm moved; the post-hoist mirror stayed (objectui#11276)', () => { + const literalsOf = (union: { options: readonly unknown[] }) => + union.options.map((arm) => (arm as { shape: { type: z.ZodLiteral } }).shape.type.value); + + it('`object-chart` is armed by the bag arm, not by the flat mirror', () => { + expect(literalsOf(ObjectQLPublicBlockComponentSchema)).toContain('object-chart'); + expect(literalsOf(ObjectQLComponentSchema)).not.toContain('object-chart'); + expect(ObjectQLPublicBlockComponentSchema.options).toContain(ObjectChartBlockSchema); + }); + + it('the flat mirror is still published and still judges the post-hoist node', () => { + // It is what `ObjectChart` reads after the hoist, and what the composers + // build — neither is an authored `object-chart` node. + expect(ObjectChartSchema.safeParse(FLAT).success).toBe(true); + expect(ObjectChartSchema.safeParse({ ...FLAT, xAxisKey: 'stage', series: [{ dataKey: 'amount' }] }).success).toBe(true); + expect(ObjectChartSchema.safeParse({ ...FLAT, chartType: 'radar' }).success).toBe(false); + }); +}); diff --git a/packages/types/src/__tests__/object-chart-react-tier-node-10770.test.ts b/packages/types/src/__tests__/object-chart-react-tier-node-10770.test.ts index efdbfe2a7f..53e36a04d6 100644 --- a/packages/types/src/__tests__/object-chart-react-tier-node-10770.test.ts +++ b/packages/types/src/__tests__/object-chart-react-tier-node-10770.test.ts @@ -29,7 +29,10 @@ * (a) the spec side: the react block publishes `type` and `series` over * `ChartConfigSchema`, whose series arm is `{ name }`; * (b) the showcase node parses, on the mirror and through - * `safeValidateSchema` (the `objectui validate` door); + * `safeValidateSchema` (the `objectui validate` door) — at the door in + * the authored spelling, its props in the `properties` bag, since + * objectui#11276 armed the authored node with `ObjectChartBlockSchema`; + * the wrapper's flat node is the mirror's (post-hoist) reading; * (c) the controls: a series entry with neither `name` nor `dataKey`, and a * node that names no chart family, are refused; * (d) both new crossings are the spec's by reference, not look-alikes; @@ -67,6 +70,16 @@ const SHOWCASE_NODE = { type: 'object-chart', } as const; +/** + * The same props as an AUTHORED node writes them (objectui#11276): in the + * `properties` bag, the binding beside it at node level. The door judges this + * spelling; the wrapper's flat node above is what the mirror judges. + */ +const SHOWCASE_AUTHORED = (() => { + const { type, dataSource, ...props } = SHOWCASE_NODE; + return { type, dataSource, properties: props }; +})(); + const issuesOf = (doc: unknown) => { const r = ObjectChartMirror.safeParse(doc); if (r.success) throw new Error('expected REFUSE, got ACCEPT'); @@ -118,7 +131,7 @@ describe('objectui#10770 (b) — the react tier\'s showcase node parses', () => }); it('through `safeValidateSchema`, the parse `objectui validate` runs', () => { - const r = safeValidateSchema(SHOWCASE_NODE); + const r = safeValidateSchema(SHOWCASE_AUTHORED); expect(r.error?.issues ?? []).toEqual([]); expect(r.success).toBe(true); }); diff --git a/packages/types/src/__tests__/objectql-union-arms-7363.test.ts b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts index 81531e371f..4d8d013480 100644 --- a/packages/types/src/__tests__/objectql-union-arms-7363.test.ts +++ b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts @@ -84,10 +84,13 @@ describe('objectui#7363 — the two objectui#6576 schemas are arms of the Object // addition, not a reshuffle — less `object-form` and `object-map`, whose // authored arms left this union for `ObjectQLPublicBlockComponentSchema` in // objectui#10859 batches 4 and 5 (`object-form-properties-bag-10859-b4.test.ts`, - // `object-map-properties-bag-10859-b5.test.ts`). - expect(literals).toHaveLength(10); + // `object-map-properties-bag-10859-b5.test.ts`), and `object-chart`, whose + // authored arm left the same way in objectui#11276 + // (`object-chart-properties-bag-11276.test.ts`). + expect(literals).toHaveLength(9); expect(literals).not.toContain('object-form'); expect(literals).not.toContain('object-map'); + expect(literals).not.toContain('object-chart'); }); it.each([ diff --git a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts index ffd10ae3c5..7d617ab156 100644 --- a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts @@ -113,14 +113,17 @@ const DECLARES_DATA_SOURCE: ReadonlySet = new Set([ ]); /** - * The one arm that refuses a node with nothing but `responsiveStyles` for a - * reason that is not about the envelope: `object-map` keeps the flat mirror's + * The arms that refuse a node with nothing but `responsiveStyles` for a reason + * that is not about the envelope: `object-map` keeps the flat mirror's * record-source rule, read in its bag (objectui#10859 batch 5, - * `requireRecordSource`). Its node carries the smallest source; every other arm - * takes the bare node. + * `requireRecordSource`), and `object-chart` keeps the flat mirror's + * chart-family floor, read in its bag (objectui#11276, + * `requireObjectChartFamilyInBag`). Each node carries the smallest bag that + * satisfies its rule; every other arm takes the bare node. */ const RECORD_SOURCE: Readonly>> = { 'object-map': { properties: { objectName: 'store' } }, + 'object-chart': { properties: { chartType: 'bar' } }, }; /** The five envelope keys the spec declares and this batch leaves undeclared. */ diff --git a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts index 3b8585b389..6b1d927f97 100644 --- a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts +++ b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts @@ -352,13 +352,15 @@ describe('the spec-row arms read the row by reference (objectui#10859)', () => { union.options.map((arm) => arm.shape.type.value); // `object-timeline` joined the union in batch 3 (`object-timeline-arm-10859-b3.test.ts`), // `object-form` in batch 4 (`object-form-properties-bag-10859-b4.test.ts`), - // `object-map` in batch 5 (`object-map-properties-bag-10859-b5.test.ts`). + // `object-map` in batch 5 (`object-map-properties-bag-10859-b5.test.ts`), + // `object-chart` in objectui#11276 (`object-chart-properties-bag-11276.test.ts`). expect(literals(ObjectQLPublicBlockComponentSchema)).toEqual([ 'object-metric', 'object-master-detail-form', 'object-timeline', 'object-form', 'object-map', + 'object-chart', ]); expect(literals(DataDisplaySchema)).toContain('pivot'); }); diff --git a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts index c05212f17d..6b3078bb77 100644 --- a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts +++ b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts @@ -235,7 +235,9 @@ describe('objectui#11070 — the read keys left undeclared pending a ruling stay }); it('`object-chart.dataSource` is refused by name until it is declared (the react-page wrapper no longer puns the adapter into that key; the declaration and the objectui#10770 node pin move together)', () => { - const doc = { type: 'object-chart', objectName: 'task', chartType: 'bar', dataSource: { object: 'task' } }; + // objectui#11276: the authored node takes its props in the `properties` bag; + // `dataSource` stays node-level, and stays undeclared on that arm too. + const doc = { type: 'object-chart', properties: { objectName: 'task', chartType: 'bar' }, dataSource: { object: 'task' } }; expect(undeclared(issuesOf(StrictAnyComponentSchema, doc))).toEqual(['dataSource']); expect(issuesOf(AnyComponentSchema, doc)).toBeNull(); }); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 24c287508e..346b69b166 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4585,6 +4585,12 @@ const EXCLUSIONS: Readonly> = { // (`ObjectMapSchema`), which stays the renderer's post-hoist reading. 'objectql.zod.ts#ObjectMapBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `object-map` literal + `properties`, which IS `ComponentPropsMap['object-map']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + one `aliasKeyRefusal` per member of the row (the flat spelling, refused by name and pointed at `properties.KEY`; the key set is read off the row) plus two for the flat mirror's `locationField` / `titleField` (pointed at `properties.map.KEY`), + the objectui#9256 `body` / `children` refusals; the TS declaration of the node, `ObjectMapSchema` (`../objectql.ts`), is the renderer's post-hoist reading and is paired with the flat `objectql.zod.ts#ObjectMapSchema` mirror, not with this arm", + // objectui#11276 — the authored `object-chart` arm, built as batches 4 and 5's + // above but with NO spec row: its bag is the flat mirror's own members. Its TS + // twin is paired with that FLAT mirror (`ObjectChartSchema`), which stays the + // renderer's post-hoist reading. + 'objectql.zod.ts#ObjectChartBlockSchema': + "mirror-owned BY REFERENCE — `BaseSchema` + the `object-chart` literal + `NODE_ENVELOPE` + `properties`, whose members ARE the flat `objectql.zod.ts#ObjectChartSchema` mirror's own members (the same schema objects; `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row), + one `aliasKeyRefusal` per bag member (the flat spelling, refused by name and pointed at `properties.KEY`; the key set is read off the bag), + the mirror's own objectui#10608 tombstones and objectui#9256 `body` / `children` refusals; the TS declaration of the node, `ObjectChartSchema` (`../objectql.ts`), is the renderer's post-hoist reading and is paired with the flat mirror, not with this arm", 'objectql.zod.ts#ObjectQLPublicBlockComponentSchema': "a union OVER the public-block arms above, not an object of its own — its members are checked individually", 'overlay.zod.ts#MenuItemSchema': From 03b6e0d98f411fdbb7b3c7dd8e7366d39ee7be4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:53:28 +0000 Subject: [PATCH 3/3] docs(types): the zod README lists the object-chart bag arm, and the changeset declares the authoring break (objectui#11276) `@object-ui/types` minor with a BREAKING (authoring) banner: a flat authored `object-chart` is refused by name; the same props in `properties` parse. Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude --- .../11276-object-chart-properties-bag.md | 29 +++++++++++++++++++ packages/types/src/zod/README.md | 11 +++---- 2 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 .changeset/11276-object-chart-properties-bag.md diff --git a/.changeset/11276-object-chart-properties-bag.md b/.changeset/11276-object-chart-properties-bag.md new file mode 100644 index 0000000000..7de0a8b9c3 --- /dev/null +++ b/.changeset/11276-object-chart-properties-bag.md @@ -0,0 +1,29 @@ +--- +'@object-ui/types': minor +--- + +feat(types): an authored `object-chart` takes its props in the `properties` bag; the flat spelling is refused by name (objectui#11276) + +**BREAKING (authoring):** an `object-chart` node that writes its props on the node itself is now refused by `safeValidateSchema`, `validateSchema` and the strict authoring face, and so by `objectui validate`. Move each prop into the node's `properties` bag: + +```json +{ "type": "object-chart", "properties": { "chartType": "bar", "dataset": "task_metrics", "dimensions": ["status"], "values": ["task_count"] } } +``` + +Nothing changes at render time: `SchemaRenderer` hoists every `properties` key onto the node before `ObjectChart` runs, so the bag node draws what the flat one drew, and a node built in code (`ObjectView`, `ListView`, the dashboard renderers and the react-page wrapper all build a flat `object-chart` node) keeps working with its flat keys. + +**Clause-②: yes (narrowing)** — the accept set narrows (a flat `object-chart` node that parsed is refused) and widens (a bag node that was refused parses). Released as `minor` under the objectui narrowing rule, with this banner. + +**Why.** `@objectstack/spec`'s strict `PageComponentSchema` refuses a prop written on any page component itself as mis-layered (ADR-0089 D3a): `properties` is the only home of a component's props. The arm here was the flat mirror of the TypeScript `ObjectChartSchema`, so the two validators disagreed both ways: the objectstack showcase's command-center charts, written in the bag, were refused (the flat mirror found no chart family on the node), and the flat node `os validate` refuses was accepted. + +**What changed, in observable terms.** + +- `@object-ui/types/zod` exports `ObjectChartBlockSchema`, built the way `ObjectFormBlockSchema` and `ObjectMapBlockSchema` are: `BaseSchema`, the `object-chart` literal, the node-level `responsiveStyles`, and `properties`. One difference: `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row, so the spec's page component takes any bag on this type, and no row is invented here. The bag's members are the members `ObjectChartSchema` already declares (beyond the node base), the same schema objects, so their value checks and spec bindings (`aggregate`, `series`, `drillDown`, `xAxis`, `yAxis`) judge the bag exactly as they judged the flat node. +- Each of those members written flat on the node is refused on both faces, with a message that names its bag member (`Did you mean \`chartType\` → \`properties.chartType\`?`). The refusal set is read off the bag. The three list-view spellings retired by objectui#10608 (`xAxisField`, `yAxisFields`, `aggregation`) keep their retirement message, on the node and in the bag. +- The chart-family floor stays, read in the bag: the node needs `properties.chartType` (or `properties.specType`). A node with neither is refused at `properties.chartType`, as the flat node was refused at `chartType`. The spec has no such rule. +- A key the chart does not declare stays as before, one layer down: unjudged by the tolerant face, refused by name by the strict face. `BaseSchema`'s keys (`className`, `hidden`, …) stay on the node. `dataSource` stays undeclared on this node, as objectui#11070 left it. `body` / `children` stay refused (objectui#9256). +- `ObjectChartBlockSchema` reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`. `ObjectChartSchema` leaves `ObjectQLComponentSchema`. + +**The same release's `.changeset/10518-object-chart-y-axis-declared.md`, `.changeset/10608-object-chart-legacy-axis-keys-retired.md`, `.changeset/10770-object-chart-react-tier-node.md` and `.changeset/10872-flat-arm-responsive-styles.md`** describe what the validators say about an `object-chart` written flat. On an authored node those readings now happen in the bag: a malformed `xAxis` is refused at `properties.xAxis`, a retired key at `properties.KEY`, a missing family at `properties.chartType`; `responsiveStyles` stays node-level and is judged there as they say. The flat mirror they name still behaves as they say. + +**What did not move.** The TypeScript `ObjectChartSchema` and its zod mirror `ObjectChartSchema` stay published and unchanged in shape. They are the node as `ObjectChart` reads it after the hoist, and as code composes it or hands it to `` directly. diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index dc1b76a3f6..7677945bcb 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -231,19 +231,20 @@ function validateComponent(config: unknown) { - `KanbanSchema`, `CalendarViewSchema` - `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema` -### ObjectQL Public Blocks (5) -The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object-timeline`, `object-form` and `object-map`: each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. Each arm also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`, which all five registrations read through `ElementDataSourceGate`. None of the five renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). +### ObjectQL Public Blocks (6) +The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object-timeline`, `object-form`, `object-map` and `object-chart`. The first five arms' `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference; `object-chart` has no row (below). Each of those five arms also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`, which all five registrations read through `ElementDataSourceGate`. None of the six renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). `ObjectFormBlockSchema` is the authored `object-form` node (objectui#10859 batch 4): its props go in the `properties` bag, and each member of the row written flat on the node is refused by name, with a message naming `properties.KEY`. `ObjectFormSchema`, the flat mirror of the TypeScript `ObjectFormSchema`, stays exported as the node as `ObjectForm` reads it after `SchemaRenderer` hoists the bag; it is no longer an arm of `AnyComponentSchema`. `ObjectMapBlockSchema` is the authored `object-map` node, built the same way (objectui#10859 batch 5). The flat mirror's `locationField` and `titleField` are refused by name too, pointing at `properties.map.KEY`. The flat mirror's record-source rule stays, read in the bag: the node needs one of `properties.data`, `properties.staticData` or `properties.objectName`, or the node's `dataSource` binding. `ObjectMapSchema`, the flat mirror of the TypeScript `ObjectMapSchema`, stays exported as the node as `ObjectMap` reads it; it is no longer an arm of `AnyComponentSchema`. -- `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema`, `ObjectFormBlockSchema`, `ObjectMapBlockSchema` +`ObjectChartBlockSchema` is the authored `object-chart` node (objectui#11276), built the same way with one difference: `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row, so the bag's members are the flat `ObjectChartSchema` mirror's own members, by reference, and no row is invented. Each of them written flat on the node is refused by name, naming `properties.KEY`; the three list-view spellings objectui#10608 retired keep their retirement message instead. The flat mirror's chart-family floor stays, read in the bag: the node needs `properties.chartType` (or `properties.specType`). It does not declare `dataSource` (objectui#11070 left that binding undeclared on this node) and refuses both content channels. `ObjectChartSchema` stays exported as the node as `ObjectChart` reads it, and as the composers build it; it is no longer an arm of `AnyComponentSchema`. +- `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema`, `ObjectFormBlockSchema`, `ObjectMapBlockSchema`, `ObjectChartBlockSchema` ### AI Components (3) - `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` ### ADR-0080 Public Blocks (27) Each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. `ElementNumberBlockSchema` also declares the spec's `dataSource` binding, and mirrors the spec gate's one waiver: its bag may omit `object` when `dataSource.object` names the object. -Every arm here, and each of the five ObjectQL public blocks above, declares the node-level `responsiveStyles` (ADR-0065 per-breakpoint style maps, which `SchemaRenderer` compiles to scoped CSS) as the spec's `ResponsiveStylesSchema`, by reference, from one shared fragment, `NODE_ENVELOPE` (objectui#10872 batch 8; named `PUBLIC_BLOCK_ENVELOPE` until batch 9). The other node-level keys the spec's `PageComponentSchema` declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` outside the arms that read it) stay undeclared, so the strict face refuses them by name. -Outside this set, `FlexSchema`, `ObjectGridSchema` and `ObjectChartSchema` spread the same fragment (objectui#10872 batch 9), and their TypeScript twins declare the key as the spec's `ResponsiveStyles`: they are the arms outside this set that objectui#10872's producer reading found `responsiveStyles` written on (a dated reading, not re-derived here). `BaseSchema` and every other arm leave it undeclared, so the strict face refuses it there by name. +Every arm here, and each of the six ObjectQL public blocks above, declares the node-level `responsiveStyles` (ADR-0065 per-breakpoint style maps, which `SchemaRenderer` compiles to scoped CSS) as the spec's `ResponsiveStylesSchema`, by reference, from one shared fragment, `NODE_ENVELOPE` (objectui#10872 batch 8; named `PUBLIC_BLOCK_ENVELOPE` until batch 9). The other node-level keys the spec's `PageComponentSchema` declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` outside the arms that read it) stay undeclared, so the strict face refuses them by name. +Outside this set, `FlexSchema`, `ObjectGridSchema` and `ObjectChartSchema` spread the same fragment (objectui#10872 batch 9; `ObjectChartSchema` is the post-hoist mirror since objectui#11276, the authored arm being `ObjectChartBlockSchema` above), and their TypeScript twins declare the key as the spec's `ResponsiveStyles`: they are the arms outside this set that objectui#10872's producer reading found `responsiveStyles` written on (a dated reading, not re-derived here). `BaseSchema` and every other arm leave it undeclared, so the strict face refuses it there by name. Every arm except the four `page:` containers (`page:card`, `page:section`, `page:footer`, `page:sidebar`) refuses `children` and `body` by name, because its renderer reads neither content channel (objectui#9256; the last six below since objectui#10872 batch 5). The four containers do render a child list, and its home is `properties.children`, the member their spec row declares; they refuse the node-level `children` and `body` with a message naming it, as `@objectstack/spec`'s `PageComponentSchema` refuses a node-level `children` (objectui#10872 batch 6). Two carve-outs: `record:alert` refuses `children` with its own message, while its `body` is the message text and belongs in `properties`, so a flat `body` is refused with a pointer to `properties.body` rather than to `children` (objectui#10872); and `page:tabs` / `page:accordion` refuse the node's own `children` while each item's `children` in `items` stays live. - `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema` - `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema`