From 9bc39ceaf2b3726cd6f28fdaef4e8180f22b8668 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:28:28 +0000 Subject: [PATCH 1/3] fix(types): the six held public blocks refuse the content channels no renderer reads (objectui#10872 batch 5) `action:button`, `action:icon`, `action:group`, `action:menu`, `element:definition-list` and `element:repeater` took `BaseSchema`'s channels as they were when batch 4 armed them: a node-level `children` parsed green on both faces and rendered nothing, and a flat `body` was refused with a remedy naming `children`. Each arm now declares `children` as a by-name refusal and restates `body` with the same guidance (the objectui#9256 construct, unchanged: `retirementTombstone` fed one `neitherContentChannelGuidance` string). The message names the block's real content channel inside `properties` (`actions`, `items`, the label and icon) and says so where there is none (`element:repeater`). The spec's own page component refuses the same key. Pinned by held-block-content-channels-10872.test.ts, on both faces. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .../held-block-content-channels-10872.test.ts | 240 ++++++++++++++++++ packages/types/src/zod/public-blocks.zod.ts | 132 +++++++++- 2 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 packages/types/src/__tests__/held-block-content-channels-10872.test.ts diff --git a/packages/types/src/__tests__/held-block-content-channels-10872.test.ts b/packages/types/src/__tests__/held-block-content-channels-10872.test.ts new file mode 100644 index 0000000000..fb0dea2463 --- /dev/null +++ b/packages/types/src/__tests__/held-block-content-channels-10872.test.ts @@ -0,0 +1,240 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10872 batch 5 — the content channels of the six public blocks + * batch 4 armed by reference (`action:button`, `action:icon`, `action:group`, + * `action:menu`, `element:definition-list`, `element:repeater`), narrowed by + * the objectui#9256 method. The arms themselves are pinned in + * `held-public-block-arms-10872.test.ts`; the nineteen blocks objectui#9256 + * narrowed first are pinned in `content-channel-public-blocks-9256.test.ts`. + * This file carries only what this batch adds. + * + * ## The defect + * + * Batch 4 gave the six an arm, and each arm took `BaseSchema`'s channels as + * they were. So `{ "type": "action:group", "children": [ … ] }` parsed green on + * both faces, while no renderer of the six reads the node's `children` (nor + * its `body`) and `SchemaRenderer` strips both out of the props it spreads: + * the child list rendered nothing, with no error and no warning. And a flat + * `body` was refused with `BaseSchema`'s objectui#6771 remedy, which points at + * `children` — the key that did nothing. + * + * ## What is narrowed + * + * Each arm declares `children` as a by-name refusal and restates `body` with + * the same guidance: two `retirementTombstone` members fed ONE + * `neitherContentChannelGuidance` string, both kept MEMBERS, exactly as the + * objectui#9256 arms are. The message names what the block renders instead, + * and so the channel an author's content belongs in: `properties.actions` for + * the group and the menu, `properties.items` for the definition list, the + * `label` and `icon` for the two buttons. `element:repeater` has no content + * channel, and its message says so rather than naming one. + * + * ## Why this is not a second dialect + * + * `@objectstack/spec`'s own `PageComponentSchema` has no node-level `children` + * and refuses one as an unrecognized key, on every one of the six. The last + * block below re-reads that verdict from the INSTALLED spec, so a spec that + * starts accepting the key turns this file red here, and the refusal has to + * be re-decided rather than drift. + * + * The two readings that say no renderer reads either channel (a TypeScript + * type-checker census of the `@object-ui/components` program on a built tree, + * and a runtime probe through the real `SchemaRenderer` and registry) were + * taken once and are recorded on objectui#10872. Nothing in this file + * re-derives them: it pins the validator's answer, not the renderers'. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { PageComponentSchema } from '@objectstack/spec/ui'; + +import { + ActionButtonBlockSchema, + ActionGroupBlockSchema, + ActionIconBlockSchema, + ActionMenuBlockSchema, + ElementDefinitionListBlockSchema, + ElementRepeaterBlockSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; + +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[]; errors?: Issue[][] }; +type Result = { success: boolean; error?: { issues: z.core.$ZodIssue[] } }; +type Arm = { + safeParse: (v: unknown) => Result; + shape: Record; +}; + +const FACES: ReadonlyArray Result]> = [ + ['tolerant', (document) => safeValidateSchema(document)], + ['strict', (document) => StrictAnyComponentSchema.safeParse(document)], +]; + +/** + * One row per narrowed block: its arm, a node that fills its real content + * channel (the positive control), and the name the refusal gives that channel + * — `null` for the block that has none. + */ +const ROWS: ReadonlyArray< + readonly [type: string, arm: Arm, node: Record, channel: string | null] +> = [ + [ + 'action:button', + ActionButtonBlockSchema as unknown as Arm, + { type: 'action:button', properties: { label: 'Open', icon: 'external-link', actionType: 'url', target: '/users/ada' } }, + '`properties.label`', + ], + [ + 'action:icon', + ActionIconBlockSchema as unknown as Arm, + { type: 'action:icon', properties: { icon: 'pencil', label: 'Edit', actionType: 'url', target: '/users/ada' } }, + '`properties.icon`', + ], + [ + 'action:group', + ActionGroupBlockSchema as unknown as Arm, + { type: 'action:group', properties: { display: 'dropdown', label: 'More', actions: [{ name: 'open', label: 'Open', type: 'url', target: '/a' }] } }, + '`properties.actions`', + ], + [ + 'action:menu', + ActionMenuBlockSchema as unknown as Arm, + { type: 'action:menu', properties: { label: 'More', actions: [{ name: 'open', label: 'Open', type: 'url', target: '/a' }] } }, + '`properties.actions`', + ], + [ + 'element:definition-list', + ElementDefinitionListBlockSchema as unknown as Arm, + { type: 'element:definition-list', properties: { items: [{ term: 'Owner', description: 'Ada' }], columns: 2 } }, + '`properties.items`', + ], + [ + 'element:repeater', + ElementRepeaterBlockSchema as unknown as Arm, + { type: 'element:repeater', properties: { object: 'account', titleField: 'name', fields: ['industry'] } }, + null, + ], +]; + +const CHANNELS = ['children', 'body'] as const; +const CONTENT = [{ type: 'element:text', properties: { content: 'measured' } }]; +const issuesOf = (result: Result): Issue[] => (result.success ? [] : (result.error!.issues as unknown as Issue[])); +const issueAt = (result: Result, path: string): Issue | undefined => + issuesOf(result).find((i) => i.path.join('.') === path); + +/** Every issue, union branches unfolded and paths made absolute. */ +const allIssues = (issues: Issue[] | undefined, prefix: PropertyKey[] = []): Issue[] => + (issues ?? []).flatMap((issue) => { + const path = [...prefix, ...issue.path]; + return [{ ...issue, path }, ...(issue.errors ?? []).flatMap((branch) => allIssues(branch, path))]; + }); + +const CASES = ROWS.flatMap(([type, arm, node, channel]) => + CHANNELS.map((key) => [`${type}.${key}`, type, arm, node, channel, key] as const)); +const FACE_CASES = CASES.flatMap((c) => FACES.map(([face, judge]) => [`${c[0]} (${face})`, ...c.slice(1), judge] as const)); + +/* ── (a) both channels are REFUSED BY NAME, at the key's own path, on both faces ── */ + +describe('objectui#10872 batch 5 — the six held blocks refuse both content channels', () => { + it('the population is the six batch-4 arms — a row dropped from the table fails here', () => { + expect(ROWS.map(([type]) => type)).toEqual([ + 'action:button', + 'action:icon', + 'action:group', + 'action:menu', + 'element:definition-list', + 'element:repeater', + ]); + expect(CASES).toHaveLength(12); + expect(FACE_CASES).toHaveLength(24); + }); + + it.each(FACE_CASES)('%s is refused with `invalid_type` at the key\'s own path', (label, _type, _arm, node, _channel, key, judge) => { + const result = judge({ ...node, [key]: CONTENT }); + expect(result.success, `${label} parsed green — the tombstone is not installed`).toBe(false); + expect(issueAt(result, key)?.code).toBe('invalid_type'); + }); + + it.each(FACE_CASES)('%s — the message names the block, the method, and what it renders instead', (_label, type, _arm, node, channel, key, judge) => { + const message = issueAt(judge({ ...node, [key]: CONTENT }), key)!.message; + expect(message).toContain(`\`${type}\` reads NEITHER content channel`); + expect(message).toContain('objectui#9256'); + const instead = message.slice(message.indexOf('What it renders instead: ')); + expect(instead.length).toBeGreaterThan('What it renders instead: '.length); + if (channel) { + // The channel the author's content belongs in, named where the message + // says what the block renders. + expect(instead).toContain(channel); + } else { + // `element:repeater` has none, and the message says so instead of + // pointing at a key that would do nothing either. + expect(instead).toContain('NO content channel'); + } + // ⛔ Not `BaseSchema`'s objectui#6771 `body` remedy, which points at + // `children` — refused here too. + expect(message).not.toContain('Did you mean'); + }); + + it.each(CASES)('%s — ONE string feeds both author-facing channels: the issue message IS the `.describe()` metadata', (_label, _type, arm, node, _channel, key) => { + const issue = issueAt(arm.safeParse({ ...node, [key]: CONTENT }), key)!; + expect(arm.shape[key]?.description).toBe(issue.message); + // One string per block, fed to both members. + expect(arm.shape.children?.description).toBe(arm.shape.body?.description); + }); + + it.each(CASES)('%s — the refusal is about the KEY, not a value domain: every value is refused', (_label, _type, arm, node, _channel, key) => { + for (const value of [CONTENT, CONTENT[0], 'text', 42, null, {}, []]) { + expect(issueAt(arm.safeParse({ ...node, [key]: value }), key)?.code, JSON.stringify(value)).toBe('invalid_type'); + } + }); + + it.each(CASES)('%s — the tombstone is a MEMBER of the arm\'s shape', (_label, _type, arm, _node, _channel, key) => { + expect(Object.keys(arm.shape)).toContain(key); + }); +}); + +/* ── (b) the positive controls — the content channel is live ─────────────── */ + +describe('objectui#10872 batch 5 — each block\'s real content channel still parses', () => { + it.each(ROWS.flatMap((row) => FACES.map(([face, judge]) => [`${row[0]} (${face})`, row[2], judge] as const)))( + '%s — the `properties` node parses', + (_label, node, judge) => { + const result = judge(node); + expect(issuesOf(result).map((i) => `${i.path.join('.')}: ${i.code}`)).toEqual([]); + expect(result.success).toBe(true); + }, + ); + + it.each(ROWS.flatMap((row) => FACES.map(([face, judge]) => [`${row[0]} (${face})`, row[2], judge] as const)))( + '%s — nested in a `page`: the block parses, and the same block with a child list is refused at its own path', + (_label, node, judge) => { + expect(judge({ type: 'page', children: [node] }).success).toBe(true); + const refused = judge({ type: 'page', children: [{ ...node, children: CONTENT }] }); + expect(refused.success).toBe(false); + const nested = allIssues(issuesOf(refused)).filter((i) => i.path.join('.') === 'children.0.children'); + expect(nested.some((i) => i.code === 'invalid_type' && i.message.includes('reads NEITHER content channel'))).toBe(true); + }, + ); +}); + +/* ── (c) the spec refuses the same key — alignment, not a second dialect ─── */ + +describe('objectui#10872 batch 5 — `@objectstack/spec`\'s page component refuses a node-level `children` on the six', () => { + it.each(ROWS.map(([type]) => type))('`%s`: the installed spec refuses `children` as an unrecognized key', (type) => { + // CONTROL — the node without it passes the spec's page-component shape, + // so the refusal below is about the one key. + expect(PageComponentSchema.safeParse({ type, id: 'n1' }).success).toBe(true); + const result = PageComponentSchema.safeParse({ type, id: 'n1', children: CONTENT }) as unknown as Result; + expect(result.success).toBe(false); + const unrecognized = issuesOf(result).find((i) => i.code === 'unrecognized_keys'); + expect(unrecognized?.keys).toContain('children'); + }); +}); diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index 7dd7851974..370bd3e00e 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -52,10 +52,9 @@ * * ## The content channels (objectui#9256) * - * Every block objectui#9256 measured here — each block armed before - * objectui#10872 batch 4 except the four `page:` containers (`page:card`, + * Every block here except the four `page:` containers (`page:card`, * `page:section`, `page:footer`, `page:sidebar`, which render the node's child - * list) — reads NEITHER content channel: no read of the node's `children` or + * list) reads NEITHER content channel: no read of the node's `children` or * `body` reaches the renderer, and `SchemaRenderer` strips both out of the * props bag it spreads. So each of those arms declares `children` as a * by-name refusal, and restates `body` with the same guidance — `BaseSchema` @@ -63,14 +62,19 @@ * the remedy, which these blocks do not read either. Both stay MEMBERS * (`retirementTombstone`), so the refusal sits at the key's own path. * - * ⚠️ The six batch-4 arms (the four `action:` controls, - * `element:definition-list`, `element:repeater`) carry NO channel refusal: - * objectui#9256's measurement never covered them, because they had no arm - * when it ran, and its guidance text states that measurement. They take - * `BaseSchema`'s channels as they are — `children` judged by the base's own - * type, a flat `body` refused by the objectui#6771 retirement. Measuring their - * channels and narrowing them is objectui#9256's method, left open on - * objectui#10872 rather than decided by this module. + * The six arms objectui#10872 batch 4 added (the four `action:` controls, + * `element:definition-list`, `element:repeater`) had no arm when + * objectui#9256 measured, so objectui#10872 batch 5 took the same measurement + * for them — the TypeScript type checker over the `@object-ui/components` + * program, where all six register, on a built tree — and took it again at + * runtime, through the real `SchemaRenderer` and registry. Both readings were + * taken once, are recorded on objectui#10872, and nothing here re-derives + * them. They carry the same two refusals, built by the same two helpers. + * `@objectstack/spec`'s own `PageComponentSchema` refuses a node-level + * `children` on each of the six as an unrecognized key, so these refusals + * align this face with the spec rather than add a rule of objectui's own; + * `../__tests__/held-block-content-channels-10872.test.ts` re-reads that + * verdict from the installed spec. * * Two carve-outs, each stated on its arm: `page:tabs` and `page:accordion` * render the `children` of each ITEM in their `items` bag member, which is @@ -723,6 +727,16 @@ export const ElementDividerBlockSchema = BaseSchema.extend({ children: retirementTombstone(ELEMENT_DIVIDER_NEITHER_CHANNEL), }); +/** objectui#10872 batch 5 (the objectui#9256 method): ONE refusal string for both content channels of `element:definition-list`. */ +const ELEMENT_DEFINITION_LIST_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'element:definition-list', + 'its registration (`element:definition-list`, `@object-ui/components`) hands the node to ' + + '`DefinitionListRenderer`, an `any`-typed renderer that reads the props bag (`readProps`) and `className` ' + + 'and nothing else off the node', + 'one term / description pair per `properties.items` entry (`{ term, description }`), in one or two ' + + '`columns`', +); + /** * `element:definition-list` — `ComponentPropsMap['element:definition-list']` * (objectui#10872 batch 4). @@ -733,12 +747,28 @@ export const ElementDividerBlockSchema = BaseSchema.extend({ * with the row's own prescription, and each item is a strict `{ term, * description }`, so the `label` / `value` items the designer once wrote * (objectui#8279) are refused by name rather than rendered blank. + * + * Its content is `properties.items`; the node's own `children` and `body` are + * refused (objectui#10872 batch 5, the objectui#9256 method). */ export const ElementDefinitionListBlockSchema = BaseSchema.extend({ type: z.literal('element:definition-list'), properties: propsBag('element:definition-list', stripImportedDefaults(SpecElementDefinitionListPropsSchema)), + // objectui#10872 batch 5: the renderer reads NEITHER content channel, so both are refused by name, + // each kept a MEMBER (see "The content channels" above). + body: retirementTombstone(ELEMENT_DEFINITION_LIST_NEITHER_CHANNEL), + children: retirementTombstone(ELEMENT_DEFINITION_LIST_NEITHER_CHANNEL), }); +/** objectui#10872 batch 5 (the objectui#9256 method): ONE refusal string for both content channels of `element:repeater`. */ +const ELEMENT_REPEATER_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'element:repeater', + 'its registration (`element:repeater`, `@object-ui/components`) hands the node to `RepeaterRenderer`, an ' + + '`any`-typed renderer that reads the props bag (`readProps`) and `className` and nothing else off the node', + 'one line per record of `properties.object` that its query returns, printing that record\'s ' + + '`titleField` and `fields` values. It has NO content channel: no per-row template and no child slot', +); + /** * `element:repeater` — `ComponentPropsMap['element:repeater']` (objectui#10872 * batch 4). @@ -750,10 +780,19 @@ export const ElementDefinitionListBlockSchema = BaseSchema.extend({ * `properties.object`. A `fields` entry is a bare name or a strict `{ field }`; * a `label` there is refused by name, because the list has no header row to * print it in. + * + * It has no content channel at all — each row prints fields of the queried + * record, and nothing an author writes is placed inside a row — so the node's + * own `children` and `body` are refused, and the message names no channel to + * move them to (objectui#10872 batch 5, the objectui#9256 method). */ export const ElementRepeaterBlockSchema = BaseSchema.extend({ type: z.literal('element:repeater'), properties: propsBag('element:repeater', stripImportedDefaults(SpecElementRepeaterPropsSchema)), + // objectui#10872 batch 5: the renderer reads NEITHER content channel, so both are refused by name, + // each kept a MEMBER (see "The content channels" above). + body: retirementTombstone(ELEMENT_REPEATER_NEITHER_CHANNEL), + children: retirementTombstone(ELEMENT_REPEATER_NEITHER_CHANNEL), }); /* ── action: — action controls ──────────────────────────────────────────── */ @@ -775,9 +814,22 @@ const ACTION_ICON_FLAT_ON_SUCCESS = + '`{ "type": "action:icon", "properties": { "onSuccess": { "navigate": "…" } } }` (objectui#10872). ' + 'The spec\'s own page component refuses the key on the node as mis-layered.'; +/** objectui#10872 batch 5 (the objectui#9256 method): ONE refusal string for both content channels of `action:button`. */ +const ACTION_BUTTON_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'action:button', + 'its registration (`action:button`, `@object-ui/components`) hands the node to `ActionButtonRenderer`, which ' + + 'reads the action keys `SchemaRenderer` hoists onto the node from the `properties` bag (`label`, `icon`, ' + + '`actionType`, `target`, …)', + 'one button labelled `properties.label`, with `properties.icon`, that runs the action ' + + '`properties.actionType` names. It has no child slot: its label and icon are its whole content', +); + /** * `action:button` — `ComponentPropsMap['action:button']` (objectui#10872 batch - * 4), plus the two handler keys its renderer reads off the node. + * 4), plus the two handler keys its renderer reads off the node, plus the + * content-channel refusals (objectui#10872 batch 5, the objectui#9256 method): + * the button's content is its `label` and `icon`, and the node's own + * `children` and `body` are refused. * * The row is the spec's page-node declaration of the button, measured at the * renderer's reads (objectstack-ai/objectstack#20371) — ⛔ not the spec's @@ -806,40 +858,94 @@ export const ActionButtonBlockSchema = BaseSchema.extend({ properties: propsBag('action:button', stripImportedDefaults(SpecActionButtonPropsSchema)), onClick: handlerKeyRefusal('onClick', 'runtime-slot', 'Click handler'), onSuccess: aliasKeyRefusal('onSuccess', 'properties.onSuccess', 'this `action:button` node', ACTION_BUTTON_FLAT_ON_SUCCESS), + // objectui#10872 batch 5: the renderer reads NEITHER content channel, so both are refused by name, + // each kept a MEMBER (see "The content channels" above). + body: retirementTombstone(ACTION_BUTTON_NEITHER_CHANNEL), + children: retirementTombstone(ACTION_BUTTON_NEITHER_CHANNEL), }); +/** objectui#10872 batch 5 (the objectui#9256 method): ONE refusal string for both content channels of `action:icon`. */ +const ACTION_ICON_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'action:icon', + 'its registration (`action:icon`, `@object-ui/components`) hands the node to `ActionIconRenderer`, which ' + + 'reads the action keys `SchemaRenderer` hoists onto the node from the `properties` bag (`icon`, `label`, ' + + '`actionType`, `target`, …)', + 'one icon button showing `properties.icon`, named and tooltipped by `properties.label`, that runs the ' + + 'action `properties.actionType` names. It has no child slot: its icon and label are its whole content', +); + /** * `action:icon` — `ComponentPropsMap['action:icon']` (objectui#10872 batch 4), * plus the same two handler keys as `action:button`, read the same way by its * renderer. Its own row, measured separately: it declares no `size`, and - * forwards neither `undoable` nor `recordIdField`. + * forwards neither `undoable` nor `recordIdField`. Its content is its `icon` + * and `label`; the node's own `children` and `body` are refused (objectui#10872 + * batch 5, the objectui#9256 method). */ export const ActionIconBlockSchema = BaseSchema.extend({ type: z.literal('action:icon'), properties: propsBag('action:icon', stripImportedDefaults(SpecActionIconPropsSchema)), onClick: handlerKeyRefusal('onClick', 'runtime-slot', 'Click handler'), onSuccess: aliasKeyRefusal('onSuccess', 'properties.onSuccess', 'this `action:icon` node', ACTION_ICON_FLAT_ON_SUCCESS), + // objectui#10872 batch 5: the renderer reads NEITHER content channel, so both are refused by name, + // each kept a MEMBER (see "The content channels" above). + body: retirementTombstone(ACTION_ICON_NEITHER_CHANNEL), + children: retirementTombstone(ACTION_ICON_NEITHER_CHANNEL), }); +/** objectui#10872 batch 5 (the objectui#9256 method): ONE refusal string for both content channels of `action:group`. */ +const ACTION_GROUP_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'action:group', + 'its registration (`action:group`, `@object-ui/components`) hands the node to `ActionGroupRenderer`, which ' + + 'reads the group keys `SchemaRenderer` hoists onto the node from the `properties` bag and draws each ' + + 'member of `actions` itself', + 'the members of `properties.actions`, in order: a row of buttons, or with `display: "dropdown"` one ' + + 'trigger labelled `properties.label` whose menu lists them. A member goes in `properties.actions`', +); + /** * `action:group` — `ComponentPropsMap['action:group']` (objectui#10872 batch * 4). The row declares `actions` as a list of member objects, each one read * and forwarded by the renderer, and refuses a group-level `name` with its own * prescription: the renderer never reads it. + * + * Its content is `properties.actions`; the node's own `children` and `body` + * are refused (objectui#10872 batch 5, the objectui#9256 method). */ export const ActionGroupBlockSchema = BaseSchema.extend({ type: z.literal('action:group'), properties: propsBag('action:group', stripImportedDefaults(SpecActionGroupPropsSchema)), + // objectui#10872 batch 5: the renderer reads NEITHER content channel, so both are refused by name, + // each kept a MEMBER (see "The content channels" above). + body: retirementTombstone(ACTION_GROUP_NEITHER_CHANNEL), + children: retirementTombstone(ACTION_GROUP_NEITHER_CHANNEL), }); +/** objectui#10872 batch 5 (the objectui#9256 method): ONE refusal string for both content channels of `action:menu`. */ +const ACTION_MENU_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'action:menu', + 'its registration (`action:menu`, `@object-ui/components`) hands the node to `ActionMenuRenderer`, which ' + + 'reads the menu keys `SchemaRenderer` hoists onto the node from the `properties` bag and draws each ' + + 'member of `actions` itself', + 'one trigger button (`properties.label`, `properties.icon`) whose dropdown lists the members of ' + + '`properties.actions`, in order. A menu item goes in `properties.actions`', +); + /** * `action:menu` — `ComponentPropsMap['action:menu']` (objectui#10872 batch 4). * Its `variant` and `size` reach the Button primitive unmapped, so the row * declares neither `primary` nor `md`. + * + * Its content is `properties.actions`; the node's own `children` and `body` + * are refused (objectui#10872 batch 5, the objectui#9256 method). */ export const ActionMenuBlockSchema = BaseSchema.extend({ type: z.literal('action:menu'), properties: propsBag('action:menu', stripImportedDefaults(SpecActionMenuPropsSchema)), + // objectui#10872 batch 5: the renderer reads NEITHER content channel, so both are refused by name, + // each kept a MEMBER (see "The content channels" above). + body: retirementTombstone(ACTION_MENU_NEITHER_CHANNEL), + children: retirementTombstone(ACTION_MENU_NEITHER_CHANNEL), }); /** From b4be1d8333e20242f5ae82bc84f21aeb3624086d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:30:42 +0000 Subject: [PATCH 2/3] docs(types): the census rows, the zod README and a changeset for the six arms' channel refusals (objectui#10872 batch 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zod-mirror-parity EXCLUSIONS rows of the six batch-4 arms, and the zod README paragraph on them, said the arms carry no content-channel refusal; both now describe the refusals batch 5 adds. The changeset declares the narrowing (`minor`, Clause-②: yes) and records why it breaks no published document: the six arms are unreleased, and in `@object-ui/types` 17.6.0 every node of these types is refused at `type`. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .../10872-held-block-content-channels.md | 26 +++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 19 +++++++------- packages/types/src/zod/README.md | 4 +-- 3 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 .changeset/10872-held-block-content-channels.md diff --git a/.changeset/10872-held-block-content-channels.md b/.changeset/10872-held-block-content-channels.md new file mode 100644 index 0000000000..f83f2d7a3c --- /dev/null +++ b/.changeset/10872-held-block-content-channels.md @@ -0,0 +1,26 @@ +--- +'@object-ui/types': minor +--- + +The six public blocks that objectui#10872 batch 4 armed now refuse an authored `children`, and name the right remedy for a flat `body`: `action:button`, `action:icon`, `action:group`, `action:menu`, `element:definition-list` and `element:repeater` (objectui#10872, batch 5, by the objectui#9256 method). + +**Clause-②: yes (narrowing).** On each of the six zod arms, `children` and `body` are declared as by-name refusals, each kept a member of the arm. Both refusals carry one message per block, from the same helpers the objectui#9256 arms use. + +**What it was.** Batch 4 gave these blocks their arms, and each took `BaseSchema`'s channels as they were. So a node-level `children` on any of them parsed green on both faces (`safeValidateSchema`, and so `objectui validate`, and `StrictAnyComponentSchema`). None of their renderers reads the node's `children` or `body`, and `SchemaRenderer` strips both out of the props it spreads. So the child list rendered nothing, with no render-time error or warning and no element; only the parser tier's `not-a-container` warning (objectui#9910) noticed it. A flat `body` was already refused, by `BaseSchema` (objectui#6771), but its message pointed the author at `children`, the key that did nothing. + +**What changed, in observable terms.** + +- `children` on any of the six is refused with `invalid_type` at its own path, on both faces and nested in a page. +- `body` is refused as before, at the same path with the same code. The message no longer says "Did you mean `body` → `children`". +- The message names what the block renders instead, and so where the content goes: + - `action:group` and `action:menu`: `properties.actions`; + - `element:definition-list`: `properties.items`; + - `action:button` and `action:icon`: the button's `properties.label` and `properties.icon`, which are its whole content. +- `element:repeater` has no content channel: each row prints fields of the queried record. Its message says so, rather than naming a key that would do nothing either. +- `@objectstack/spec`'s own `PageComponentSchema` refuses a node-level `children` on all six as an unrecognized key. This refusal brings objectui's face into line with the spec; it is not a second rule. + +**Why `minor`, and why this breaks no published document.** `minor` follows the objectui#9256 narrowing precedent: this repo's version policy forbids `major` in any changeset (one `fixed` group), and records `minor` with an explicit note as the spelling of a narrowing. But this narrowing is not breaking against the last release. In `@object-ui/types` 17.6.0, every document naming one of these six types is refused with `invalid_union` at `type`, because the batch-4 arms are unreleased. They ship in the same release as this entry. So no published version ever accepted a `children` on these nodes: across the release, the accept set only widens. + +No render behaviour changes. Nothing read these keys, which is why they can be refused. + +Migration: put the content where the message says. A member action goes in `properties.actions`, and a term / description pair goes in `properties.items`. Anything else goes beside the node, in a container that reads `children` (`page:section`, `page:card`), or is dropped. diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 7ba9b50fe8..2e59eb838e 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4057,23 +4057,24 @@ const EXCLUSIONS: Readonly> = { 'public-blocks.zod.ts#ElementDividerBlockSchema': "spec-owned, RESTATED — `BaseSchema` + the `element:divider` literal + a member-less strict `properties`, because `@objectstack/spec/ui` exports `ComponentPropsMap['element:divider']` under no name to read by reference; its key set and accept set are pinned to the row's in `./public-block-arms-10872.test.ts`, and no TS declaration in this package restates the node", // objectui#10872 batch 4 — the six blocks held until `@objectstack/spec` - // 17.5.0 carried their rows. Same shape as the rows above, minus the - // objectui#9256 channel refusals (that measurement never covered them); + // 17.5.0 carried their rows. Same shape as the rows above; // `action:button` / `action:icon` add the two handler keys their renderers // read off the node. `action:group` / `action:menu` have renderer-side - // interfaces in `@object-ui/components`, outside this package. + // interfaces in `@object-ui/components`, outside this package. The + // objectui#9256 channel refusals reached them in objectui#10872 batch 5, + // pinned in `./held-block-content-channels-10872.test.ts`. 'public-blocks.zod.ts#ElementDefinitionListBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `element:definition-list` literal + `properties`, which IS `ComponentPropsMap['element:definition-list']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `element:definition-list` literal + `properties`, which IS `ComponentPropsMap['element:definition-list']`, + the objectui#9256 `body` / `children` refusals (objectui#10872 batch 5; its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#ElementRepeaterBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `element:repeater` literal + `properties`, which IS `ComponentPropsMap['element:repeater']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `element:repeater` literal + `properties`, which IS `ComponentPropsMap['element:repeater']`, + the objectui#9256 `body` / `children` refusals (objectui#10872 batch 5; its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#ActionButtonBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `action:button` literal + `properties`, which IS `ComponentPropsMap['action:button']`, + the `onClick` runtime-slot refusal (objectui#6124) and the flat-`onSuccess` alias refusal naming `properties.onSuccess`, the two handler keys its renderer reads off the node; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `action:button` literal + `properties`, which IS `ComponentPropsMap['action:button']`, + the `onClick` runtime-slot refusal (objectui#6124) and the flat-`onSuccess` alias refusal naming `properties.onSuccess`, the two handler keys its renderer reads off the node, + the objectui#9256 `body` / `children` refusals (objectui#10872 batch 5; its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#ActionIconBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `action:icon` literal + `properties`, which IS `ComponentPropsMap['action:icon']`, + the `onClick` runtime-slot refusal (objectui#6124) and the flat-`onSuccess` alias refusal naming `properties.onSuccess`, the two handler keys its renderer reads off the node; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `action:icon` literal + `properties`, which IS `ComponentPropsMap['action:icon']`, + the `onClick` runtime-slot refusal (objectui#6124) and the flat-`onSuccess` alias refusal naming `properties.onSuccess`, the two handler keys its renderer reads off the node, + the objectui#9256 `body` / `children` refusals (objectui#10872 batch 5; its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#ActionGroupBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `action:group` literal + `properties`, which IS `ComponentPropsMap['action:group']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `action:group` literal + `properties`, which IS `ComponentPropsMap['action:group']`, + the objectui#9256 `body` / `children` refusals (objectui#10872 batch 5; its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#ActionMenuBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `action:menu` literal + `properties`, which IS `ComponentPropsMap['action:menu']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `action:menu` literal + `properties`, which IS `ComponentPropsMap['action:menu']`, + the objectui#9256 `body` / `children` refusals (objectui#10872 batch 5; its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PublicBlockComponentSchema': "a union OVER the public-block arms, not an object of its own — its members are accounted for individually above", // objectui#10919 — the `cloud:plan-status` widget's arm, declared locally from diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 396d1eb89b..b76fcb05bc 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -240,7 +240,7 @@ The ADR-0080 public blocks `object-metric` and `object-master-detail-form`: each ### ADR-0080 Public Blocks (27) Each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. `ElementNumberBlockSchema` also declares the spec's `dataSource` binding, and mirrors the spec gate's one waiver: its bag may omit `object` when `dataSource.object` names the object. -Every arm before the six below, except the four `page:` containers (`page:card`, `page:section`, `page:footer`, `page:sidebar`) refuses `children` and `body` by name, because its renderer reads neither content channel (objectui#9256). Two carve-outs: `record:alert` refuses `children` with its own message, while its `body` is the message text and belongs in `properties`, so a flat `body` is refused with a pointer to `properties.body` rather than to `children` (objectui#10872); and `page:tabs` / `page:accordion` refuse the node's own `children` while each item's `children` in `items` stays live. +Every arm except the four `page:` containers (`page:card`, `page:section`, `page:footer`, `page:sidebar`) refuses `children` and `body` by name, because its renderer reads neither content channel (objectui#9256; the last six below since objectui#10872 batch 5). Two carve-outs: `record:alert` refuses `children` with its own message, while its `body` is the message text and belongs in `properties`, so a flat `body` is refused with a pointer to `properties.body` rather than to `children` (objectui#10872); and `page:tabs` / `page:accordion` refuse the node's own `children` while each item's `children` in `items` stays live. - `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema` - `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema` - `RecordDetailsBlockSchema`, `RecordHighlightsBlockSchema`, `RecordRelatedListBlockSchema` @@ -250,7 +250,7 @@ Every arm before the six below, except the four `page:` containers (`page:card`, - `ElementDefinitionListBlockSchema`, `ElementRepeaterBlockSchema` - `ActionButtonBlockSchema`, `ActionIconBlockSchema`, `ActionGroupBlockSchema`, `ActionMenuBlockSchema` -The last six arrived with `@objectstack/spec` 17.5.0, whose rows for them were measured at their renderers' reads (objectui#10872 batch 4). They carry no content-channel refusal: objectui#9256 never measured them, so they take `BaseSchema`'s `children` and `body` as they are. `ActionButtonBlockSchema` and `ActionIconBlockSchema` also refuse two keys their renderers read off the node: `onClick`, a runtime slot for a host-supplied function (objectui#6124), and a flat `onSuccess`, pointed at `properties.onSuccess`, the row's member. +The last six arrived with `@objectstack/spec` 17.5.0, whose rows for them were measured at their renderers' reads (objectui#10872 batch 4). Their content channels were measured by the objectui#9256 method in objectui#10872 batch 5, and none of their renderers reads either one, so each refuses `children` and `body` like the arms above. The message names where the content goes instead: `properties.actions` for `action:group` and `action:menu`, `properties.items` for `element:definition-list`, and the label and icon for the two buttons. `element:repeater` has no content channel, and its message says so. `ActionButtonBlockSchema` and `ActionIconBlockSchema` also refuse two keys their renderers read off the node: `onClick`, a runtime slot for a host-supplied function (objectui#6124), and a flat `onSuccess`, pointed at `properties.onSuccess`, the row's member. ### Cloud Widgets (1) `@object-ui/app-shell`'s `cloud:` SDUI widgets. `@objectstack/spec` has no `ComponentPropsMap` row for them, so each arm is declared here from the keys its widget reads. From 79b4668aeada59f27a803fb99d6992d4c8126baa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:34:42 +0000 Subject: [PATCH 3/3] test(types): keep the batch-5 pin's face cases typed as tuples (objectui#10872) `tsc -p tsconfig.test.json` lost the tuple element types through a spread of `slice(1)`; the cases are now built by destructuring. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .../src/__tests__/held-block-content-channels-10872.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/held-block-content-channels-10872.test.ts b/packages/types/src/__tests__/held-block-content-channels-10872.test.ts index fb0dea2463..827f82bfef 100644 --- a/packages/types/src/__tests__/held-block-content-channels-10872.test.ts +++ b/packages/types/src/__tests__/held-block-content-channels-10872.test.ts @@ -139,7 +139,8 @@ const allIssues = (issues: Issue[] | undefined, prefix: PropertyKey[] = []): Iss const CASES = ROWS.flatMap(([type, arm, node, channel]) => CHANNELS.map((key) => [`${type}.${key}`, type, arm, node, channel, key] as const)); -const FACE_CASES = CASES.flatMap((c) => FACES.map(([face, judge]) => [`${c[0]} (${face})`, ...c.slice(1), judge] as const)); +const FACE_CASES = CASES.flatMap(([label, type, arm, node, channel, key]) => + FACES.map(([face, judge]) => [`${label} (${face})`, type, arm, node, channel, key, judge] as const)); /* ── (a) both channels are REFUSED BY NAME, at the key's own path, on both faces ── */