From c90b2a9ed4cd8822526ff591add5472fa64fec8c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 13:13:15 +0000 Subject: [PATCH 01/18] refactor(components,types,sdui-parser,core): retire the `body` child-list dialect `children` is the one spelling. Ruling steps 2-4 of the `body` retirement: the twelve `body`-only registrations converge on `children`, the wider `children || body` readers drop their `body` arm in the same change, the published type refuses the key by name on both faces, and the parser tier answers it with the replacement rather than with the warning a typo draws. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- .../components/src/renderers/basic/div.tsx | 2 +- .../src/renderers/data-display/alert.tsx | 2 +- .../src/renderers/data-display/badge.tsx | 2 +- .../components/src/renderers/form/button.tsx | 2 +- .../src/renderers/layout/aspect-ratio.tsx | 4 +- .../components/src/renderers/layout/box.tsx | 13 +-- .../components/src/renderers/layout/card.tsx | 2 +- .../src/renderers/layout/containers.tsx | 6 +- .../components/src/renderers/layout/page.tsx | 24 ++--- .../src/renderers/layout/semantic.tsx | 4 +- .../src/renderers/navigation/sidebar.tsx | 20 ++-- .../src/renderers/overlay/tooltip.tsx | 12 ++- .../core/src/validation/schema-validator.ts | 6 +- packages/sdui-parser/src/body-dialect.ts | 96 +++++++++++++++++++ packages/sdui-parser/src/index.ts | 1 + packages/sdui-parser/src/validate.ts | 31 ++++-- packages/types/src/base.ts | 42 +++++++- packages/types/src/zod/base.zod.ts | 20 +++- 18 files changed, 230 insertions(+), 59 deletions(-) create mode 100644 packages/sdui-parser/src/body-dialect.ts diff --git a/packages/components/src/renderers/basic/div.tsx b/packages/components/src/renderers/basic/div.tsx index aaf268f443..2c312b4af8 100644 --- a/packages/components/src/renderers/basic/div.tsx +++ b/packages/components/src/renderers/basic/div.tsx @@ -147,7 +147,7 @@ const DivRenderer = forwardRef - {renderChildren(schema.children || schema.body)} + {renderChildren(schema.children)} ); } diff --git a/packages/components/src/renderers/data-display/alert.tsx b/packages/components/src/renderers/data-display/alert.tsx index 5746aeb2ee..654addf37c 100644 --- a/packages/components/src/renderers/data-display/alert.tsx +++ b/packages/components/src/renderers/data-display/alert.tsx @@ -19,7 +19,7 @@ ComponentRegistry.register('alert', ({ schema, className, ...props }: { schema: AlertSchema; className?: string; [key: string]: any }) => ( {schema.title} - {schema.description || renderChildren(schema.body)} + {schema.description || renderChildren(schema.children)} ), { diff --git a/packages/components/src/renderers/data-display/badge.tsx b/packages/components/src/renderers/data-display/badge.tsx index c3ced6c8e4..29703092eb 100644 --- a/packages/components/src/renderers/data-display/badge.tsx +++ b/packages/components/src/renderers/data-display/badge.tsx @@ -29,7 +29,7 @@ ComponentRegistry.register('badge', // Apply designer props {...{ 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, style }} > - {schema.label || renderChildren(schema.body)} + {schema.label || renderChildren(schema.children)} ); }, diff --git a/packages/components/src/renderers/form/button.tsx b/packages/components/src/renderers/form/button.tsx index 0d461ce5e7..9bbc2e2e80 100644 --- a/packages/components/src/renderers/form/button.tsx +++ b/packages/components/src/renderers/form/button.tsx @@ -70,7 +70,7 @@ const ButtonRenderer = forwardRef( > {isLoading && } {!isLoading && Icon && schema.iconPosition !== 'right' && } - {schema.label || renderChildren(schema.body || schema.children)} + {schema.label || renderChildren(schema.children)} {!isLoading && Icon && schema.iconPosition === 'right' && } ); diff --git a/packages/components/src/renderers/layout/aspect-ratio.tsx b/packages/components/src/renderers/layout/aspect-ratio.tsx index 9eab52c38c..b2656b2ef3 100644 --- a/packages/components/src/renderers/layout/aspect-ratio.tsx +++ b/packages/components/src/renderers/layout/aspect-ratio.tsx @@ -30,7 +30,7 @@ ComponentRegistry.register('aspect-ratio', {schema.image ? ( {schema.alt ) : ( - renderChildren(schema.children || schema.body) + renderChildren(schema.children) )} ); @@ -39,7 +39,7 @@ ComponentRegistry.register('aspect-ratio', namespace: 'ui', label: 'Aspect Ratio', // Same declaration, same reason as `semantic.tsx` (objectui#6764): the - // renderer falls back to `renderChildren(schema.children || schema.body)` + // renderer falls back to `renderChildren(schema.children)` // whenever no `image` is set, so a ratio box wrapping authored content is // the documented shape -- and it drew `not-a-container` for it. Not in // `PUBLIC_BLOCKS`, so the react-page scope builder never saw this tag and diff --git a/packages/components/src/renderers/layout/box.tsx b/packages/components/src/renderers/layout/box.tsx index 219b9b6359..dd9f71637a 100644 --- a/packages/components/src/renderers/layout/box.tsx +++ b/packages/components/src/renderers/layout/box.tsx @@ -30,12 +30,13 @@ import { forwardRef } from 'react'; * 2. the authored `className` passes through VERBATIM; * 3. zero injected classes — no `cn()` merge, no default class, nothing. * - * Deliberately unlike `div`, this renderer reads `children` ONLY — never - * `schema.body`. The `div` renderer's `children || body` fallback is exactly - * what made a mechanical `div`→X swap unsafe (four catalog fixtures authored - * `body` and would have lost their content silently, with an unchanged element - * count); `box` keeps the one spelling so that hazard cannot re-grow. Content - * on a migrated node moves into `children` — the #6771 B-ruling direction. + * This renderer reads `children` ONLY, and since objectui#6771 retired the + * `body` dialect every other renderer does too — `box` is no longer the + * exception it was written as. The hazard it was minted against was `div`'s + * `children || body` fallback, which made a mechanical `div`→X swap unsafe + * (four catalog fixtures authored `body` and would have lost their content + * silently, with an unchanged element count). That fallback is retired; the + * one spelling is now the protocol's, not just this renderer's. */ // Index signature on the parameter annotation, not on the `forwardRef` type // argument — mechanism note on `action:bar` (objectui#4422), pinned by diff --git a/packages/components/src/renderers/layout/card.tsx b/packages/components/src/renderers/layout/card.tsx index 598c600b03..6f9696e041 100644 --- a/packages/components/src/renderers/layout/card.tsx +++ b/packages/components/src/renderers/layout/card.tsx @@ -69,7 +69,7 @@ const CardRenderer = forwardRef ( + {renderNodeSlot(schema.children, (body) => ( {renderChildren(body)} ))} {renderNodeSlot(schema.footer, (footer) => ( diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index c17512562c..09ef597af1 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -438,11 +438,13 @@ const collectRelatedLists = (nodes: any, acc: any[] = []): any[] => { acc.push(n); continue; // Don't descend into a related_list's own subtree. } + // `body` left this list with objectui#6771's retirement of the dialect; + // `items` stays — it is the `list` registration's own item channel and + // its own card (objectui#9590), not this spelling. const candidates = [ n.children, n.properties?.children, n.properties?.items, - n.body, n.items, ]; for (const c of candidates) { @@ -479,7 +481,7 @@ const containsAttachmentsNode = (nodes: any): boolean => { for (const n of list) { if (!n || typeof n !== 'object') continue; if (n.type === 'record:attachments') return true; - const candidates = [n.children, n.properties?.children, n.properties?.items, n.body, n.items]; + const candidates = [n.children, n.properties?.children, n.properties?.items, n.items]; for (const c of candidates) { if (c && containsAttachmentsNode(c)) return true; } diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx index 48fb71622b..d19c242d95 100644 --- a/packages/components/src/renderers/layout/page.tsx +++ b/packages/components/src/renderers/layout/page.tsx @@ -8,8 +8,8 @@ * The Page renderer interprets PageSchema into structured layouts. * It supports four page types (record, home, app, utility) and * renders named regions (header, sidebar, main, footer, aside) with - * configurable widths. When no regions are defined, it falls back to - * body/children for backward compatibility. + * configurable widths. When no regions are defined, it falls back to the + * node's `children` list (objectui#6771 retired the `body` spelling). */ import React, { useMemo } from 'react'; @@ -123,9 +123,9 @@ function isTitledPageHeader(node: any): boolean { /** * Depth-bounded walk over the component shapes a page can nest. * - * Takes ONE node or a LIST of them, and judges both by the same rule. `body` - * and `children` are declared `SchemaNode | SchemaNode[]` on `PageNodeSchema` - * and on `BaseSchema`, and `FlatContent` below has always rendered the bare-node + * Takes ONE node or a LIST of them, and judges both by the same rule. + * `children` is declared `SchemaNode | SchemaNode[]` on `PageNodeSchema` and on + * `BaseSchema`, and `FlatContent` below has always rendered the bare-node * form, so a single node is a first-class authored shape — at the top level and * at every nested level the recursion re-enters (objectui#8923). This used to * open with `if (!Array.isArray(nodes)) return false`, so every bare-node @@ -148,8 +148,7 @@ function containsTitledPageHeader(nodes: unknown, depth = 0): boolean { typeof n === 'object' && (isTitledPageHeader(n) || containsTitledPageHeader(n.components, depth + 1) || - containsTitledPageHeader(n.children, depth + 1) || - containsTitledPageHeader(n.body, depth + 1)), + containsTitledPageHeader(n.children, depth + 1)), ); } @@ -176,7 +175,6 @@ function pageHeaderOwnsTitle(schema: PageNodeSchema): boolean { const regionNodes = (schema.regions ?? []).flatMap((r: any) => r?.components ?? []); return ( containsTitledPageHeader(regionNodes) || - containsTitledPageHeader(schema.body) || containsTitledPageHeader(schema.children) ); } @@ -278,11 +276,11 @@ const RegionLayout: React.FC<{ }; // --------------------------------------------------------------------------- -// FlatContent — legacy body/children fallback +// FlatContent — the `children` fallback when a page declares no regions // --------------------------------------------------------------------------- const FlatContent: React.FC<{ schema: PageNodeSchema }> = ({ schema }) => { - const content = schema.body || schema.children; + const content = schema.children; const nodes: SchemaNode[] = Array.isArray(content) ? content : content @@ -701,7 +699,11 @@ const pageMeta: any = { itemType: 'object', }, { - name: 'body', + // The flat content list `FlatContent` renders when a page declares no + // regions. Published as `body` until objectui#6771 retired that + // spelling; `pageMeta` backs five registrations, so this one line is + // the authoring face of `page` / `app` / `utility` / `home` / `record`. + name: 'children', type: 'array', itemType: 'component', }, diff --git a/packages/components/src/renderers/layout/semantic.tsx b/packages/components/src/renderers/layout/semantic.tsx index 61d3abfe0e..266b78053a 100644 --- a/packages/components/src/renderers/layout/semantic.tsx +++ b/packages/components/src/renderers/layout/semantic.tsx @@ -37,7 +37,7 @@ tags.forEach(tag => { {...restProps} {...{ 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, style }} > - {renderChildren(schema.children || schema.body)} + {renderChildren(schema.children)} ); }); @@ -48,7 +48,7 @@ tags.forEach(tag => { label: tag.charAt(0).toUpperCase() + tag.slice(1), category: 'layout', // Declared because the factory above RENDERS a child list - // (`renderChildren(schema.children || schema.body)`), which is the whole + // (`renderChildren(schema.children)`), which is the whole // question this flag answers -- `children` is a BASE property of every // node in the JSON protocol (`BASE_PROPS` in `sdui-parser/src/validate.ts`), // not a per-component authoring key, so declaring it widens no spec diff --git a/packages/components/src/renderers/navigation/sidebar.tsx b/packages/components/src/renderers/navigation/sidebar.tsx index bb0b018506..da22a7573f 100644 --- a/packages/components/src/renderers/navigation/sidebar.tsx +++ b/packages/components/src/renderers/navigation/sidebar.tsx @@ -40,7 +40,7 @@ import { ComponentRegistry.register('sidebar-provider', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -56,7 +56,7 @@ ComponentRegistry.register('sidebar-provider', ComponentRegistry.register('sidebar', ({ schema, ...props }: { schema: SidebarSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -76,7 +76,7 @@ ComponentRegistry.register('sidebar', ComponentRegistry.register('sidebar-header', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -86,7 +86,7 @@ ComponentRegistry.register('sidebar-header', ComponentRegistry.register('sidebar-content', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -109,7 +109,7 @@ ComponentRegistry.register('sidebar-group', {resolveInlineI18nLabel(schema.label, locale)} )} - {renderChildren(schema.body)} + {renderChildren(schema.children)} ); @@ -128,7 +128,7 @@ ComponentRegistry.register('sidebar-group', ComponentRegistry.register('sidebar-menu', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -138,7 +138,7 @@ ComponentRegistry.register('sidebar-menu', ComponentRegistry.register('sidebar-menu-item', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -161,7 +161,7 @@ ComponentRegistry.register('sidebar-menu-button', {...toFormControlDomProps(buttonProps)} style={style} > - {renderChildren(schema.body)} + {renderChildren(schema.children)} ); }, @@ -181,7 +181,7 @@ ComponentRegistry.register('sidebar-menu-button', ComponentRegistry.register('sidebar-footer', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -191,7 +191,7 @@ ComponentRegistry.register('sidebar-footer', ComponentRegistry.register('sidebar-inset', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', diff --git a/packages/components/src/renderers/overlay/tooltip.tsx b/packages/components/src/renderers/overlay/tooltip.tsx index 769c550284..79283a2b9b 100644 --- a/packages/components/src/renderers/overlay/tooltip.tsx +++ b/packages/components/src/renderers/overlay/tooltip.tsx @@ -26,7 +26,7 @@ ComponentRegistry.register('tooltip', {renderTriggerSlot(TooltipTrigger, schema.trigger)} - {(schema.content || renderChildren(schema.body)) as any} + {(schema.content || renderChildren(schema.children)) as any} @@ -46,9 +46,13 @@ ComponentRegistry.register('tooltip', name: 'content', type: 'string', }, - { - name: 'body', - type: 'slot' }, + // The rich-content slot. This registration published `body` and was + // the one place on the whole authoring surface that ADVERTISED the + // dialect in its `inputs`; objectui#6771 retired it, so the slot is + // declared under the one spelling the protocol keeps. Same two-sided + // move as the `page:card` retirement pinned by + // `components/src/__tests__/page-container-authorable-keys.test.tsx`. + { name: 'children', type: 'slot' }, { name: 'className', type: 'string' } ], defaultProps: { diff --git a/packages/core/src/validation/schema-validator.ts b/packages/core/src/validation/schema-validator.ts index 380217743f..cf11d741b8 100644 --- a/packages/core/src/validation/schema-validator.ts +++ b/packages/core/src/validation/schema-validator.ts @@ -464,7 +464,11 @@ function validateChildren( ): SchemaNodeValidationError[] { const errors: SchemaNodeValidationError[] = []; - const children = schema.children || schema.body; + // One spelling. This walker resolved `children || body` for ANY node type, + // so it outlived every per-registration read of the dialect — objectui#6771 + // retired it, and a recursive validator that still descended `body` would + // keep validating a child list no renderer puts on the page. + const children = schema.children; if (children) { if (Array.isArray(children)) { children.forEach((child: unknown, index: number) => { diff --git a/packages/sdui-parser/src/body-dialect.ts b/packages/sdui-parser/src/body-dialect.ts new file mode 100644 index 0000000000..dd09d77ad4 --- /dev/null +++ b/packages/sdui-parser/src/body-dialect.ts @@ -0,0 +1,96 @@ +/** + * ObjectUI — the retired `body` child-list dialect (objectui#6771) + * + * `body` was a declared twin of `children` on `BaseSchema`, and for a long + * while it was the ONLY child-list key a dozen registrations read. This tier + * never knew it: `BASE_PROPS` lists `children` and not `body`, so the one + * spelling that resolved drew `unknown-prop` — the same warning a typo draws, + * on the tier built to accept AI-authored pages, where the diagnostic IS the + * contract. The maintainer ruled the retirement (2026-09-01, option B): one + * concept, one spelling, and the spelling is `children`. + * + * This module is the tier's half of that ruling. It REPLACES the diagnostic the + * prop walk would otherwise emit for `body`, rather than adding to it — two + * diagnostics for one mistake is the shape `checkMemberTypes` already refuses + * (objectui#8067), and it is the shape `checkKanbanQuickAdd` is written against + * next door. + * + * WHY A REPLACEMENT AND NOT A BARE `unknown-prop`. After the retirement + * "`` has no prop body" is TRUE — but it is the answer that sent authors + * the wrong way in the first place. Every `body` node in this repository's own + * docs, examples and READMEs was migrated in the same change, and an author + * carrying the old spelling in from anywhere else needs the replacement NAMED, + * not merely the key refused. The two codes below are the ruling's own words: + * the tier teaches `children` only, and a `body` child list under a + * non-container draws the same `not-a-container` the `children` spelling draws. + * + * SCOPE — deliberately the opposite of `checkKanbanQuickAdd`'s. That one is + * asked AHEAD of the declaration lookup, because its claim is about the render + * path and declaring the key must not disarm it. This one is asked INSIDE the + * `!input` branch, because its claim is about a key nobody declares: a + * component that publishes its own `body` input — `detail`'s section `body` + * takes an inline translation map, and that is not this spelling — keeps its + * declared type check untouched. + */ + +import type { Diagnostic, SchemaNode } from './types.js'; + +/** The child-list spelling objectui#6771 retired. `children` replaced it. */ +export const RETIRED_CHILD_LIST_KEY = 'body'; + +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +/** + * Is this value shaped like the child list the dialect used to carry? + * + * A list of nodes, or the bare single node `FlatContent` and `renderChildren` + * have always accepted. A scalar is not — `body="hi"` is a dead key and nothing + * more, so it does not get the containment sentence, which would name a shape + * its author did not write. + */ +const isChildList = (value: unknown): value is SchemaNode | SchemaNode[] => + Array.isArray(value) || isPlainObject(value); + +/** + * The diagnostic for an authored `body`, or `null` when this module has nothing + * to say about the key. + * + * @param tag the node's component type, for the message and the `tag` field + * @param key the authored prop name + * @param value its authored value + * @param isContainer whether the manifest says this component accepts children + */ +export function checkRetiredBodyDialect( + tag: string, + key: string, + value: unknown, + isContainer: boolean | undefined, +): Diagnostic | null { + if (key !== RETIRED_CHILD_LIST_KEY) return null; + + if (isChildList(value) && !isContainer) { + // The ruling, verbatim: "a `body` child list under a non-container draws + // the same `not-a-container` the `children` spelling draws". Same code, so + // a consumer keying on the code cannot tell the two spellings apart — which + // is the single-spelling rule stated in the diagnostic stream. The sentence + // carries the extra fact the `children` path has no need of. + return { + severity: 'warning', + code: 'not-a-container', + message: + `<${tag}> does not accept children — and "body" is the retired spelling of ` + + `"children" (objectui#6771), so this child list has no door here at all`, + tag, + }; + } + + return { + severity: 'warning', + code: 'unknown-prop', + message: + `<${tag}> has no prop "body" — the child-list key is "children" ` + + `("body" was retired by objectui#6771)`, + tag, + }; +} diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts index 91b19dab8d..4a53bd618a 100644 --- a/packages/sdui-parser/src/index.ts +++ b/packages/sdui-parser/src/index.ts @@ -25,6 +25,7 @@ export { export { generateDts, propsName, generateBlockList } from './codegen.js'; export type { CodegenOptions } from './codegen.js'; export { inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES } from './input-type.js'; +export { checkRetiredBodyDialect, RETIRED_CHILD_LIST_KEY } from './body-dialect.js'; import { parseJsx } from './parse.js'; import { validateTree } from './validate.js'; diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts index c95691430c..c649d27315 100644 --- a/packages/sdui-parser/src/validate.ts +++ b/packages/sdui-parser/src/validate.ts @@ -19,8 +19,17 @@ import type { import { inputTypeArms } from './input-type.js'; import { checkDashboardWidgetOptions } from './dashboard-widget-options.js'; import { checkKanbanQuickAdd } from './kanban-quick-add.js'; +import { checkRetiredBodyDialect } from './body-dialect.js'; -/** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */ +/** + * Base props every node may carry (mirrors BaseSchema) — never "unknown prop". + * + * ⛔ `body` is NOT here and must not be added. It was `BaseSchema`'s second + * child-list spelling until objectui#6771 retired it; teaching this set the key + * was the option that ruling refused, because it would have blessed a second + * permanent spelling of one concept. `./body-dialect.ts` answers it by name + * instead. + */ const BASE_PROPS = new Set([ 'type', 'id', @@ -88,12 +97,20 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Ma } const input = byName.get(key); if (!input) { - diagnostics.push({ - severity: 'warning', - code: 'unknown-prop', - message: `<${node.type}> has no prop "${key}"`, - tag: node.type, - }); + // The retired `body` child-list dialect gets its replacement named + // rather than the bare "has no prop" every typo gets + // (objectui#6771). Asked INSIDE this branch, so a component that + // declares its own `body` input keeps its declared type check — + // mechanism in `./body-dialect.ts`. + const retiredBody = checkRetiredBodyDialect(node.type, key, value, comp.isContainer); + diagnostics.push( + retiredBody ?? { + severity: 'warning', + code: 'unknown-prop', + message: `<${node.type}> has no prop "${key}"`, + tag: node.type, + }, + ); continue; } if (input.binding) { diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index bcebba9b90..548244c121 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -299,14 +299,46 @@ export interface BaseSchema { bind?: string; /** - * Child components or content. - * Can be a single component, array of components, or primitive values. + * RETIRED (objectui#6771, maintainer ruling 2026-09-01, ADR-0049) — the + * second spelling of {@link BaseSchema.children}. Author `children`. + * + * ## Why it is refused by name rather than merely deleted + * + * `BaseSchema` carries an index signature on the TS side and + * `BaseSchemaCore` ends `.passthrough()` on the zod side, so DELETING a + * member does not refuse it — it makes it silently acceptable and + * silently inert, which is the state this retirement exists to leave. The + * `?: never` twin of the mirror's `aliasKeyRefusal` is what turns the + * removal into an answer: `tsc` refuses the key at the authoring site and + * the parse names `children` as the remedy. + * + * ## What it used to be + * + * A declared twin of `children`, and for a dozen registrations — `badge`, + * `alert`, the `sidebar-*` family, `tooltip` — the ONLY child-list key + * their renderer read, while `div` / `card` / `page` / `button` / + * `aspect-ratio` / the sectioning tags read `children || body` and took + * either. One concept with two spellings is the lenient-fallback shape + * AGENTS.md #0.1 names, and the parser tier only ever knew `children`, so + * the one spelling that resolved drew the warning a typo draws. The + * ruling retired the spelling rather than blessing it: the registrations + * converged on `children` and this repository's own corpus and teaching + * moved in the same change. + * + * @deprecated Retired spelling of `children` — author `children`. */ - body?: SchemaNode | SchemaNode[]; + body?: never; /** - * Alternative name for children (React-style). - * Some components use 'children' instead of 'body'. + * Child components or content — THE child-list key, and since + * objectui#6771 the only one. Can be a single component, an array of + * components, or primitive values. + * + * This docblock used to read "alternative name for children" and admit + * that "some components use 'children' instead of 'body'" without saying + * which — a sentence that was itself load-bearing evidence on three + * separate cards, because it told an author both spellings were live and + * left them to guess per component. */ children?: SchemaNode | SchemaNode[]; diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index bf2529370f..ba3da6c9f7 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -18,7 +18,7 @@ import { z } from 'zod'; import { I18nLabelSchema } from '@objectstack/spec/ui'; -import { retirementTombstone } from './tombstone.zod.js'; +import { aliasKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { ExpressionWireSchema } from './expression.zod.js'; import type { SchemaNode } from '../base.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -369,12 +369,24 @@ const BaseSchemaCore = z.object({ bind: z.string().optional().describe('Data-scope binding path (resolved by useDataScope)'), /** - * Child components or content + * RETIRED (objectui#6771) — mirrors `BaseSchema.body: never` (`../base.ts`). + * + * A bare deletion would be a silent accept, not a refusal: this object ends + * `.passthrough()`, so an undeclared `body` parses green and reaches a + * renderer that no longer reads it. The alias refusal keeps the key + * declared and unwritable, and names `children` in the issue message — + * the remedy, not just the rejection. */ - body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components'), + body: aliasKeyRefusal( + 'body', + 'children', + 'this node', + 'The `body` child-list spelling was retired by objectui#6771 — one concept, ' + + 'one spelling. Every registration that read it now reads `children`.', + ), /** - * Alternative children property + * Child components or content — the one child-list spelling (objectui#6771) */ children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components (React-style)'), From 7a7af8f67a8fcc785ee8eb468583a9a69c3a0e11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 13:17:58 +0000 Subject: [PATCH 02/18] docs(corpus,vscode-extension): migrate the `body` child-list corpus and teaching to `children` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruling binds corpus and teaching to the same commit as the retirement — the platform never refuses a spelling it still ships. 114 authored nodes move across the docs site, the schema catalog, five package READMEs and the VS Code extension's snippets, JSON schema, syntax map, hovers and completions; the extension's preview and validator drop the `body` arm their own comments deferred to this card. Left alone, each for a stated reason: HTTP and email `body` values, `detail`'s own activity `body`, the item-level `body` on `tabs` items and `dashboard` widgets (a different key, and objectui#9590's family), and the census instrument's own fixtures. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- README.md | 2 +- content/docs/api/schema-reference.md | 24 +++++++------- content/docs/components/feedback/toaster.mdx | 2 +- content/docs/components/index.md | 2 +- content/docs/core/schema-renderer.mdx | 2 +- content/docs/guide/architecture.md | 4 +-- content/docs/guide/layout.md | 18 +++++------ content/docs/guide/quick-start.md | 2 +- content/docs/guide/schema-playground.md | 14 ++++---- content/docs/guide/schema-rendering.md | 20 ++++++------ content/docs/plugins/plugin-dashboard.mdx | 2 +- content/docs/utilities/cli.mdx | 8 ++--- content/docs/utilities/runner.mdx | 6 ++-- .../basic-sidebar.json | 28 ++++++++-------- .../collapsible-sidebar.json | 24 +++++++------- .../grouped-sidebar.json | 32 +++++++++---------- .../sidebar-with-badges.json | 28 ++++++++-------- .../documentation-page.json | 2 +- .../full-dashboard.json | 2 +- .../page-with-header.json | 2 +- .../settings-layout.json | 2 +- .../components-layout-page/simple-page.json | 2 +- .../test/deprecated-component-types.test.ts | 2 +- .../test/overlay-trigger-mirror-6939.test.tsx | 2 +- packages/components/README.md | 2 +- packages/components/docs/FilterBuilder.md | 4 +-- packages/core/README.md | 2 +- packages/runner/README.md | 8 ++--- packages/vscode-extension/DESIGN.md | 2 +- packages/vscode-extension/README.md | 4 +-- .../schemas/objectui-schema.json | 2 +- .../vscode-extension/snippets/objectui.json | 24 +++++++------- .../src/providers/CompletionProvider.ts | 8 ++--- .../src/providers/HoverProvider.ts | 8 ++--- .../src/providers/PreviewProvider.ts | 21 ++++++------ .../src/providers/SchemaValidator.ts | 28 ++++++---------- .../syntaxes/objectui.tmLanguage.json | 2 +- 37 files changed, 169 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index 38bb5c280a..e35c4a93e4 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ import '@object-ui/components' const schema = { type: "page", title: "Dashboard", - body: { + children: { type: "grid", columns: 3, children: [ diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 3379a61a2b..50a292cb34 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -60,7 +60,7 @@ All schema types extend `BaseSchema`. These shared properties are available on e "disabledOn": "${data.isLocked}", "testId": "wrapper-element", "ariaLabel": "Content wrapper", - "body": [] + "children": [] } ``` @@ -118,11 +118,11 @@ Top-level page container. Defines a full page with optional regions (header, sid "regions": [ { "name": "header", - "body": [{ "type": "text", "content": "Welcome back" }] + "children": [{ "type": "text", "content": "Welcome back" }] } ], - "body": [ - { "type": "card", "title": "Activity", "body": [] } + "children": [ + { "type": "card", "title": "Activity", "children": [] } ] } ``` @@ -181,7 +181,7 @@ A styled container with optional header, body, and footer regions. "header": [ { "type": "badge", "label": "Live", "variant": "secondary" } ], - "body": [ + "children": [ { "type": "statistic", "label": "Total Revenue", "value": "$12,400" } ], "footer": [ @@ -215,9 +215,9 @@ A responsive grid layout. Columns can be a fixed number or responsive breakpoint "columns": { "sm": 1, "md": 2, "lg": 3 }, "gap": 6, "children": [ - { "type": "card", "title": "Card 1", "body": [] }, - { "type": "card", "title": "Card 2", "body": [] }, - { "type": "card", "title": "Card 3", "body": [] } + { "type": "card", "title": "Card 1", "children": [] }, + { "type": "card", "title": "Card 2", "children": [] }, + { "type": "card", "title": "Card 3", "children": [] } ] } ``` @@ -246,7 +246,7 @@ A tabbed interface for organizing content into switchable panels. "value": "overview", "label": "Overview", "icon": "Info", - "content": { "type": "div", "body": [{ "type": "text", "content": "Overview content" }] } + "content": { "type": "div", "children": [{ "type": "text", "content": "Overview content" }] } }, { "value": "settings", @@ -1263,7 +1263,7 @@ Schemas are designed to compose. Nest any `SchemaNode` inside another to build c { "type": "page", "title": "CRM Dashboard", - "body": [ + "children": [ { "type": "grid", "columns": { "sm": 1, "lg": 2 }, @@ -1272,7 +1272,7 @@ Schemas are designed to compose. Nest any `SchemaNode` inside another to build c { "type": "card", "title": "Quick Stats", - "body": { + "children": { "type": "dashboard", "columns": 2, "widgets": [ @@ -1284,7 +1284,7 @@ Schemas are designed to compose. Nest any `SchemaNode` inside another to build c { "type": "card", "title": "Recent Activity", - "body": { + "children": { "type": "tabs", "items": [ { "value": "deals", "label": "Deals", "content": { "type": "table", "columns": [], "data": [] } }, diff --git a/content/docs/components/feedback/toaster.mdx b/content/docs/components/feedback/toaster.mdx index 304334d86e..08502f98ab 100644 --- a/content/docs/components/feedback/toaster.mdx +++ b/content/docs/components/feedback/toaster.mdx @@ -42,7 +42,7 @@ interface ToasterSchema { className: 'min-h-screen', children: [ // Your app content - { type: 'page', title: 'Dashboard', body: [...] }, + { type: 'page', title: 'Dashboard', children: [...] }, // Toaster at root level { type: 'toaster' } diff --git a/content/docs/components/index.md b/content/docs/components/index.md index 8e38865de6..c3e1633a2f 100644 --- a/content/docs/components/index.md +++ b/content/docs/components/index.md @@ -140,7 +140,7 @@ All ObjectUI components follow the same schema-based pattern: "type": "card", "title": "User Profile", "className": "max-w-md", - "body": { + "children": { "type": "form", "fields": [ { diff --git a/content/docs/core/schema-renderer.mdx b/content/docs/core/schema-renderer.mdx index 59129201aa..c3e23f1e81 100644 --- a/content/docs/core/schema-renderer.mdx +++ b/content/docs/core/schema-renderer.mdx @@ -84,7 +84,7 @@ function App() { const schema: PageNodeSchema = { type: 'page', title: 'Dashboard', - body: [ + children: [ { type: 'text', content: 'Welcome to your dashboard' } ] }; diff --git a/content/docs/guide/architecture.md b/content/docs/guide/architecture.md index 5ae2d81fc6..d68b127584 100644 --- a/content/docs/guide/architecture.md +++ b/content/docs/guide/architecture.md @@ -121,7 +121,7 @@ A backend system sends a JSON schema: { "type": "card", "title": "Welcome", - "body": { + "children": { "type": "text", "content": "Hello, ${user.name}!" } @@ -151,7 +151,7 @@ import type { BaseSchema } from '@object-ui/types' const schema: BaseSchema = { type: 'card', title: 'Welcome', - body: { + children: { type: 'text', content: 'Hello, ${user.name}!', }, diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index de515f406b..7ddc782641 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -134,7 +134,7 @@ The `Page` component provides a consistent wrapper for individual pages with opt "type": "page", "title": "User Management", "description": "Manage users and permissions", - "body": { + "children": { "type": "container", "children": [ { "type": "text", "content": "User list goes here" } @@ -151,7 +151,7 @@ A `page` node has no action row of its own. Buttons are NODES, and they go in `b { "type": "page", "title": "Products", - "body": [ + "children": [ { "type": "flex", "justify": "end", @@ -217,7 +217,7 @@ and `button.tsx`, which reads `schema.label`, renders a button with no text. // NO `breadcrumbs` — refused by name (objectui#8871); put a `breadcrumb` node in `body` // Content - body: SchemaNode, // Main page content + children: SchemaNode, // Main page content // Layout options maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full', @@ -239,7 +239,7 @@ Control page content width: "type": "page", "title": "Settings", "maxWidth": "lg", // Centered content with max width - "body": { + "children": { "type": "form", "fields": [...] } @@ -509,7 +509,7 @@ Omit `sidebar` and the content fills the width under the top bar. "type": "page", "title": "Settings", "maxWidth": "2xl", - "body": { + "children": { "type": "tabs", "tabs": [ { @@ -541,7 +541,7 @@ Same rule as above, and it governs the trail too: the breadcrumb and the buttons { "type": "page", "title": "Acme Corporation", - "body": [ + "children": [ { "type": "breadcrumb", "items": [ @@ -661,7 +661,7 @@ Control page content padding: { "type": "page", "padding": false, // Remove default padding - "body": { + "children": { "type": "container", "className": "p-8", // Custom padding "children": [...] @@ -718,7 +718,7 @@ Place primary actions in the first `body` node, so they sit above the content: { "type": "page", "title": "Orders", - "body": [ + "children": [ { "type": "flex", "justify": "end", @@ -744,7 +744,7 @@ Use constrained width for forms and reading content: { "type": "page", "maxWidth": "lg", // Better for forms - "body": { + "children": { "type": "form", "fields": [...] } diff --git a/content/docs/guide/quick-start.md b/content/docs/guide/quick-start.md index 915f89758b..879cb3f98b 100644 --- a/content/docs/guide/quick-start.md +++ b/content/docs/guide/quick-start.md @@ -88,7 +88,7 @@ const schema: CardSchema = { title: 'Team Directory', description: 'Rendered from JSON metadata', className: 'mx-auto max-w-3xl', - body: { + children: { type: 'data-table', caption: 'Users', columns: [ diff --git a/content/docs/guide/schema-playground.md b/content/docs/guide/schema-playground.md index 7c15edac72..98af994e66 100644 --- a/content/docs/guide/schema-playground.md +++ b/content/docs/guide/schema-playground.md @@ -93,7 +93,7 @@ Try changing `"variant"` to `"destructive"`, `"outline"`, `"secondary"`, or `"gh ### Card -A content card with a header, description, and body: +A content card with a header, a description, and a `children` content list: ```json { @@ -101,7 +101,7 @@ A content card with a header, description, and body: "title": "Monthly Revenue", "description": "Revenue summary for the current month", "icon": "dollar-sign", - "body": { + "children": { "type": "stack", "direction": "col", "gap": 4, @@ -207,7 +207,7 @@ Schemas can be nested to build complex layouts. Here is a dashboard that combine { "type": "page", "title": "Project Dashboard", - "body": { + "children": { "type": "grid", "columns": 3, "gap": "md", @@ -215,7 +215,7 @@ Schemas can be nested to build complex layouts. Here is a dashboard that combine { "type": "card", "title": "Open Issues", - "body": { + "children": { "type": "text", "content": "24", "className": "text-4xl font-bold text-primary" @@ -224,7 +224,7 @@ Schemas can be nested to build complex layouts. Here is a dashboard that combine { "type": "card", "title": "Pull Requests", - "body": { + "children": { "type": "text", "content": "8", "className": "text-4xl font-bold text-primary" @@ -233,7 +233,7 @@ Schemas can be nested to build complex layouts. Here is a dashboard that combine { "type": "card", "title": "Deployments", - "body": { + "children": { "type": "text", "content": "142", "className": "text-4xl font-bold text-primary" @@ -269,7 +269,7 @@ import type { CardSchema } from '@object-ui/types'; const schema: CardSchema = { type: 'card', title: 'Hello', - body: { type: 'text', content: 'World' }, + children: { type: 'text', content: 'World' }, }; // One line to go from JSON to UI diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md index 2a4c5259d0..eab90bdc17 100644 --- a/content/docs/guide/schema-rendering.md +++ b/content/docs/guide/schema-rendering.md @@ -32,7 +32,7 @@ function App() { const schema: PageNodeSchema = { type: "page", title: "My Dashboard", - body: [{ type: "text", content: "Hello" }] + children: [{ type: "text", content: "Hello" }] } return @@ -66,7 +66,7 @@ interface BaseSchema { "className": "p-6 shadow-lg", "title": "User Statistics", "visibleOn": "${user.role === 'admin'}", - "body": { + "children": { "type": "text", "content": "Total Users: ${stats.totalUsers}" } @@ -167,14 +167,14 @@ Schemas can be nested to create complex UIs: { "type": "page", "title": "Dashboard", - "body": { + "children": { "type": "grid", "columns": 2, "children": [ { "type": "card", "title": "Card 1", - "body": { + "children": { "type": "text", "content": "Nested content" } @@ -182,7 +182,7 @@ Schemas can be nested to create complex UIs: { "type": "card", "title": "Card 2", - "body": { + "children": { "type": "chart", "chartType": "bar", "xAxisKey": "month", @@ -275,7 +275,7 @@ it on `label`: `text` is not a `BadgeSchema` key. "type": "alert", "variant": "default", "title": "Welcome!", - "body": { + "children": { "type": "text", "content": "${ user.isNew ? 'Start with the quick tour.' : @@ -400,7 +400,7 @@ const form: FormSchema = { const schema: PageNodeSchema = { type: "page", title: "Typed Page", - body: [form] + children: [form] } ``` @@ -421,7 +421,7 @@ const footerSchema = { /* ... */ } const pageSchema = { type: "page", - body: [headerSchema, contentSchema, footerSchema] + children: [headerSchema, contentSchema, footerSchema] } ``` @@ -468,7 +468,7 @@ const schema = user.isAdmin ? adminSchema : userSchema // ✅ Good const schema = { type: "page", - body: [ + children: [ { type: "admin-panel", visibleOn: "${user.isAdmin}" @@ -521,7 +521,7 @@ Always type your schemas for better IDE support and fewer runtime errors. "variant": "destructive", "visibleOn": "${error}", "title": "Something went wrong", - "body": { "type": "text", "content": "${error.message}" } + "children": { "type": "text", "content": "${error.message}" } } ``` diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 3607cee3ad..b1d4570302 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -217,7 +217,7 @@ resolved in the registry and then sat on `Loading line-chart…` forever { "type": "card", "title": "Sales Trend", - "body": { + "children": { "type": "chart", "chartType": "line", "data": [ diff --git a/content/docs/utilities/cli.mdx b/content/docs/utilities/cli.mdx index faa1c999ca..bbdaf276ce 100644 --- a/content/docs/utilities/cli.mdx +++ b/content/docs/utilities/cli.mdx @@ -257,10 +257,10 @@ Analyze the built application. { "type": "div", "className": "min-h-screen flex items-center justify-center", - "body": { + "children": { "type": "card", "title": "Hello World", - "body": { "type": "text", "content": "Welcome to Object UI!" } + "children": { "type": "text", "content": "Welcome to Object UI!" } } } ``` @@ -270,10 +270,10 @@ Analyze the built application. ```yaml type: div className: min-h-screen flex items-center justify-center -body: +children: type: card title: Hello World - body: + children: type: text content: Welcome to Object UI! ``` diff --git a/content/docs/utilities/runner.mdx b/content/docs/utilities/runner.mdx index 4e61771b01..36ee17ef50 100644 --- a/content/docs/utilities/runner.mdx +++ b/content/docs/utilities/runner.mdx @@ -270,7 +270,7 @@ import '@object-ui/plugin-myplugin' { "type": "page", "title": "Plugin test", - "body": [ + "children": [ { "type": "my-component", "message": "Testing my plugin" } ] } @@ -315,7 +315,7 @@ pnpm dev --host ## Schemas to Start From These live here in the docs, not in the package — copy one, wrap it in a page document -(`{ "type": "page", "body": [ … ] }`), and save it as `src/app-data/pages/index.json`, or +(`{ "type": "page", "children": [ … ] }`), and save it as `src/app-data/pages/index.json`, or serve it from your own backend and load it with `?api=`. ### Dashboard Example @@ -498,7 +498,7 @@ served at `/my-page`. { "type": "page", "title": "My Page", - "body": [ + "children": [ { "type": "div", "className": "p-8", diff --git a/examples/schema-catalog/src/schemas/components-basic-sidebar/basic-sidebar.json b/examples/schema-catalog/src/schemas/components-basic-sidebar/basic-sidebar.json index 2398f01d21..cefb70f864 100644 --- a/examples/schema-catalog/src/schemas/components-basic-sidebar/basic-sidebar.json +++ b/examples/schema-catalog/src/schemas/components-basic-sidebar/basic-sidebar.json @@ -1,25 +1,25 @@ { "type": "sidebar-provider", - "body": [ + "children": [ { "type": "sidebar", - "body": [ + "children": [ { "type": "sidebar-content", - "body": [ + "children": [ { "type": "sidebar-group", "label": "Menu", - "body": [ + "children": [ { "type": "sidebar-menu", - "body": [ + "children": [ { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Dashboard" @@ -30,10 +30,10 @@ }, { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Projects" @@ -44,10 +44,10 @@ }, { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Team" @@ -58,10 +58,10 @@ }, { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Settings" @@ -80,7 +80,7 @@ }, { "type": "sidebar-inset", - "body": [ + "children": [ { "type": "box", "className": "p-4", diff --git a/examples/schema-catalog/src/schemas/components-basic-sidebar/collapsible-sidebar.json b/examples/schema-catalog/src/schemas/components-basic-sidebar/collapsible-sidebar.json index 254076014b..1399293b09 100644 --- a/examples/schema-catalog/src/schemas/components-basic-sidebar/collapsible-sidebar.json +++ b/examples/schema-catalog/src/schemas/components-basic-sidebar/collapsible-sidebar.json @@ -1,25 +1,25 @@ { "type": "sidebar-provider", - "body": [ + "children": [ { "type": "sidebar", "collapsible": "icon", - "body": [ + "children": [ { "type": "sidebar-content", - "body": [ + "children": [ { "type": "sidebar-group", - "body": [ + "children": [ { "type": "sidebar-menu", - "body": [ + "children": [ { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Home" @@ -30,10 +30,10 @@ }, { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Files" @@ -44,10 +44,10 @@ }, { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Settings" @@ -66,7 +66,7 @@ }, { "type": "sidebar-inset", - "body": [ + "children": [ { "type": "box", "className": "p-4", diff --git a/examples/schema-catalog/src/schemas/components-basic-sidebar/grouped-sidebar.json b/examples/schema-catalog/src/schemas/components-basic-sidebar/grouped-sidebar.json index 2ace338234..af7568315b 100644 --- a/examples/schema-catalog/src/schemas/components-basic-sidebar/grouped-sidebar.json +++ b/examples/schema-catalog/src/schemas/components-basic-sidebar/grouped-sidebar.json @@ -1,25 +1,25 @@ { "type": "sidebar-provider", - "body": [ + "children": [ { "type": "sidebar", - "body": [ + "children": [ { "type": "sidebar-content", - "body": [ + "children": [ { "type": "sidebar-group", "label": "Main", - "body": [ + "children": [ { "type": "sidebar-menu", - "body": [ + "children": [ { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Dashboard" @@ -30,10 +30,10 @@ }, { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Analytics" @@ -49,16 +49,16 @@ { "type": "sidebar-group", "label": "Management", - "body": [ + "children": [ { "type": "sidebar-menu", - "body": [ + "children": [ { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Projects" @@ -69,10 +69,10 @@ }, { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Team" @@ -91,7 +91,7 @@ }, { "type": "sidebar-inset", - "body": [ + "children": [ { "type": "box", "className": "p-4", diff --git a/examples/schema-catalog/src/schemas/components-basic-sidebar/sidebar-with-badges.json b/examples/schema-catalog/src/schemas/components-basic-sidebar/sidebar-with-badges.json index ab333b40fe..4e17b0722b 100644 --- a/examples/schema-catalog/src/schemas/components-basic-sidebar/sidebar-with-badges.json +++ b/examples/schema-catalog/src/schemas/components-basic-sidebar/sidebar-with-badges.json @@ -1,24 +1,24 @@ { "type": "sidebar-provider", - "body": [ + "children": [ { "type": "sidebar", - "body": [ + "children": [ { "type": "sidebar-content", - "body": [ + "children": [ { "type": "sidebar-group", - "body": [ + "children": [ { "type": "sidebar-menu", - "body": [ + "children": [ { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Inbox" @@ -34,10 +34,10 @@ }, { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Drafts" @@ -53,10 +53,10 @@ }, { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Sent" @@ -67,10 +67,10 @@ }, { "type": "sidebar-menu-item", - "body": [ + "children": [ { "type": "sidebar-menu-button", - "body": [ + "children": [ { "type": "text", "content": "Trash" @@ -89,7 +89,7 @@ }, { "type": "sidebar-inset", - "body": [ + "children": [ { "type": "box", "className": "p-4", diff --git a/examples/schema-catalog/src/schemas/components-layout-page/documentation-page.json b/examples/schema-catalog/src/schemas/components-layout-page/documentation-page.json index 67f2846c04..6a660a3080 100644 --- a/examples/schema-catalog/src/schemas/components-layout-page/documentation-page.json +++ b/examples/schema-catalog/src/schemas/components-layout-page/documentation-page.json @@ -2,7 +2,7 @@ "type": "page", "title": "Getting Started", "description": "Learn how to use the platform", - "body": [ + "children": [ { "type": "container", "maxWidth": false, diff --git a/examples/schema-catalog/src/schemas/components-layout-page/full-dashboard.json b/examples/schema-catalog/src/schemas/components-layout-page/full-dashboard.json index 59903f1248..cd1a50e1ce 100644 --- a/examples/schema-catalog/src/schemas/components-layout-page/full-dashboard.json +++ b/examples/schema-catalog/src/schemas/components-layout-page/full-dashboard.json @@ -2,7 +2,7 @@ "type": "page", "title": "Dashboard", "description": "Monitor your business metrics in real-time", - "body": [ + "children": [ { "type": "grid", "columns": 4, diff --git a/examples/schema-catalog/src/schemas/components-layout-page/page-with-header.json b/examples/schema-catalog/src/schemas/components-layout-page/page-with-header.json index 3aad06b368..4b2b0bff78 100644 --- a/examples/schema-catalog/src/schemas/components-layout-page/page-with-header.json +++ b/examples/schema-catalog/src/schemas/components-layout-page/page-with-header.json @@ -2,7 +2,7 @@ "type": "page", "title": "Analytics Overview", "description": "Track your key metrics and performance indicators", - "body": [ + "children": [ { "type": "grid", "columns": 3, diff --git a/examples/schema-catalog/src/schemas/components-layout-page/settings-layout.json b/examples/schema-catalog/src/schemas/components-layout-page/settings-layout.json index fd1788a9cf..dea39305d1 100644 --- a/examples/schema-catalog/src/schemas/components-layout-page/settings-layout.json +++ b/examples/schema-catalog/src/schemas/components-layout-page/settings-layout.json @@ -2,7 +2,7 @@ "type": "page", "title": "Settings", "description": "Manage your account settings and preferences", - "body": [ + "children": [ { "type": "card", "className": "mb-6", diff --git a/examples/schema-catalog/src/schemas/components-layout-page/simple-page.json b/examples/schema-catalog/src/schemas/components-layout-page/simple-page.json index a5c5567cdc..c5dc683afd 100644 --- a/examples/schema-catalog/src/schemas/components-layout-page/simple-page.json +++ b/examples/schema-catalog/src/schemas/components-layout-page/simple-page.json @@ -2,7 +2,7 @@ "type": "page", "title": "Dashboard", "description": "Welcome to your dashboard", - "body": [ + "children": [ { "type": "text", "content": "Page content goes here" diff --git a/examples/schema-catalog/test/deprecated-component-types.test.ts b/examples/schema-catalog/test/deprecated-component-types.test.ts index cdb1b16353..0244cd9004 100644 --- a/examples/schema-catalog/test/deprecated-component-types.test.ts +++ b/examples/schema-catalog/test/deprecated-component-types.test.ts @@ -254,7 +254,7 @@ describe('deprecated component types are refused across the catalog (#3965, clos // mode every arm above would read as green. const found = new Map(); countDeprecated( - { type: 'stack', children: [{ type: 'div', body: [{ type: 'span' }] }, { type: 'card' }] }, + { type: 'stack', children: [{ type: 'div', children: [{ type: 'span' }] }, { type: 'card' }] }, found, ); expect(found.get('div')).toBe(1); diff --git a/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx b/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx index 6fed70aa1a..1337ccfb04 100644 --- a/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx +++ b/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx @@ -193,7 +193,7 @@ describe('objectui#6939 — the keys the renderers read are DECLARED, not passth const shape = (TooltipSchema as unknown as { shape: Record }).shape; expect(Object.keys(shape)).toEqual(expect.arrayContaining(['trigger', 'content', 'body'])); expect(TooltipSchema.safeParse({ type: 'tooltip', content: 'text only' }).success).toBe(true); - expect(TooltipSchema.safeParse({ type: 'tooltip', body: { type: 'text', content: 'rich only' } }).success).toBe(true); + expect(TooltipSchema.safeParse({ type: 'tooltip', children: { type: 'text', content: 'rich only' } }).success).toBe(true); }); it('context-menu declares triggerClassName / contentClassName / modal', () => { diff --git a/packages/components/README.md b/packages/components/README.md index 681f8d644f..ff94a172e7 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -92,7 +92,7 @@ initializeComponents() const schema = { type: 'card', title: 'Welcome', - body: { + children: { type: 'text', content: 'Hello from Object UI!' } diff --git a/packages/components/docs/FilterBuilder.md b/packages/components/docs/FilterBuilder.md index 786aa3a8a8..89c1691638 100644 --- a/packages/components/docs/FilterBuilder.md +++ b/packages/components/docs/FilterBuilder.md @@ -225,10 +225,10 @@ The Filter Builder integrates seamlessly with Object UI's schema system and can const pageSchema = { type: 'page', title: 'User Management', - body: [ + children: [ { type: 'card', - body: [ + children: [ { type: 'filter-builder', name: 'userFilters', diff --git a/packages/core/README.md b/packages/core/README.md index c16b161b07..245641e967 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -37,7 +37,7 @@ import type { const mySchema: PageNodeSchema = { type: 'page', title: 'My Page', - body: [] + children: [] } ``` diff --git a/packages/runner/README.md b/packages/runner/README.md index 2d24c15390..6767268c65 100644 --- a/packages/runner/README.md +++ b/packages/runner/README.md @@ -128,7 +128,7 @@ that do configure it are: { "type": "page", "title": "Dashboard", - "body": { + "children": { "type": "grid", "columns": 3, "gap": 4, @@ -136,7 +136,7 @@ that do configure it are: { "type": "card", "title": "Total Users", - "body": { + "children": { "type": "statistic", "value": 1234, "trend": "up" @@ -145,7 +145,7 @@ that do configure it are: { "type": "card", "title": "Revenue", - "body": { + "children": { "type": "statistic", "value": "$56,789", "trend": "up" @@ -154,7 +154,7 @@ that do configure it are: { "type": "card", "title": "Orders", - "body": { + "children": { "type": "statistic", "value": 432, "trend": "down" diff --git a/packages/vscode-extension/DESIGN.md b/packages/vscode-extension/DESIGN.md index e8297f0856..6c2109ca9f 100644 --- a/packages/vscode-extension/DESIGN.md +++ b/packages/vscode-extension/DESIGN.md @@ -70,7 +70,7 @@ The Object UI VSCode extension is a comprehensive development tool designed to e - **Component Types**: `div`, `card`, `button`, `input`, etc. - **Properties**: 根据component type提供相关属性 -- **Common Properties**: `type`, `className`, `body`, etc. +- **Common Properties**: `type`, `className`, `children`, etc. - **Trigger Characters**: `"`, `:`, ` ` **实现特点**: diff --git a/packages/vscode-extension/README.md b/packages/vscode-extension/README.md index 855a2a0606..0731d1ef77 100644 --- a/packages/vscode-extension/README.md +++ b/packages/vscode-extension/README.md @@ -75,10 +75,10 @@ Create a file `app.objectui.json`: { "type": "div", "className": "p-6", - "body": { + "children": { "type": "card", "title": "Hello Object UI", - "body": { + "children": { "type": "text", "content": "This is a schema-driven UI!" } diff --git a/packages/vscode-extension/schemas/objectui-schema.json b/packages/vscode-extension/schemas/objectui-schema.json index 602526cc2e..ad1c504611 100644 --- a/packages/vscode-extension/schemas/objectui-schema.json +++ b/packages/vscode-extension/schemas/objectui-schema.json @@ -34,7 +34,7 @@ "type": "string", "description": "Unique identifier for the component" }, - "body": { + "children": { "description": "Child components or content", "oneOf": [ { diff --git a/packages/vscode-extension/snippets/objectui.json b/packages/vscode-extension/snippets/objectui.json index 7300e06131..34b745f1f2 100644 --- a/packages/vscode-extension/snippets/objectui.json +++ b/packages/vscode-extension/snippets/objectui.json @@ -5,7 +5,7 @@ "{", " \"type\": \"${1:div}\",", " \"className\": \"${2:p-4}\",", - " \"body\": {", + " \"children\": {", " $0", " }", "}" @@ -18,13 +18,13 @@ "{", " \"type\": \"div\",", " \"className\": \"max-w-2xl mx-auto p-6\",", - " \"body\": {", + " \"children\": {", " \"type\": \"card\",", " \"title\": \"${1:Form Title}\",", - " \"body\": {", + " \"children\": {", " \"type\": \"div\",", " \"className\": \"space-y-4\",", - " \"body\": [", + " \"children\": [", " {", " \"type\": \"input\",", " \"label\": \"${2:Name}\",", @@ -50,7 +50,7 @@ " \"title\": \"${1:Card Title}\",", " \"description\": \"${2:Card description}\",", " \"className\": \"${3:shadow-lg}\",", - " \"body\": {", + " \"children\": {", " $0", " }", "}" @@ -110,7 +110,7 @@ "{", " \"type\": \"div\",", " \"className\": \"grid gap-${1:4} md:grid-cols-${2:2} lg:grid-cols-${3:3}\",", - " \"body\": [", + " \"children\": [", " $0", " ]", "}" @@ -123,11 +123,11 @@ "{", " \"type\": \"div\",", " \"className\": \"min-h-screen p-6\",", - " \"body\": [", + " \"children\": [", " {", " \"type\": \"div\",", " \"className\": \"mb-6\",", - " \"body\": {", + " \"children\": {", " \"type\": \"text\",", " \"content\": \"${1:Dashboard}\",", " \"className\": \"text-3xl font-bold\"", @@ -136,11 +136,11 @@ " {", " \"type\": \"div\",", " \"className\": \"grid gap-4 md:grid-cols-3\",", - " \"body\": [", + " \"children\": [", " {", " \"type\": \"card\",", " \"title\": \"${2:Metric 1}\",", - " \"body\": {", + " \"children\": {", " \"type\": \"text\",", " \"content\": \"${3:1,234}\",", " \"className\": \"text-2xl font-bold\"", @@ -169,7 +169,7 @@ "{", " \"type\": \"div\",", " \"className\": \"${1|container mx-auto,max-w-2xl mx-auto,max-w-4xl mx-auto,max-w-6xl mx-auto|} ${2:p-6}\",", - " \"body\": [", + " \"children\": [", " $0", " ]", "}" @@ -182,7 +182,7 @@ "{", " \"type\": \"div\",", " \"className\": \"flex ${1|flex-row,flex-col|} ${2|items-center,items-start,items-end|} ${3|justify-between,justify-center,justify-start,justify-end|} gap-${4:4}\",", - " \"body\": [", + " \"children\": [", " $0", " ]", "}" diff --git a/packages/vscode-extension/src/providers/CompletionProvider.ts b/packages/vscode-extension/src/providers/CompletionProvider.ts index 9052ee0cbc..8055749626 100644 --- a/packages/vscode-extension/src/providers/CompletionProvider.ts +++ b/packages/vscode-extension/src/providers/CompletionProvider.ts @@ -105,7 +105,7 @@ export class CompletionProvider card: [ { name: 'title', desc: 'Card title' }, { name: 'description', desc: 'Card description' }, - { name: 'body', desc: 'Card content' }, + { name: 'children', desc: 'Card content' }, ], text: [{ name: 'content', desc: 'Text content' }], }; @@ -130,7 +130,7 @@ export class CompletionProvider const common = [ { name: 'type', desc: 'Component type' }, { name: 'className', desc: 'Tailwind CSS classes' }, - { name: 'body', desc: 'Child components or content' }, + { name: 'children', desc: 'Child components or content' }, { name: 'id', desc: 'Component ID' }, { name: 'visible', desc: 'Visibility condition' }, ]; @@ -142,8 +142,8 @@ export class CompletionProvider ); item.detail = prop.desc; - if (prop.name === 'body') { - item.insertText = new vscode.SnippetString('"body": {\n $0\n}'); + if (prop.name === 'children') { + item.insertText = new vscode.SnippetString('"children": {\n $0\n}'); } else if (prop.name === 'className') { item.insertText = new vscode.SnippetString('"className": "$1"'); } else { diff --git a/packages/vscode-extension/src/providers/HoverProvider.ts b/packages/vscode-extension/src/providers/HoverProvider.ts index 96d98ead3d..c095c9e5d2 100644 --- a/packages/vscode-extension/src/providers/HoverProvider.ts +++ b/packages/vscode-extension/src/providers/HoverProvider.ts @@ -69,7 +69,7 @@ Example: [Tailwind CSS Documentation](https://tailwindcss.com)`, - body: `**body** (object | array) + children: `**children** (object | array) Child components or content. @@ -80,7 +80,7 @@ Can be: Example: \`\`\`json -"body": [ +"children": [ { "type": "text", "content": "Hello" }, { "type": "button", "label": "Click me" } ] @@ -182,7 +182,7 @@ Example: { "type": "div", "className": "flex gap-4 p-6", - "body": [...] + "children": [...] } \`\`\``, @@ -196,7 +196,7 @@ Example: "type": "card", "title": "Card Title", "description": "Card description", - "body": {...} + "children": {...} } \`\`\``, diff --git a/packages/vscode-extension/src/providers/PreviewProvider.ts b/packages/vscode-extension/src/providers/PreviewProvider.ts index befde17787..1de2da0fc1 100644 --- a/packages/vscode-extension/src/providers/PreviewProvider.ts +++ b/packages/vscode-extension/src/providers/PreviewProvider.ts @@ -223,8 +223,9 @@ export class PreviewProvider { desc.textContent = schema.description; element.appendChild(desc); } - // children first, body kept -- see the container branch below. - const cardChildren = schema.children || schema.body; + // children is the one child-list spelling -- see the container + // branch below. + const cardChildren = schema.children; if (cardChildren) { const body = Array.isArray(cardChildren) ? cardChildren : [cardChildren]; body.forEach(child => { @@ -240,18 +241,18 @@ export class PreviewProvider { default: // Handle generic div/container. // - // children is read FIRST, body is kept as a second arm. This guard - // used to name schema.body alone, so a node spelling its child list - // children -- the spelling the TypeScript declaration, the zod - // mirror, core's validator and the manifest tier all bless -- - // rendered as an EMPTY element: no error, no diagnostic, just a - // blank preview (objectui#7181). The body arm stays; removing it is - // objectui#6771 step 2, not this change. + // children is the one child-list spelling. This guard used to name + // schema.body alone, so a node spelling its child list children -- + // the spelling the TypeScript declaration, the zod mirror, core's + // validator and the manifest tier all bless -- rendered as an EMPTY + // element: no error, no diagnostic, just a blank preview + // (objectui#7181). objectui#6771 retired body, so the second arm is + // gone and the preview shows what the runtime will show. // // NOTE: this comment carries no backticks on purpose. It lives // INSIDE the webview's template literal, where an unescaped backtick // ends the string and the file stops parsing. - const containerChildren = schema.children || schema.body; + const containerChildren = schema.children; if (containerChildren) { const body = Array.isArray(containerChildren) ? containerChildren : [containerChildren]; body.forEach(child => { diff --git a/packages/vscode-extension/src/providers/SchemaValidator.ts b/packages/vscode-extension/src/providers/SchemaValidator.ts index 9fc641b8c4..853af637a1 100644 --- a/packages/vscode-extension/src/providers/SchemaValidator.ts +++ b/packages/vscode-extension/src/providers/SchemaValidator.ts @@ -100,32 +100,22 @@ export class SchemaValidator { // Recursively validate children. // - // `children` is read FIRST and `body` is kept as a second arm, matching - // `schema.children || schema.body` in `@object-ui/core`'s `validateSchema` - // and in the `div` / `card` renderers (objectui#7181). - // - // This guard used to name `schema.body` alone, which made the extension a - // `body`-ONLY reader while the TypeScript declaration, the zod mirror, - // core's validator and the manifest tier all declare `children`. An author - // who wrote the declared spelling therefore had every child silently - // skipped by this recursion — no diagnostic, no refusal, just an unvisited - // subtree — while the preview rendered the same document blank. - // - // The `body` arm is deliberately KEPT: dropping it is objectui#6771 step 2, - // not this change, and every `body`-spelled document must keep working. - const childList = schema.children || schema.body; + // `children` is the one child-list spelling. This guard once named + // `schema.body` ALONE, so an author writing the declared spelling had + // every child silently skipped by this recursion — no diagnostic, no + // refusal, just an unvisited subtree (objectui#7181 added the `children` + // arm). objectui#6771 then retired `body` itself, so the second arm went + // with it: a `body`-spelled document no longer has a child list for this + // recursion to walk, and the manifest tier answers the key by name. + const childList = schema.children; if (childList) { - // The diagnostic path has to name the key this document actually used, - // or a `children`-spelled node reports its problems at a `.body[...]` - // address that does not occur anywhere in the file being validated. - const childKey = schema.children ? 'children' : 'body'; const children = Array.isArray(childList) ? childList : [childList]; children.forEach((child: any, index: number) => { this.validateSchema( child, diagnostics, document, - `${path}.${childKey}[${index}]` + `${path}.children[${index}]` ); }); } diff --git a/packages/vscode-extension/syntaxes/objectui.tmLanguage.json b/packages/vscode-extension/syntaxes/objectui.tmLanguage.json index 022e1cd8bd..000bb5fccf 100644 --- a/packages/vscode-extension/syntaxes/objectui.tmLanguage.json +++ b/packages/vscode-extension/syntaxes/objectui.tmLanguage.json @@ -5,7 +5,7 @@ "include": "source.json" }, { - "match": "\"(type|className|body|label|placeholder|content|title|description)\"", + "match": "\"(type|className|children|label|placeholder|content|title|description)\"", "name": "keyword.other.objectui" } ] From d59bc42023dda516442ff1b85a035f8bc854ceb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 13:29:01 +0000 Subject: [PATCH 03/18] refactor(types): retire `body` on the four per-component redeclarations, and move their pins `CardSchema`, `AspectRatioSchema`, `PageNodeSchema` and `TooltipSchema` each redeclared the child slot under the retired spelling. Each is now `never` on the TypeScript face and an alias refusal naming `children` on the Zod mirror. Three pins INVERT rather than disappear, because the rule they encode is unchanged and only the key it names moved: objectui#8284's per-component content-channel tombstone on `alert` / `badge` / `tooltip` (the renderers converged on `children`, so the refused channel is `body`), objectui#8310's page flat-content arity, and objectui#9256's family-C controls. The two chatbot twins gain the NEITHER-channel tombstone their `children` sentence already asserted, so they answer `requestBody` rather than inheriting a refusal that names a key they also refuse. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- content/docs/api/schema-reference.md | 12 +- content/docs/components/basic/div.mdx | 5 +- content/docs/components/layout/box.mdx | 7 +- content/docs/guide/layout.md | 16 +-- content/docs/guide/quick-start.md | 2 +- content/docs/guide/schema-rendering.md | 2 +- content/docs/utilities/cli.mdx | 2 +- .../content-channel-family-d-9256.test.ts | 56 +++++--- ...content-channel-per-component-8284.test.ts | 36 +++-- .../node-recursion-point-8344.test.ts | 44 +++--- .../page-actions-refusal-7926.test.ts | 11 +- .../__tests__/page-body-arity-8310.test.ts | 132 +++++++++++++----- .../page-breadcrumbs-refusal-8871.test.ts | 9 +- packages/types/src/data-display.ts | 60 ++++---- packages/types/src/layout.ts | 86 +++++++++--- packages/types/src/overlay.ts | 55 ++++---- packages/types/src/zod/complex.zod.ts | 38 +++++ packages/types/src/zod/data-display.zod.ts | 26 ++-- packages/types/src/zod/layout.zod.ts | 43 ++++-- packages/types/src/zod/overlay.zod.ts | 21 +-- 20 files changed, 438 insertions(+), 225 deletions(-) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 50a292cb34..8de3ce483a 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -25,7 +25,7 @@ prop, a type annotation in an example. Reach for `SchemaNode` only where the wider union is genuinely correct. `SchemaNode` is `BaseSchema` **plus** the primitive members that render as text, so it is the right word -for a slot that also accepts a bare string (`body`, `children`) and the wrong word for a +for a slot that also accepts a bare string (`children`) and the wrong word for a position that must be an object: a renderer that narrows a slot with `typeof node === 'object'` before reading its keys drops those primitive members on the floor. Naming the union where only the object half is accepted is the mismatch @@ -78,8 +78,8 @@ One row per declared member, in declaration order, so the list can be checked ag | `style` | `Record` | Inline CSS styles. Use sparingly — prefer `className`. | | `data` | `any` | Arbitrary data attached to the node. `any` because the shape is defined by the consuming component rather than by `BaseSchema`. | | `bind` | `string` | Data-scope path this node draws its rows or value from, resolved by `useDataScope()`. Honoured only by components that call it. | -| `body` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component — **as `BaseSchema` declares it**. Which of the two channels a given node type actually renders is per component; see the note below. | -| `children` | `SchemaNode \| SchemaNode[]` | A second content channel `BaseSchema` declares beside `body`. ⚠️ **Not an alias** — nothing folds one into the other at runtime, and this row used to say it was. See the note below. | +| `body` | *retired* | ⛔ Refused by name (objectui#6771). `body` was a second child-list spelling `BaseSchema` declared beside `children`; it is now `never` on the TypeScript face and an alias refusal on the Zod mirror, and the refusal names `children`. | +| `children` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component — the child-list key, and since objectui#6771 the only one. Whether a given node type renders a child list at all is still per component; see the note below. | | `visible` | `boolean \| string \| { dialect?: string; source: string }` | Visibility control. Accepts a boolean, a predicate expression string, **or** the CEL envelope object (`{ dialect: 'cel', source }` — what `objectstack build` emits for every authored predicate) — the renderer evaluates this key rather than reading it as a boolean. The string-or-envelope half is `ExpressionWire`, the one wire type `visibleWhen` on form fields already carries. | | `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. | | `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. | @@ -93,7 +93,7 @@ One row per declared member, in declaration order, so the list can be checked ag Two things the table cannot show in a cell: - **A concrete schema may narrow an inherited member, and its own declaration wins.** Many component schemas restate `label`, `description` or `disabled` more narrowly than `BaseSchema` declares them, so the unions above are what a node gets when its own schema does not restate the key. Check the component's own property table before writing a predicate string or a locale map into an inherited slot. -- **⚠️ `body` and `children` are TWO channels, not one key with two spellings, and which one a node type renders is decided PER COMPONENT.** Each renderer reads one, the other, both, or neither, and `SchemaRenderer` strips both out of the props bag it spreads — so writing the channel a renderer does not read rendered an EMPTY element, with no error at authoring time, none at validation time and none at render time. That is the defect objectui#8284 named after seven cards had repaired one page of it each. It is being closed per component, by measurement: each schema narrows to the channel its renderer actually reads and **tombstones the other as `never`**, refused by name on both published faces (objectui#9254 for the components that read exactly one channel, objectui#9256 for the ones that read neither). ⇒ check the component's own section before writing either key; the types in this row are `BaseSchema`'s, and a concrete schema's own declaration wins. +- **⚠️ `body` and `children` WERE two channels, not one key with two spellings — and the second one is retired (objectui#6771).** Each renderer read one, the other, both, or neither, and `SchemaRenderer` strips both out of the props bag it spreads — so writing the channel a renderer did not read rendered an EMPTY element, with no error at authoring time, none at validation time and none at render time. That is the defect objectui#8284 named after seven cards had repaired one page of it each, and it was closed per component by measurement: each schema narrows to the channel its renderer actually reads and **tombstones the other as `never`**, refused by name on both published faces (objectui#9254 for the components that read exactly one channel, objectui#9256 for the ones that read neither). objectui#6771 then closed the class at its source: `children` is the one child-list spelling and `body` is refused on `BaseSchema` itself. ⇒ **whether a node type accepts a child list at all is still per component** — check the component's own section, where a node that renders no children tombstones `children` too. - **This list is exhaustive for *declared* members, not for *accepted* keys.** `BaseSchema` carries an index signature (`[key: string]: any`) and its Zod mirror is `.passthrough()`, so an undeclared key — a misspelling included — is still accepted by both halves. Absence from this table does not mean a key is rejected. --- @@ -136,7 +136,7 @@ Top-level page container. Defines a full page with optional regions (header, sid | `template` | `string` | Template name for page layout. | | `variables` | `PageVariable[]` | Page-level variables with types and defaults. | | `regions` | `PageRegion[]` | Named layout regions (header, sidebar, footer). | -| `body` | `SchemaNode \| SchemaNode[]` | Main page content — one node, or a list of them. | +| `children` | `SchemaNode \| SchemaNode[]` | Main page content when the page declares no regions — one node, or a list of them. Spelled `body` until objectui#6771 retired that spelling. | | `isDefault` | `boolean` | Whether this is the default page for the object. | | `assignedProfiles` | `string[]` | Security profiles that can access this page. | @@ -198,7 +198,7 @@ A styled container with optional header, body, and footer regions. | `hoverable` | `boolean` | Add hover elevation effect. | | `clickable` | `boolean` | Make the entire card a click target. | | `header` | `SchemaNode \| SchemaNode[]` | Content rendered in the card header. | -| `body` | `SchemaNode \| SchemaNode[]` | Main card content. | +| `children` | `SchemaNode \| SchemaNode[]` | Main card content. Spelled `body` until objectui#6771 retired that spelling. | | `footer` | `SchemaNode \| SchemaNode[]` | Content rendered in the card footer. | **Related:** [DivSchema](#divschema), [GridSchema](#gridschema) diff --git a/content/docs/components/basic/div.mdx b/content/docs/components/basic/div.mdx index 93b4fff712..76bfaad35e 100644 --- a/content/docs/components/basic/div.mdx +++ b/content/docs/components/basic/div.mdx @@ -42,8 +42,9 @@ Measured through the real renderer, on a node carrying an authored `className`: inside an extra element. So a mechanical `div` → `card`/`flex`/`grid` swap changes the page; a `div` → `box` swap does not. -One caveat that applies to every option except `card`: they read `children` and -never `body`. A node that authored `body` must move that content into `children` +One caveat for a node still authored in the retired `body` spelling: every +option reads `children` and never `body` (objectui#6771 retired it across the +protocol, `card` included). Such a node must move that content into `children` as part of the swap, or it disappears from the page without changing the element count — silently. diff --git a/content/docs/components/layout/box.mdx b/content/docs/components/layout/box.mdx index edea41ae62..67c9cf5366 100644 --- a/content/docs/components/layout/box.mdx +++ b/content/docs/components/layout/box.mdx @@ -54,9 +54,10 @@ interface BoxSchema { ## Migrating from `div` `box` is a drop-in replacement for a JSON-authored `div`, with one deliberate -difference: **`box` reads `children` only.** The old `div` renderer fell back -to `body` when `children` was absent; a `div` node authored with `body` must -move that content into `children` when it becomes a `box`: +difference: **`box` reads `children` only** — as, since objectui#6771 retired +the `body` spelling, every renderer now does. The old `div` renderer fell back +to `body` when `children` was absent; a `div` node still authored with `body` +must move that content into `children`, whether or not it becomes a `box`: ```json { diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index 7ddc782641..c56fd060f9 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -145,7 +145,7 @@ The `Page` component provides a consistent wrapper for individual pages with opt ### With Action Buttons -A `page` node has no action row of its own. Buttons are NODES, and they go in `body`: +A `page` node has no action row of its own. Buttons are NODES, and they go in `children`: ```json { @@ -189,7 +189,7 @@ and `button.tsx`, which reads `schema.label`, renders a button with no text. > rather than refusing it — so the author got a green validation and an empty page (before > objectui#7933 it also reached the DOM as `actions="[object Object]"`). `PageNodeSchema` > now declares the key as a refusal, so the same document fails with the remedy in the -> message instead of rendering silently short. Buttons in `body`, as above; on a record page, +> message instead of rendering silently short. Buttons in `children`, as above; on a record page, > the `page:header` block's own `actions` — which are **action ids**, not nodes > (see the [PageHeader reference](/docs/layout/page-header)). @@ -198,7 +198,7 @@ and `button.tsx`, which reads `schema.label`, renders a button with no text. > **nothing**: no renderer has ever read the key, and `BaseSchema`'s `.passthrough()` kept > the array rather than refusing it — the same silent-accept shape as `actions`, retired > under the same ADR-0049 enforce-or-remove gate. The trail is a **node**, not a key: put -> a `breadcrumb` node in `body`, as [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation) +> a `breadcrumb` node in `children`, as [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation) > shows. ⛔ Not the `page:header` block's `breadcrumb` either — that one is singular and a > **boolean** display toggle, not a list of links. @@ -213,8 +213,8 @@ and `button.tsx`, which reads `schema.label`, renders a button with no text. title?: string, // Page title description?: string, // Page description/subtitle icon?: string, // Optional icon - // NO `actions` — refused by name (objectui#7926); put the buttons in `body` - // NO `breadcrumbs` — refused by name (objectui#8871); put a `breadcrumb` node in `body` + // NO `actions` — refused by name (objectui#7926); put the buttons in `children` + // NO `breadcrumbs` — refused by name (objectui#8871); put a `breadcrumb` node in `children` // Content children: SchemaNode, // Main page content @@ -535,7 +535,7 @@ Omit `sidebar` and the content fills the width under the top bar. ### Detail Page with Actions Same rule as above, and it governs the trail too: the breadcrumb and the buttons are both -**nodes in `body`** — never a `breadcrumbs` or an `actions` key on the page. +**nodes in `children`** — never a `breadcrumbs` or an `actions` key on the page. ```json { @@ -685,7 +685,7 @@ Compose the shell once and let the page JSON change per route: ### 2. Breadcrumbs for Deep Navigation -Add a breadcrumb trail to help users navigate. It is a **node in `body`**, not a key on the +Add a breadcrumb trail to help users navigate. It is a **node in `children`**, not a key on the page — `breadcrumb`, singular, is the registered renderer: ```json @@ -712,7 +712,7 @@ which is a **boolean** display toggle rather than a list of links. ### 3. Action Buttons at the Top of the Body -Place primary actions in the first `body` node, so they sit above the content: +Place primary actions in the first `children` node, so they sit above the content: ```json { diff --git a/content/docs/guide/quick-start.md b/content/docs/guide/quick-start.md index 879cb3f98b..9fca28bf4e 100644 --- a/content/docs/guide/quick-start.md +++ b/content/docs/guide/quick-start.md @@ -133,7 +133,7 @@ Open [http://localhost:5173](http://localhost:5173). You should see a card and d ## What Just Happened? -1. **Schema** - the UI was described as JSON with `type`, visual props, and nested `body`. +1. **Schema** - the UI was described as JSON with `type`, visual props, and nested `children`. 2. **Registry** - importing the component packages registered renderers for `card` and `data-table`. 3. **Renderer** - `SchemaRenderer` resolved each `type` and rendered React components. 4. **Provider** - `SchemaRendererProvider` is where a host injects its `DataSource`; this app has none, so it passes `undefined`. diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md index eab90bdc17..e13445837c 100644 --- a/content/docs/guide/schema-rendering.md +++ b/content/docs/guide/schema-rendering.md @@ -528,7 +528,7 @@ Always type your schemas for better IDE support and fewer runtime errors. `visibleOn` is a condition key and is evaluated on every node type. The message text is a nested `text` node because `alert` carries no expression rows — and `message` is not an `AlertSchema` key at all: the alert's own text keys are `title` and `description`, and the -renderer falls back from `description` to `body`. `destructive` is the variant this state +renderer falls back from `description` to `children`. `destructive` is the variant this state wants; `error` is not in the closed set. ## Next Steps diff --git a/content/docs/utilities/cli.mdx b/content/docs/utilities/cli.mdx index bbdaf276ce..41ddd5716b 100644 --- a/content/docs/utilities/cli.mdx +++ b/content/docs/utilities/cli.mdx @@ -195,7 +195,7 @@ Scan the project for ObjectUI schema files and report on the ones it recognises. No flags. A JSON file with a root `type` is recognised when its root carries an ObjectUI -structural key (`children`, `body`, `className`, …) **or** when the whole +structural key (`children`, `className`, …) **or** when the whole document validates as an ObjectUI component schema. Recognised files have their `type` checked against the component types this build registers. diff --git a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts index c7d627cba6..3aa2377a5f 100644 --- a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts @@ -394,27 +394,39 @@ describe('objectui#9256 — CONTROLS: the node itself, and the held-out channel, // objectui#8572. This control was aimed at `ChatbotSchema`, whose own `body` // the parity ledger recorded as "two different meanings of one key — a naming // collision to rule on"; that ruling has since been made and belongs to its - // own card, ⛔ which is why this line does not restate its verdict here. The - // hold-out this card recorded still stands on the two TWIN faces: they - // inherit `body` as the content channel and only their `children` is - // narrowed above — so the control keeps doing its job, which is to keep the - // chatbot rows a reading about `children` rather than about the whole mirror. + // own card, ⛔ which is why this line does not restate its verdict here. + // + // ⚠️ THE HOLD-OUT ENDED, and not by a decision on this card. It read: the + // two TWIN faces inherit `body` as the content channel, only their + // `children` is narrowed here. objectui#6771 retired `body` on `BaseSchema` + // itself, so there is no content channel left to inherit — and an inherited + // refusal would have named `children` as the remedy, which is refused on + // these two as well. Each twin therefore declares the same NEITHER-channel + // tombstone its `children` already carried, pointing at `requestBody`. + // ⛔ This is not a narrowing this card chose: the sentence was already + // asserted here, only the declaration was missing. const r = (ChatbotEnhancedMirror as unknown as Mirror) .safeParse({ type: 'chatbot-enhanced', messages: [], body: CONTENT }); - expect(r.success).toBe(true); + expect(r.success).toBe(false); + const bodyIssue = (r.success ? [] : r.error!.issues).find((i) => i.path.join('.') === 'body'); + expect(bodyIssue?.message).toContain('requestBody'); + // CONTROL — the key an author should write instead still parses. + expect((ChatbotEnhancedMirror as unknown as Mirror) + .safeParse({ type: 'chatbot-enhanced', messages: [], requestBody: { model: 'gpt-4' } }).success).toBe(true); }); - it('LIVE CONTROL — `button` still accepts BOTH channels: it is family C, not family D', () => { - // Re-derived at this branch point, and it contradicts the table this slice - // was dispatched from: `ui:button` owns the bare `button` key, and that - // renderer reads `schema.label || renderChildren(schema.body || schema.children)` - // — a LIVE fallback. Narrowing it would REMOVE A LIVE READ, which is a - // behaviour change and the maintainer's call (objectui#8284), so `button` - // is untouched and this line is what keeps the rows above a reading about - // the six that moved rather than about the whole mirror. + it('`button` is family C with ONE channel left — the `body` arm was retired, not narrowed away', () => { + // This line used to read `button` accepts BOTH channels, on the ground that + // `ui:button` renders `schema.label || renderChildren(schema.body || schema.children)` + // — a LIVE fallback whose removal was a behaviour change and the + // maintainer's call. That call was made: objectui#6771's ruling puts the + // `body` arm of every fallback reader in scope ("single-spelling applies to + // fallback readers too") and names this expression explicitly. `button` is + // still family C — it reads a content channel, unlike family D — and the + // channel it reads is `children`. const mirror = ButtonFamilyCControl as unknown as Mirror; - expect(issues(mirror, { type: 'button', body: CONTENT })).toBeNull(); expect(issues(mirror, { type: 'button', children: CONTENT })).toBeNull(); + expect(issues(mirror, { type: 'button', body: CONTENT })).not.toBeNull(); }); it('LIVE CONTROL — the ITEM-level channel is untouched: a `tabs` item still parses `content`', () => { @@ -441,13 +453,15 @@ describe('objectui#9256 — CONTROLS: the node itself, and the held-out channel, })).toBeNull(); }); - it('LIVE CONTROL — family C is NOT narrowed: a `div` still accepts both channels', () => { - // `div` reads `children || body` (a live fallback). Removing a live read is - // a behaviour change and is with the maintainer, so this line must stay - // green for this card to be a declaration repair rather than a behaviour - // change. + it('family C still reads a content channel — `div` takes `children`, and only `children`', () => { + // `div` read `children || body`, a live fallback, and this line used to pin + // BOTH as accepted so that this card stayed a declaration repair rather than + // a behaviour change. objectui#6771 made the behaviour change deliberately, + // under a maintainer ruling, and the distinction this control exists to + // guard survives it: family C reads a content channel and family D reads + // none. What changed is how many spellings family C answers to. expect(AnyComponentSchema.safeParse({ type: 'div', children: CONTENT }).success).toBe(true); - expect(AnyComponentSchema.safeParse({ type: 'div', body: CONTENT }).success).toBe(true); + expect(AnyComponentSchema.safeParse({ type: 'div', body: CONTENT }).success).toBe(false); }); }); diff --git a/packages/types/src/__tests__/content-channel-per-component-8284.test.ts b/packages/types/src/__tests__/content-channel-per-component-8284.test.ts index a38bfed22b..5754ad9811 100644 --- a/packages/types/src/__tests__/content-channel-per-component-8284.test.ts +++ b/packages/types/src/__tests__/content-channel-per-component-8284.test.ts @@ -106,9 +106,17 @@ const ROWS: ReadonlyArray ({ type: 'card', title: 'Parent', body: [child] }); +const nested = (child: unknown) => ({ type: 'card', title: 'Parent', children: [child] }); describe('objectui#7869 — the off-spec node gets the same verdict at both depths', () => { it('is refused STANDING ALONE (unchanged — this half was never the defect)', () => { @@ -142,8 +142,11 @@ describe('the late-binding wiring, read by IDENTITY on the exported wrapper', () }); it('that identity survives through a declared child slot', () => { - const body = (CardSchema.shape.body as unknown as { _zod: { def: { innerType: { _zod: { def: { options: unknown[] } } } } } }); - expect(body._zod.def.innerType._zod.def.options).toContain(SchemaNodeSchema); + // Read off `children`: objectui#6771 retired `card`'s `body` spelling, so + // the declared child slot whose union must hold the recursion point is + // this one. `body` on this mirror is now an alias refusal with no union. + const slot = (CardSchema.shape.children as unknown as { _zod: { def: { innerType: { _zod: { def: { options: unknown[] } } } } } }); + expect(slot._zod.def.innerType._zod.def.options).toContain(SchemaNodeSchema); }); it('the holder is FILLED by importing the barrel — the module-cycle break works', () => { @@ -282,17 +285,22 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a it('names `body` AT ITS OWN PATH one slot down, and carries the remedy there too', () => { // ⭐ RE-POINTED by objectui#9659, carrying a contract-review residual on objectui#9639. - // This leg used to read `JSON.stringify(issues)` for the substring `"body"` and was - // VACUOUS: the parent card slot puts `"body"` in the issue path for ANY refused child, so - // the old assertion held whether or not the `chatbot` arm named anything. Measured on this - // head — three documents with nothing wrong at `body`, all three satisfying the old - // assertion, none of them carrying an issue at the child's own `body` path: + // This leg used to read `JSON.stringify(issues)` for the substring naming the + // parent's child slot and was VACUOUS: that slot is in the issue path for ANY + // refused child, so the old assertion held whether or not the `chatbot` arm + // named anything. Measured on this head — three documents with nothing wrong + // at the child's own `body`, all three satisfying the old assertion, none of + // them carrying an issue at that path: // - // nested off-spec `icon` (size: 'huge') → blob contains `"body"`: true, issues at - // `body.0.body`: 0 + // nested off-spec `icon` (size: 'huge') → blob contains the slot name: true, + // issues at `.0.body`: 0 // nested unmirrored `metric-card` → true / 0 // nested `chatbot` missing `messages` → true / 0 - // nested `chatbot` with a record `body` → true / 1 ← the only one that is about `body` + // nested `chatbot` with a record `body` → true / 1 ← the only one about `body` + // + // ⚠️ The parent slot is spelled `children` since objectui#6771 retired `body`; + // the child's own refused key is still `body`, so the two halves of the path + // no longer read alike and the reading is easier to see, not different. // // ⇒ the reading that discriminates is the child's OWN path, not the serialized blob. The // controls above are kept as assertions below so the discrimination is pinned rather than @@ -312,7 +320,7 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a walk(issues, []); return out; }; - const named = at(result.error.issues, 'body.0.body'); + const named = at(result.error.issues, 'children.0.body'); expect(named.length).toBeGreaterThan(0); // the refusal one slot down is the ARM's tombstone, and the REMEDY reaches the author at // depth and not only at the root — the root leg above reads the same string at depth 0. @@ -326,7 +334,7 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a ['`chatbot` missing `messages`', { type: 'chatbot' }], ])('CONTROL — a nested %s is refused with NOTHING at the child\'s `body` path', (_label, child) => { // These are the documents that made the old leg vacuous. Each is refused for a reason that - // has nothing to do with `body`, so the leg above must find nothing at `body.0.body` here. + // has nothing to do with `body`, so the leg above must find nothing at `children.0.body`. // ⛔ Do not "repair" a future failure by widening the path: a refusal that starts naming // `body` for an off-spec `icon` is a defect in the recursion point, not in this control. const result = AnyComponentSchema.safeParse(nested(child)); @@ -341,9 +349,11 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a for (const bucket of issue.errors ?? []) walk(bucket, here); }; walk(result.error.issues, []); - expect(paths).not.toContain('body.0.body'); + expect(paths).not.toContain('children.0.body'); // and the old assertion holds anyway — which is the whole reason it was replaced. - expect(JSON.stringify(result.error.issues)).toContain('"body"'); + // (It reads the PARENT's slot name, which is what made it vacuous; that name is + // `children` since objectui#6771, and the vacuity is unchanged.) + expect(JSON.stringify(result.error.issues)).toContain('"children"'); }); }); @@ -365,7 +375,7 @@ describe('objectui#8344 + objectui#8498 — a refusal at depth 4 stays bounded a const deep = (levels: number): unknown => levels === 0 ? { type: 'badge', variant: 'not-a-variant' } - : { type: 'card', title: 'p', body: [deep(levels - 1)] }; + : { type: 'card', title: 'p', children: [deep(levels - 1)] }; it('refuses at every depth 0 through 4 without throwing', () => { for (const depth of [0, 1, 2, 3, 4]) { @@ -387,7 +397,7 @@ describe('objectui#8344 + objectui#8498 — a refusal at depth 4 stays bounded a const legal = (levels: number): unknown => levels === 0 ? { type: 'badge', variant: 'default' } - : { type: 'card', title: 'p', body: [legal(levels - 1)] }; + : { type: 'card', title: 'p', children: [legal(levels - 1)] }; expect(safeValidateSchema(legal(4)).success).toBe(true); }); }); diff --git a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts index 10179f5bfd..b8d4eb96c3 100644 --- a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts +++ b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts @@ -94,7 +94,8 @@ describe('objectui#7926 — the `page` node refuses `actions` (contract half)', // sentence: pinning prose byte-for-byte turns every wording fix red for no // gain (AGENTS.md — assert the named subject, not the copy). expect(message).toContain('actions'); - expect(message).toContain('body'); + // The remedy key, which objectui#6771 moved from `body` to `children`. + expect(message).toContain('children'); expect(message).toContain('page:header'); // Zod's own default for a `never` arm says none of this. expect(message).not.toBe('Invalid input: expected never, received array'); @@ -108,11 +109,11 @@ describe('objectui#7926 — the `page` node refuses `actions` (contract half)', expect(PageNodeSchema.safeParse(withoutActions).success).toBe(true); }); - it('the remedy the message names actually parses — buttons as nodes in `body`', () => { + it('the remedy the message names actually parses — buttons as nodes in `children`', () => { const r = PageNodeSchema.safeParse({ type: 'page', title: 'Products', - body: [ + children: [ { type: 'flex', justify: 'end', @@ -233,13 +234,13 @@ describe('objectui#7926 — the guide no longer authors `actions` on a `page` no } }); - it('every `page` node the guide teaches declares its content under `body`', () => { + it('every `page` node the guide teaches declares its content under `children`', () => { // The positive form of the same fact: the passages were not merely stripped // of `actions`, they were REWRITTEN onto the key that renders. const pages = fences.filter((f) => isPageNode(f.doc)); for (const f of pages) { const doc = f.doc as Record; - expect({ line: f.line, hasBody: 'body' in doc }).toEqual({ line: f.line, hasBody: true }); + expect({ line: f.line, hasContent: 'children' in doc }).toEqual({ line: f.line, hasContent: true }); } }); }); diff --git a/packages/types/src/__tests__/page-body-arity-8310.test.ts b/packages/types/src/__tests__/page-body-arity-8310.test.ts index 4851e5c904..1b9e13a445 100644 --- a/packages/types/src/__tests__/page-body-arity-8310.test.ts +++ b/packages/types/src/__tests__/page-body-arity-8310.test.ts @@ -7,14 +7,25 @@ */ /** - * `PageNodeSchema.body` admits ONE node or a list of them — on both faces - * (objectui#8310, maintainer ruling 2026-09-07, director decision batch #2). + * `PageNodeSchema`'s flat content list admits ONE node or a list of them — on + * both faces (objectui#8310, maintainer ruling 2026-09-07, director decision + * batch #2). + * + * ⚠️ THE KEY MOVED, THE RULING DID NOT. objectui#8310 was decided about the + * `body` spelling, which was this node's flat content list at the time. + * objectui#6771 retired that spelling across the protocol; `FlatContent` now + * reads `schema.children` and nothing else, so the arity ruling governs + * `children` and every assertion below was moved onto it. The last block in + * this file is the other half: `body` is refused by name on both faces, so a + * page still written in the retired spelling is answered rather than silently + * rendered empty. Read the two together — the arity survives, the spelling did + * not. * * ## The defect this pins closed * - * `BaseSchema.body` is `SchemaNode | SchemaNode[]`. `PageNodeSchema` inherited - * that channel and NARROWED it to `SchemaNode[]`, while the only reader of the - * channel — `FlatContent`, the legacy body/children fallback inside + * The base declares this channel `SchemaNode | SchemaNode[]`. `PageNodeSchema` + * inherited it and NARROWED it to `SchemaNode[]`, while the only reader of the + * channel — `FlatContent`, the flat fallback inside * `PageRenderer` (`packages/components/src/renderers/layout/page.tsx`) — went on * accepting a bare node, normalizing it into a one-element list behind a * `content as SchemaNode` cast. The cast was the marker: the branch was @@ -89,7 +100,7 @@ const README = readFileSync(join(REPO_ROOT, 'README.md'), 'utf8'); /* -------------------------------------------------------------------------- */ /** - * The `grid` node the README's flagship example hands to `body` — child list + * The `grid` node the README's flagship example hands to `children` — child list * spelled `children`, the key `GridSchema` declares and `grid` reads * (objectui#8912). */ @@ -106,7 +117,7 @@ const flagshipGrid: SchemaNode = { /** * The root `README.md` "Basic Usage" example, as a `PageNodeSchema`. * - * ⭐ THE pin of this card. Narrowing `body` back to `SchemaNode[]` makes this + * ⭐ THE pin of this card. Narrowing `children` back to `SchemaNode[]` makes this * declaration stop compiling; that is exactly the state the repository was in * before objectui#8310, and it is why the landing page's own example could not * be annotated with the type of the node it is. @@ -114,7 +125,7 @@ const flagshipGrid: SchemaNode = { const readmeFlagshipPage: PageNodeSchema = { type: 'page', title: 'Dashboard', - body: flagshipGrid, + children: flagshipGrid, }; /** @@ -126,15 +137,15 @@ const readmeFlagshipPage: PageNodeSchema = { const listArityPage: PageNodeSchema = { type: 'page', title: 'Dashboard', - body: [flagshipGrid], + children: [flagshipGrid], }; /* -------------------------------------------------------------------------- */ /* Leg 2 + 3 — the zod mirror, at runtime */ /* -------------------------------------------------------------------------- */ -describe('PageNodeSchema.body arity — the declaration matches its reader (objectui#8310)', () => { - it('parses the README flagship example, whose `body` is ONE node', () => { +describe('PageNodeSchema flat-content arity — the declaration matches its reader (objectui#8310)', () => { + it('parses the README flagship example, whose `children` is ONE node', () => { const result = ZodPageNodeSchema.safeParse(readmeFlagshipPage); expect(result.success).toBe(true); }); @@ -145,9 +156,9 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje }); it('keeps the single node intact through the parse rather than wrapping it', () => { - const parsed = ZodPageNodeSchema.parse(readmeFlagshipPage) as { body?: unknown }; - expect(Array.isArray(parsed.body)).toBe(false); - expect((parsed.body as { type?: string } | undefined)?.type).toBe('grid'); + const parsed = ZodPageNodeSchema.parse(readmeFlagshipPage) as { children?: unknown }; + expect(Array.isArray(parsed.children)).toBe(false); + expect((parsed.children as { type?: string } | undefined)?.type).toBe('grid'); }); }); @@ -156,19 +167,19 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje /* -------------------------------------------------------------------------- */ /** - * REFUSAL CONTROL — what the widened `body` must still turn away. + * REFUSAL CONTROL — what the widened `children` must still turn away. * - * Legs 1-3 all assert ACCEPTANCE. A `body` arm that had degraded into + * Legs 1-3 all assert ACCEPTANCE. A `children` arm that had degraded into * accept-anything (`z.any()`, or a union whose floor fell out) passes every one * of them green, so on their own they measure the widening's DIRECTION and * never its BOUND. This block is the missing half. It pins the refusal - * ENVELOPE — an `invalid_union` issue at path `['body']` — rather than the bare - * falsity of `success`, so a page refused for some unrelated reason cannot - * stand in for a `body` that was judged and rejected. + * ENVELOPE — an `invalid_union` issue at path `['children']` — rather than the + * bare falsity of `success`, so a page refused for some unrelated reason cannot + * stand in for a `children` that was judged and rejected. * * ## ⚠️ Measured, and it is not what the obvious guess says * - * `body: 42` and `body: 'x'` are **LEGAL**, on both faces, and an assertion + * `children: 42` and `children: 'x'` are **LEGAL**, on both faces, and an assertion * that they are refused pins a contract this repo does not have: * * - `../base.ts`: `type SchemaNode = BaseSchema | string | number | boolean | null | undefined` @@ -177,8 +188,8 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje * * A primitive IS a `SchemaNode` here — the text-node convention — so * `SchemaNode | SchemaNode[]` admits `42` by construction, exactly as - * `BaseSchema.body`, `CardSchema.body` and `AspectRatioSchema.body` already - * did before this card. ⛔ Do not "harden" this block by asserting a primitive + * `BaseSchema.children`, `CardSchema.children` and `AspectRatioSchema.children` + * already did before this card. ⛔ Do not "harden" this block by asserting a primitive * is refused: that assertion is RED today, and if it ever went green it would * mean the node union had lost arms it is declared to have. * @@ -194,30 +205,75 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje * requires `type`, and a discriminated union cannot dispatch without it — so * that is the value this control is built on, in either arity. */ -const REFUSED_BODIES: ReadonlyArray<[label: string, body: unknown]> = [ +const REFUSED_CONTENT: ReadonlyArray<[label: string, children: unknown]> = [ ['an object that is not a node — it carries no `type`', {}], ['an object with node-ish keys but still no `type`', { columns: 3 }], ['the LIST arity carrying that same non-node object', [{}]], ]; -describe('PageNodeSchema.body still refuses a non-node — the widening kept its floor (objectui#8310)', () => { - for (const [label, body] of REFUSED_BODIES) { +describe('PageNodeSchema flat content still refuses a non-node — the widening kept its floor (objectui#8310)', () => { + for (const [label, children] of REFUSED_CONTENT) { it(`refuses ${label}`, () => { - const result = ZodPageNodeSchema.safeParse({ type: 'page', title: 'Dashboard', body }); + const result = ZodPageNodeSchema.safeParse({ type: 'page', title: 'Dashboard', children }); expect(result.success).toBe(false); if (result.success) return; - const bodyIssues = result.error.issues.filter((issue) => issue.path[0] === 'body'); - expect(bodyIssues.length).toBeGreaterThan(0); - expect(bodyIssues.map((issue) => issue.code)).toContain('invalid_union'); + const contentIssues = result.error.issues.filter((issue) => issue.path[0] === 'children'); + expect(contentIssues.length).toBeGreaterThan(0); + expect(contentIssues.map((issue) => issue.code)).toContain('invalid_union'); }); } - it('BOUNDARY — a primitive body stays legal, because `SchemaNode` declares it', () => { + it('BOUNDARY — a primitive stays legal, because `SchemaNode` declares it', () => { // ⛔ Not a defect and ⛔ not a candidate for "hardening" — see the docblock. - expect(ZodPageNodeSchema.safeParse({ type: 'page', body: 42 }).success).toBe(true); - expect(ZodPageNodeSchema.safeParse({ type: 'page', body: 'Hello' }).success).toBe(true); + expect(ZodPageNodeSchema.safeParse({ type: 'page', children: 42 }).success).toBe(true); + expect(ZodPageNodeSchema.safeParse({ type: 'page', children: 'Hello' }).success).toBe(true); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* The other half of the move — the retired spelling is ANSWERED, not ignored */ +/* -------------------------------------------------------------------------- */ + +/** + * objectui#6771 retired `body`. The arity above moved onto `children`; this + * block pins what happened to the key it moved off. + * + * ⛔ The assertion is NOT "the old pin is gone". A deleted member would be + * ACCEPTED, silently: `BaseSchemaCore` ends `.passthrough()` and the TS face + * carries `[key: string]: any`, so dropping the declaration would leave a page + * written in the retired spelling parsing green and rendering nothing — the + * exact silence the retirement exists to end. What is pinned is the REFUSAL, + * at the key's own path, carrying the remedy. + */ +describe('`body` is refused by name on the page node (objectui#6771)', () => { + it('refuses the single-node form the README used to author under `body`', () => { + const result = ZodPageNodeSchema.safeParse({ + type: 'page', + title: 'Dashboard', + body: flagshipGrid, + }); + expect(result.success).toBe(false); + if (result.success) return; + const bodyIssues = result.error.issues.filter((issue) => issue.path[0] === 'body'); + expect(bodyIssues.length).toBeGreaterThan(0); + expect(bodyIssues[0]!.message).toContain('`children`'); + }); + + it('refuses the LIST arity under the retired spelling too — this is about the key', () => { + const result = ZodPageNodeSchema.safeParse({ type: 'page', body: [flagshipGrid] }); + expect(result.success).toBe(false); + }); + + it('CONTROL — the identical document under `children` parses green', () => { + // Without this leg, a mirror that refused every page would satisfy the two + // assertions above. + expect(ZodPageNodeSchema.safeParse({ + type: 'page', + title: 'Dashboard', + children: flagshipGrid, + }).success).toBe(true); }); }); @@ -248,16 +304,16 @@ function basicUsageSchemaLiteral(): string { throw new Error('unbalanced braces in the README "Basic Usage" schema literal'); } -/** The first non-whitespace character of the literal's `body:` value. */ -function bodyValueOpener(literal: string): string { - const at = literal.indexOf('body:'); +/** The first non-whitespace character of the literal's `children:` value. */ +function contentValueOpener(literal: string): string { + const at = literal.indexOf('children:'); expect(at).toBeGreaterThan(-1); - return literal.slice(at + 'body:'.length).trimStart()[0]; + return literal.slice(at + 'children:'.length).trimStart()[0]; } describe('the root README keeps authoring the single-node form (objectui#8310)', () => { - it('gives `body` ONE node, not a list — the shape this card made legal', () => { - expect(bodyValueOpener(basicUsageSchemaLiteral())).toBe('{'); + it('gives `children` ONE node, not a list — the shape this card made legal', () => { + expect(contentValueOpener(basicUsageSchemaLiteral())).toBe('{'); }); it('still authors the page/grid/statistic shape transcribed above', () => { diff --git a/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts index e74b87ec07..b2c327c331 100644 --- a/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts +++ b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts @@ -128,7 +128,8 @@ describe('objectui#8871 — the `page` node refuses `breadcrumbs` (contract half // copy). expect(message).toContain('breadcrumbs'); expect(message).toContain('breadcrumb'); - expect(message).toContain('body'); + // The remedy key, which objectui#6771 moved from `body` to `children`. + expect(message).toContain('children'); expect(message).toContain('page:header'); // Zod's own default for a `never` arm says none of this. expect(message).not.toBe('Invalid input: expected never, received array'); @@ -142,11 +143,11 @@ describe('objectui#8871 — the `page` node refuses `breadcrumbs` (contract half expect(PageNodeSchema.safeParse(withoutBreadcrumbs).success).toBe(true); }); - it('the remedy the message names actually parses — a `breadcrumb` node in `body`', () => { + it('the remedy the message names actually parses — a `breadcrumb` node in `children`', () => { const r = PageNodeSchema.safeParse({ type: 'page', title: 'Acme Corporation', - body: [ + children: [ { type: 'breadcrumb', items: [ @@ -235,7 +236,7 @@ describe('objectui#8871 — the guide teaches the node, not the key', () => { // A deletion that leaves no trace teaches the next author nothing — they // rewrite the key from memory. The guide must name the card and the remedy. expect(guide).toContain('objectui#8871'); - expect(guide).toMatch(/breadcrumb.*node.*`body`|`body`.*breadcrumb.*node/s); + expect(guide).toMatch(/breadcrumb.*node.*`children`|`children`.*breadcrumb.*node/s); }); }); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 591f90256d..b1d08e37ea 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -55,26 +55,26 @@ export interface AlertSchema extends BaseSchema { */ onDismiss?: never; /** - * REFUSED BY NAME (objectui#8284, ADR-0049) — `alert` reads `body`, and no - * renderer read consumes `children`. + * REFUSED BY NAME (objectui#8284, ADR-0049; INVERTED by objectui#6771) — + * `alert` reads `children`, and no renderer read consumes `body`. * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a - * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.body` read in - * `packages/components/src/renderers/data-display/alert.tsx`. The same sweep finds zero `children` reads - * for this node type. + * docblock mention is not a read): the `schema.children` read in + * `packages/components/src/renderers/data-display/alert.tsx`. The same sweep + * finds zero `body` reads for this node type. * - * `children` is inherited-and-optional from {@link BaseSchema}, whose own - * docblock admits "some components use `children` instead of `body`" without - * saying which — so authoring it here type-checked, parsed green through - * `.passthrough()`, and rendered an EMPTY element with no error and no - * warning. Per component, the channel a renderer does not read is now - * tombstoned on both published faces (maintainer ruling, summon #17 decision batch #2, 2026-09-07). + * ⚠️ THIS MEMBER SWAPPED SIDES, and the principle did not. objectui#8284 + * tombstoned `children` here, because `body` was then the only key this + * renderer read — `alert` was one of the dozen registrations for which + * `body` was the ONLY door. objectui#6771 retired that spelling across the + * protocol, the renderer converged on `children`, and the tombstone follows + * the read: the channel a renderer does not read is refused by name, on both + * published faces (maintainer ruling, summon #17 decision batch #2, + * 2026-09-07). Which key that is changed; the rule did not. * - * @deprecated Not a channel `alert` reads — author `body`. + * @deprecated Not a channel `alert` reads — author `children`. */ - children?: never; + body?: never; } /** @@ -171,26 +171,26 @@ export interface BadgeSchema extends BaseSchema { */ icon?: string; /** - * REFUSED BY NAME (objectui#8284, ADR-0049) — `badge` reads `body`, and no - * renderer read consumes `children`. + * REFUSED BY NAME (objectui#8284, ADR-0049; INVERTED by objectui#6771) — + * `badge` reads `children`, and no renderer read consumes `body`. * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a - * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.body` read in - * `packages/components/src/renderers/data-display/badge.tsx`. The same sweep finds zero `children` reads - * for this node type. + * docblock mention is not a read): the `schema.children` read in + * `packages/components/src/renderers/data-display/badge.tsx`. The same sweep + * finds zero `body` reads for this node type. * - * `children` is inherited-and-optional from {@link BaseSchema}, whose own - * docblock admits "some components use `children` instead of `body`" without - * saying which — so authoring it here type-checked, parsed green through - * `.passthrough()`, and rendered an EMPTY element with no error and no - * warning. Per component, the channel a renderer does not read is now - * tombstoned on both published faces (maintainer ruling, summon #17 decision batch #2, 2026-09-07). + * ⚠️ THIS MEMBER SWAPPED SIDES, and the principle did not. objectui#8284 + * tombstoned `children` here, because `body` was then the only key this + * renderer read — `badge` was one of the dozen registrations for which + * `body` was the ONLY door. objectui#6771 retired that spelling across the + * protocol, the renderer converged on `children`, and the tombstone follows + * the read: the channel a renderer does not read is refused by name, on both + * published faces (maintainer ruling, summon #17 decision batch #2, + * 2026-09-07). Which key that is changed; the rule did not. * - * @deprecated Not a channel `badge` reads — author `body`. + * @deprecated Not a channel `badge` reads — author `children`. */ - children?: never; + body?: never; } /** diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 01496bc198..dc994cbf69 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -785,11 +785,27 @@ export interface CardSchema extends BaseSchema { */ header?: SchemaNode | SchemaNode[]; /** - * Card body/content (Legacy, use children) + * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body` + * child-list spelling. Author {@link CardSchema.children}. + * + * This key was already labelled "Legacy, use children" and the renderer + * read `renderNodeSlot(schema.children || schema.body, …)`, so `card` was + * one of the fallback readers whose `body` arm the ruling drops in the + * same change as the `body`-only registrations converge. + * + * Refused by name rather than deleted: `BaseSchema` carries + * `[key: string]: any` and the zod twin ends `.passthrough()`, so a + * deleted member is ACCEPTED silently and rendered by nothing. The + * `?: never` is what makes `tsc` answer at the authoring site, and the + * mirror's `aliasKeyRefusal` names `children` at parse time. + * + * @deprecated Retired spelling of `children` — author `children`. */ - body?: SchemaNode | SchemaNode[]; + body?: never; /** - * Child components + * Card content — the card's child list, and since objectui#6771 its only + * spelling. READ SITE: `renderNodeSlot(schema.children, …)` in + * `packages/components/src/renderers/layout/card.tsx`. */ children?: SchemaNode | SchemaNode[]; /** @@ -1135,11 +1151,25 @@ export interface AspectRatioSchema extends BaseSchema { */ alt?: string; /** - * Child components (alternative to image) + * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body` + * child-list spelling. Author {@link AspectRatioSchema.children}. + * + * `aspect-ratio` read `renderChildren(schema.children || schema.body)` + * whenever no `image` was set, so it is one of the fallback readers whose + * `body` arm drops in the same change. + * + * Refused by name rather than deleted: `BaseSchema` carries + * `[key: string]: any` and the zod twin ends `.passthrough()`, so a + * deleted member is ACCEPTED silently and rendered by nothing. The + * `?: never` is what makes `tsc` answer at the authoring site, and the + * mirror's `aliasKeyRefusal` names `children` at parse time. + * + * @deprecated Retired spelling of `children` — author `children`. */ - body?: SchemaNode | SchemaNode[]; + body?: never; /** - * Child components (alternative syntax) + * Child components rendered inside the ratio box when no `image` is set — + * the one child-list spelling (objectui#6771). */ children?: SchemaNode | SchemaNode[]; } @@ -1257,13 +1287,13 @@ export interface PageNodeSchema extends BaseSchema { * * `PageRenderer` has no read point for it: a `page` node carrying * `actions: [{type:'button',label:'Add Product'}, …]` drew 0 buttons through - * the real `SchemaRenderer`, while the SAME two buttons in {@link body} drew + * the real `SchemaRenderer`, while the SAME two buttons in {@link PageNodeSchema.children} drew * 2. `BaseSchema` is `.passthrough()`, so the array was not dropped — it was * kept, and before objectui#7933 it reached the DOM as * `actions="[object Object],[object Object]"`. * * The remedy is a NODE, not a key: put a `button` (or an `action:button` with - * a declared `actionType`) in {@link body}; on a record page declare them on a + * a declared `actionType`) in {@link PageNodeSchema.children}; on a record page declare them on a * `page:header` block, whose own `actions` are ACTION IDS resolved from the * object's metadata (objectui#7182) rather than nodes. * @@ -1300,7 +1330,7 @@ export interface PageNodeSchema extends BaseSchema { * * The remedy is a NODE that already ships: put * `{ "type": "breadcrumb", "items": [{ "label": "Home", "href": "/" }] }` in - * {@link body}. `breadcrumb` is a registered renderer taking that exact item + * {@link PageNodeSchema.children}. `breadcrumb` is a registered renderer taking that exact item * shape, plus `separator`, `maxItems` and a per-item `icon`. * ⛔ Not the `page:header` block's `breadcrumb`: that one is SINGULAR and a * BOOLEAN display toggle, not a list of links. @@ -1358,21 +1388,41 @@ export interface PageNodeSchema extends BaseSchema { // blankLayout removed — the `blank` page type has no renderer and was dropped // from @objectstack/spec PageTypeSchema (framework#2265, enforce-or-remove). /** - * Main content (Legacy/Simple mode) — ONE node, or a list of them. + * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body` + * child-list spelling. Author {@link PageNodeSchema.children}. + * + * `PageRenderer`'s `FlatContent` read `schema.body || schema.children`, and + * the registration published `body` as the flat content list on all five + * page kinds. Both moved to `children` in the retirement; the ARITY ruling + * this key carried (objectui#8310 — one node OR a list) moved with it and + * is restated on `children` below, because that is now the key it governs. + * + * Refused by name rather than deleted: `BaseSchema` carries + * `[key: string]: any` and the zod twin ends `.passthrough()`, so a deleted + * member is ACCEPTED silently and rendered by nothing. + * + * @deprecated Retired spelling of `children` — author `children`. + */ + body?: never; + /** + * Main content when a page declares no regions — ONE node, or a list of + * them. * * The union is the declaration catching up to its reader, not a widening for - * convenience (objectui#8310, maintainer ruling 2026-09-07). This key read - * `SchemaNode[]` and was the OUTLIER in this file: `CardSchema.body` and - * `AspectRatioSchema.body` already spell the union, and so does - * `BaseSchema.body` — the channel this interface inherits and then narrowed. + * convenience (objectui#8310, maintainer ruling 2026-09-07). The ruling was + * made about the `body` spelling this interface used to declare; objectui#6771 + * retired that spelling, so the union lives here, on the key the same reader + * now takes. `CardSchema.children` and `AspectRatioSchema.children` spell the + * same union, and so does `BaseSchema.children`. * `PageRenderer`'s `FlatContent` fallback * (`packages/components/src/renderers/layout/page.tsx`) has always accepted a * bare node, normalizing it into a one-element list; under the narrowing that * branch was unreachable through the renderer's own declared props and stood * only behind a `content as SchemaNode` cast, which the same ruling deletes. * - * The refused value is authored on this project's own landing page: the root - * `README.md` "Basic Usage" example gives `body` a single `grid` node. It + * The refused value was authored on this project's own landing page: the root + * `README.md` "Basic Usage" example gives this key a single `grid` node (it + * spelled the key `body` until objectui#6771 moved the corpus). It * type-checked only because that snippet is annotated `BaseSchema` — the * WIDER parent — so nothing on the authoring path ever asked this key about * its arity. Pinned by `__tests__/page-body-arity-8310.test.ts`. @@ -1383,10 +1433,6 @@ export interface PageNodeSchema extends BaseSchema { * absorbed by the index signature, zero diagnostics). This union repairs the * first case only. */ - body?: SchemaNode | SchemaNode[]; - /** - * Alternative content prop - */ children?: SchemaNode | SchemaNode[]; /** * Whether this is the default page for the object/app. diff --git a/packages/types/src/overlay.ts b/packages/types/src/overlay.ts index 7fbafd07d4..fc7c9c546e 100644 --- a/packages/types/src/overlay.ts +++ b/packages/types/src/overlay.ts @@ -717,38 +717,41 @@ export interface TooltipSchema extends BaseSchema { * Tooltip content/text — the FIRST half of the content read. * * READ SITE: `packages/components/src/renderers/overlay/tooltip.tsx` — - * `schema.content || renderChildren(schema.body)`. Optional because - * {@link TooltipSchema.body} is the other half of that same read. + * `schema.content || renderChildren(schema.children)`. Optional because + * {@link TooltipSchema.children} is the other half of that same read. */ content?: string | SchemaNode; /** - * Rich tooltip content — the FALLBACK half of the same read at - * `packages/components/src/renderers/overlay/tooltip.tsx`, listed by the - * registration as the "Rich Content" slot. - */ - body?: SchemaNode | SchemaNode[]; - /** - * REFUSED BY NAME (objectui#8284, ADR-0049) — `tooltip` reads `content` and, - * as the fallback for that same slot, `body`. No renderer read consumes - * `children`. - * - * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a - * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.body` read in - * `packages/components/src/renderers/overlay/tooltip.tsx`. The same sweep finds zero `children` reads - * for this node type. + * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body` + * child-list spelling. Author {@link TooltipSchema.children}. * - * `children` is inherited-and-optional from {@link BaseSchema}, whose own - * docblock admits "some components use `children` instead of `body`" without - * saying which — so authoring it here type-checked, parsed green through - * `.passthrough()`, and rendered an EMPTY element with no error and no - * warning. Per component, the channel a renderer does not read is now - * tombstoned on both published faces (maintainer ruling, summon #17 decision batch #2, 2026-09-07). + * ⭐ `tooltip` was the ONE registration in the whole tree that ADVERTISED + * this spelling in its `inputs` (a `slot`, labelled "Rich Content"), which + * made it the most discoverable instance of the dialect on the authoring + * surface — and the ruled step-2 population never listed it. Leaving it out + * would have kept the dialect alive precisely where an author was most + * likely to meet it, so it converged with the rest. * - * @deprecated Not a channel `tooltip` reads — author `body`. + * @deprecated Retired spelling of `children` — author `children`. */ - children?: never; + body?: never; + /** + * Rich tooltip content — the FALLBACK half of the same read at + * `packages/components/src/renderers/overlay/tooltip.tsx`, and the slot the + * registration publishes as "Rich Content". + * + * ⚠️ THIS DECLARATION IS AN INVERSION, not a widening. objectui#8284 (ADR-0049, + * maintainer ruling summon #17 decision batch #2, 2026-09-07) tombstoned + * `children` on this node with the measured ground that the renderer read + * `schema.content || renderChildren(schema.body)` and no read consumed + * `children`. objectui#6771 changed the READ, not the principle: the same + * renderer now reads `schema.content || renderChildren(schema.children)`, so + * the channel this node does not read is `body`, and that is where the + * tombstone moved. The per-component rule — tombstone the channel the + * renderer does not read, on both published faces — is unchanged and is + * still what both of these members express. + */ + children?: SchemaNode | SchemaNode[]; /** * Tooltip side * @default 'top' diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 56f931cdb5..430ab45b47 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -793,6 +793,25 @@ export const ChatbotEnhancedSchema = BaseSchema.extend({ surface: z.enum(['card', 'plain']).optional() .describe("Visual chrome for the chat surface: 'card' bordered panel (default) or 'plain' frameless full-page workspace (objectui#6687)"), onClear: chatbotOnClearArm(), + // Declared HERE rather than inherited, because objectui#6771's retirement + // of `BaseSchema.body` refuses the key with a message naming `children` — + // which is the right remedy on every node except this family, where + // `children` is refused too and the key an author actually wants is + // `requestBody`. The sentence below is the one this node's `children` + // tombstone already asserts about BOTH channels; only the declaration was + // missing, and inheriting a wrong remedy is worse than inheriting none. + body: retirementTombstone( + 'REFUSED (objectui#9256, ADR-0049) — `chatbot-enhanced` reads NEITHER content channel: measured with the ' + + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + + 'authored value therefore rendered NOTHING — no error, no warning, no element. ' + + 'The chat API body params go on `requestBody`, which the registration forwards to the chat runtime. ' + + 'What it renders instead: `api`, `assistantAvatarFallback`, `assistantAvatarUrl`, `autoResponse`, ' + + '`autoResponseDelay`, `autoResponseText`, `conversationId`, `enableFileUpload`, `enableMarkdown`, ' + + '`headers`, `maxHeight`, `maxToolRoundtrips`, `messages`, `model`, `onClear`, `onError`, ' + + '`onSend`, `placeholder`, `processVisibility`, `requestBody`, `showTimestamp`, ' + + '`streamingEnabled`, `surface`, `systemPrompt`, `userAvatarFallback`, `userAvatarUrl`.', + ), children: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `chatbot-enhanced` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' @@ -838,6 +857,25 @@ export const ChatbotFloatingSchema = BaseSchema.extend({ enableMarkdown: chatbotEnableMarkdownArm(), enableFileUpload: chatbotEnableFileUploadArm(), onClear: chatbotOnClearArm(), + // Declared HERE rather than inherited, because objectui#6771's retirement + // of `BaseSchema.body` refuses the key with a message naming `children` — + // which is the right remedy on every node except this family, where + // `children` is refused too and the key an author actually wants is + // `requestBody`. The sentence below is the one this node's `children` + // tombstone already asserts about BOTH channels; only the declaration was + // missing, and inheriting a wrong remedy is worse than inheriting none. + body: retirementTombstone( + 'REFUSED (objectui#9256, ADR-0049) — `chatbot-floating` reads NEITHER content channel: measured with the ' + + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + + 'authored value therefore rendered NOTHING — no error, no warning, no element. ' + + 'The chat API body params go on `requestBody`, which the registration forwards to the chat runtime. ' + + 'What it renders instead: `api`, `assistantAvatarFallback`, `assistantAvatarUrl`, `autoResponse`, ' + + '`autoResponseDelay`, `autoResponseText`, `conversationId`, `enableFileUpload`, `enableMarkdown`, ' + + '`floatingConfig`, `headers`, `maxToolRoundtrips`, `messages`, `model`, `onClear`, `onError`, ' + + '`onSend`, `placeholder`, `requestBody`, `showTimestamp`, `streamingEnabled`, `systemPrompt`, ' + + '`userAvatarFallback`, `userAvatarUrl`.', + ), children: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `chatbot-floating` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 9996fc9aa8..3a157b0bed 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -65,13 +65,16 @@ export const AlertSchema = BaseSchema.extend({ icon: z.string().optional().describe('Alert icon'), dismissible: z.boolean().optional().describe('Whether alert can be dismissed'), onDismiss: handlerKeyRefusal('onDismiss', 'retired', 'Dismiss handler'), - children: aliasKeyRefusal( - 'children', + // INVERTED by objectui#6771, which retired `body`: the refusal follows the + // read, and this renderer now reads `children`. objectui#8284's rule — the + // channel a renderer does not read is refused by name — is unchanged. + body: aliasKeyRefusal( 'body', + 'children', 'this alert node', - '`alert` reads `body`, never `children` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/alert.tsx`). ' - + '`children` is inherited from `BaseSchema`, so an authored `children` parsed green here and rendered ' - + 'an EMPTY element — no error, no warning. objectui#8284.', + '`alert` reads `children`, never `body` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/alert.tsx`). ' + + '`body` was this node\'s only child-list key until objectui#6771 retired the spelling; an authored `body` now parses green through ' + + '`.passthrough()` and renders an EMPTY element — no error, no warning. objectui#8284.', ), }); @@ -109,13 +112,16 @@ export const BadgeSchema = BaseSchema.extend({ label: z.string().optional().describe('Badge label'), variant: z.enum(['default', 'secondary', 'destructive', 'outline']).optional().describe('Badge variant'), icon: z.string().optional().describe('Badge icon'), - children: aliasKeyRefusal( - 'children', + // INVERTED by objectui#6771, which retired `body`: the refusal follows the + // read, and this renderer now reads `children`. objectui#8284's rule — the + // channel a renderer does not read is refused by name — is unchanged. + body: aliasKeyRefusal( 'body', + 'children', 'this badge node', - '`badge` reads `body`, never `children` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/badge.tsx`). ' - + '`children` is inherited from `BaseSchema`, so an authored `children` parsed green here and rendered ' - + 'an EMPTY element — no error, no warning. objectui#8284.', + '`badge` reads `children`, never `body` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/badge.tsx`). ' + + '`body` was this node\'s only child-list key until objectui#6771 retired the spelling; an authored `body` now parses green through ' + + '`.passthrough()` and renders an EMPTY element — no error, no warning. objectui#8284.', ), }); diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 13efd8312a..c53fe5d6b3 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -405,8 +405,15 @@ export const CardSchema = BaseSchema.extend({ title: z.string().optional().describe('Card title'), description: z.string().optional().describe('Card description'), header: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card header content'), - body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card body content'), - children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components'), + body: aliasKeyRefusal( + 'body', + 'children', + 'this card node', + '`card` read `renderNodeSlot(schema.children || schema.body, …)`, one of the fallback readers whose ' + + '`body` arm objectui#6771 dropped in the same change as the `body`-only registrations converged. ' + + 'The TS face declares this member `never` and the corpus moved with it.', + ), + children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card content — the one child-list spelling (objectui#6771)'), footer: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card footer content'), variant: z.enum(['default', 'outline', 'ghost']).optional().describe('Card variant style'), hoverable: z.boolean().optional().describe('Whether the card is hoverable'), @@ -520,8 +527,14 @@ export const AspectRatioSchema = BaseSchema.extend({ ratio: z.number().optional().describe('Aspect ratio (width / height)'), image: z.string().optional().describe('Image URL to display'), alt: z.string().optional().describe('Image alt text'), - body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components (alternative to image)'), - children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components'), + body: aliasKeyRefusal( + 'body', + 'children', + 'this aspect-ratio node', + '`aspect-ratio` read `renderChildren(schema.children || schema.body)` whenever no `image` was set; ' + + 'objectui#6771 dropped the `body` arm with the rest of the dialect.', + ), + children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components rendered when no image is set'), }); /** @@ -685,7 +698,7 @@ const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).sh const PAGE_ACTIONS_REFUSAL = '`actions` is not a key of the `page` node and never was (objectui#7926): no renderer ' + 'reads it, so an authored array drew nothing and rode `.passthrough()` onto the wrapper ' + - 'element. Author the buttons as NODES in `body` (a `button` node, or an `action:button` ' + + 'element. Author the buttons as NODES in `children` (a `button` node, or an `action:button` ' + 'node with a declared `actionType`); on a record page declare them on a `page:header` ' + 'block instead, whose own `actions` are ACTION IDS resolved from the object metadata ' + '(objectui#7182), not nodes.'; @@ -775,7 +788,7 @@ const PAGE_BREADCRUMBS_REFUSAL = '`breadcrumbs` is not a key of the `page` node and never was (objectui#8871, ADR-0049 ' + 'enforce-or-remove): no renderer reads it, so an authored trail drew nothing and rode ' + '`.passthrough()` through the validator as a silent accept. Author the trail as a NODE ' + - 'in `body` instead — { "type": "breadcrumb", "items": [{ "label": "Home", "href": "/" }] } ' + + 'in `children` instead — { "type": "breadcrumb", "items": [{ "label": "Home", "href": "/" }] } ' + '— which is a registered renderer and takes the same item shape, plus `separator` and ' + '`maxItems`. ⛔ Not the `page:header` block\'s `breadcrumb` either: that one is SINGULAR ' + 'and a BOOLEAN display toggle, not a list of links.'; @@ -797,16 +810,24 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({ template: z.string().optional().describe('Layout template name'), variables: z.array(PageVariableSchema).optional().describe('Local page state variables'), regions: z.array(PageNodeRegionSchema).optional().describe('Page layout regions'), + body: aliasKeyRefusal( + 'body', + 'children', + 'this page node', + '`PageRenderer`\'s `FlatContent` read `schema.body || schema.children` and the registration published ' + + '`body` as the flat content list on all five page kinds; objectui#6771 retired the spelling and ' + + 'moved both to `children`.', + ), // objectui#8310: ONE node or a list, mirroring the TS face and the runtime. // `FlatContent` in `page.tsx` normalizes a bare node into a one-element list, - // and the root README's flagship example authors exactly that; array-only here - // made this the only `body` in this file that is not the union (`CardSchema` - // and `AspectRatioSchema` already spell it, as does `BaseSchema`). - body: z + // and the root README's flagship example authors exactly that. The ruling was + // made about the `body` spelling; objectui#6771 retired it, so the union lives + // on the key the same reader now takes (`CardSchema` and `AspectRatioSchema` + // spell the same union on `children`, as does `BaseSchema`). + children: z .union([SchemaNodeSchema, z.array(SchemaNodeSchema)]) .optional() .describe('Main content — one node or a list of nodes'), - children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Alternative content prop'), isDefault: z.boolean().optional().describe('Whether this is the default page'), assignedProfiles: z.array(z.string()).optional().describe('Profiles that can access this page'), }); diff --git a/packages/types/src/zod/overlay.zod.ts b/packages/types/src/zod/overlay.zod.ts index e70bde566c..937c1f8db7 100644 --- a/packages/types/src/zod/overlay.zod.ts +++ b/packages/types/src/zod/overlay.zod.ts @@ -334,20 +334,25 @@ export const TooltipSchema = BaseSchema.extend({ trigger: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() .describe('Element the tooltip attaches to (objectui#6939)'), content: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() - .describe('Tooltip content, checked before `body` — optional because `body` is the fallback for the same slot (objectui#6939)'), - body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() - .describe('Rich tooltip content — the fallback for `content`, listed by the registration as the "Rich Content" slot (objectui#6939)'), + .describe('Tooltip content, checked before `children` — optional because `children` is the fallback for the same slot (objectui#6939)'), + children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() + .describe('Rich tooltip content — the fallback for `content`, published by the registration as the "Rich Content" slot (objectui#6939, objectui#6771)'), side: z.enum(['top', 'right', 'bottom', 'left']).optional().describe('Tooltip side'), align: z.enum(['start', 'center', 'end']).optional().describe('Tooltip alignment'), delayDuration: z.number().optional().describe('Delay before showing (ms)'), - children: aliasKeyRefusal( - 'children', + // AN INVERSION, not a widening. objectui#8284 tombstoned `children` here on + // the measured ground that the renderer read `content || body` and never + // `children`. objectui#6771 changed the READ, not the principle: the renderer + // now reads `content || children`, so the channel this node does not read is + // `body`, and that is where the tombstone moved. + body: aliasKeyRefusal( 'body', + 'children', 'this tooltip node', - '`tooltip` reads `content` first and `body` as the fallback for that same slot, and never `children` ' + '`tooltip` reads `content` first and `children` as the fallback for that same slot, and never `body` ' + '(READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/overlay/tooltip.tsx`). ' - + '`children` is inherited from `BaseSchema`, so an authored `children` parsed green here and rendered ' - + 'an EMPTY element — no error, no warning. objectui#8284.', + + 'This registration was the one place in the tree that ADVERTISED `body` in its `inputs`, which is why ' + + 'objectui#6771 brought it into the retirement rather than leaving it as the dialect\'s last discoverable home. objectui#8284.', ), }); From f6e4e5c942ec8b4e83f20b2286e739baedee97ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 13:46:38 +0000 Subject: [PATCH 04/18] test(components): move the `body` render pins onto `children`, and pin the cost the convergence moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten registrations that rendered `renderChildren(schema.body)` now put an authored `children` list on the page, so objectui#6808's runtime predicate calls them containers. objectui#6804's ruling wrote the answer in advance — a pin exception WITH ITS GROUND, ⛔ not `isContainer` — so each gets a reasoned entry in `container-declaration-baseline.json`'s `excluded`, and the exact-equality pin on that set moves with them. ⚠️ The exception is no longer free, and the entries say so: `validateTree`'s containment branch reads `node.children?.length`, so the false `not-a-container` that used to sit on a key these registrations did not read now sits on the only key they do. That is objectui#6771's own defect shape, moved rather than removed, and it is pinned as a refusal rather than left to be rediscovered. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- .../src/__tests__/basic-renderers.test.tsx | 4 +- .../src/__tests__/form-renderers.test.tsx | 2 +- .../guide-layout-page-buttons-7926.test.tsx | 6 +- .../page-body-single-node-8310.test.tsx | 22 +- ...eader-single-node-delegation-8923.test.tsx | 64 ++--- .../__tests__/box-neutral-container.test.tsx | 47 ++- .../container-declaration-census.test.tsx | 29 +- .../container-declaration-ratchet.test.tsx | 107 +++++-- .../node-slot-numeric-falsy.test.tsx | 45 ++- scripts/container-declaration-baseline.json | 270 +++++++++++++++--- 10 files changed, 444 insertions(+), 152 deletions(-) diff --git a/packages/components/src/__tests__/basic-renderers.test.tsx b/packages/components/src/__tests__/basic-renderers.test.tsx index cd18f6a833..62b30852b0 100644 --- a/packages/components/src/__tests__/basic-renderers.test.tsx +++ b/packages/components/src/__tests__/basic-renderers.test.tsx @@ -100,7 +100,7 @@ describe('Basic Renderers - Display Issue Detection', () => { it('should render children correctly', () => { const { container } = renderComponent({ type: 'div', - body: [ + children: [ { type: 'text', content: 'Child 1' }, { type: 'text', content: 'Child 2' }, ], @@ -113,7 +113,7 @@ describe('Basic Renderers - Display Issue Detection', () => { it('should not have display issues', () => { const { container } = renderComponent({ type: 'div', - body: [{ type: 'text', content: 'Content' }], + children: [{ type: 'text', content: 'Content' }], }); const issues = getAllDisplayIssues(container); diff --git a/packages/components/src/__tests__/form-renderers.test.tsx b/packages/components/src/__tests__/form-renderers.test.tsx index cdf7ecca00..bcbfa4a0bd 100644 --- a/packages/components/src/__tests__/form-renderers.test.tsx +++ b/packages/components/src/__tests__/form-renderers.test.tsx @@ -79,7 +79,7 @@ describe('Form Renderers - Display Issue Detection', () => { it('should render children when no label provided', () => { const { container } = renderComponent({ type: 'button', - body: [{ type: 'text', content: 'Child Content' }], + children: [{ type: 'text', content: 'Child Content' }], }); expect(container.textContent).toContain('Child Content'); diff --git a/packages/components/src/__tests__/guide-layout-page-buttons-7926.test.tsx b/packages/components/src/__tests__/guide-layout-page-buttons-7926.test.tsx index 8ff15ac7e3..8c3fc23523 100644 --- a/packages/components/src/__tests__/guide-layout-page-buttons-7926.test.tsx +++ b/packages/components/src/__tests__/guide-layout-page-buttons-7926.test.tsx @@ -29,7 +29,7 @@ * page node with actions: [{type:'button',label:'Add Product'}, {…}] * -> buttons found in the DOM: 0 * -> "Add Product" appears anywhere in the DOM: false - * the SAME two buttons moved into page.body + * the SAME two buttons moved into the page's child list * -> buttons found in the DOM: 2 texts: ["Add Product","Export"] * * `renders nothing when authored as page.actions` below is that first reading @@ -168,14 +168,14 @@ describe('objectui#7926 — the guide’s action passages draw buttons (render h cleanup(); }); - it('LIVE CONTROL — the same two buttons moved into `body` draw both', () => { + it('LIVE CONTROL — the same two buttons moved into `children` draw both', () => { // The other half of the original measurement: the remedy the refusal message // names is the one that works, so "0 buttons" above is about the KEY and not // about this test being unable to draw anything. const texts = buttonsDrawnBy({ type: 'page', title: 'Products', - body: [ + children: [ { type: 'flex', justify: 'end', diff --git a/packages/components/src/__tests__/page-body-single-node-8310.test.tsx b/packages/components/src/__tests__/page-body-single-node-8310.test.tsx index d5784b0522..79bc9daba8 100644 --- a/packages/components/src/__tests__/page-body-single-node-8310.test.tsx +++ b/packages/components/src/__tests__/page-body-single-node-8310.test.tsx @@ -7,13 +7,15 @@ */ /** - * `PageRenderer` draws a `body` given as ONE node and a `body` given as a list + * `PageRenderer` draws a flat content list given as ONE node and as a list * (objectui#8310, maintainer ruling 2026-09-07, director decision batch #2), * and the root README's flagship example draws its CHILDREN (objectui#8912). * * ## Two cards share this file, and they are not the same question * - * objectui#8310 asked the ARITY of `PageNodeSchema.body` — one node, or a list. + * objectui#8310 asked the ARITY of the page node's flat content list — one node, + * or a list. ⚠️ It was decided about the `body` spelling; objectui#6771 retired + * that spelling, so the ruling governs `children` and this file reads it there. * objectui#8912 asked whether the child key the flagship example AUTHORS has * any reader at all. This file now pins both, because both are properties of * the same transcribed snippet, and the second was originally left visible here @@ -101,36 +103,36 @@ const flagshipGridPreRepair = { items: flagshipGrid.children, }; -function renderPage(body: unknown) { - return render(); +function renderPage(children: unknown) { + return render(); } const gridsIn = (container: HTMLElement) => container.querySelectorAll('[data-obj-type="grid"]'); -describe('PageRenderer — both `body` arities reach the renderer (objectui#8310, CONTROL)', () => { - it('draws the node when `body` is ONE node — the README flagship shape', () => { +describe('PageRenderer — both flat-content arities reach the renderer (objectui#8310, CONTROL)', () => { + it('draws the node when `children` is ONE node — the README flagship shape', () => { const { container } = renderPage(flagshipGrid); expect(gridsIn(container)).toHaveLength(1); }); - it('draws the node when `body` is a LIST of nodes', () => { + it('draws the node when `children` is a LIST of nodes', () => { const { container } = renderPage([flagshipGrid]); expect(gridsIn(container)).toHaveLength(1); }); - it('draws nothing when `body` is absent — the negative control', () => { + it('draws nothing when `children` is absent — the negative control', () => { const { container } = renderPage(undefined); expect(gridsIn(container)).toHaveLength(0); }); }); describe('PageRenderer — the flagship example draws its CHILDREN (objectui#8912)', () => { - it('renders every child of a single-node `body`', () => { + it('renders every child of a single-node `children`', () => { const { container } = renderPage(flagshipGrid); for (const label of LABELS) expect(container.textContent).toContain(label); }); - it('renders every child of a list `body`', () => { + it('renders every child of a list `children`', () => { const { container } = renderPage([flagshipGrid]); for (const label of LABELS) expect(container.textContent).toContain(label); }); diff --git a/packages/components/src/__tests__/page-header-single-node-delegation-8923.test.tsx b/packages/components/src/__tests__/page-header-single-node-delegation-8923.test.tsx index dc88a78159..55f728bbf3 100644 --- a/packages/components/src/__tests__/page-header-single-node-delegation-8923.test.tsx +++ b/packages/components/src/__tests__/page-header-single-node-delegation-8923.test.tsx @@ -11,15 +11,15 @@ * the shape is declared `SchemaNode[]` and only ever a list. The delegation * check `containsTitledPageHeader` opened with `if (!Array.isArray(nodes)) … * return false`, so every channel whose declared arity is - * `SchemaNode | SchemaNode[]` — `PageNodeSchema.body`, `PageNodeSchema.children` - * and, through the recursion, every nested `body` / `children` — took that early + * `SchemaNode | SchemaNode[]` — the page node's flat content list and, through + * the recursion, every nested child list — took that early * return and answered "no titled header here". PageRenderer then drew its own * `h1` next to the one the header renders: the exact broken document outline * objectui#3434 closed, reopened for the arity the README flagship example - * actually authors (`body: { type: 'grid', … }`, a bare node). + * actually authors (`children: { type: 'grid', … }`, a bare node). * * The single-node arity became a declared, first-class authored shape in - * objectui#8914 (card objectui#8310), which widened `PageNodeSchema.body` / + * objectui#8914 (card objectui#8310), which widened the page node's flat content list / * `.children` to `SchemaNode | SchemaNode[]`; `FlatContent` in the same file has * always normalised a bare node into a one-element list. This suite is the * runtime reproduction the card asked the fixing seat to take first: assertions @@ -55,7 +55,7 @@ function titledHeader(props: Record = { title: HEADER_TITLE }) } /** - * A region-less page. `body` / `children` are handed through verbatim so each + * A region-less page. `children` is handed through verbatim so each * row controls the ARITY under test — the whole point of the suite. */ function pageWith(extra: Record) { @@ -79,25 +79,25 @@ function renderPage(schema: any) { } /** - * A chain of `cards` nested containers whose INNERMOST `body` is the bare - * header node and whose every outer `body` is a one-element list. The header is + * A chain of `cards` nested containers whose INNERMOST `children` is the bare + * header node and whose every outer `children` is a one-element list. The header is * therefore examined at recursion depth `cards`, through the single-node hop — * exactly the arity the budget must keep treating like the list arity. */ function chainToBareHeader(cards: number): any { - let node: any = { type: 'card', body: titledHeader() }; + let node: any = { type: 'card', children: titledHeader() }; for (let i = 1; i < cards; i += 1) { - node = { type: 'card', body: [node] }; + node = { type: 'card', children: [node] }; } return node; } -describe('PageRenderer — single-node `body`/`children` delegate the h1 too (objectui#8923)', () => { +describe('PageRenderer — a single-node `children` delegates the h1 too (objectui#8923)', () => { // ------------------------------------------------------------------------- // A1 — the reproduction the card asked for, at the top level. // ------------------------------------------------------------------------- - it('DISCRIMINATING — `body` as a SINGLE titled `page:header` node renders exactly ONE h1', () => { - renderPage(pageWith({ body: titledHeader() })); + it('DISCRIMINATING — `children` as a SINGLE titled `page:header` node renders exactly ONE h1', () => { + renderPage(pageWith({ children: titledHeader() })); // Before the normaliser this read 2: the page's implicit `label` h1 plus // the header's own. `getAllByRole` is the locator the live e2e depends on. @@ -117,28 +117,28 @@ describe('PageRenderer — single-node `body`/`children` delegate the h1 too (ob // ------------------------------------------------------------------------- // A2 — the hole is per-RECURSION-LEVEL, not only at the top. The recursion - // feeds `n.body` / `n.children` back into the SAME early return. + // feeds `n.children` back into the SAME early return. // ------------------------------------------------------------------------- - it('DISCRIMINATING — a nested container whose `body` is a SINGLE titled header still delegates', () => { - renderPage(pageWith({ body: [{ type: 'card', body: titledHeader() }] })); + it('DISCRIMINATING — a nested container whose `children` is a SINGLE titled header still delegates', () => { + renderPage(pageWith({ children: [{ type: 'card', children: titledHeader() }] })); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); expect(screen.getByRole('heading', { level: 1 }).textContent).toBe(HEADER_TITLE); }); it('DISCRIMINATING — a nested container whose `children` is a SINGLE titled header still delegates', () => { - renderPage(pageWith({ body: [{ type: 'card', children: titledHeader() }] })); + renderPage(pageWith({ children: [{ type: 'card', children: titledHeader() }] })); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); expect(screen.getByRole('heading', { level: 1 }).textContent).toBe(HEADER_TITLE); }); it('DISCRIMINATING — a bare node at EVERY level of the chain (single-node all the way down)', () => { - // Neither the outer `body` nor any inner one is a list. Before the fix the + // Neither the outer `children` nor any inner one is a list. Before the fix the // walk stopped at the very first hop. renderPage( pageWith({ - body: { type: 'card', body: { type: 'card', body: titledHeader() } }, + children: { type: 'card', children: { type: 'card', children: titledHeader() } }, }), ); @@ -150,22 +150,22 @@ describe('PageRenderer — single-node `body`/`children` delegate the h1 too (ob // LIVE CONTROLS — the list arity that already worked. Green on BOTH ablation // legs; they are what says the normaliser did not cross a boundary. // ------------------------------------------------------------------------- - it('LIVE CONTROL — `body` as a one-element LIST still renders exactly ONE h1', () => { - renderPage(pageWith({ body: [titledHeader()] })); + it('LIVE CONTROL — `children` as a one-element LIST still renders exactly ONE h1', () => { + renderPage(pageWith({ children: [titledHeader()] })); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); expect(screen.getByRole('heading', { level: 1 }).textContent).toBe(HEADER_TITLE); }); - it('LIVE CONTROL — a nested container whose `body` is a LIST still renders exactly ONE h1', () => { - renderPage(pageWith({ body: [{ type: 'card', body: [titledHeader()] }] })); + it('LIVE CONTROL — a nested container whose `children` is a LIST still renders exactly ONE h1', () => { + renderPage(pageWith({ children: [{ type: 'card', children: [titledHeader()] }] })); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); expect(screen.getByRole('heading', { level: 1 }).textContent).toBe(HEADER_TITLE); }); it('LIVE CONTROL — a page that authors NO `page:header` keeps its own implicit h1', () => { - renderPage(pageWith({ body: { type: 'element:text', properties: { text: 'body' } } })); + renderPage(pageWith({ children: { type: 'element:text', properties: { text: 'body' } } })); const h1s = screen.getAllByRole('heading', { level: 1 }); expect(h1s).toHaveLength(1); @@ -178,27 +178,27 @@ describe('PageRenderer — single-node `body`/`children` delegate the h1 too (ob // widens the arity, never the "counts as titled" test. Without these rows the // repair could trade a duplicate h1 for a page with ZERO h1 — strictly worse. // ------------------------------------------------------------------------- - it('LIVE CONTROL — single-node `body` header with NO title leaves the page its own h1', () => { + it('LIVE CONTROL — single-node `children` header with NO title leaves the page its own h1', () => { // PageHeaderRenderer's bare branch renders `{explicitTitle &&

}`, so an // untitled header contributes no heading at all. - renderPage(pageWith({ body: titledHeader({ subtitle: 'Just a subtitle' }) })); + renderPage(pageWith({ children: titledHeader({ subtitle: 'Just a subtitle' }) })); const h1s = screen.getAllByRole('heading', { level: 1 }); expect(h1s).toHaveLength(1); expect(h1s[0].textContent).toBe(PAGE_LABEL); }); - it('LIVE CONTROL — single-node `body` header whose title interpolates to nothing leaves the page its own h1', () => { + it('LIVE CONTROL — single-node `children` header whose title interpolates to nothing leaves the page its own h1', () => { // `title: '{name}'` with no record in scope → `interpolate()` blanks it. - renderPage(pageWith({ body: titledHeader({ title: '{name}' }) })); + renderPage(pageWith({ children: titledHeader({ title: '{name}' }) })); const h1s = screen.getAllByRole('heading', { level: 1 }); expect(h1s).toHaveLength(1); expect(h1s[0].textContent).toBe(PAGE_LABEL); }); - it('DISCRIMINATING — delegating from a single-node `body` keeps the page `description`', () => { - renderPage(pageWith({ body: titledHeader(), description: 'Page-level prose.' })); + it('DISCRIMINATING — delegating from a single-node `children` keeps the page `description`', () => { + renderPage(pageWith({ children: titledHeader(), description: 'Page-level prose.' })); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); expect(screen.getByText('Page-level prose.')).toBeTruthy(); @@ -211,10 +211,10 @@ describe('PageRenderer — single-node `body`/`children` delegate the h1 too (ob describe('depth budget (`depth > 6`) — unchanged by the normaliser', () => { it('DISCRIMINATING — a bare header at the LAST in-budget depth (6) still delegates', () => { // Six containers; the header is reached through the innermost container's - // single-node `body`, so it is examined at depth 6 — the last value the + // single-node `children`, so it is examined at depth 6 — the last value the // guard admits. If normalising a bare node spent a depth level this row // would fall out of budget and the page would draw a second h1. - renderPage(pageWith({ body: [chainToBareHeader(6)] })); + renderPage(pageWith({ children: [chainToBareHeader(6)] })); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); expect(screen.getByRole('heading', { level: 1 }).textContent).toBe(HEADER_TITLE); @@ -226,7 +226,7 @@ describe('PageRenderer — single-node `body`/`children` delegate the h1 too (ob // buy the walk an extra level. The duplicate h1 it asserts is the // pre-existing outcome for headers buried deeper than the walk looks // (objectui#3434's depth bound), and is out of scope for objectui#8923. - renderPage(pageWith({ body: [chainToBareHeader(7)] })); + renderPage(pageWith({ children: [chainToBareHeader(7)] })); expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(2); }); diff --git a/packages/components/src/renderers/__tests__/box-neutral-container.test.tsx b/packages/components/src/renderers/__tests__/box-neutral-container.test.tsx index ba98ff011e..3a59b48427 100644 --- a/packages/components/src/renderers/__tests__/box-neutral-container.test.tsx +++ b/packages/components/src/renderers/__tests__/box-neutral-container.test.tsx @@ -31,14 +31,16 @@ * element must carry no class attribute at all. An injected default (like * `div`'s designer `defaultProps`) would surface here first. * - * - `box` reads `children` ONLY — never `schema.body`. That is deliberate and - * load-bearing, not an omission: `div`'s `children || body` fallback is what + * - `box` reads `children` ONLY — never `schema.body`. This clause was minted + * when `box` was the EXCEPTION: `div`'s `children || body` fallback is what * made a mechanical `div`→X swap silently DROP content on `body`-authoring * nodes while the element count stayed unchanged (the failure both - * superseded rulings on objectui#3965 died on). Content moves into - * `children` at migration time — the objectui#6771 B-ruling direction. The - * `div` control render beside it proves the fixture is renderable and the - * difference is this renderer's read, not a broken fixture. + * superseded rulings on objectui#3965 died on). objectui#6771 retired the + * `body` spelling across the protocol, so `box` is no longer the exception + * and the clause now measures something stronger: a `body`-authored node + * draws NOTHING on EITHER tag, while the identical content under `children` + * draws on both. The `children` control render is what keeps the two empty + * readings a measurement of the retired key rather than of a broken fixture. */ import { describe, it, expect } from 'vitest'; @@ -96,19 +98,34 @@ describe('box — the class-transparent neutral container (objectui#3965)', () = expect(boxEl.getAttribute('class'), 'zero injected classes').toBeNull(); }); - it('reads `children` only — `body` is deliberately not a content channel', () => { - // The div control FIRST: proves this exact fixture shape renders content - // through the `children || body` reader, so the empty box below is a - // measurement of box's deliberate read, not of a broken fixture. + it('reads `children` only — and since objectui#6771 so does `div`, which used to take `body` too', () => { const bodyFixture = (type: string) => ({ type, body: [{ type: 'text', content: 'body-authored-content' }], }); - const divControl = renderNode(bodyFixture('div')); - expect(divControl.container.textContent).toContain('body-authored-content'); - divControl.unmount(); + const childrenFixture = (type: string) => ({ + type, + children: [{ type: 'text', content: 'children-authored-content' }], + }); + + // The CONTROLS first: the identical content under `children` draws on both + // tags, so the two empty readings below are a measurement of the retired + // key and not of a fixture that never rendered. + for (const type of ['div', 'box']) { + const live = renderNode(childrenFixture(type)); + expect(live.container.textContent, `\`${type}\` lost its \`children\``) + .toContain('children-authored-content'); + live.unmount(); + } - const { container } = renderNode(bodyFixture('box')); - expect(container.textContent).not.toContain('body-authored-content'); + // `div` used to answer this fixture — `renderChildren(schema.children || schema.body)` + // — which is exactly what made a mechanical `div`→`box` swap unsafe. The + // arm is retired, so the swap is safe and this reading is the reason why. + for (const type of ['div', 'box']) { + const dead = renderNode(bodyFixture(type)); + expect(dead.container.textContent, `\`${type}\` still reads the retired \`body\``) + .not.toContain('body-authored-content'); + dead.unmount(); + } }); }); diff --git a/packages/components/src/renderers/__tests__/container-declaration-census.test.tsx b/packages/components/src/renderers/__tests__/container-declaration-census.test.tsx index 20de2c70e7..720b2b06b7 100644 --- a/packages/components/src/renderers/__tests__/container-declaration-census.test.tsx +++ b/packages/components/src/renderers/__tests__/container-declaration-census.test.tsx @@ -46,8 +46,8 @@ * the card: they are NOT swept in here, because the three exception populations * below prove the predicate has real exceptions -- `tabs` (renders * `items[].content`), the void tags out of the same loop factory as 34 that do - * render children, and the `schema.body` readers the containment check never - * inspects. + * render children, and the former `schema.body` readers, which objectui#6771 + * converged on `children` and objectui#6804's ruling keeps undeclared. */ import { describe, it, expect } from 'vitest'; @@ -163,15 +163,24 @@ describe('the declaration is confined to what was measured (objectui#6764)', () expect(diagnose(withChildren(type)).map((d) => d.code)).toContain(CONTAINMENT); }); - it('leaves `badge` and `alert` alone — they read `schema.body`, a key the check never inspects', async () => { - // The third exception, and the one a "renders children OR body" predicate - // would have collapsed: these render `renderChildren(schema.body)` and never - // touch `schema.children`. `validateTree`'s containment branch is guarded by - // `node.children?.length` ALONE, so no author writing `body` on them has - // ever drawn a false diagnostic, and declaring the flag would buy nothing - // while removing both from the react-page scope (both are PUBLIC). + it('leaves `badge` and `alert` undeclared — objectui#6771 gave them a `children` read, objectui#6804 keeps the flag off', async () => { + // ⚠️ INVERTED, and the exception it guards is now LOAD-BEARING rather than + // free. These two used to render `renderChildren(schema.body)` and never + // touch `schema.children`, so `validateTree`'s containment branch — guarded + // by `node.children?.length` ALONE — never fired on them and declaring the + // flag would have bought nothing while removing both from the react-page + // scope (both are PUBLIC). + // + // objectui#6771 retired the `body` spelling; they read `children` now, so + // the branch DOES fire, on the only key they read. The flag still stays off + // — objectui#6804's ruling covers exactly this population and orders a + // reasoned baseline exception instead — and the react-page cost is the + // reason the ruling gave. What this pin records is that the warning is now + // false rather than absent; the full statement of that cost, and the + // question it raises for the `sidebar-*` family, is in + // `container-declaration-ratchet.test.tsx`'s converged-readers block. for (const type of ['badge', 'alert']) { - expect(await rendersChildren(type)).toBe(false); + expect(await rendersChildren(type), `\`${type}\` lost its \`children\` read`).toBe(true); expect(diagnose(withChildren(type)).map((d) => d.code)).toContain(CONTAINMENT); } }); diff --git a/packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx b/packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx index eeb1703987..5e320e4296 100644 --- a/packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx +++ b/packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx @@ -375,8 +375,25 @@ describe('`button` is EXCLUDED by ruling — permanently, and with its ground (o // 13 do not violate today, and listing a non-violator trips the OTHER // direction of the baseline's red. They are pinned as non-violators by the // `schema.body` readers block below instead. - expect(Object.keys(baseline.excluded)).toEqual(['button']); - expect(baseline.excluded.button.issue).toBe('objectui#6804'); + // ⚠️ `button` is no longer alone. objectui#6804's ruling covered FOURTEEN + // tags — `button` plus the `schema.body` readers — and said in as many + // words what to do when objectui#6771 gave those readers a `children` + // read: a pin exception WITH ITS GROUND, ⛔ not `isContainer`. That + // happened, so ten of them are here now, each carrying its own reason. + // Exact equality is kept deliberately — admitting an entry stays a + // deliberate act, and the set is asserted rather than its size, so a + // swap for an unruled tag cannot ride in on a count. + expect(Object.keys(baseline.excluded).sort()).toEqual([ + 'alert', 'badge', 'button', + 'sidebar-content', 'sidebar-footer', 'sidebar-group', 'sidebar-header', + 'sidebar-inset', 'sidebar-menu', 'sidebar-menu-item', 'sidebar-provider', + ]); + // Every one of them points at the ruling that decided it, not at the card + // that deferred it or the one that executed it. + for (const [tag, entry] of Object.entries(baseline.excluded)) { + expect(entry.issue, tag).toBe('objectui#6804'); + expect(entry.reason.length, `\`${tag}\` carries no ground`).toBeGreaterThan(200); + } // Pinned as a live description rather than as prose, so the exclusion // cannot outlive its reason. These three are exactly the facts the ruling @@ -427,27 +444,71 @@ describe('the predicate is RUNTIME, so the exception shapes need no skip-list (o expect(diagnose(withChildren(type)).map((d) => d.code)).toContain(CONTAINMENT); }); - it('the `schema.body` readers stay non-containers — `body` is a key the check never inspects', async () => { - // The other 13 of objectui#6804's 14, and the population a "children or - // body" predicate would collapse: these render `renderChildren(schema.body)` - // and never touch `schema.children`. That 2026-08-30 ruling gives them the - // SAME answer as `button` — no `isContainer` — but they get no baseline - // entry while they do not violate, because listing a non-violator is the - // other direction of that file's red. If objectui#6771's retirement of the - // `body` dialect gives one of them a `children` read, that is when it earns - // a reasoned entry of its own. ⚠️ Two of them are PUBLIC — `badge` and - // `alert` — so that change would be moving published contract, not a - // mechanical fix. That is a measurement, so it is pinned rather than - // asserted in prose: see the block below. - // `validateTree`'s containment branch is guarded by `node.children?.length` - // ALONE, so no author writing `body` on them has ever drawn a false - // diagnostic. Measured: 13 bare keys, not the 10 objectui#6779 estimated — - // the `sidebar-` family is 11 keys, not 8. - const bodyReaders = ['badge', 'alert', ...bareTags().filter((t) => t.startsWith('sidebar'))]; - expect(bodyReaders.length).toBe(13); - for (const type of bodyReaders) { - expect(await rendersChildren(type), `\`${type}\` started rendering children`).toBe(false); - expect(diagnose(withChildren(type)).map((d) => d.code), type).toContain(CONTAINMENT); + it('the former `schema.body` readers now read `children` — and the containment warning moved WITH them', async () => { + // ⚠️ THIS PIN IS INVERTED, and it is inverted into a REFUSAL rather than + // deleted, because what it now records is a cost the ruling did not price. + // + // It used to read: these thirteen render `renderChildren(schema.body)` and + // never touch `schema.children`, so `validateTree`'s containment branch — + // guarded by `node.children?.length` ALONE — never fired on them and no + // author writing `body` had ever drawn a false diagnostic. It closed by + // saying that if objectui#6771's retirement gave one of them a `children` + // read, that is when it would earn a reasoned baseline entry of its own. + // + // That is what happened, and it moved the defect rather than removing it: + // + // before `body` renders, `children` does not, `children` draws the warning + // after `children` renders, `body` does not, `children` draws the warning + // + // ⇒ the false `not-a-container` is now on the ONE key these registrations + // read — which is the shape objectui#6771 was filed about, one key over. + // ⛔ Do not resolve it by declaring `isContainer`: objectui#6804's ruling + // (2026-08-30) forbids that for exactly this population and orders a + // reasoned pin exception instead, which is what the baseline now carries. + // The ruling's GROUND — `children` here is a label/content fallback, not + // layout containment — transfers cleanly to `badge`, `alert` and `button`; + // whether it transfers to the `sidebar-*` family, whose child lists ARE + // layout, is the question reported back on objectui#6771 rather than + // answered here. Until it is answered, this pin is what keeps the cost + // visible instead of rediscovered. + const converged = ['badge', 'alert', ...bareTags().filter((t) => t.startsWith('sidebar'))]; + expect(converged.length).toBe(13); + + // (a) the retired spelling is ANSWERED, and the answer names the remedy. + // ⛔ Not "draws nothing": silence on a retired key is the state this whole + // retirement exists to end. + for (const type of converged) { + const messages = diagnose({ type, body: [{ type: 'text', content: MARK }] } as unknown) + .map((d) => d.message); + expect( + messages.some((m) => m.includes('"body"') && m.includes('"children"')), + `\`${type}\` does not name \`children\` when an author writes the retired \`body\``, + ).toBe(true); + } + + // (b) none of them declares the flag — objectui#6804's ruling, still held. + const rows = await census(); + const byType = new Map(rows.map((r) => [r.type, r])); + for (const type of converged) { + expect(byType.get(type)?.isContainer, `\`${type}\` declared \`isContainer\``).toBe(false); + } + + // (c) THE REFUSAL. Every one of them that puts an authored `children` list + // on the page still draws `not-a-container` for it. Asserted on the + // measured renderers rather than on all thirteen, because three of them + // (`sidebar`, `sidebar-menu-button`, `sidebar-trigger`) do not put a child + // on the page under this probe and so are not part of the cost. + const rendering: string[] = []; + for (const type of converged) { + if (await rendersChildren(type)) rendering.push(type); + } + expect(rendering.length, 'no converged registration renders children — the retirement did not land') + .toBeGreaterThan(0); + for (const type of rendering) { + expect( + diagnose(withChildren(type)).map((d) => d.code), + `\`${type}\` stopped drawing the false warning — resolve the exception rather than this pin`, + ).toContain(CONTAINMENT); } }, CENSUS_TIMEOUT); }); diff --git a/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx b/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx index 96955a46bd..bfe31d6e55 100644 --- a/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx +++ b/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx @@ -55,6 +55,7 @@ */ import { describe, it, expect, afterEach } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; import { render, cleanup } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; // Registered at module scope, NOT in a `beforeAll`: there the cold transform is @@ -90,9 +91,17 @@ interface Site { * The eleven leaking sites objectui#9162 measured, plus the three the same * instrument reaches once the class is being closed rather than the instances: * `ui:card` `children` (clean on `main` only by the accident of `||` operand - * order — see `cardChildrenWasCleanOnlyByAccident`), and `page:card`'s `body` / - * `footer`, which the card's TypeScript census could not see because that - * renderer's `schema` is `any`. + * order — see `uiCardChildrenNoLongerDependsOnOperandOrder`), and `page:card`'s + * `body` / `footer`, which the card's TypeScript census could not see because + * that renderer's `schema` is `any`. + * + * ⚠️ `ui:card body` LEFT this table, and that is a slot that ceased to exist, + * not a leak that was fixed: objectui#6771 retired the `body` child-list + * spelling, so `ui:card` reads `renderNodeSlot(schema.children, …)` and there is + * no `body` slot left for a numeric-falsy value to leak through. `page:card`'s + * `body` STAYS — that key is `PageCardProps.body`, a read-only back-compat path + * for stored documents under a different retirement (objectstack#5775, ADR-0087 + * D2), and it is still a live node slot. */ const SITES: readonly Site[] = [ { name: 'container children', type: 'container', namespace: 'ui', slot: 'children' }, @@ -100,7 +109,6 @@ const SITES: readonly Site[] = [ { name: 'grid children', type: 'grid', namespace: 'ui', slot: 'children' }, { name: 'stack children', type: 'stack', namespace: 'ui', slot: 'children' }, { name: 'ui:card header', type: 'card', namespace: 'ui', slot: 'header' }, - { name: 'ui:card body', type: 'card', namespace: 'ui', slot: 'body' }, { name: 'ui:card children', type: 'card', namespace: 'ui', slot: 'children' }, { name: 'ui:card footer', type: 'card', namespace: 'ui', slot: 'footer' }, { name: 'page:card body', type: 'card', namespace: 'page', slot: 'body' }, @@ -146,7 +154,7 @@ describe('SchemaNode slots refuse numeric-falsy authored values (objectui#9162)' expect(ContainerSchemaZod.safeParse({ type: 'container', children: 0 }).success).toBe(true); expect(CardSchemaZod.safeParse({ type: 'card', footer: 0 }).success).toBe(true); expect(CardSchemaZod.safeParse({ type: 'card', header: 0 }).success).toBe(true); - expect(CardSchemaZod.safeParse({ type: 'card', body: 0 }).success).toBe(true); + expect(CardSchemaZod.safeParse({ type: 'card', children: 0 }).success).toBe(true); expect(DialogSchemaZod.safeParse({ type: 'dialog', footer: 0 }).success).toBe(true); }); }); @@ -212,15 +220,28 @@ describe('SchemaNode slots refuse numeric-falsy authored values (objectui#9162)' }); describe('the one row that was clean on `main`, and why that was not protection', () => { - it('cardChildrenWasCleanOnlyByAccident — `body: 0` leaked through the SAME `||` chain', () => { + it('uiCardChildrenNoLongerDependsOnOperandOrder — the `||` chain that made it accidental is gone', () => { // `ui:card` read `(schema.children || schema.body)`, and `0 || undefined` // is `undefined` — so `children: 0` was converted away by accident of - // OPERAND ORDER, not by any guard. The proof is the sibling key: with - // `children` absent, `undefined || 0` is `0`, and that row leaked. Both - // are asserted clean above; this row states WHY the pair had to be tested - // together, so a later reader does not re-derive "children is protected" - // from the `||`. - const site = SITES.find((s) => s.name === 'ui:card body')!; + // OPERAND ORDER, not by any guard. The proof used to be the sibling key: + // with `children` absent, `undefined || 0` is `0`, and THAT row leaked. + // + // objectui#6771 retired the `body` spelling, so the chain is one operand + // long: `renderNodeSlot(schema.children, …)`. The accident cannot recur, + // and `children: 0` is clean because the guard says so. ⛔ The assertion + // is on the SOURCE, not on the render: a render reading is green under + // both spellings of the reader and would not see the chain come back. + // Resolved from the vitest ROOT, which AGENTS.md pins to the repository + // root for every supported invocation. ⛔ Not `import.meta.url`: in the + // `dom` project that is not a `file:` URL and `readFileSync` throws. + const CARD_SRC = 'packages/components/src/renderers/layout/card.tsx'; + expect(existsSync(CARD_SRC), `run vitest from the repo root — ${CARD_SRC} not found`).toBe(true); + const src = readFileSync(CARD_SRC, 'utf8'); + expect(src).toContain('renderNodeSlot(schema.children,'); + expect(src).not.toContain('schema.children || schema.body'); + + // And the row it used to lean on is still clean, through the guard. + const site = SITES.find((s) => s.name === 'ui:card children')!; const baseline = renderSite(site, OMIT); cleanup(); expect(renderSite(site, 0)).toBe(baseline); diff --git a/scripts/container-declaration-baseline.json b/scripts/container-declaration-baseline.json index 562c9780b9..9773108609 100644 --- a/scripts/container-declaration-baseline.json +++ b/scripts/container-declaration-baseline.json @@ -74,52 +74,234 @@ "reason": "PERMANENT, RULED EXCEPTION -- not an oversight, not pending anything. Admitted 2026-08-29 as provisional by objectui#6779, which deferred the question to a separate card; that card is objectui#6804 and the maintainer ruled it 2026-08-30: `button` does NOT declare `isContainer`. Three grounds, all re-derived on this tree. (1) SEMANTICS -- `isContainer` means LAYOUT CONTAINMENT, not `this tag renders children`. `button` reads `schema.children` only as a fallback for `schema.label` (packages/components/src/renderers/form/button.tsx, `schema.label || renderChildren(...)`), so it is not a container; declaring it would make one predicate mean two different things. (2) COST -- `button` is the only public-tier member of the 45, and `renderers/layout/react-page.tsx` skips containers when it builds the JSX scope of every `kind:'react'` page, so declaring the flag DELETES the `Button` identifier from all of them. That is an immediate, user-visible capability loss. (3) PULL -- zero measured: nothing records anyone wanting to nest layout content inside a button, so the trade is a certain cost against a hypothetical gain. ⛔ Do NOT `fix` this entry by declaring the flag, and do NOT move `button` into `undeclared` -- both override the ruling. What keeps the exception honest is the pin, which asserts all three facts it rests on (still renders children, still undeclared, still public); if any one of them changes this goes RED and the exception must be RE-RULED, not quietly re-based. Scope note for the next hand: this card deliberately did not edit that `button.tsx` line -- the `schema.body || schema.children` expression on it belongs to objectui#6771 (retirement of the `body` dialect).", "issue": "objectui#6804", "since": "2026-08-29" + }, + "alert": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a content fallback behind `description`, not a layout slot, so declaring it would make one predicate mean two things. ⚠️ PUBLIC TIER: this tag is in `PUBLIC_BLOCKS`, so declaring the flag would also delete its identifier from the JSX scope of every `kind:'react'` page — the same immediate, user-visible capability loss that made `button` an exclusion rather than a ratchet line. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "badge": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a label fallback behind `label`, the same shape `button` was excluded for, so declaring it would make one predicate mean two things. ⚠️ PUBLIC TIER: this tag is in `PUBLIC_BLOCKS`, so declaring the flag would also delete its identifier from the JSX scope of every `kind:'react'` page — the same immediate, user-visible capability loss that made `button` an exclusion rather than a ratchet line. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-content": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-footer": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-group": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-header": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-inset": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-menu": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-menu-item": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-provider": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" } }, "undeclared": { - "a": { "since": "2026-08-29", "issue": "objectui#6779" }, - "abbr": { "since": "2026-08-29", "issue": "objectui#6779" }, - "address": { "since": "2026-08-29", "issue": "objectui#6779" }, - "app": { "since": "2026-08-29", "issue": "objectui#6779" }, - "b": { "since": "2026-08-29", "issue": "objectui#6779" }, - "blockquote": { "since": "2026-08-29", "issue": "objectui#6779" }, - "cite": { "since": "2026-08-29", "issue": "objectui#6779" }, - "dd": { "since": "2026-08-29", "issue": "objectui#6779" }, - "del": { "since": "2026-08-29", "issue": "objectui#6779" }, - "div": { "since": "2026-08-29", "issue": "objectui#6779" }, - "dl": { "since": "2026-08-29", "issue": "objectui#6779" }, - "dt": { "since": "2026-08-29", "issue": "objectui#6779" }, - "em": { "since": "2026-08-29", "issue": "objectui#6779" }, - "figcaption": { "since": "2026-08-29", "issue": "objectui#6779" }, - "figure": { "since": "2026-08-29", "issue": "objectui#6779" }, - "form": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h1": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h2": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h3": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h4": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h5": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h6": { "since": "2026-08-29", "issue": "objectui#6779" }, - "home": { "since": "2026-08-29", "issue": "objectui#6779" }, - "i": { "since": "2026-08-29", "issue": "objectui#6779" }, - "ins": { "since": "2026-08-29", "issue": "objectui#6779" }, - "li": { "since": "2026-08-29", "issue": "objectui#6779" }, - "mark": { "since": "2026-08-29", "issue": "objectui#6779" }, - "ol": { "since": "2026-08-29", "issue": "objectui#6779" }, - "p": { "since": "2026-08-29", "issue": "objectui#6779" }, - "page": { "since": "2026-08-29", "issue": "objectui#6779" }, - "pre": { "since": "2026-08-29", "issue": "objectui#6779" }, - "q": { "since": "2026-08-29", "issue": "objectui#6779" }, - "record": { "since": "2026-08-29", "issue": "objectui#6779" }, - "scroll-area": { "since": "2026-08-29", "issue": "objectui#6779" }, - "small": { "since": "2026-08-29", "issue": "objectui#6779" }, - "span": { "since": "2026-08-29", "issue": "objectui#6779" }, - "strong": { "since": "2026-08-29", "issue": "objectui#6779" }, - "sub": { "since": "2026-08-29", "issue": "objectui#6779" }, - "sup": { "since": "2026-08-29", "issue": "objectui#6779" }, - "time": { "since": "2026-08-29", "issue": "objectui#6779" }, - "toggle": { "since": "2026-08-29", "issue": "objectui#6779" }, - "u": { "since": "2026-08-29", "issue": "objectui#6779" }, - "ul": { "since": "2026-08-29", "issue": "objectui#6779" }, - "utility": { "since": "2026-08-29", "issue": "objectui#6779" } + "a": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "abbr": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "address": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "app": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "b": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "blockquote": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "cite": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "dd": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "del": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "div": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "dl": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "dt": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "em": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "figcaption": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "figure": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "form": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h1": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h2": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h3": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h4": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h5": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h6": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "home": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "i": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "ins": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "li": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "mark": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "ol": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "p": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "page": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "pre": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "q": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "record": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "scroll-area": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "small": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "span": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "strong": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "sub": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "sup": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "time": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "toggle": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "u": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "ul": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "utility": { + "since": "2026-08-29", + "issue": "objectui#6779" + } } } From 963f127b2fb955b708a327de02e2aed51bdd20a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 13:47:42 +0000 Subject: [PATCH 05/18] chore(changeset): declare the `body` child-list retirement Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- .../6771-retire-body-child-list-dialect.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .changeset/6771-retire-body-child-list-dialect.md diff --git a/.changeset/6771-retire-body-child-list-dialect.md b/.changeset/6771-retire-body-child-list-dialect.md new file mode 100644 index 0000000000..ce8161fc42 --- /dev/null +++ b/.changeset/6771-retire-body-child-list-dialect.md @@ -0,0 +1,51 @@ +--- +'@object-ui/types': minor +'@object-ui/components': minor +'@object-ui/core': minor +'@object-ui/sdui-parser': minor +'object-ui': minor +--- + +**BREAKING (authoring surface): `body` is no longer a child-list key. Author `children`.** + +`BaseSchema` declared two spellings of one concept — `body` and `children` — and left the +choice per component. Twelve registrations read `body` and nothing else, so it was their +ONLY door; `div`, `card`, `page`, `button`, `aspect-ratio` and the seven sectioning tags +read `children || body` and took either. The authoring tier only ever knew `children`, so +an author writing the one spelling that resolved got `unknown-prop` — the same warning a +typo draws, on the tier built to accept AI-authored pages, where the diagnostic **is** the +contract (objectui#6771). + +Ruled 2026-09-01: one concept, one spelling, and the spelling is `children`. + +## What changed + +- **Renderers.** `alert`, `badge`, `tooltip` and the `sidebar-*` family read `children`. + Every `children || body` fallback drops its `body` arm — `div`, `card`, `button`, + `aspect-ratio`, the sectioning tags, `page`'s flat content list, `@object-ui/core`'s + recursive `validateSchema`, and the two container walkers that enumerated child + channels. +- **The published type.** `BaseSchema.body` is `never` on the TypeScript face and an + alias refusal naming `children` on the Zod mirror, as are the four per-component + redeclarations (`CardSchema`, `AspectRatioSchema`, `PageNodeSchema`, `TooltipSchema`). + ⚠️ Refused **by name**, not deleted: `BaseSchema` carries an index signature and the + mirror ends `.passthrough()`, so a deleted member would be accepted silently and + rendered by nothing — the exact silence this retirement ends. +- **The authoring tier.** `sdui-parser` answers an authored `body` with the replacement + named, instead of the bare "has no prop" every typo gets — and a `body` child list + under a non-container draws the same containment code the `children` spelling draws. +- **Corpus and teaching, in the same change.** 114 authored nodes across the docs site, + the schema catalog, five package READMEs and the VS Code extension's snippets, JSON + schema, syntax map, hovers and completions. The platform does not refuse a spelling it + still ships. + +## Migrating + +Rename the key. `{ "type": "card", "body": [...] }` becomes +`{ "type": "card", "children": [...] }`; both faces now name `children` in the refusal, +and `pnpm census:body-dialect` reports where the dialect still lives in a tree. + +⚠️ One reader is deliberately untouched: `page:card` still reads `body` first, as a +read-only back-compat path for stored documents. That key is `PageCardProps.body` under a +different, already-ruled retirement (objectstack#5775, ADR-0087 D2) whose own sequencing +note requires the load-time conversion to land first. From 22859c85f42602611797d521be476ef3580ff987 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 14:13:15 +0000 Subject: [PATCH 06/18] fix(types,cli,scripts,examples): close the six reds the retirement left on its own pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `Type Check`: the objectui#9256 chatbot control asserted `body` type-checks on a TWIN face, which inherited it from `BaseSchema`. The base moved, so the control inverts to a `@ts-expect-error` beside a `requestBody` control that compiles — the key an author should actually write. - `Lint`: `check-test-path-roots` refused a cwd-relative read this branch added. Re-rooted at the file, the spelling that gate teaches. - The census (ruling step 1's own artefact) is REPOINTED, not deleted: it now asserts the retirement's reality — `badge` / `alert` / `tooltip` read `children`, ten `sidebar` reads not nine or eleven, all three generic readers on one arm, the completion provider emitting `children` — so reintroducing `body` reddens it. - The `examples/schema-catalog` counter-probes are re-pointed at `body`, the key that is inert NOW. Measured: 0 of the catalogue's 2267 typed nodes author `children` or `body` on a `badge`, so no demo's rendered output moved. - The CLI's `good.json` authored the retired spelling; diagnosed before converting, with both legs run. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- .../test/badge-demo-label-6829.test.tsx | 67 ++++++++----- .../catalog-authored-key-6805-6806.test.tsx | 20 ++-- .../test/overlay-trigger-mirror-6939.test.tsx | 30 ++++-- packages/cli/src/__tests__/cli-bin.test.ts | 2 +- .../node-slot-numeric-falsy.test.tsx | 18 +++- .../content-channel-family-d-9256.test.ts | 28 ++++-- scripts/__tests__/body-dialect-census.test.ts | 97 ++++++++++++++----- 7 files changed, 187 insertions(+), 75 deletions(-) diff --git a/examples/schema-catalog/test/badge-demo-label-6829.test.tsx b/examples/schema-catalog/test/badge-demo-label-6829.test.tsx index 33cbe687f0..b4caf78f61 100644 --- a/examples/schema-catalog/test/badge-demo-label-6829.test.tsx +++ b/examples/schema-catalog/test/badge-demo-label-6829.test.tsx @@ -89,17 +89,26 @@ * touched entries' own containers still draw their children, and that the * blind-sweep shape is the empty demo it is. * - * ## ⛔ What arm A does NOT do — the class stays open + * ## ⛔ What arm A did NOT do — and what closed it afterwards * - * This restores four demos; it does not close the class. `children` is + * Arm A restored four demos and did not close the class: `children` was * declared on `BaseSchema`, accepted by every other container renderer, * refused by neither zod nor tsc (`BaseSchema` is `.passthrough()` with an - * index signature), and consumed by the pipeline before it can leak to the - * DOM — so the next author who writes `children` on a badge draws an empty - * pill again, and the only instruments that see it are the exact ledger and - * this file's corpus sweep. Arm B (teaching `badge.tsx` to read `children`) - * widens a published renderer's read set, which AGENTS.md #0.1 governs; it is - * objectui#6810's decision and deliberately untouched here. + * index signature), and consumed by the pipeline before it could leak to the + * DOM — so the next author who wrote `children` on a badge drew an empty pill + * again. Arm B (teaching `badge.tsx` to read `children`) widened a published + * renderer's read set, which AGENTS.md #0.1 governs, and was left to + * objectui#6810's decision. + * + * ⭐ **objectui#6771's retirement of the `body` dialect closed it from the other + * side, and this file's counter-probes moved with it.** The maintainer ruled + * one spelling for the child list; `badge` converged on `children`, so the key + * that draws an empty pill is now `body`. ⛔ Read this as the class being + * closed by a CONTRACT decision, not as arm B being taken — nothing widened, + * one spelling was retired and the other is what every renderer reads. + * ⚠️ objectui#6810 asked whether `badge` should read `children`; that question + * is answered by a different ruling than the one it was waiting for, which is + * reported on objectui#6771 rather than decided here. * * Module-scope import of `@object-ui/components`, not `beforeAll` (AGENTS.md * §测试纪律): registering the renderers is an unbounded module load and must @@ -293,40 +302,50 @@ describe('the four entries objectui#6829 measured draw their badges', () => { // --------------------------------------------------------------------------- describe('counter-probes (objectui#6157 discipline)', () => { - it('the pre-arm-A array shape draws an EMPTY pill — and does not even leak', () => { - // Verbatim the shape `default-badge.json` carried before this change. + it('the RETIRED spelling draws an EMPTY pill — and does not even leak', () => { + // ⚠️ RE-POINTED at `body` by objectui#6771. This was verbatim the shape + // `default-badge.json` carried before arm A, spelled `children` — the key + // `badge` now reads, so it draws and can no longer prove the assertions + // above able to fail. The retired spelling is what is inert now, and the + // counter-probe's job is unchanged. const drawn = draw({ type: 'badge', variant: 'default', - children: [{ type: 'text', content: 'Badge Style' }], + body: [{ type: 'text', content: 'Badge Style' }], }); try { expect(drawn.text).toBe(''); - // `children` is a pipeline key the renderer machinery consumes, so it - // never reaches the DOM: the objectui#5574 attribute sweep that finds - // the `content` spelling structurally cannot find this one. + // `body` is still stripped by `SchemaRenderer` before the props bag is + // spread, so it never reaches the DOM either: the objectui#5574 attribute + // sweep that finds the `content` spelling structurally cannot find it. expect(drawn.leaked).toBe(0); } finally { drawn.unmount(); } }); - it('the pre-arm-A string shape draws an EMPTY pill', () => { - // Verbatim the shape the two `nested-schema-example` badges carried. - expect(drawnText({ type: 'badge', children: 'Nested' })).toBe(''); + it('the RETIRED spelling draws an EMPTY pill as a bare string too', () => { + // The string arity of the same shape, re-pointed for the same reason. + expect(drawnText({ type: 'badge', body: 'Nested' })).toBe(''); + // LIVE CONTROL — without it, an empty draw is indistinguishable from a + // renderer that stopped drawing anything at all. + expect(drawnText({ type: 'badge', children: 'Nested' })).toBe('Nested'); }); - it('⭐ the gallery control cannot see this: the pre-fix `status-badges` demo is five elements of empty DOM', () => { - // Verbatim `status-badges.json` before this change. Five elements — this - // file's wrapper, the `flex`, three empty pills — with no text at all: - // `elements > 2 || text` reads it as "drew something", on the pills. + it('⭐ the gallery control cannot see this: a `status-badges` demo in the retired spelling is five elements of empty DOM', () => { + // `status-badges.json` before arm A, with the pills' key re-pointed at the + // retired spelling (the wrapper `flex` keeps `children`, which it reads — + // that asymmetry is the point: the container draws, the pills do not). + // Five elements — this file's wrapper, the `flex`, three empty pills — with + // no text at all: `elements > 2 || text` reads it as "drew something", on + // the pills. const drawn = draw({ type: 'flex', gap: 2, children: [ - { type: 'badge', variant: 'default', children: [{ type: 'text', content: '● Active' }] }, - { type: 'badge', variant: 'secondary', children: [{ type: 'text', content: '● Pending' }] }, - { type: 'badge', variant: 'destructive', children: [{ type: 'text', content: '● Inactive' }] }, + { type: 'badge', variant: 'default', body: [{ type: 'text', content: '● Active' }] }, + { type: 'badge', variant: 'secondary', body: [{ type: 'text', content: '● Pending' }] }, + { type: 'badge', variant: 'destructive', body: [{ type: 'text', content: '● Inactive' }] }, ], }); try { diff --git a/examples/schema-catalog/test/catalog-authored-key-6805-6806.test.tsx b/examples/schema-catalog/test/catalog-authored-key-6805-6806.test.tsx index 0dc49e20d2..e86bb0fed8 100644 --- a/examples/schema-catalog/test/catalog-authored-key-6805-6806.test.tsx +++ b/examples/schema-catalog/test/catalog-authored-key-6805-6806.test.tsx @@ -463,14 +463,22 @@ describe('counter-probes (objectui#6157 discipline)', () => { } }); - it('⭐ the WRONG generalisation: a `badge` repaired to `children` is STILL empty', () => { - // THE assertion this PR exists for. A blind `content` -> `children` sweep - // is correct for `scroll-area` and leaves both badges blank — and it would - // pass a "no `content` left in the catalog" check while doing it. - const wrong = draw({ type: 'badge', children: '12', className: 'ml-auto' }); + it('⭐ the WRONG generalisation: a `badge` repaired to the RETIRED key is STILL empty', () => { + // THE assertion this PR exists for: a blind sweep onto one renderer's key + // is correct for `scroll-area` and leaves both badges blank, while passing + // a "no `content` left in the catalog" check as it does it. + // + // ⚠️ RE-POINTED at `body` by objectui#6771. The key this probe used was + // `children`, which `badge` did not read; the retirement converged `badge` + // onto `children`, so the blind sweep that is still wrong is the one onto + // the retired spelling. ⛔ The PRINCIPLE is untouched and is the reason the + // probe is re-pointed rather than deleted: author the key THIS renderer + // reads, never the key some other renderer reads. + const wrong = draw({ type: 'badge', body: '12', className: 'ml-auto' }); try { expect(wrong.text).toBe(''); - // …and it does not even leak, so the objectui#5574 attribute sweep that + // …and it does not even leak — `SchemaRenderer` still strips the retired + // key out of the props bag — so the objectui#5574 attribute sweep that // catches the `content` spelling cannot catch this one (objectui#6829). expect(wrong.leaked).toBe(0); } finally { diff --git a/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx b/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx index 1337ccfb04..5dae17f08a 100644 --- a/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx +++ b/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx @@ -124,26 +124,40 @@ describe('objectui#6939 — `children` is no longer required on either member', expect(ContextMenuSchema.safeParse({ type: 'context-menu', items: [] }).success).toBe(true); }); - it('objectui#8284 and objectui#9256 SUPERSEDE the widen-only half: `children` is REFUSED by name on BOTH members', () => { + it('objectui#8284 and objectui#9256 SUPERSEDE the widen-only half — and objectui#6771 moved WHICH key it refuses', () => { // This case used to assert "the accept set only WIDENED — the `children` // spelling still parses", on objectui#6939's reasoning that `children` - // survives as `BaseSchema`'s optional key. That half is now overruled for + // survives as `BaseSchema`'s optional key. That half was overruled for // `tooltip` by the objectui#8284 ruling (summon #17, decision batch #2, // 2026-09-07): the channel a renderer does not read is tombstoned per // component, so authoring it is refused at validation instead of drawing - // an empty tooltip. #6939's own declaration already said "nothing reads - // `children` here" — this is that sentence made enforceable. + // an empty tooltip. + // + // ⚠️ AND THE CHANNEL SWAPPED. objectui#6771 retired the `body` spelling and + // `tooltip` converged on `children`, so the channel this renderer does not + // read is `body` and that is what the tombstone names now. ⛔ The rule is + // unchanged — only the key it points at moved, which is why this assertion + // is inverted rather than dropped, and why it still demands objectui#8284 + // in the message. const refused = TooltipSchema.safeParse({ type: 'tooltip', content: 'Helpful information', - children: [{ type: 'button', label: 'Hover me' }], + body: [{ type: 'button', label: 'Hover me' }], }); expect(refused.success).toBe(false); if (!refused.success) { - expect(refused.error.issues.map((i) => i.path.join('.'))).toEqual(['children']); + expect(refused.error.issues.map((i) => i.path.join('.'))).toEqual(['body']); expect(refused.error.issues[0]!.message).toContain('objectui#8284'); } + // CONTROL — the key it DOES read parses, so the refusal above is about the + // retired spelling rather than about the whole content slot. + expect(TooltipSchema.safeParse({ + type: 'tooltip', + content: 'Helpful information', + children: [{ type: 'button', label: 'Hover me' }], + }).success).toBe(true); + // ⚠️ THE CONTROL THAT STOOD HERE IS SPENT, on its own terms. PR // objectui#9254 left `context-menu` parsing `children` deliberately, and // named the condition for that to change: "that verdict needs a @@ -191,7 +205,9 @@ describe('objectui#6939 — the keys the renderers read are DECLARED, not passth it('tooltip declares BOTH halves of its content read', () => { const shape = (TooltipSchema as unknown as { shape: Record }).shape; - expect(Object.keys(shape)).toEqual(expect.arrayContaining(['trigger', 'content', 'body'])); + // `body` until objectui#6771 retired the spelling; the declaration is still + // TWO halves, and the second half is now spelled `children`. + expect(Object.keys(shape)).toEqual(expect.arrayContaining(['trigger', 'content', 'children'])); expect(TooltipSchema.safeParse({ type: 'tooltip', content: 'text only' }).success).toBe(true); expect(TooltipSchema.safeParse({ type: 'tooltip', children: { type: 'text', content: 'rich only' } }).success).toBe(true); }); diff --git a/packages/cli/src/__tests__/cli-bin.test.ts b/packages/cli/src/__tests__/cli-bin.test.ts index 5c13056bef..da9dbf0394 100644 --- a/packages/cli/src/__tests__/cli-bin.test.ts +++ b/packages/cli/src/__tests__/cli-bin.test.ts @@ -207,7 +207,7 @@ describe('@object-ui/cli bin', () => { JSON.stringify({ type: 'div', className: 'p-4', - body: { type: 'text', content: 'ok' }, + children: { type: 'text', content: 'ok' }, }), ); writeFileSync(join(work, 'bad.json'), JSON.stringify({ no_type_field: true })); diff --git a/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx b/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx index bfe31d6e55..91f29a677f 100644 --- a/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx +++ b/packages/components/src/renderers/__tests__/node-slot-numeric-falsy.test.tsx @@ -231,11 +231,19 @@ describe('SchemaNode slots refuse numeric-falsy authored values (objectui#9162)' // and `children: 0` is clean because the guard says so. ⛔ The assertion // is on the SOURCE, not on the render: a render reading is green under // both spellings of the reader and would not see the chain come back. - // Resolved from the vitest ROOT, which AGENTS.md pins to the repository - // root for every supported invocation. ⛔ Not `import.meta.url`: in the - // `dom` project that is not a `file:` URL and `readFileSync` throws. - const CARD_SRC = 'packages/components/src/renderers/layout/card.tsx'; - expect(existsSync(CARD_SRC), `run vitest from the repo root — ${CARD_SRC} not found`).toBe(true); + // Rooted at THIS FILE, the spelling `check-test-path-roots.mjs` teaches: + // the cwd is the package directory under one invocation and the repo root + // under the form CI runs, so a cwd-relative literal reaches two verdicts + // (objectui#7799). ⛔ And ⛔ not `readFileSync(new URL(…))` either — in the + // `dom` project `import.meta.url` is not a `file:` URL and that throws; + // reading its `pathname` is scheme-independent. + const SELF_DEPTH_BELOW_REPO_ROOT = 6; // packages/components/src/renderers/__tests__/ + const REPO_ROOT = decodeURIComponent(new URL(import.meta.url).pathname) + .split('/') + .slice(0, -SELF_DEPTH_BELOW_REPO_ROOT) + .join('/'); + const CARD_SRC = `${REPO_ROOT}/packages/components/src/renderers/layout/card.tsx`; + expect(existsSync(CARD_SRC), `depth is wrong — ${CARD_SRC} not found`).toBe(true); const src = readFileSync(CARD_SRC, 'utf8'); expect(src).toContain('renderNodeSlot(schema.children,'); expect(src).not.toContain('schema.children || schema.body'); diff --git a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts index 3aa2377a5f..6e47db0299 100644 --- a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts @@ -601,15 +601,27 @@ describe('objectui#9256 — the TypeScript face refuses both channels at the AUT expect(ok).toHaveLength(7); }); - it('CONTROL — the chatbot family still TYPE-CHECKS with `body`, the held-out channel', () => { + it('the chatbot family no longer TYPE-CHECKS with `body` — the held-out channel was retired under it', () => { // RE-POINTED by objectui#8572, for the reason spelled out at the mirror // control above, and ⛔ deliberately not restating that card's verdict here. - // What survives unchanged is this card's own claim: it narrowed `children` - // on the chatbot faces and left their `body` channel alone. On a TWIN that - // is still visible on both faces — `body` arrives from `BaseSchema` as the - // content channel and `tsc` admits it — and `tsc`, not vitest, is the reader - // of this line. - const held = { type: 'chatbot-enhanced', messages: [], body: CONTENT } satisfies ChatbotEnhancedSchema; - expect(held.type).toBe('chatbot-enhanced'); + // + // ⚠️ INVERTED, and not by a decision on this card. Its claim was: objectui#9256 + // narrowed `children` on the chatbot faces and left their `body` channel + // alone, `body` arriving from `BaseSchema` as the content channel. objectui#6771 + // retired `body` on `BaseSchema` itself, so there is no content channel left + // to inherit and each twin now declares the same NEITHER-channel tombstone + // its `children` sentence already asserted. ⇒ the hold-out ended by the base + // moving, not by this card re-deciding anything. `tsc`, not vitest, is the + // reader of both lines below. + // @ts-expect-error objectui#6771 — `body` is retired; this family authors `requestBody` + const retired = { type: 'chatbot-enhanced', messages: [], body: CONTENT } satisfies ChatbotEnhancedSchema; + // CONTROL — the key an author should write instead still compiles, so the + // line above is a reading about `body` and not about the whole declaration. + const live = { + type: 'chatbot-enhanced', + messages: [], + requestBody: { model: 'gpt-4' }, + } satisfies ChatbotEnhancedSchema; + expect([retired.type, live.type]).toEqual(['chatbot-enhanced', 'chatbot-enhanced']); }); }); diff --git a/scripts/__tests__/body-dialect-census.test.ts b/scripts/__tests__/body-dialect-census.test.ts index 461df31a8f..9f529972d4 100644 --- a/scripts/__tests__/body-dialect-census.test.ts +++ b/scripts/__tests__/body-dialect-census.test.ts @@ -130,9 +130,18 @@ describe('the scanner sees the shapes authored metadata actually uses', () => { it('CONTROL — a corpus-shaped input returns hits, so a zero elsewhere is readable', () => { // Without this, every "0" in the census is indistinguishable from a // scanner that resolved nothing at all. + // + // ⚠️ RE-POINTED at `children` by objectui#6771. This file was the densest + // `body` corpus in the tree — 14 nodes — and the retirement migrated it, so + // a `body` control here now measures the migration instead of the scanner. + // The control's job is unchanged: prove the scanner resolves NODES and + // their child key on a real corpus file, so a zero read somewhere else is a + // reading rather than a blind walk. const nodes = scanNodes(read('examples/schema-catalog/src/schemas/components-basic-sidebar/basic-sidebar.json')); expect(nodes.length).toBeGreaterThan(0); - expect(nodes.filter((n: { keys: Set }) => n.keys.has('body')).length).toBeGreaterThan(0); + expect(nodes.filter((n: { keys: Set }) => n.keys.has('children')).length).toBeGreaterThan(0); + // And the retired spelling is gone from it, which is the migration half. + expect(nodes.filter((n: { keys: Set }) => n.keys.has('body')).length).toBe(0); }); }); @@ -154,14 +163,21 @@ describe('the measured population is read off the renderers, not off the card bo expect(BODY_ONLY.filter((k) => k.startsWith('sidebar')).length).toBe(10); }); - it('`badge` and `alert` read `body` and never `children`', () => { + it('`badge` and `alert` read `children` and never `body` — CONVERGED by objectui#6771', () => { + // ⚠️ INVERTED. This pin held the ground the whole census rested on: these + // two rendered `renderChildren(schema.body)` and never touched + // `schema.children`, which is what made `body` their ONLY door and made + // retiring it a reject-direction change on published contract. The ruling + // was executed; the door is `children` now, and the reading the census + // produced is what cleared it (`badge` and `alert` carried ZERO authored + // `body` in both populations, so the published cost measured zero). for (const [file, type] of [ ['packages/components/src/renderers/data-display/badge.tsx', 'badge'], ['packages/components/src/renderers/data-display/alert.tsx', 'alert'], ] as const) { const src = read(file); - expect(src, `${type} stopped reading schema.body`).toContain('renderChildren(schema.body)'); - expect(src, `${type} now reads schema.children — it is no longer body-only`).not.toContain('schema.children'); + expect(src, `${type} does not read schema.children`).toContain('renderChildren(schema.children)'); + expect(src, `${type} still reads the retired schema.body`).not.toContain('schema.body'); } }); @@ -174,24 +190,32 @@ describe('the measured population is read off the renderers, not off the card bo // 11 `sidebar-*` registrations. The one without is `sidebar-trigger`, whose // renderer never receives `schema` at all. expect(src.match(/ComponentRegistry\.register\('sidebar/g)?.length).toBe(11); - expect(src.match(/renderChildren\(schema\.body\)/g)?.length).toBe(10); + // Spelled `children` since objectui#6771; the COUNT is the load-bearing + // half and it did not move — ten reads across eleven registrations. + expect(src.match(/renderChildren\(schema\.children\)/g)?.length).toBe(10); + expect(src.match(/renderChildren\(schema\.body\)/g)?.length ?? 0).toBe(0); const trigger = src.slice(src.indexOf("register('sidebar-trigger'")); expect(trigger).not.toContain('schema.body'); expect(trigger).not.toContain('schema.children'); }); - it('⚠️ `tooltip` is a `body`-only reader that the ruled 13 does NOT include', () => { - // Measured, and it matters: `tooltip` renders `renderChildren(schema.body)` - // and never `schema.children`, so direction B removes its only rich-content - // key too — yet it is absent from the ruling's step 2 list. It is also the - // ONE registration in the tree that DECLARES `body` as an authorable input, - // which makes it the most discoverable spelling of `body` on the whole - // authoring surface. + it('⚠️ `tooltip` was the `body`-only reader the ruled 13 omitted — and it converged with the rest', () => { + // ⚠️ INVERTED, and this one was a SCOPE call before it was an edit. The + // census measured `tooltip` rendering `renderChildren(schema.body)` and + // never `schema.children` — a `body`-only reader absent from the ruling's + // step 2 list — and, decisively, the ONE registration in the tree that + // DECLARED `body` as an authorable input, which made it the most + // discoverable spelling of the dialect on the whole authoring surface. + // Seat 1 read the ruling rather than filing for it (2026-09-17) and triage + // recorded the answer as already given: step 2's two clauses leave no third + // class, so `tooltip` is IN. It is, and its declared input moved too — the + // half that would otherwise have left the dialect advertised. expect(BODY_ONLY_UNRULED).toEqual(['tooltip']); const src = read('packages/components/src/renderers/overlay/tooltip.tsx'); - expect(src).toContain('renderChildren(schema.body)'); - expect(src).not.toContain('schema.children'); - expect(src).toMatch(/name:\s*'body'/); + expect(src).toContain('renderChildren(schema.children)'); + expect(src).not.toContain('schema.body'); + expect(src).toMatch(/name:\s*'children'/); + expect(src).not.toMatch(/name:\s*'body'/); }); it('the two published keys are exactly `badge` and `alert`', () => { @@ -222,7 +246,7 @@ describe('the census stays re-runnable', () => { }); describe('the `body` consumers the ruling does not enumerate', () => { - it('three generic readers outside the renderer tree still resolve `body`', () => { + it('three generic readers outside the renderer tree read `children` ALONE — step 2 reached them', () => { // Recorded because ruling steps 2-5 name renderers, the published type and // the tier — and these three are none of those. They read `body` for ANY // node type, so they outlive the per-registration convergence and would @@ -237,15 +261,26 @@ describe('the `body` consumers the ruling does not enumerate', () => { // objectui#7181 converged both onto the core spelling, adding the // `children` arm and KEEPING `body`. Nothing was retired; that is step 2. // - // ⇒ all three now read the same way, which is what these three assertions - // pin. The `body` arm surviving in each is the half that says step 2 is - // still outstanding. + // ⇒ all three then read the same way, and the `body` arm surviving in each + // was the half that said step 2 was still outstanding. + // + // ⚠️ INVERTED — step 2 landed (objectui#6771). These three are the reason + // the block exists: a per-registration convergence would have left them + // resolving `body` for ANY node type and the dialect alive underneath it, + // so the retirement is only real if all three dropped the arm. Both vscode + // readers even deferred it to that card BY NUMBER in their own comments. + // The lit control stays FIRST, because "does not contain `body`" holds just + // as well over a file that was renamed, emptied or moved. for (const reader of [ 'packages/core/src/validation/schema-validator.ts', 'packages/vscode-extension/src/providers/SchemaValidator.ts', 'packages/vscode-extension/src/providers/PreviewProvider.ts', ]) { - expect(readCode(reader), reader).toContain('schema.children || schema.body'); + const text = readCode(reader); + expect(text, `${reader} — lit control: this reader still resolves a child list`) + .toMatch(/=\s*schema\.children;/); + expect(text, `${reader} still resolves the retired \`body\``) + .not.toContain('schema.children || schema.body'); } }); @@ -322,14 +357,28 @@ describe('the `body` consumers the ruling does not enumerate', () => { // once nothing does, say so here in these terms, which is the day step 4 // becomes landable. ⛔ Do not delete the claim to make the block green. - // Inserted into the user's own document the moment the completion is - // accepted, which is the most direct form a producer takes: the author does - // not even type the spelling. + // ⭐ THE HANDOFF THE BLOCK ABOVE ASKS FOR, taken rather than avoided. + // The third subject was the VS Code completion snippet — inserted into the + // user's own document the moment the completion is accepted, the most direct + // form a producer takes, because the author does not even type the spelling. + // objectui#6771's step 5 migrated it, so it no longer ships the dialect and + // the assertion is inverted rather than deleted. The lit control stays and + // stays first: "does not contain" is satisfied by an unreadable file. const completion = readCode('packages/vscode-extension/src/providers/CompletionProvider.ts'); expect(completion, 'lit control — the snippet table is being read at all').toContain( '"className": "$1"' ); - expect(completion).toContain('"body": {'); + expect(completion).not.toContain('"body": {'); + expect(completion).toContain('"children": {'); + + // ⇒ AND THE CLAIM IN THIS TEST'S NAME, restated in the terms the block asks + // for. What still ships the spelling is the `tabs` ITEM below, and that is + // a DIFFERENT KEY from the one step 4 refuses: the tier answers `body` on a + // NODE the manifest knows, walking `Object.entries(node)`, and a `tabs` item + // is a member of a declared `items` input rather than a node in that walk. + // ⇒ step 4 refuses nothing the platform still ships, which is exactly what + // step 5's ordering rule asks — and the item-level dialect is objectui#9590's + // card, ⛔ neither refused nor migrated here. // A shipped `defaultProps` child list, the same construct as the three // registrations objectui#7181 moved — spelled on a tab ITEM, where the From 9ea343622efb1374967da0b38db6a3a92b305393 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 14:17:31 +0000 Subject: [PATCH 07/18] docs(changeset): re-read the 82 pending claims this diff's files appear in, and correct the ten it falsifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `changeset-claim-re-read` names every pending changeset whose body mentions a file this change touches. Their prose publishes verbatim into the CHANGELOG at the next release — beside this retirement — so a sentence calling `body` a live spelling ships as a contradiction. Read: 82. Corrected: 10, prose only, frontmatter untouched. Still true: 72 — 65 whose claim never mentions the dialect, and 7 whose `body` is a function body, a `text.variant` value, a code-sketch placeholder, the `list` ITEM channel (objectui#9590's, fenced) or a changeset's own body. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- .changeset/3965-mint-box-neutral-container.md | 11 ++++++----- .changeset/5632-sidebar-trigger-dom-passthrough.md | 5 +++-- .changeset/6764-container-declaration-census.md | 7 ++++--- .changeset/6771-retire-body-child-list-dialect.md | 1 + .changeset/6939-overlay-trigger-mirror.md | 10 ++++++---- .changeset/7926-page-node-refuses-actions.md | 10 ++++++---- .changeset/8284-content-channel-per-component.md | 9 ++++++--- .changeset/8344-node-recursion-point-redirect.md | 9 +++++---- .changeset/8572-chatbot-body-retired.md | 4 ++-- .changeset/8871-page-node-refuses-breadcrumbs.md | 6 ++++-- .changeset/9659-node-recursion-point-inert-clause.md | 8 ++++++-- 11 files changed, 49 insertions(+), 31 deletions(-) diff --git a/.changeset/3965-mint-box-neutral-container.md b/.changeset/3965-mint-box-neutral-container.md index 816615d608..0b75745d96 100644 --- a/.changeset/3965-mint-box-neutral-container.md +++ b/.changeset/3965-mint-box-neutral-container.md @@ -19,11 +19,12 @@ gap with a three-clause contract, pinned in 2. authored `className` passes through **verbatim**; 3. **zero** injected classes. -Deliberately unlike `div`, `box` reads `children` only — never `schema.body`. -The `div` renderer's `children || body` fallback is what made a mechanical -`div`→X swap silently drop content on `body`-authoring nodes while the element -count stayed unchanged; content moves into `children` at migration time (the -objectui#6771 B-ruling direction). +Deliberately unlike `div` AT THE TIME, `box` reads `children` only — never +`schema.body`. The `div` renderer's `children || body` fallback is what made a +mechanical `div`→X swap silently drop content on `body`-authoring nodes while the +element count stayed unchanged. ⚠️ objectui#6771 has since executed that B-ruling and +retired `body` across the protocol, so `div` reads `children` only too and `box` is no +longer the exception this paragraph mints it as. Landed on both contract faces per the zod-mirror-parity pairing (objectui#6424 family form): `BoxSchema` interface in `@object-ui/types`, its zod mirror in diff --git a/.changeset/5632-sidebar-trigger-dom-passthrough.md b/.changeset/5632-sidebar-trigger-dom-passthrough.md index 62eaee2504..8873b496b3 100644 --- a/.changeset/5632-sidebar-trigger-dom-passthrough.md +++ b/.changeset/5632-sidebar-trigger-dom-passthrough.md @@ -9,8 +9,9 @@ The renderer forwarded its whole prop bag to `SidebarTrigger`, which spreads its own rest onto the `