From 42d3ea57eec9ad0c54ce69e51a31ef4e71729bce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:37:18 +0000 Subject: [PATCH] feat(types)!: refuse the content channels on the nineteen public-block arms and metric-card (objectui#9256) Each renderer reads neither `children` nor `body` off the node, so both keys are refused by name on the zod arm, each kept a member: sixteen arms in public-blocks.zod.ts and object-metric / object-master-detail-form in objectql.zod.ts. record:alert refuses `children` only (its `body` is the message text). page:tabs / page:accordion refuse the node's own `children` while each item's `children` stays live. metric-card: `children?: never` (and `body` restated) on DashboardWidgetSlotComponentSchema, and both members on the private slot arm; the refusal surfaces inside the widget slot's invalid_union. Text repairs: the stale chatbot comment and test name in content-channel-family-d-9256.test.ts, and CodeEditorSchema.children's docblock (it omitted onChange). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk --- .../9256-public-blocks-content-channels.md | 56 ++++ .../content-channel-family-d-9256.test.ts | 39 ++- ...content-channel-public-blocks-9256.test.ts | 260 +++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 42 +-- packages/types/src/complex.ts | 39 +++ packages/types/src/form.ts | 9 +- packages/types/src/zod/README.md | 3 +- packages/types/src/zod/complex.zod.ts | 31 ++ packages/types/src/zod/objectql.zod.ts | 33 +++ packages/types/src/zod/public-blocks.zod.ts | 274 +++++++++++++++++- 10 files changed, 740 insertions(+), 46 deletions(-) create mode 100644 .changeset/9256-public-blocks-content-channels.md create mode 100644 packages/types/src/__tests__/content-channel-public-blocks-9256.test.ts diff --git a/.changeset/9256-public-blocks-content-channels.md b/.changeset/9256-public-blocks-content-channels.md new file mode 100644 index 0000000000..f0812aa7d0 --- /dev/null +++ b/.changeset/9256-public-blocks-content-channels.md @@ -0,0 +1,56 @@ +--- +'@object-ui/types': minor +--- + +**BREAKING (shipped as `minor` — see below):** nineteen ADR-0080 public-block +arms and the dashboard widget slot's `metric-card` node now refuse an authored +`children` by name. None of these renderers reads the node's child list, so the +key rendered nothing, with no render-time error or warning and no element +(objectui#9256). + +- `page:header`, `page:tabs`, `page:accordion`, `record:details`, + `record:highlights`, `record:related_list`, `record:path`, `record:activity`, + `record:discussion`, `record:history`, `record:quick_actions`, + `record:reference_rail`, `element:text`, `element:number`, `element:button`, + `element:divider`, `object-metric` and `object-master-detail-form`: `children` + and `body` are declared as by-name refusals on the zod arm, each kept a member. + This package has no TypeScript declaration of these nodes, so the zod face is + the only one that changes. +- `record:alert`: `children` only. Its `body` is the alert's message text, not a + content channel, and is left as it was. +- `page:tabs` and `page:accordion`: the node's own `children` only. Each item's + `children` in `items` stays live — that is what these blocks render. +- `metric-card` in a dashboard's `widgets`: `children?: never` and `body?: never` + on the TypeScript face (`DashboardWidgetSlotComponentSchema`), and both keys + refused by name on its zod twin. That twin is the first arm of the widget + slot's union, so the refusal reaches the author inside the union's + `invalid_union` issue at the widget's path, beside the strict widget schema's + `unrecognized_keys`; `objectui validate` prints it as one arm of two. + +What moves for an author: + +- `children` on any of these nodes parsed green (and, on `metric-card`, also + type-checked). It is now refused at `safeParse` time at its own path — on + `metric-card`, under the widget's `invalid_union` — and on + `DashboardWidgetSlotComponentSchema` at authoring time. +- `body` was already refused on all of them, by `BaseSchema`. Everywhere except + `record:alert`, the zod refusal message now names what the node renders + instead, where it used to point at `children`, which these nodes do not read + either. + +No render behaviour changes: nothing read these keys, which is the whole reason +they could be refused. + +Migration: each of these nodes renders from its own keys, so there is no channel +to move the content to. Put it in the key the node does render +(`element:text`'s `properties.content`, the item-level `children` of a +`page:tabs` or `page:accordion` item, `record:alert`'s `properties.body`), place +it beside the node in a container that reads `children` (`page:section`, +`page:card`), or drop it. + +Also, text only: `CodeEditorSchema.children`'s docblock now names `onChange` +among the keys `CodeEditorRenderer` forwards to Monaco. + +`minor` rather than `major` because this repo's version policy forbids `major` +in any changeset — one `fixed` group — and records `minor` plus an explicit +breaking note as the spelling for a breaking change here. 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 6d35496990..685dcd969c 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 @@ -116,6 +116,13 @@ * RE-POINTED at a twin rather than inverted; what this card asserts — that it * narrowed `children` on the chatbot faces and left their `body` alone — is * unmoved. + * ⚠️ AMENDED AGAIN (objectui#9256, public-block slice): the hold-out on the + * two TWIN faces has ended too, and not by a decision on this card — + * objectui#6771 retired `body` on `BaseSchema`, so each twin now declares + * the same neither-channel tombstone on `body` that its `children` carries, + * pointing at `requestBody`. `body` is REFUSED on all three chatbot faces + * today; the two controls below assert that refusal (the mirror control) + * and its compile-time twin, and neither proves `body` parses anywhere. * * ## ⚠️ Half of this file is a COMPILE-TIME assertion and vitest CANNOT read it * @@ -301,17 +308,22 @@ const ROWS: ReadonlyArray { + // Named for what it asserts since the objectui#9256 public-block slice: the + // name used to say the family "still accepts `body`", which the body below has + // not asserted since the twins gained their `body` tombstone. + it('LIVE CONTROL — the chatbot twins REFUSE `body` too, pointing the author at `requestBody`', () => { // The held-out channel, still held out — RE-POINTED, not inverted, by // objectui#8572. This control was aimed at `ChatbotSchema`, whose own `body` // the parity ledger recorded as "two different meanings of one key — a naming diff --git a/packages/types/src/__tests__/content-channel-public-blocks-9256.test.ts b/packages/types/src/__tests__/content-channel-public-blocks-9256.test.ts new file mode 100644 index 0000000000..34d13a087b --- /dev/null +++ b/packages/types/src/__tests__/content-channel-public-blocks-9256.test.ts @@ -0,0 +1,260 @@ +/** + * 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#9256 — the PUBLIC-BLOCK slice: the nineteen ADR-0080 public-block + * arms whose renderer reads NEITHER content channel, plus `metric-card` in the + * dashboard widget slot. Family D's earlier pins live in + * `content-channel-family-d-9256.test.ts`, `content-channel-e3-residual-9256.test.ts`, + * `content-channel-input-9256.test.ts` and `content-channel-remeasure-9256.test.ts`; + * this file carries only what this slice adds. + * + * ## What is narrowed here + * + * - Eighteen zod-only arms — sixteen in `zod/public-blocks.zod.ts` + * (`page:header`, `page:tabs`, `page:accordion`, the nine `record:` blocks + * below, and the four `element:` blocks) and two in `zod/objectql.zod.ts` + * (`object-metric`, `object-master-detail-form`): a by-name refusal of + * `children`, and `body` restated with the same neither-channel guidance, + * each kept a MEMBER. No TypeScript declaration in this package restates + * any of these nodes, so the zod arm is the only face that moves. + * - `record:alert`: `children` only. Its renderer reads a key named `body` as + * the message TEXT, so `body` is not a content channel there, and this + * slice leaves it to `BaseSchema`. + * - `page:tabs` / `page:accordion`: the NODE's channels only. Each renders the + * `children` of every ITEM in its `items` bag member, and that item-level + * key stays live. + * - `metric-card`: `children?: never` (and `body` restated) on the TypeScript + * face `DashboardWidgetSlotComponentSchema`, and both members on its + * private zod twin, the first arm of the widget slot's `z.union`. + * + * ## ⚠️ How a `metric-card` refusal surfaces — measured, and the pin follows it + * + * The slot arm is not reachable on its own: `widgets` is + * `z.union([slot arm, strict DashboardWidgetSchema])`. A widget the slot arm + * refuses falls through to the strict widget schema, which refuses the same + * key as unrecognized, so the author gets ONE `invalid_union` at the widget's + * path with each arm's issues under `errors`. The slot arm's message is there, + * at the arm-relative path `['children']`, and `objectui validate` prints it as + * one arm of two. So the metric-card rows below assert the refusal INSIDE the + * union's `errors`, not as a top-level issue — a top-level assertion would pin + * a shape the union never produces. + * + * ## ⚠️ Half of the metric-card block is a COMPILE-TIME assertion + * + * `?: never` is erased before a test runs. The `@ts-expect-error` lines at the + * bottom are read by `tsc -p tsconfig.test.json` (the `type-check` script), + * NOT by vitest: under vitest alone, deleting the TypeScript tombstone leaves + * this file GREEN. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { + PageHeaderBlockSchema, + PageTabsBlockSchema, + PageAccordionBlockSchema, + RecordDetailsBlockSchema, + RecordHighlightsBlockSchema, + RecordRelatedListBlockSchema, + RecordPathBlockSchema, + RecordActivityBlockSchema, + RecordDiscussionBlockSchema, + RecordHistoryBlockSchema, + RecordQuickActionsBlockSchema, + RecordReferenceRailBlockSchema, + RecordAlertBlockSchema, + ElementTextBlockSchema, + ElementNumberBlockSchema, + ElementButtonBlockSchema, + ElementDividerBlockSchema, + PageCardBlockSchema, + PageSectionBlockSchema, + PageFooterBlockSchema, + PageSidebarBlockSchema, +} from '../zod/public-blocks.zod'; +import { ObjectMetricBlockSchema, ObjectMasterDetailFormBlockSchema } from '../zod/objectql.zod'; +import { DashboardComponentSchema as DashboardMirror } from '../zod/complex.zod'; +import { AnyComponentSchema } from '../zod/index.zod'; +import type { DashboardWidgetSlotComponentSchema } from '../complex'; + +type Issue = { + code: string; + path: PropertyKey[]; + message: string; + keys?: string[]; + errors?: Issue[][]; +}; +type Mirror = { + safeParse: (v: unknown) => { success: boolean; error?: z.ZodError }; + shape: Record; +}; + +/** One row per arm whose renderer reads NEITHER channel: both channels are refused. */ +const ROWS: ReadonlyArray = [ + ['page:header', PageHeaderBlockSchema as unknown as Mirror], + ['page:tabs', PageTabsBlockSchema as unknown as Mirror], + ['page:accordion', PageAccordionBlockSchema as unknown as Mirror], + ['record:details', RecordDetailsBlockSchema as unknown as Mirror], + ['record:highlights', RecordHighlightsBlockSchema as unknown as Mirror], + ['record:related_list', RecordRelatedListBlockSchema as unknown as Mirror], + ['record:path', RecordPathBlockSchema as unknown as Mirror], + ['record:activity', RecordActivityBlockSchema as unknown as Mirror], + ['record:discussion', RecordDiscussionBlockSchema as unknown as Mirror], + ['record:history', RecordHistoryBlockSchema as unknown as Mirror], + ['record:quick_actions', RecordQuickActionsBlockSchema as unknown as Mirror], + ['record:reference_rail', RecordReferenceRailBlockSchema as unknown as Mirror], + ['element:text', ElementTextBlockSchema as unknown as Mirror], + ['element:number', ElementNumberBlockSchema as unknown as Mirror], + ['element:button', ElementButtonBlockSchema as unknown as Mirror], + ['element:divider', ElementDividerBlockSchema as unknown as Mirror], + ['object-metric', ObjectMetricBlockSchema as unknown as Mirror], + ['object-master-detail-form', ObjectMasterDetailFormBlockSchema as unknown as Mirror], +]; + +const CHANNELS = ['body', 'children'] as const; +const CONTENT = [{ type: 'text', content: 'measured' }]; +const issuesOf = (m: Mirror, doc: unknown): Issue[] | null => { + const r = m.safeParse(doc); + return r.success ? null : (r.error!.issues as unknown as Issue[]); +}; +const at = (issues: Issue[] | null, key: string) => issues?.find((i) => i.path.join('.') === key); +const CASES = ROWS.flatMap(([type, mirror]) => CHANNELS.map((key) => [`${type}.${key}`, type, mirror, key] as const)); + +/* ── (a) both channels are REFUSED BY NAME, at the key's own path ─────────── */ + +describe('objectui#9256 public blocks — both content channels are refused where the renderer reads neither', () => { + it('the population is the narrowed one — a row dropped from the table fails here', () => { + // 18 two-channel rows + `record:alert`'s one channel = the nineteen arms. + expect(ROWS).toHaveLength(18); + expect(CASES).toHaveLength(36); + }); + + it.each(CASES)('%s is refused at that key\'s own path', (label, type, mirror, key) => { + const found = issuesOf(mirror, { type, [key]: CONTENT }); + expect(found, `${label} parsed green — the tombstone is not installed`).not.toBeNull(); + expect(at(found, key)?.code).toBe('invalid_type'); + }); + + it.each(CASES)('%s — the message names the channel, the card, and what the node renders instead', (_label, type, mirror, key) => { + const issue = at(issuesOf(mirror, { type, [key]: CONTENT }), key)!; + expect(issue.message).toContain(`\`${type}\` reads NEITHER content channel`); + expect(issue.message).toContain('objectui#9256'); + expect(issue.message).toContain('What it renders instead: '); + // ⛔ Not `BaseSchema`'s generic `body` refusal, which names `children` as + // the remedy — on these nodes `children` is refused as well. + expect(issue.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, mirror, key) => { + const issue = at(issuesOf(mirror, { type, [key]: CONTENT }), key)!; + expect(mirror.shape[key]?.description).toBe(issue.message); + }); + + it.each(CASES)('%s — the refusal is about the KEY, not a value domain: every value is refused', (_label, type, mirror, key) => { + for (const value of [CONTENT, 'text', 42, null, {}, []]) { + expect(at(issuesOf(mirror, { type, [key]: value }), key), JSON.stringify(value)).toBeDefined(); + } + }); + + it.each(CASES)('%s — the refusal reaches the node through `AnyComponentSchema`, not only its own arm', (_label, type, _mirror, key) => { + expect(AnyComponentSchema.safeParse({ type }).success).toBe(true); + expect(AnyComponentSchema.safeParse({ type, [key]: CONTENT }).success).toBe(false); + }); + + it.each(CASES)('%s — the tombstone is a MEMBER of the arm\'s shape', (_label, _type, mirror, key) => { + expect(Object.keys(mirror.shape)).toContain(key); + }); +}); + +/* ── (b) the carve-outs — what this slice deliberately leaves live ────────── */ + +describe('objectui#9256 public blocks — the carve-outs', () => { + it('`record:alert` refuses `children` with its OWN message, which does not say the renderer reads no `body`', () => { + const issue = at(issuesOf(RecordAlertBlockSchema as unknown as Mirror, { type: 'record:alert', children: CONTENT }), 'children'); + expect(issue?.code).toBe('invalid_type'); + expect(issue?.message).toContain('`record:alert` renders no child list'); + expect(issue?.message).toContain('objectui#9256'); + expect(issue?.message).toContain('What it renders instead: '); + // The neither-channel builder's sentence is FALSE here: this renderer reads + // a key named `body` (the message text). The row must not carry it. + expect(issue?.message).not.toContain('reads NEITHER content channel'); + expect((RecordAlertBlockSchema as unknown as Mirror).shape.children?.description).toBe(issue?.message); + }); + + it('`record:alert`\'s `body` is NOT restated: a flat `body` is still `BaseSchema`\'s objectui#6771 refusal, and `properties.body` still parses', () => { + const issue = at(issuesOf(RecordAlertBlockSchema as unknown as Mirror, { type: 'record:alert', body: 'Overdue' }), 'body'); + expect(issue?.message).toContain('objectui#6771'); + expect(issue?.message).not.toContain('objectui#9256'); + // CONTROL — the message TEXT, where the spec row declares it, is live. + expect(AnyComponentSchema.safeParse({ type: 'record:alert', properties: { severity: 'warning', body: 'Overdue' } }).success) + .toBe(true); + }); + + it.each(['page:tabs', 'page:accordion'])('`%s` still takes `children` on each ITEM — only the node\'s own channel is refused', (type) => { + const item = { label: 'Details', children: CONTENT }; + expect(AnyComponentSchema.safeParse({ type, properties: { items: [item] } }).success).toBe(true); + expect(AnyComponentSchema.safeParse({ type, properties: { items: [item] }, children: CONTENT }).success).toBe(false); + }); + + it.each([ + ['page:card', PageCardBlockSchema], + ['page:section', PageSectionBlockSchema], + ['page:footer', PageFooterBlockSchema], + ['page:sidebar', PageSidebarBlockSchema], + ] as const)('CONTROL — `%s` renders the node\'s child list, so it still takes `children`', (type, mirror) => { + // Measured in the same run as the rows above: a reader keeps its channel. + expect(issuesOf(mirror as unknown as Mirror, { type, children: CONTENT })).toBeNull(); + expect(AnyComponentSchema.safeParse({ type, children: CONTENT }).success).toBe(true); + }); + + it('a narrowed block nested in a `page` is refused; the same block without a child list parses', () => { + const page = (block: Record) => ({ type: 'page', children: [block] }); + expect(AnyComponentSchema.safeParse(page({ type: 'record:details' })).success).toBe(true); + expect(AnyComponentSchema.safeParse(page({ type: 'record:details', children: CONTENT })).success).toBe(false); + }); +}); + +/* ── (c) `metric-card` — the refusal lives INSIDE the widget-slot union ───── */ + +describe('objectui#9256 public blocks — `metric-card` in the dashboard widget slot', () => { + const dashboard = (widget: Record) => ({ type: 'dashboard', widgets: [widget] }); + const card = { type: 'metric-card', title: 'Revenue', value: '42' }; + const unionIssue = (doc: unknown) => + issuesOf(DashboardMirror as unknown as Mirror, doc)?.find((i) => i.path.join('.') === 'widgets.0'); + + it('CONTROL — the card without a content channel parses, on the arm and at the root', () => { + expect(issuesOf(DashboardMirror as unknown as Mirror, dashboard(card))).toBeNull(); + expect(AnyComponentSchema.safeParse(dashboard(card)).success).toBe(true); + }); + + it.each(CHANNELS)('`%s` is refused: ONE `invalid_union` at the widget, the slot arm\'s by-name refusal among its `errors`', (key) => { + const issue = unionIssue(dashboard({ ...card, [key]: CONTENT })); + expect(issue?.code).toBe('invalid_union'); + const armIssues = (issue?.errors ?? []).flat(); + // The slot arm reports RELATIVE to the widget — the path is the key alone. + const refusal = armIssues.find((i) => i.path.join('.') === key && i.code === 'invalid_type'); + expect(refusal?.message).toContain('`metric-card` reads NEITHER content channel'); + expect(refusal?.message).toContain('objectui#9256'); + expect(refusal?.message).toContain('What it renders instead: '); + expect(refusal?.message).not.toContain('Did you mean'); + // The fall-through arm refuses the same key too, so no arm accepts it. + const strict = armIssues.find((i) => i.code === 'unrecognized_keys'); + expect(strict?.keys).toContain(key); + expect(AnyComponentSchema.safeParse(dashboard({ ...card, [key]: CONTENT })).success).toBe(false); + }); + + it('the TypeScript face refuses both channels at the AUTHORING site (`tsc` is the reader)', () => { + const ok = { type: 'metric-card', title: 'Revenue', value: '42' } satisfies DashboardWidgetSlotComponentSchema; + // @ts-expect-error objectui#9256 — `metric-card` reads neither channel + const withChildren: DashboardWidgetSlotComponentSchema = { ...ok, children: CONTENT }; + // @ts-expect-error objectui#9256 — `metric-card` reads neither channel + const withBody: DashboardWidgetSlotComponentSchema = { ...ok, body: CONTENT }; + expect([ok, withChildren, withBody]).toHaveLength(3); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 73c04e052c..f2993f8a2f 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -3989,21 +3989,25 @@ const EXCLUSIONS: Readonly> = { 'ai.zod.ts#AIComponentSchema': "a union OVER the mirrors, not an object of its own — its members are checked individually above", // objectui#10872 — the ADR-0080 public-block arms. Each is `BaseSchema` plus a - // `type` literal plus ONE member, `properties`, which is the block's + // `type` literal plus `properties`, which is the block's // `@objectstack/spec` `ComponentPropsMap` row by reference (through the // objectui#8317 boundary). No TS declaration in this package restates any of // these nodes — the spec's row types are re-exported under `Spec…` names by // `../index.ts`, which is the spec's declaration, not a second one — so there // is no pair to register, and the arm-to-row agreement is measured against // the installed spec by `./public-block-arms-10872.test.ts` instead. + // objectui#9256 then added the content-channel refusals as members on every + // arm here but the four `page:` containers (`record:alert`: `children` only), + // pinned in `./content-channel-public-blocks-9256.test.ts`; each entry below + // names them where they apply. 'public-blocks.zod.ts#PageHeaderBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `page:header` literal + `properties`, which IS `ComponentPropsMap['page:header']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `page:header` literal + `properties`, which IS `ComponentPropsMap['page:header']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageTabsBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `page:tabs` literal + `properties`, which IS `ComponentPropsMap['page:tabs']`, + the `onTabChange` runtime-slot refusal (objectui#6124) its renderer's read requires; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `page:tabs` literal + `properties`, which IS `ComponentPropsMap['page:tabs']`, + the `onTabChange` runtime-slot refusal (objectui#6124) its renderer's read requires, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageCardBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:card` literal + `properties`, which IS `ComponentPropsMap['page:card']`; no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageAccordionBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `page:accordion` literal + `properties`, which IS `ComponentPropsMap['page:accordion']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `page:accordion` literal + `properties`, which IS `ComponentPropsMap['page:accordion']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageSectionBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:section` literal + `properties`, which IS `ComponentPropsMap['page:section']`; no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageFooterBlockSchema': @@ -4011,33 +4015,33 @@ const EXCLUSIONS: Readonly> = { 'public-blocks.zod.ts#PageSidebarBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:sidebar` literal + `properties`, which IS `ComponentPropsMap['page:sidebar']`; no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordDetailsBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:details` literal + `properties`, which IS `ComponentPropsMap['record:details']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:details` literal + `properties`, which IS `ComponentPropsMap['record:details']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordHighlightsBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:highlights` literal + `properties`, which IS `ComponentPropsMap['record:highlights']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:highlights` literal + `properties`, which IS `ComponentPropsMap['record:highlights']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordRelatedListBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:related_list` literal + `properties`, which IS `ComponentPropsMap['record:related_list']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:related_list` literal + `properties`, which IS `ComponentPropsMap['record:related_list']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordPathBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:path` literal + `properties`, which IS `ComponentPropsMap['record:path']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:path` literal + `properties`, which IS `ComponentPropsMap['record:path']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordActivityBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:activity` literal + `properties`, which IS `ComponentPropsMap['record:activity']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:activity` literal + `properties`, which IS `ComponentPropsMap['record:activity']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordDiscussionBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:discussion` literal + `properties`, which IS `ComponentPropsMap['record:discussion']` (the spec's shared `record:chatter` row); no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:discussion` literal + `properties`, which IS `ComponentPropsMap['record:discussion']` (the spec's shared `record:chatter` row), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordHistoryBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:history` literal + `properties`, which IS `ComponentPropsMap['record:history']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:history` literal + `properties`, which IS `ComponentPropsMap['record:history']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordQuickActionsBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:quick_actions` literal + `properties`, which IS `ComponentPropsMap['record:quick_actions']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:quick_actions` literal + `properties`, which IS `ComponentPropsMap['record:quick_actions']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordReferenceRailBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:reference_rail` literal + `properties`, which IS `ComponentPropsMap['record:reference_rail']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:reference_rail` literal + `properties`, which IS `ComponentPropsMap['record:reference_rail']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordAlertBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:alert` literal + `properties`, which IS `ComponentPropsMap['record:alert']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:alert` literal + `properties`, which IS `ComponentPropsMap['record:alert']`, + the objectui#9256 `children` refusal (its `body` is a text prop, left to `BaseSchema`); no TS declaration in this package restates the node", 'public-blocks.zod.ts#ElementTextBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `element:text` literal + `properties`, which IS `ComponentPropsMap['element:text']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `element:text` literal + `properties`, which IS `ComponentPropsMap['element:text']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", // objectui#10872 batch 2 — the one arm with a node-level refinement: the // spec's `dataSource` waiver on the row's required `object`. 'public-blocks.zod.ts#ElementNumberBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `element:number` literal + `properties`, which IS `ComponentPropsMap['element:number']` with `object` alone made optional (`.partial({ object: true })`), + `dataSource`, which IS the spec's `ElementDataSourceSchema`, + the refinement that restores `object`'s requiredness wherever the spec gate's `dataSource` waiver does not apply; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `element:number` literal + `properties`, which IS `ComponentPropsMap['element:number']` with `object` alone made optional (`.partial({ object: true })`), + `dataSource`, which IS the spec's `ElementDataSourceSchema`, + the refinement that restores `object`'s requiredness wherever the spec gate's `dataSource` waiver does not apply, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#ElementButtonBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `element:button` literal + `properties`, which IS `ComponentPropsMap['element:button']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `element:button` literal + `properties`, which IS `ComponentPropsMap['element:button']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", '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", 'public-blocks.zod.ts#PublicBlockComponentSchema': @@ -4131,9 +4135,9 @@ const EXCLUSIONS: Readonly> = { // built the objectui#10872 way: `BaseSchema` + a `type` literal + // `properties`, which is the block's `ComponentPropsMap` row by reference. 'objectql.zod.ts#ObjectMetricBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `object-metric` literal + `properties`, which IS `ComponentPropsMap['object-metric']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `object-metric` literal + `properties`, which IS `ComponentPropsMap['object-metric']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'objectql.zod.ts#ObjectMasterDetailFormBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `object-master-detail-form` literal + `properties`, which IS `ComponentPropsMap['object-master-detail-form']`, + three `handlerKeyRefusal` runtime slots; no TS declaration in this package restates the node (`@object-ui/plugin-form`'s `MasterDetailFormSchema` is the type of `MasterDetailForm`'s `schema` prop, the renderer's post-hoist reading)", + "spec-owned BY REFERENCE — `BaseSchema` + the `object-master-detail-form` literal + `properties`, which IS `ComponentPropsMap['object-master-detail-form']`, + three `handlerKeyRefusal` runtime slots, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node (`@object-ui/plugin-form`'s `MasterDetailFormSchema` is the type of `MasterDetailForm`'s `schema` prop, the renderer's post-hoist reading)", 'objectql.zod.ts#ObjectQLPublicBlockComponentSchema': "a union OVER the two public-block arms above, not an object of its own — its members are checked individually", 'overlay.zod.ts#MenuItemSchema': diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index c1033fa60c..b79ca45d8a 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -2271,6 +2271,45 @@ export interface DashboardWidgetSchema export interface DashboardWidgetSlotComponentSchema extends BaseSchema { /** An objectui component type legal in a widget slot — the CLOSED set. */ type: DashboardComponentWidgetType; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `metric-card` reads NEITHER + * content channel; see `children` below for the measurement. + * + * {@link BaseSchema} already refuses `body` (objectui#6771), but its + * refusal names `children` as the remedy, which this node does not read + * either. The member is restated here so the refusal points at what the + * node renders instead. + * + * @deprecated Not a channel `metric-card` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `metric-card` reads NEITHER + * content channel, so an authored child list here rendered NOTHING: no + * render-time error or warning and no element. In a widget slot nothing + * else noticed it either: the parser tier's `not-a-container` warning + * (objectui#9910) walks `children`, never `widgets`. + * + * Measured with the TypeScript type checker, not grep, over one program per + * workspace package on a built tree (the family-D public-block slice). + * `DashboardRenderer` hands a `metric-card` widget to `SchemaRenderer` as + * the widget's own keys; the registration (`plugin-dashboard:metric-card`) + * renders `MetricCard`, which reads `title`, `value`, `icon`, `trend`, + * `trendValue`, `description`, `loading` and `error`, and no read of the + * node's `body` or `children` exists on that path. `SchemaRenderer` strips + * `body` and `children` out of the props bag it spreads, so neither reaches + * the component by another route, and the registration declares no + * `children` slot (objectui#9910). + * + * The zod twin is the private slot arm in `zod/complex.zod.ts`, which + * declares both members with the same guidance. + * + * What it renders instead: one KPI card — `title`, `value`, `icon`, + * `trend` / `trendValue` and `description`. + * + * @deprecated Not a channel `metric-card` reads — nothing renders it. + */ + children?: never; } /** diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index b983b057cb..d1886de467 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -2579,10 +2579,11 @@ export interface CodeEditorSchema extends BaseSchema { * workspace package on a built tree (the family-D re-measure). The * `code-editor` registration (`@object-ui/plugin-editor`) reads the node * through `CodeEditorRenderer`'s inline props type, which forwards `value`, - * `language`, `theme`, `height`, `readOnly` and `className` to Monaco and - * nothing else. `SchemaRenderer` strips `body` and `children` out of the - * props bag it spreads, so neither reaches the component by another route, - * and the registration declares no `children` slot (objectui#9910). + * `language`, `theme`, `height`, `readOnly`, `className` and the `onChange` + * callback to Monaco; none of them is the node's `body` or `children`. + * `SchemaRenderer` strips `body` and `children` out of the props bag it + * spreads, so neither reaches the component by another route, and the + * registration declares no `children` slot (objectui#9910). * * What it renders instead: a Monaco editor over `value`, in `language` and * `theme`. diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index a3d1de0fd0..662da13045 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -232,7 +232,7 @@ function validateComponent(config: unknown) { - `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema` ### ObjectQL Public Blocks (2) -The ADR-0080 public blocks `object-metric` and `object-master-detail-form`: each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. +The ADR-0080 public blocks `object-metric` and `object-master-detail-form`: each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. Neither renderer reads a content channel, so both arms refuse `children` and `body` by name (objectui#9256). - `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema` ### AI Components (3) @@ -240,6 +240,7 @@ The ADR-0080 public blocks `object-metric` and `object-master-detail-form`: each ### ADR-0080 Public Blocks (21) 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 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` only, since its `body` is the message text; 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` diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index a37afa75be..bdebef1197 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1237,6 +1237,24 @@ export const DashboardWidgetSchema = specFieldsExcept(stripImportedDefaults(Spec component: BaseSchema.optional().describe('Widget Component (legacy format)'), }).strict(); +/** + * objectui#9256 (public-block slice): ONE refusal string for both content channels of the + * widget-slot `metric-card` node. Its own string rather than `neitherContentChannelGuidance`, because + * that builder says the parser tier's `not-a-container` warning noticed the key, and in a widget slot + * it does not: that tier walks `children`, never `widgets`. + */ +const METRIC_CARD_NEITHER_CHANNEL = + 'REFUSED (objectui#9256, ADR-0049) — `metric-card` reads NEITHER content channel: measured with the ' + + 'TypeScript type checker over one program per workspace package on a BUILT tree, no renderer read consumes ' + + '`body` or `children` for this node — `DashboardRenderer` hands a `metric-card` widget to `SchemaRenderer` ' + + 'as the widget\'s own keys, and the registration (`plugin-dashboard:metric-card`) renders `MetricCard`, ' + + 'which reads named props and forwards the rest to its `Card` as DOM attributes — and `SchemaRenderer` ' + + 'strips both out of the props bag it spreads. An ' + + 'authored value therefore rendered NOTHING — no render-time error or warning and no element — and in a ' + + 'widget slot nothing else noticed it: the parser tier\'s `not-a-container` warning (objectui#9910) walks ' + + '`children`, never `widgets`. What it renders instead: one KPI card — `title`, `value`, `icon`, ' + + '`trend` / `trendValue` and `description`.'; + /** * A COMPONENT node sitting directly in a dashboard's widget slot — the * `metric-card` extension the 2026-08-14 ruling (objectstack#8593) admits: @@ -1255,10 +1273,23 @@ export const DashboardWidgetSchema = specFieldsExcept(stripImportedDefaults(Spec * cannot become a passthrough hatch around #6002's refusal. Deliberately NOT * exported: the routing is an internal property of the widget slot, not new * authoring surface. + * + * `children` and `body` are refused by name (objectui#9256): `MetricCard` + * reads neither content channel, as its TypeScript twin's `?: never` pair + * states. ⚠️ A refusal here does not surface on its own: this is the first arm + * of the slot's `z.union`, so a document it refuses falls through to the + * strict {@link DashboardWidgetSchema}, which refuses the same key as + * unrecognized, and the author gets one `invalid_union` at the widget's path + * with each arm's issues under `errors` — this arm's message among them, which + * `objectui validate` prints as one arm of two. */ const DashboardWidgetSlotComponentSchema = BaseSchema.extend({ type: z.enum(DASHBOARD_COMPONENT_WIDGET_TYPES) .describe('objectui component type legal in a widget slot (closed set)'), + // objectui#9256: `MetricCard` reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER, as on the TypeScript twin. + body: retirementTombstone(METRIC_CARD_NEITHER_CHANNEL), + children: retirementTombstone(METRIC_CARD_NEITHER_CHANNEL), }); /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 942c233d19..a2ddd96171 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -3191,6 +3191,14 @@ function objectBlockPropsBag(type: string, row: T) { ); } +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `object-metric`. */ +const OBJECT_METRIC_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-metric', + 'its registration (`plugin-dashboard:object-metric`) hands the node to `ObjectMetricBlock`, which resolves its ' + + '`dataSource` binding and renders `ObjectMetricWidget`, whose props are named keys and never a child list', + 'one aggregated number over `objectName`, computed by `aggregate` and scoped by `filter`', +); + /** * `object-metric` — `ComponentPropsMap['object-metric']` (objectui#10859, * batch 2). @@ -3226,12 +3234,33 @@ function objectBlockPropsBag(type: string, row: T) { * refused by the strict authoring face. Whether the flat spelling is also an * authoring channel for these blocks is the question objectui#10872 left open * for the whole family; declaring it later is additive. + * + * ## The content channels (objectui#9256) + * + * The renderer reads NEITHER content channel, so the arm declares `children` + * as a by-name refusal and restates `body` with the same guidance — as the + * public blocks in `./public-blocks.zod.ts` do, and for the same reason: + * `BaseSchema` already refuses `body`, but names `children` as the remedy. + * Both stay MEMBERS. `object-master-detail-form` below does the same. */ export const ObjectMetricBlockSchema = BaseSchema.extend({ type: z.literal('object-metric'), properties: objectBlockPropsBag('object-metric', stripImportedDefaults(SpecObjectMetricPropsSchema)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER. + body: retirementTombstone(OBJECT_METRIC_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_METRIC_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `object-master-detail-form`. */ +const OBJECT_MASTER_DETAIL_FORM_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-master-detail-form', + 'its registration (`plugin-form:object-master-detail-form`) hands the node to `MasterDetailFormRenderer`, ' + + 'which resolves its `dataSource` binding and renders `MasterDetailForm`; that form builds its parent ' + + '`object-form` node key by key and reads no child list', + 'a parent form over `objectName` with an editable grid for each `details` entry', +); + /** * `object-master-detail-form` — `ComponentPropsMap['object-master-detail-form']`, * plus the three handler keys its renderer reads off the node (objectui#10859, @@ -3271,6 +3300,10 @@ export const ObjectMasterDetailFormBlockSchema = BaseSchema.extend({ onSuccess: handlerKeyRefusal('onSuccess', 'runtime-slot', 'Called with the saved parent record after a successful save'), onError: handlerKeyRefusal('onError', 'runtime-slot', 'Called after a refused save, for bookkeeping only'), onCancel: handlerKeyRefusal('onCancel', 'runtime-slot', 'Cancel button callback'), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER. + body: retirementTombstone(OBJECT_MASTER_DETAIL_FORM_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_MASTER_DETAIL_FORM_NEITHER_CHANNEL), }); /** diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index 812a6d1c81..964b80c180 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -38,11 +38,30 @@ * refinements and retired-key tombstones all arrive from the spec, and a row * the spec changes changes the arm the same day. No member is restated here, * so none can drift — except the one row the spec does not export by name - * (`element:divider`, below). The node-level members an arm adds are two: an - * `on*` key a renderer reads off the node (`page:tabs`'s `onTabChange`), - * refused by name with `handlerKeyRefusal` as `check:handler-key-reads` - * requires of every such read, and `element:number`'s `dataSource`, the spec's - * own binding schema by reference (below). + * (`element:divider`, below). The node-level members an arm adds are three + * kinds: an `on*` key a renderer reads off the node (`page:tabs`'s + * `onTabChange`), refused by name with `handlerKeyRefusal` as + * `check:handler-key-reads` requires of every such read; `element:number`'s + * `dataSource`, the spec's own binding schema by reference (below); and the + * content-channel tombstones (next section). + * + * ## The content channels (objectui#9256) + * + * Every block armed 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 + * `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` + * already refuses `body` (objectui#6771), but its message names `children` as + * the remedy, which these blocks do not read either. Both stay MEMBERS + * (`retirementTombstone`), so the refusal sits at the key's own path. + * + * 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 + * the spec row's and stays live — only the node's own `children` is refused; + * and `record:alert` reads a key named `body` as its message TEXT, so only its + * `children` is declared here, and its `body` is left to `BaseSchema`. * * The bag is the one spelling every block here is read through at runtime: * `SchemaRenderer` hoists each `properties` key onto the node before the @@ -57,8 +76,11 @@ * "columns": "2" }`) passes the tolerant face unjudged, exactly as every * undeclared key of every arm does, and the strict authoring face refuses it; * one `BaseSchema` does declare (`children`, `label`, `disabled`, `visible`) is - * judged by the base's own type, and a flat `body` is refused on both faces by - * the base's objectui#6771 retirement. Whether the flat spelling is ALSO an authoring channel for + * judged by the base's own type — unless the arm declares it again, as the + * content-channel tombstones below do for `children` on every block but the + * four `page:` containers. A flat `body` is refused on both faces: by those + * tombstones, or, on `record:alert` and the four containers, by the base's + * objectui#6771 retirement. Whether the flat spelling is ALSO an authoring channel for * the `page:` / `record:` families (their renderers read the hoisted node * keys) is left open on objectui#10872 rather than decided by this module — * declaring it later is additive, and it must never extend to `element:*`, @@ -132,7 +154,7 @@ import { } from '@objectstack/spec/ui'; import { BaseSchema } from './base.zod.js'; import { stripImportedDefaults } from './imported-defaults.js'; -import { handlerKeyRefusal } from './tombstone.zod.js'; +import { handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; /** * The `properties` member of one public block: the spec row, optional, with @@ -150,12 +172,33 @@ function propsBag(type: string, row: T) { /* ── page: — structure ──────────────────────────────────────────────────── */ +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `page:header`. */ +const PAGE_HEADER_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'page:header', + 'its registration (`page:header`, `@object-ui/components`) hands the node to `PageHeaderRenderer`, an ' + + '`any`-typed renderer whose reads, attributed in its own file, are the header keys and the `properties` bag', + 'the page title bar — `title`, `subtitle`, `breadcrumb` and the `actions` toolbar', +); + /** `page:header` — `ComponentPropsMap['page:header']`. */ export const PageHeaderBlockSchema = BaseSchema.extend({ type: z.literal('page:header'), properties: propsBag('page:header', stripImportedDefaults(SpecPageHeaderProps)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(PAGE_HEADER_NEITHER_CHANNEL), + children: retirementTombstone(PAGE_HEADER_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `page:tabs`. */ +const PAGE_TABS_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'page:tabs', + 'its registration (`page:tabs`, `@object-ui/components`) hands the node to `PageTabsRenderer`, an ' + + '`any`-typed renderer that reads `items` and renders the `children` of each ITEM, never the node\'s own', + 'one tab per `items` entry, whose content is that item\'s own `children` (`properties.items[].children`, ' + + 'which stays live)', +); + /** * `page:tabs` — `ComponentPropsMap['page:tabs']`, plus the one handler key its * renderer reads off the node. @@ -166,11 +209,19 @@ export const PageHeaderBlockSchema = BaseSchema.extend({ * So it is a RUNTIME SLOT (objectui#6124): refused by name when authored, * because JSON has no function value, rather than left to `.passthrough()` to * keep an authored value and hand it to a call site. + * + * The node's own `children` and `body` are refused (objectui#9256): the + * renderer draws each tab from the `children` of that ITEM of `items`, never + * from the node's child list. The item-level `children` is the spec row's + * member and stays live. */ export const PageTabsBlockSchema = BaseSchema.extend({ type: z.literal('page:tabs'), properties: propsBag('page:tabs', stripImportedDefaults(SpecPageTabsProps)), onTabChange: handlerKeyRefusal('onTabChange', 'runtime-slot', 'Tab switch callback'), + // objectui#9256: the NODE's content channels only — each item's `children` stays live. + body: retirementTombstone(PAGE_TABS_NEITHER_CHANNEL), + children: retirementTombstone(PAGE_TABS_NEITHER_CHANNEL), }); /** `page:card` — `ComponentPropsMap['page:card']`. */ @@ -179,10 +230,26 @@ export const PageCardBlockSchema = BaseSchema.extend({ properties: propsBag('page:card', stripImportedDefaults(SpecPageCardProps)), }); -/** `page:accordion` — `ComponentPropsMap['page:accordion']`. */ +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `page:accordion`. */ +const PAGE_ACCORDION_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'page:accordion', + 'its registration (`page:accordion`, `@object-ui/components`) hands the node to `PageAccordionRenderer`, an ' + + '`any`-typed renderer that reads `items` and renders the `children` of each ITEM, never the node\'s own', + 'one collapsible panel per `items` entry, whose content is that item\'s own `children` ' + + '(`properties.items[].children`, which stays live)', +); + +/** + * `page:accordion` — `ComponentPropsMap['page:accordion']`. The node's own + * `children` and `body` are refused (objectui#9256); each item's `children` + * is the spec row's member and stays live, as on `page:tabs`. + */ export const PageAccordionBlockSchema = BaseSchema.extend({ type: z.literal('page:accordion'), properties: propsBag('page:accordion', stripImportedDefaults(SpecPageAccordionProps)), + // objectui#9256: the NODE's content channels only — each item's `children` stays live. + body: retirementTombstone(PAGE_ACCORDION_NEITHER_CHANNEL), + children: retirementTombstone(PAGE_ACCORDION_NEITHER_CHANNEL), }); /** @@ -209,36 +276,107 @@ export const PageSidebarBlockSchema = BaseSchema.extend({ /* ── record: — record-context blocks ────────────────────────────────────── */ +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `record:details`. */ +const RECORD_DETAILS_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'record:details', + 'its registration (`record:details`, `@object-ui/plugin-detail`) hands the node to `RecordDetailsRenderer`, ' + + 'which reads the record-layout keys (`sections`, `fields`, `columns`, `hideFields`, `inlineEdit`, `showHeader`)', + 'the current record\'s fields, laid out by `sections`, `fields` and `columns`', +); + /** `record:details` — `ComponentPropsMap['record:details']`. */ export const RecordDetailsBlockSchema = BaseSchema.extend({ type: z.literal('record:details'), properties: propsBag('record:details', stripImportedDefaults(SpecRecordDetailsProps)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(RECORD_DETAILS_NEITHER_CHANNEL), + children: retirementTombstone(RECORD_DETAILS_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `record:highlights`. */ +const RECORD_HIGHLIGHTS_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'record:highlights', + 'its registration (`record:highlights`, `@object-ui/plugin-detail`) hands the node to ' + + '`RecordHighlightsRenderer`, which reads `fields`', + 'a strip of the current record\'s key `fields`', +); + /** `record:highlights` — `ComponentPropsMap['record:highlights']`. */ export const RecordHighlightsBlockSchema = BaseSchema.extend({ type: z.literal('record:highlights'), properties: propsBag('record:highlights', stripImportedDefaults(SpecRecordHighlightsProps)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(RECORD_HIGHLIGHTS_NEITHER_CHANNEL), + children: retirementTombstone(RECORD_HIGHLIGHTS_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `record:related_list`. */ +const RECORD_RELATED_LIST_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'record:related_list', + 'its registration (`record:related_list`, `@object-ui/plugin-detail`) hands the node to ' + + '`RecordRelatedListRenderer`, which resolves its `dataSource` binding and reads the list keys ' + + '(`objectName`, `relationshipField`, `columns`, `filter`, `sort`, `limit`)', + 'the records of `objectName` that point back at the current record through `relationshipField`, in `columns`', +); + /** `record:related_list` — `ComponentPropsMap['record:related_list']`. */ export const RecordRelatedListBlockSchema = BaseSchema.extend({ type: z.literal('record:related_list'), properties: propsBag('record:related_list', stripImportedDefaults(SpecRecordRelatedListProps)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(RECORD_RELATED_LIST_NEITHER_CHANNEL), + children: retirementTombstone(RECORD_RELATED_LIST_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `record:path`. */ +const RECORD_PATH_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'record:path', + 'its registration (`record:path`, `@object-ui/plugin-detail`) hands the node to `RecordPathRenderer`, ' + + 'which reads `statusField` and `stages`', + 'a stage path over the current record\'s `statusField`, drawn from `stages`', +); + /** `record:path` — `ComponentPropsMap['record:path']`. */ export const RecordPathBlockSchema = BaseSchema.extend({ type: z.literal('record:path'), properties: propsBag('record:path', stripImportedDefaults(SpecRecordPathProps)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(RECORD_PATH_NEITHER_CHANNEL), + children: retirementTombstone(RECORD_PATH_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `record:activity`. */ +const RECORD_ACTIVITY_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'record:activity', + 'its registration (`record:activity`, `@object-ui/plugin-detail`) hands the node to ' + + '`RecordActivityRenderer`, which reads the feed keys by name (`types`, `filterMode`, ' + + '`limit`, `showCompleted`, `unifiedTimeline` and the comment and reaction switches)', + 'the current record\'s activity feed, filtered by `types` and `filterMode` and paged by `limit`', +); + /** `record:activity` — `ComponentPropsMap['record:activity']`. */ export const RecordActivityBlockSchema = BaseSchema.extend({ type: z.literal('record:activity'), properties: propsBag('record:activity', stripImportedDefaults(SpecRecordActivityProps)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(RECORD_ACTIVITY_NEITHER_CHANNEL), + children: retirementTombstone(RECORD_ACTIVITY_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `record:discussion`. */ +const RECORD_DISCUSSION_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'record:discussion', + 'its registration (`record:discussion`, `@object-ui/plugin-detail`) hands the node to ' + + '`RecordChatterRenderer` (shared with `record:chatter`), which copies the node into its panel config; the ' + + 'config is read for `position`, `width`, `collapsible`, `defaultCollapsed` and `feed` only', + 'the current record\'s discussion feed, placed by `position` and configured by `feed`', +); + /** * `record:discussion` — `ComponentPropsMap['record:discussion']`, which the * spec binds to the SAME row object as `record:chatter` (one renderer, one @@ -247,38 +385,117 @@ export const RecordActivityBlockSchema = BaseSchema.extend({ export const RecordDiscussionBlockSchema = BaseSchema.extend({ type: z.literal('record:discussion'), properties: propsBag('record:discussion', stripImportedDefaults(SpecRecordChatterProps)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(RECORD_DISCUSSION_NEITHER_CHANNEL), + children: retirementTombstone(RECORD_DISCUSSION_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `record:history`. */ +const RECORD_HISTORY_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'record:history', + 'its registration (`record:history`, `@object-ui/plugin-detail`) hands the node to ' + + '`RecordHistoryRenderer`, which reads `limit`, `emptyText` and `unknownUserText`', + 'the current record\'s field-change history, with `emptyText` when it has none', +); + /** `record:history` — `ComponentPropsMap['record:history']`. */ export const RecordHistoryBlockSchema = BaseSchema.extend({ type: z.literal('record:history'), properties: propsBag('record:history', stripImportedDefaults(SpecRecordHistoryProps)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(RECORD_HISTORY_NEITHER_CHANNEL), + children: retirementTombstone(RECORD_HISTORY_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `record:quick_actions`. */ +const RECORD_QUICK_ACTIONS_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'record:quick_actions', + 'its registration (`record:quick_actions`, `@object-ui/plugin-detail`) hands the node to ' + + '`RecordQuickActionsRenderer`, which reads the toolbar keys (`actionNames`, `location`, `align`, ' + + '`inline`, `variant`, `size`)', + 'a toolbar of the current record\'s actions, chosen by `actionNames` or by `location`', +); + /** `record:quick_actions` — `ComponentPropsMap['record:quick_actions']`. */ export const RecordQuickActionsBlockSchema = BaseSchema.extend({ type: z.literal('record:quick_actions'), properties: propsBag('record:quick_actions', stripImportedDefaults(SpecRecordQuickActionsProps)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(RECORD_QUICK_ACTIONS_NEITHER_CHANNEL), + children: retirementTombstone(RECORD_QUICK_ACTIONS_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `record:reference_rail`. */ +const RECORD_REFERENCE_RAIL_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'record:reference_rail', + 'its registration (`record:reference_rail`, `@object-ui/plugin-detail`) hands the node to ' + + '`RecordReferenceRailRenderer`, which reads `entries` and `hideEmpty`', + 'a rail of related-record lists, one per `entries` item', +); + /** `record:reference_rail` — `ComponentPropsMap['record:reference_rail']`. */ export const RecordReferenceRailBlockSchema = BaseSchema.extend({ type: z.literal('record:reference_rail'), properties: propsBag('record:reference_rail', stripImportedDefaults(SpecRecordReferenceRailProps)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(RECORD_REFERENCE_RAIL_NEITHER_CHANNEL), + children: retirementTombstone(RECORD_REFERENCE_RAIL_NEITHER_CHANNEL), }); -/** `record:alert` — `ComponentPropsMap['record:alert']`. */ +/** + * objectui#9256 (public-block slice): the `children` refusal of `record:alert` — its OWN string, because + * `neitherContentChannelGuidance` says no read consumes `body` or `children`, and this renderer does read + * a key named `body`: its message TEXT. That key is not a child list and is not refused here. + */ +const RECORD_ALERT_NO_CHILD_LIST = + 'REFUSED (objectui#9256, ADR-0049) — `record:alert` renders no child list: measured with the TypeScript ' + + 'type checker over one program per workspace package on a BUILT tree, its registration (`record:alert`, ' + + '`@object-ui/plugin-detail`) hands the node to `RecordAlertRenderer`, which merges the node\'s own keys ' + + 'with `properties` (`readProps`) and reads `severity`, `title`, `body`, `icon`, `action`, `dismissible`, ' + + '`dismissKey` and `visible` from the result — never `children` — and `SchemaRenderer` strips `children` ' + + 'out of the props bag it spreads. An authored child list therefore rendered NOTHING — no render-time ' + + 'error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) ' + + 'noticed it. What it renders instead: one callout of `severity`, with its `title` and its `body` message ' + + 'TEXT, both set in `properties`.'; + +/** + * `record:alert` — `ComponentPropsMap['record:alert']`. + * + * Only `children` is declared here (objectui#9256). `body` is this block's + * message TEXT — a row member, read by the renderer — not a content channel, so + * it gets no neither-channel tombstone; a flat `body` is still refused by + * `BaseSchema`'s objectui#6771 retirement, whose remedy text is objectui#10872's + * to correct. + */ export const RecordAlertBlockSchema = BaseSchema.extend({ type: z.literal('record:alert'), properties: propsBag('record:alert', stripImportedDefaults(SpecRecordAlertProps)), + // objectui#9256: `children` only — see the docblock for why `body` is not restated. + children: retirementTombstone(RECORD_ALERT_NO_CHILD_LIST), }); /* ── element: — content blocks ──────────────────────────────────────────── */ +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `element:text`. */ +const ELEMENT_TEXT_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'element:text', + 'its registration (`element:text`, `@object-ui/components`) hands the node to `ElementTextRenderer`, an ' + + '`any`-typed renderer that reads the props bag (`readProps`) and `className` and nothing else off the node', + '`properties.content` as a heading, subheading or paragraph chosen by `variant`, aligned by `align`', +); + /** `element:text` — `ComponentPropsMap['element:text']`. */ export const ElementTextBlockSchema = BaseSchema.extend({ type: z.literal('element:text'), properties: propsBag('element:text', stripImportedDefaults(SpecElementTextPropsSchema)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(ELEMENT_TEXT_NEITHER_CHANNEL), + children: retirementTombstone(ELEMENT_TEXT_NEITHER_CHANNEL), }); /** @@ -351,6 +568,15 @@ function elementNumberObjectIsSupplied( }); } +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `element:number`. */ +const ELEMENT_NUMBER_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'element:number', + 'its registration (`element:number`, `@object-ui/components`) hands the node to `ElementNumberRenderer`, an ' + + '`any`-typed renderer that reads the props bag (`readProps`), the node\'s `dataSource` binding and `className`', + 'one aggregate — `aggregate` of `field` over `object` (or `dataSource.object`), formatted by `format` with ' + + '`prefix` and `suffix`', +); + /** * `element:number` — `ComponentPropsMap['element:number']`, with the spec's one * `dataSource` waiver on its required `object` (see the module docblock and the @@ -372,14 +598,38 @@ export const ElementNumberBlockSchema = BaseSchema.extend({ 'Per-element data binding — `@objectstack/spec` `ElementDataSourceSchema`, the schema ' + '`PageComponentSchema.dataSource` declares, by reference.', ), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(ELEMENT_NUMBER_NEITHER_CHANNEL), + children: retirementTombstone(ELEMENT_NUMBER_NEITHER_CHANNEL), }).superRefine(elementNumberObjectIsSupplied, { when: () => true }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `element:button`. */ +const ELEMENT_BUTTON_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'element:button', + 'its registration (`element:button`, `@object-ui/components`) hands the node to `ElementButtonRenderer`, an ' + + '`any`-typed renderer that reads the props bag (`readProps`) and `className` and nothing else off the node', + 'a button labelled `properties.label` that runs `properties.action`', +); + /** `element:button` — `ComponentPropsMap['element:button']`. */ export const ElementButtonBlockSchema = BaseSchema.extend({ type: z.literal('element:button'), properties: propsBag('element:button', stripImportedDefaults(SpecElementButtonPropsSchema)), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(ELEMENT_BUTTON_NEITHER_CHANNEL), + children: retirementTombstone(ELEMENT_BUTTON_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `element:divider`. */ +const ELEMENT_DIVIDER_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'element:divider', + 'its registration (`element:divider`, `@object-ui/components`) hands the node to `ElementDividerRenderer`, an ' + + '`any`-typed renderer that reads `className` and nothing else', + 'a horizontal separator, styled by `className`', +); + /** * `element:divider` — the ONE row restated rather than read by reference. * @@ -401,6 +651,10 @@ export const ElementDividerBlockSchema = BaseSchema.extend({ 'The `element:divider` props bag — `@objectstack/spec` `ComponentPropsMap[\'element:divider\']` declares ' + 'no prop, so the only bag it accepts is `{}`. The divider takes its styling from `className`.', ), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(ELEMENT_DIVIDER_NEITHER_CHANNEL), + children: retirementTombstone(ELEMENT_DIVIDER_NEITHER_CHANNEL), }); /**