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/__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, 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'),