From 51373bfb86afb26746929845b9cafd9e651d0137 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 17:03:26 +0000 Subject: [PATCH 1/2] fix(types,docs): refuse `actions` by name on the `page` node, teach the shape that draws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PageNodeSchema` never declared `actions` and `PageRenderer` never read it, but `BaseSchema` is `.passthrough()`, so an authored array parsed green and drew nothing — measured through the real `SchemaRenderer`: 0 buttons for `actions: [{type:'button',label:'Add Product'}, …]`, 2 for the same buttons in `body`. Three passages of `content/docs/guide/layout.md` taught that document. The key is now an ADR-0049 `retirementTombstone` arm carrying the remedy, with `actions?: never` as its TypeScript twin. One key by name, NOT `.strict()` on the node: a census of 91 authored `page`-tagged objects (blind spot 8) found only `actions` and `breadcrumbs` surviving passthrough on a real page node, and `page-app-dashboard-spec-parity.test.ts` pins the node staying open to unknown renderer props. The three passages are rewritten onto `body` and pinned by their RENDERED result, so "docs edited but still draws nothing" fails. `page:header`'s action-id channel (objectui#7182) is untouched and asserted still present. Ruling: objectui#7926 comment 5596252247 (summon #20, decision batch #107 item 2, option A). B and C refused. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH --- .changeset/7926-page-node-refuses-actions.md | 57 +++++ content/docs/guide/layout.md | 120 ++++++--- .../guide-layout-page-buttons-7926.test.tsx | 192 +++++++++++++++ .../page-dom-leak-whitelist-7933.test.tsx | 20 +- .../page-actions-refusal-7926.test.ts | 233 ++++++++++++++++++ packages/types/src/layout.ts | 22 ++ packages/types/src/zod/layout.zod.ts | 59 +++++ 7 files changed, 656 insertions(+), 47 deletions(-) create mode 100644 .changeset/7926-page-node-refuses-actions.md create mode 100644 packages/components/src/__tests__/guide-layout-page-buttons-7926.test.tsx create mode 100644 packages/types/src/__tests__/page-actions-refusal-7926.test.ts diff --git a/.changeset/7926-page-node-refuses-actions.md b/.changeset/7926-page-node-refuses-actions.md new file mode 100644 index 0000000000..2d31ba8154 --- /dev/null +++ b/.changeset/7926-page-node-refuses-actions.md @@ -0,0 +1,57 @@ +--- +'@object-ui/types': patch +--- + +Refuse `actions` by name on the `page` node (objectui#7926, maintainer ruling +2026-09-09, decision batch #107 item 2 — option A). + +**Accept-set change, deliberately.** A `page` document carrying `actions` used to +parse GREEN and render nothing. `PageNodeSchema` never declared the key and +`PageRenderer` never read it — `git grep -ni action` on +`packages/components/src/renderers/layout/page.tsx` returns only the +`PageVariableActionBridge` import and its render — so the array survived purely +through `BaseSchema`'s `.passthrough()`. Measured through the real +`SchemaRenderer`: a `page` node with `actions: [{type:'button',label:'Add +Product'}, …]` drew **0** buttons and the label appeared nowhere in the DOM, +while the SAME two buttons in `body` drew **2**. Until objectui#7933 the array +also reached the wrapper element as `actions="[object Object],[object Object]"`. + +`PageNodeSchema` now declares `actions` as an ADR-0049 refusal arm, so the same +document fails at parse with the remedy in the message. The TypeScript twin is +`actions?: never`, so `tsc` refuses it at the authoring site before anything runs. + +**Why a refusal and not a reader.** This was the third surface carrying an +`actions` array no reader consumes (objectui#7469 — the app node; objectui#7693 — +the alert-dialog fixtures), and the authorable action FORM was already ruled on +2026-08-25 for objectui#6497 / #6182: the declarative action object. Growing a +reader here would have minted a fourth `actions` shape. + +**Migration.** Put the buttons in `body` as nodes — a `button`, or an +`action:button` with a declared `actionType`: + +```json +{ + "type": "page", + "title": "Products", + "body": [ + { "type": "flex", "justify": "end", "gap": 2, "children": [ + { "type": "button", "label": "Add Product", "variant": "default" } + ] } + ] +} +``` + +On a record page the second door is the `page:header` block, whose own `actions` +are **action ids** resolved from the object's metadata (objectui#7182), not nodes +— that channel is unchanged. + +**Scope.** One key, by name; the node is NOT strict. A census over this tree read +91 authored `page`-tagged objects with a blind-spot reading of 8 unreadable sites, +and found only `actions` (3 sites, all in `content/docs/guide/layout.md`) and +`breadcrumbs` (1 site, its own question, untouched) surviving passthrough on a +real `page` node — every other undeclared key belongs to a different declaration +that merely spells `type: 'page'`. `PageNodeSchema` still passes unknown renderer +props through. + +The three teaching passages in `content/docs/guide/layout.md` are rewritten onto +the shape that draws, and pinned by their rendered result rather than their text. diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index 12b73b1300..963ce79377 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -145,28 +145,37 @@ The `Page` component provides a consistent wrapper for individual pages with opt ### With Action Buttons +A `page` node has no action row of its own. Buttons are NODES, and they go in `body`: + ```json { "type": "page", "title": "Products", - "actions": [ + "body": [ { - "type": "button", - "label": "Add Product", - "variant": "default", - "icon": "plus" + "type": "flex", + "justify": "end", + "gap": 2, + "children": [ + { + "type": "button", + "label": "Add Product", + "variant": "default", + "icon": "plus" + }, + { + "type": "button", + "label": "Export", + "variant": "outline", + "icon": "download" + } + ] }, { - "type": "button", - "label": "Export", - "variant": "outline", - "icon": "download" + "type": "object-grid", + "object": "products" } - ], - "body": { - "type": "object-grid", - "object": "products" - } + ] } ``` @@ -174,6 +183,16 @@ The `Page` component provides a consistent wrapper for individual pages with opt `BaseSchema` is `.passthrough()` nothing refuses it: the validator keeps the unknown key and `button.tsx`, which reads `schema.label`, renders a button with no text. +> **⛔ `actions` on a `page` node is refused by name** (objectui#7926). This page used to +> teach `"actions": [ … ]` as a sibling of `title`, and it drew **nothing**: `PageRenderer` +> has never had a read point for the key, and `BaseSchema`'s `.passthrough()` kept the array +> rather than refusing it — so the author got a green validation and an empty page (before +> objectui#7933 it also reached the DOM as `actions="[object Object]"`). `PageNodeSchema` +> now declares the key as a refusal, so the same document fails with the remedy in the +> message instead of rendering silently short. Buttons in `body`, as above; on a record page, +> the `page:header` block's own `actions` — which are **action ids**, not nodes +> (see the [PageHeader reference](/docs/layout/page-header)). + ### Schema API @@ -189,8 +208,8 @@ and `button.tsx`, which reads `schema.label`, renders a button with no text. label: string, href?: string }>, - actions?: SchemaNode[], // Action buttons - + // NO `actions` — refused by name (objectui#7926); put the buttons in `body` + // Content body: SchemaNode, // Main page content @@ -509,6 +528,8 @@ Omit `sidebar` and the content fills the width under the top bar. ### Detail Page with Actions +Same rule as above: the buttons are nodes in `body`, not an `actions` key on the page. + ```json { "type": "page", @@ -518,30 +539,37 @@ Omit `sidebar` and the content fills the width under the top bar. { "label": "Customers", "href": "/customers" }, { "label": "Acme Corporation" } ], - "actions": [ + "body": [ { - "type": "action:button", - "name": "edit_record", - "label": "Edit", - "variant": "default", - "icon": "pencil", - "actionType": "editRecord" + "type": "flex", + "justify": "end", + "gap": 2, + "children": [ + { + "type": "action:button", + "name": "edit_record", + "label": "Edit", + "variant": "default", + "icon": "pencil", + "actionType": "editRecord" + }, + { + "type": "action:button", + "name": "delete_record", + "label": "Delete", + "variant": "destructive", + "icon": "trash", + "actionType": "deleteRecord" + } + ] }, { - "type": "action:button", - "name": "delete_record", - "label": "Delete", - "variant": "destructive", - "icon": "trash", - "actionType": "deleteRecord" + "type": "card", + "children": [ + { "type": "text", "content": "Record details..." } + ] } - ], - "body": { - "type": "card", - "children": [ - { "type": "text", "content": "Record details..." } - ] - } + ] } ``` @@ -660,20 +688,32 @@ Add breadcrumbs to help users navigate: } ``` -### 3. Action Buttons in Headers +### 3. Action Buttons at the Top of the Body -Place primary actions in page headers: +Place primary actions in the first `body` node, so they sit above the content: ```json { "type": "page", "title": "Orders", - "actions": [ - { "type": "button", "label": "New Order", "variant": "default" } + "body": [ + { + "type": "flex", + "justify": "end", + "gap": 2, + "children": [ + { "type": "button", "label": "New Order", "variant": "default" } + ] + } ] } ``` +⛔ Not `"actions"` on the `page` node — that key has no reader and is refused by name +(objectui#7926). A record page has a second door: the `page:header` block, whose `actions` +are **action ids** resolved from the object's own actions metadata, not nodes +(see the [PageHeader reference](/docs/layout/page-header)). + ### 4. Max Width for Forms Use constrained width for forms and reading content: diff --git a/packages/components/src/__tests__/guide-layout-page-buttons-7926.test.tsx b/packages/components/src/__tests__/guide-layout-page-buttons-7926.test.tsx new file mode 100644 index 0000000000..8ff15ac7e3 --- /dev/null +++ b/packages/components/src/__tests__/guide-layout-page-buttons-7926.test.tsx @@ -0,0 +1,192 @@ +/** + * 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. + */ + +/** + * The layout guide's action passages DRAW BUTTONS (objectui#7926, maintainer + * ruling 2026-09-09, decision batch #107 item 2 — option A). + * + * ## Why this file exists beside the schema pin + * + * The ruling names two pins, and they are not interchangeable. The contract half + * — "`actions` on a `page` node is refused at parse" — lives in + * `packages/types/src/__tests__/page-actions-refusal-7926.test.ts`. It is + * satisfied by DELETING the key from the guide, which is exactly the outcome the + * ruling calls out as a failure: docs edited, still nothing drawn. + * + * So this file asserts the RENDERED RESULT, through the real `SchemaRenderer` and + * the real renderers, on the fences as they are committed. It is the load-bearing + * half. + * + * ## The measurement this replaces + * + * objectui#7926 measured the old shape end to end: + * + * page node with actions: [{type:'button',label:'Add Product'}, {…}] + * -> buttons found in the DOM: 0 + * -> "Add Product" appears anywhere in the DOM: false + * the SAME two buttons moved into page.body + * -> buttons found in the DOM: 2 texts: ["Add Product","Export"] + * + * `renders nothing when authored as page.actions` below is that first reading + * kept as a LIVE CONTROL. Without it, "at least one button" would be satisfied by + * a renderer that draws a button for any input at all, and the assertion could + * not fail for the reason it exists. + * + * ## The passages are DERIVED, not listed + * + * A hand-maintained list of line numbers or headings is the artefact that rots. + * The sections are found by their heading text matching /action/i, and the fences + * inside them by parsing; the count is pinned so a passage that quietly loses its + * fence reddens instead of shrinking the population to zero and passing. + * + * ⚠️ `content/docs/**` is EXCLUDED from `ci.yml`'s full-run decision on + * `pull_request` (`ci.yml`'s "Decide whether this change needs a full run" step), + * so a green PR page is NOT evidence that this file ran on a docs-only edit. It is + * placed in `packages/components` — a package a `page`/`button` change does move — + * on purpose. + * + * Module-scope import of the renderers, not `beforeAll` (AGENTS.md §测试纪律): + * registering them is an unbounded module load and must not be billed to a + * bounded hook timeout. + */ +import { describe, it, expect } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import '../renderers'; +import { SchemaRenderer } from '@object-ui/react'; + +const GUIDE_PATH = resolve(__dirname, '../../../../content/docs/guide/layout.md'); + +interface Passage { + heading: string; + line: number; + doc: Record; +} + +/** + * Every `### `-delimited section whose heading mentions an action, paired with the + * `type: "page"` document its first parseable JSON fence carries. + */ +function actionPassages(): Passage[] { + const src = readFileSync(GUIDE_PATH, 'utf8'); + const lines = src.split('\n'); + const out: Passage[] = []; + let heading: string | null = null; + let headingLine = 0; + for (let i = 0; i < lines.length; i++) { + const h = /^### (.+)$/.exec(lines[i]); + if (h) { + heading = h[1]; + headingLine = i + 1; + continue; + } + if (!heading || !/action/i.test(heading)) continue; + if (lines[i] !== '```json') continue; + const end = lines.indexOf('```', i + 1); + if (end === -1) continue; + let doc: unknown; + try { + doc = JSON.parse(lines.slice(i + 1, end).join('\n')); + } catch { + i = end; + continue; + } + if (doc && typeof doc === 'object' && (doc as Record).type === 'page') { + out.push({ heading, line: headingLine, doc: doc as Record }); + } + i = end; + } + return out; +} + +/** Buttons the real renderers put in the DOM for this document. */ +function buttonsDrawnBy(doc: unknown): string[] { + const { container } = render(); + const texts = Array.from(container.querySelectorAll('button')).map( + (b) => b.textContent?.trim() ?? '', + ); + cleanup(); + return texts; +} + +const PASSAGES = actionPassages(); + +describe('objectui#7926 — the guide’s action passages draw buttons (render half)', () => { + it('the population is the three passages the ruling names', () => { + // Derived, but PINNED — a passage that loses its fence would otherwise shrink + // this to an empty list and every assertion below would pass vacuously. + expect(PASSAGES.map((p) => p.heading)).toEqual([ + 'With Action Buttons', + 'Detail Page with Actions', + '3. Action Buttons at the Top of the Body', + ]); + }); + + it.each(PASSAGES.map((p) => [p.heading, p.doc] as const))( + 'renders at least one button: %s', + (_heading, doc) => { + const texts = buttonsDrawnBy(doc); + expect(texts.length).toBeGreaterThanOrEqual(1); + expect(texts.every((t) => t.length > 0)).toBe(true); + }, + ); + + it('the button LABELS the passages teach reach the DOM', () => { + // "at least one button" alone would be satisfied by chrome the renderer draws + // for itself. These are the words the author copied out of the page. + const all = PASSAGES.flatMap((p) => buttonsDrawnBy(p.doc)); + for (const label of ['Add Product', 'Export', 'Edit', 'Delete', 'New Order']) { + expect({ label, drawn: all.some((t) => t.includes(label)) }).toEqual({ + label, + drawn: true, + }); + } + }); + + it('LIVE CONTROL — the SAME buttons authored as `page.actions` draw nothing', () => { + // The reading objectui#7926 was filed on. If this ever draws a button, the + // node grew a reader (option B, refused) and every assertion above stopped + // measuring what it claims to. + const retired = { + type: 'page', + title: 'Products', + actions: [ + { type: 'button', label: 'Add Product', variant: 'default', icon: 'plus' }, + { type: 'button', label: 'Export', variant: 'outline', icon: 'download' }, + ], + }; + const texts = buttonsDrawnBy(retired); + expect(texts).toEqual([]); + const { container } = render(); + expect(container.textContent).not.toContain('Add Product'); + cleanup(); + }); + + it('LIVE CONTROL — the same two buttons moved into `body` draw both', () => { + // The other half of the original measurement: the remedy the refusal message + // names is the one that works, so "0 buttons" above is about the KEY and not + // about this test being unable to draw anything. + const texts = buttonsDrawnBy({ + type: 'page', + title: 'Products', + body: [ + { + type: 'flex', + justify: 'end', + gap: 2, + children: [ + { type: 'button', label: 'Add Product', variant: 'default', icon: 'plus' }, + { type: 'button', label: 'Export', variant: 'outline', icon: 'download' }, + ], + }, + ], + }); + expect(texts.filter((t) => /Add Product|Export/.test(t))).toHaveLength(2); + }); +}); diff --git a/packages/components/src/__tests__/page-dom-leak-whitelist-7933.test.tsx b/packages/components/src/__tests__/page-dom-leak-whitelist-7933.test.tsx index d5a5507d2c..b0cff852b0 100644 --- a/packages/components/src/__tests__/page-dom-leak-whitelist-7933.test.tsx +++ b/packages/components/src/__tests__/page-dom-leak-whitelist-7933.test.tsx @@ -25,13 +25,19 @@ * data-obj-type="page" * actions="[object Object],[object Object]" <- the defect * - * `actions` is declared nowhere on `PageNodeSchema` (it survives parse only - * through `BaseSchema`'s `.passthrough()`) and `PageRenderer` has zero read - * points for it, so it was neither read nor dropped. Whether `page` should - * ever GROW an `actions` read point is a separate, open capability question - * (objectui#7926); this pin is orthogonal to it, because an authored key must - * end up either read or dropped under EITHER answer, and never as an illegal - * HTML attribute. + * When this pin was written, `actions` was declared nowhere on `PageNodeSchema` + * (it survived parse only through `BaseSchema`'s `.passthrough()`) and + * `PageRenderer` had zero read points for it, so it was neither read nor + * dropped. That capability question has since been ANSWERED: objectui#7926 was + * ruled 2026-09-09 (decision batch #107 item 2, option A) — `page` grows NO + * `actions` reader, and `PageNodeSchema` now REFUSES the key by name + * (`packages/types/src/__tests__/page-actions-refusal-7926.test.ts`). + * + * ⭐ This pin is unchanged by that, and deliberately so: it renders rather than + * parses, so it still measures the DOM outcome for a document the validator now + * rejects — which is the case that matters, because a host can hand + * `SchemaRenderer` a node that never went through `safeParse`. An authored key + * must end up either read or dropped, and never as an illegal HTML attribute. * * ## Why this is a whitelist and not a longer list * diff --git a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts new file mode 100644 index 0000000000..add0333e2b --- /dev/null +++ b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts @@ -0,0 +1,233 @@ +/** + * 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. + */ + +/** + * `actions` on a `page` NODE is refused at parse, and `content/docs/guide/layout.md` + * no longer teaches it (objectui#7926, maintainer ruling 2026-09-09, decision batch + * #107 item 2 — option A; B "declare + render" and C "docs only" were both refused). + * + * ## What was wrong + * + * `PageNodeSchema` never declared `actions` and `PageRenderer` never read it, but + * `BaseSchema` is `.passthrough()`, so an authored array parsed GREEN and drew + * NOTHING — and, until objectui#7933 replaced the renderer's hand-maintained + * destructure with `toDomProps`, it reached the wrapper element as + * `actions="[object Object],[object Object]"`. Three passages of the layout guide + * taught exactly that document. + * + * ## The two halves, and which file owns which + * + * This file owns the CONTRACT half: the key is refused by name, the refusal is + * TARGETED (the node stays open), and no fence on the guide authors it any more. + * The RENDER half — "the rewritten passages each draw at least one button" — is + * `packages/components/src/__tests__/guide-layout-page-buttons-7926.test.tsx`, + * because it needs the real registry. The ruling names both, and the render one is + * the load-bearing half: a docs edit that still draws nothing passes this file. + * + * ## ⛔ Why NOT `.strict()` on the node + * + * The ruling required a CENSUS before the refusal, so a strict node could not take + * a living key with it. Measured over this tree at `8fda00905`: 91 authored + * `page`-tagged objects read, 8 sites unreadable (7 elided doc fences, 1 literal + * with a spread member) — a blind-spot reading, because a zero without one is not + * a measured zero. On a real `page` NODE only two undeclared keys survive + * passthrough: `actions` (3 sites, all of them the guide passages this card + * rewrites) and `breadcrumbs` (1 site, no reader either — its own question, NOT + * ruled on here). Every other undeclared key the same grep found belongs to a + * DIFFERENT declaration that merely spells `type: 'page'`: nav items, spec `page` + * list views, `registerMetadataResource` rows. None of them is parsed by this + * schema — and `page-app-dashboard-spec-parity.test.ts` PINS the node staying open + * to unknown renderer props, so `.strict()` would have reddened a living pin. + * `the refusal is targeted, not a strict node` below is that census as an + * assertion. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { PageNodeSchema } from '../zod/layout.zod.js'; +import type { PageNodeSchema as TsPageNodeSchema } from '../layout.js'; + +const GUIDE_PATH = resolve(__dirname, '../../../../content/docs/guide/layout.md'); + +/** The document the guide used to teach, verbatim in shape. */ +const RETIRED_DOC = { + type: 'page', + title: 'Products', + actions: [ + { type: 'button', label: 'Add Product', variant: 'default', icon: 'plus' }, + { type: 'button', label: 'Export', variant: 'outline', icon: 'download' }, + ], +}; + +describe('objectui#7926 — the `page` node refuses `actions` (contract half)', () => { + it('the key is DECLARED, which is what makes the refusal loud rather than a strip', () => { + // The whole defect was that it was NOT in the shape: an undeclared key on a + // `.passthrough()` object is kept in silence. A refusal has to be declared. + expect(Object.keys(PageNodeSchema.shape)).toContain('actions'); + }); + + it('refuses the retired document at parse, at the `actions` path', () => { + const r = PageNodeSchema.safeParse(RETIRED_DOC); + expect(r.success).toBe(false); + const issues = r.success ? [] : r.error.issues; + expect(issues.map((i) => i.path.join('.'))).toContain('actions'); + }); + + it('the refusal message carries the remedy, not just a type name', () => { + const r = PageNodeSchema.safeParse(RETIRED_DOC); + const issue = (r.success ? [] : r.error.issues).find((i) => i.path.join('.') === 'actions'); + expect(issue).toBeDefined(); + const message = issue!.message; + // Named subject + the two doors an author actually has. NOT the whole + // sentence: pinning prose byte-for-byte turns every wording fix red for no + // gain (AGENTS.md — assert the named subject, not the copy). + expect(message).toContain('actions'); + expect(message).toContain('body'); + expect(message).toContain('page:header'); + // Zod's own default for a `never` arm says none of this. + expect(message).not.toBe('Invalid input: expected never, received array'); + }); + + it('POSITIVE CONTROL — the same document without `actions` parses green', () => { + // Without this leg, a schema that refused EVERY page document would pass the + // assertions above. + const { actions, ...withoutActions } = RETIRED_DOC; + expect(actions).toBeDefined(); + expect(PageNodeSchema.safeParse(withoutActions).success).toBe(true); + }); + + it('the remedy the message names actually parses — buttons as nodes in `body`', () => { + const r = PageNodeSchema.safeParse({ + type: 'page', + title: 'Products', + body: [ + { + type: 'flex', + justify: 'end', + gap: 2, + children: [ + { type: 'button', label: 'Add Product', variant: 'default', icon: 'plus' }, + { type: 'button', label: 'Export', variant: 'outline', icon: 'download' }, + ], + }, + ], + }); + expect(r.success).toBe(true); + }); + + it('the refusal is TARGETED, not a strict node — the census leg', () => { + // `page-app-dashboard-spec-parity.test.ts` pins this same fact from the other + // side ("the component envelope still passes unknown renderer props + // through"). It is restated here because THIS card is the one that would + // break it: the cheap way to refuse `actions` is `.strict()`, and the census + // is the reason that is the wrong shape. + expect(PageNodeSchema.safeParse({ type: 'page', someRendererProp: 42 }).success).toBe(true); + // `breadcrumbs` is the OTHER undeclared key the census found on a real page + // node. It has no reader either, and objectui#7926 does NOT rule on it — so + // it must still parse. If a later card retires it, this line is the one that + // says so out loud instead of the change happening by accident here. + expect( + PageNodeSchema.safeParse({ + type: 'page', + breadcrumbs: [{ label: 'Home', href: '/' }], + }).success, + ).toBe(true); + }); + + it('the TypeScript twin refuses it too', () => { + // `?: never` — the pair `zod-mirror-parity.test.ts` compares. The `@ts-expect-error` + // IS the assertion: it fails to compile (packages/types `type-check`) if the + // key ever becomes assignable again. + const page: TsPageNodeSchema = { + type: 'page', + title: 'Products', + // @ts-expect-error `actions` is refused by name on the page node (objectui#7926) + actions: [{ type: 'button', label: 'Add Product' }], + }; + expect(page.type).toBe('page'); + }); +}); + +describe('objectui#7926 — the guide no longer authors `actions` on a `page` node', () => { + /** Every ```json fence on the page, as {startLine, parsed|null}. */ + const fences = (() => { + const src = readFileSync(GUIDE_PATH, 'utf8'); + const out: Array<{ line: number; doc: unknown; body: string }> = []; + const re = /```json\n([\s\S]*?)```/g; + let m: RegExpExecArray | null; + while ((m = re.exec(src))) { + const line = src.slice(0, m.index).split('\n').length; + let doc: unknown; + try { + doc = JSON.parse(m[1]); + } catch { + doc = null; // elided fragment (`[...]`), counted as blind below + } + out.push({ line, doc, body: m[1] }); + } + return out; + })(); + + const isPageNode = (d: unknown): d is Record => + !!d && typeof d === 'object' && (d as Record).type === 'page'; + + it('LIT CONTROL — the scan can see JSON fences, page nodes, and an `actions` key', () => { + // Three separate ways this scan could report a vacuous zero, so three + // controls. The last one is the important one: `page:header`'s `actions` is + // the READ action-id channel (objectui#7182) and it must STILL be here — a + // refusal that took it with it would be the collateral damage the census + // exists to prevent. + expect(fences.length).toBeGreaterThan(5); + expect(fences.filter((f) => isPageNode(f.doc)).length).toBeGreaterThan(3); + expect( + fences.some( + (f) => + !!f.doc && + typeof f.doc === 'object' && + (f.doc as Record).type === 'page-header' && + Array.isArray((f.doc as Record).actions), + ), + ).toBe(true); + }); + + it('no `page` node on the page carries `actions`', () => { + const offenders = fences + .filter((f) => isPageNode(f.doc) && 'actions' in (f.doc as Record)) + .map((f) => `${GUIDE_PATH}:${f.line}`); + expect(offenders).toEqual([]); + }); + + it('BLIND SPOT — the fences this scan could not parse are counted, not ignored', () => { + // A zero with no blind-spot reading is not a measured zero (objectui#7933's + // requirement, carried into this card by its dispatch). The COUNT is pinned, + // not the line numbers — a line list would redden on every unrelated edit + // above it, and a permanently red pin is one nobody reads. + const blind = fences.filter((f) => f.doc === null); + expect(blind).toHaveLength(4); + // …and each is blind for the DECLARED reason, an author's `[...]` elision — + // never because a real document stopped parsing. That is the half that makes + // the count above mean something. + for (const f of blind) { + expect({ line: f.line, elided: f.body.includes('...') }).toEqual({ + line: f.line, + elided: true, + }); + } + }); + + it('every `page` node the guide teaches declares its content under `body`', () => { + // The positive form of the same fact: the passages were not merely stripped + // of `actions`, they were REWRITTEN onto the key that renders. + const pages = fences.filter((f) => isPageNode(f.doc)); + for (const f of pages) { + const doc = f.doc as Record; + expect({ line: f.line, hasBody: 'body' in doc }).toEqual({ line: f.line, hasBody: true }); + } + }); +}); diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index c069db8259..f455dcb486 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -746,6 +746,28 @@ export interface PageNodeRegion { */ export interface PageNodeSchema extends BaseSchema { type: 'page'; + /** + * ⛔ REFUSED BY NAME — `actions` is not a member of this node and never was + * (objectui#7926, maintainer ruling 2026-09-09, decision batch #107 item 2). + * + * `PageRenderer` has no read point for it: a `page` node carrying + * `actions: [{type:'button',label:'Add Product'}, …]` drew 0 buttons through + * the real `SchemaRenderer`, while the SAME two buttons in {@link body} drew + * 2. `BaseSchema` is `.passthrough()`, so the array was not dropped — it was + * kept, and before objectui#7933 it reached the DOM as + * `actions="[object Object],[object Object]"`. + * + * The remedy is a NODE, not a key: put a `button` (or an `action:button` with + * a declared `actionType`) in {@link body}; on a record page declare them on a + * `page:header` block, whose own `actions` are ACTION IDS resolved from the + * object's metadata (objectui#7182) rather than nodes. + * + * `?: never` is the twin of `layout.zod.ts`'s `retirementTombstone` arm — the + * pair is what `__tests__/zod-mirror-parity.test.ts` compares, and it is what + * makes `tsc` refuse the key at the authoring site before anything runs. + * ⛔ Do not "restore" it as a reader: that is option B, and it was refused. + */ + actions?: never; /** * Page title */ diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index ab0fd93dc4..77794aaa9b 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -438,6 +438,64 @@ const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).sh 'regions', ] as const); +/** + * The `actions` REFUSAL on the `page` node (objectui#7926, maintainer ruling + * 2026-09-09, decision batch #107 item 2 — option A). + * + * ## What was measured + * + * `PageNodeSchema` never declared `actions`, and `PageRenderer` never read it: + * `git grep -ni action packages/components/src/renderers/layout/page.tsx` + * returns only the `PageVariableActionBridge` import and its render, with + * `schema.title` / `schema.pageType` (3 hits in the same file) as the lit + * control. Rendered through the real `SchemaRenderer`, a `page` node carrying + * `actions: [{type:'button',label:'Add Product'}, …]` drew **0** buttons and + * the label appeared nowhere in the DOM; the SAME two buttons moved into + * `body` drew **2**. + * + * `BaseSchema` is `.passthrough()`, so the array was not refused — it was KEPT, + * and until objectui#7933 it was spread onto the wrapper element as + * `actions="[object Object],[object Object]"`. That half is closed (`toDomProps`, + * `page.tsx:521`), which leaves the silent half: an author writes a key, the + * validator says yes, and nothing draws. + * + * ## Why a REFUSAL rather than a reader + * + * This was the THIRD surface carrying an `actions` array no reader consumes + * (objectui#7469 — the app node; objectui#7693 — the alert-dialog fixtures), + * and the authorable action FORM was already ruled on 2026-08-25 for + * objectui#6497 / #6182 (option A: the declarative action object). Growing a + * reader here would have minted a FOURTH `actions` shape, so the ruling pulls + * the node back to its declared contract instead. + * + * ⛔ NOT `.strict()` on the node, and that is the census talking rather than + * taste. Measured over this tree before the refusal was written: 91 authored + * `page`-tagged objects, 8 sites the census could not read (7 elided doc fences + * plus one literal carrying a spread), and the undeclared keys that survive + * passthrough on a real `page` NODE are exactly `actions` (3 sites, all of them + * the `content/docs/guide/layout.md` passages this card rewrites) and + * `breadcrumbs` (1 site, its own question — objectui#7926 does not rule on it). + * Every other undeclared key the grep found sits on a DIFFERENT declaration + * that merely spells `type: 'page'` — nav items (`pageName`, `href`, `badge`, + * `labelKey`, `requiredPermissions`), `registerMetadataResource` rows + * (`domain`, `listColumns`, `anchors`, `create*`) and spec `page` LIST VIEWS + * (`pageName` + empty `columns`) — none of which this schema parses. And + * `page-app-dashboard-spec-parity.test.ts` PINS the node staying open + * ("the component envelope still passes unknown renderer props through"), so a + * strict node would have taken a living pin with it. One key, by name. + * + * Same helper and the same reasoning as `MenuItemSchema.type` + * (`./overlay.zod.ts`, objectui#6523): a spelling the type never declared, + * turned into a named refusal that carries the remedy. + */ +const PAGE_ACTIONS_REFUSAL = + '`actions` is not a key of the `page` node and never was (objectui#7926): no renderer ' + + 'reads it, so an authored array drew nothing and rode `.passthrough()` onto the wrapper ' + + 'element. Author the buttons as NODES in `body` (a `button` node, or an `action:button` ' + + 'node with a declared `actionType`); on a record page declare them on a `page:header` ' + + 'block instead, whose own `actions` are ACTION IDS resolved from the object metadata ' + + '(objectui#7182), not nodes.'; + /** * Page Schema — top-level page layout, derived from `@objectstack/spec/ui` * `PageSchema` (see {@link SpecPageFields}). The drift guard is @@ -445,6 +503,7 @@ const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).sh */ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({ type: z.literal('page'), + actions: retirementTombstone(PAGE_ACTIONS_REFUSAL), title: z.string().optional().describe('Page title'), icon: z.string().optional().describe('Page icon (Lucide icon name)'), description: z.string().optional().describe('Page description'), From 353997f515e63cbae86cd1dcd49eb81c48637a83 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 17:09:34 +0000 Subject: [PATCH 2/2] test(types): register the `page.actions` refusal arm in the spec-parity ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `page-app-dashboard-spec-parity.test.ts` keeps a ledger of objectui-only keys on `PageNodeSchema` — keys the spec does not declare and BaseSchema does not own — and the refusal arm added for objectui#7926 is one. It went red with `expected [ 'actions' ] to deeply equal []`. Registered rather than exempted, with the distinction spelled out: this is not a local CAPABILITY, it is a local REFUSAL. The behaviour is owned by `page-actions-refusal-7926.test.ts`; this row owns the fact that the key is declared at all. Found by running the tests that READ the edited file, not only the gates of the package that changed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH --- .../__tests__/page-app-dashboard-spec-parity.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts index 370c41ffe7..fa58a45e87 100644 --- a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts @@ -71,7 +71,17 @@ const CASES: Record = { // `type` collides semantically (spec = page kind, objectui = component // discriminator, kind lives on `pageType`); `regions` is a local fork. omitted: ['type', 'regions'], - local: ['title', 'pageType', 'body', 'children'], + // `actions` is the odd one out and is listed HERE deliberately rather than + // exempted: it is not a local CAPABILITY, it is a local REFUSAL. The spec + // does not declare it, no renderer reads it, and objectui#7926 (maintainer + // ruling 2026-09-09, batch #107 item 2, option A) ruled that `page` grows no + // reader — so the key is DECLARED as an ADR-0049 tombstone precisely so an + // authored value is refused by name instead of being kept in silence by + // `BaseSchema`'s `.passthrough()`. That makes it an objectui-only key on + // this shape, and this ledger is the right place for the decision to be + // visible. `../__tests__/page-actions-refusal-7926.test.ts` owns the + // behaviour; this row owns the fact that the key exists at all. + local: ['title', 'pageType', 'body', 'children', 'actions'], }, App: { spec: SpecAppSchema,