From deaf47555439376666886cf66fdc5b42deabf642 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:33:22 +0000 Subject: [PATCH 1/2] docs: taught action:* nodes, the record:activity block and the page-builder object-form move their props into the spec's properties bag The spec's PageComponentSchema is .strict() (ADR-0089 D3a) and refuses a flat actionType / target on a component node; objectui's strict authoring face refuses the same keys. Every taught action:button node under AGENTS.md, skills/objectui and content/docs/guide now writes the block's props in the properties bag, as the ComponentPropsMap row declares them. Same move for the record:activity example in plugin-detail.mdx (limit / showCompleted) and the page-builder "Form plugin example" (object-form, refused flat by name since objectui#11291). The runtime reads both spellings through the SchemaRenderer properties hoist, so only documents change. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg --- AGENTS.md | 4 ++-- content/docs/guide/layout.md | 29 +++++++++++++++---------- content/docs/guide/quick-start.md | 16 +++++++++----- content/docs/guide/record-edit-modes.md | 29 ++++++++++++++----------- content/docs/guide/schema-rendering.md | 16 +++++++++----- content/docs/plugins/plugin-detail.mdx | 6 +++-- skills/objectui/SKILL.md | 15 ++++++++----- skills/objectui/guides/page-builder.md | 29 +++++++++++++++---------- skills/objectui/rules/protocol.md | 18 ++++++++++----- 9 files changed, 100 insertions(+), 62 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 534a8ee514..57af347790 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,9 +93,9 @@ interface BaseSchema { - **#1 — Protocol-agnostic.** Never hardcode `objectql.find()`. Use the DataSource interface; inject `dataSource` via ``. - **#2 — Docs-driven.** For every feature/refactor, update package `README.md` **and** `content/docs/guide/*.md`. Not done until docs reflect the code. - **#3 — "Shadcn-native" aesthetics.** We are "serializable Shadcn". Follow Shadcn's DOM structure (`CardHeader`/`CardTitle`/`CardContent`). Always expose `className` in schema props so users can override via JSON. -- **#4 — Action system (objectui#7898, objectui#6497).** Actions are **data, not functions**, and a control that RUNS something is its own NODE TYPE — `action:button` — never a handler key or an event bag on an ordinary node. `actionType` names the executor the action runner dispatches to; the node's own keys carry that executor's arguments: +- **#4 — Action system (objectui#7898, objectui#6497, objectui#11183).** Actions are **data, not functions**, and a control that RUNS something is its own NODE TYPE — `action:button` — never a handler key or an event bag on an ordinary node. The block's props live in the node's `properties` bag, which is the spec's `ComponentPropsMap` row for it: `actionType` names the executor the action runner dispatches to, and the row's other keys carry that executor's arguments. ⛔ Never write them flat on the node: the spec's `PageComponentSchema` is `.strict()` (ADR-0089 D3a) and refuses a node-level `actionType` / `target` as mis-layered, and objectui's strict authoring face refuses the same two keys. The runtime reads both spellings through the `SchemaRenderer` `properties` hoist, so the bag costs nothing at render: ```json - { "type": "action:button", "label": "Open details", "actionType": "url", "target": "/users/ada" } + { "type": "action:button", "properties": { "label": "Open details", "actionType": "url", "target": "/users/ada" } } ``` ⛔ Never author an `events` bag: `BaseSchema` declares no `events` member and no renderer reads `schema.events` — the node is `.passthrough()`, so one authored there is kept, judged by nothing and run by nothing. `ButtonSchema.onClick` is a runtime slot for a host-supplied function and is refused by name for the same reason. - **#5 — Layout as components.** Treat `Grid`/`Stack`/`Container` as first-class. Layout schemas declare responsive columns on the node as `columns` — a number, or a breakpoint object (`columns: { xs: 1, md: 2, lg: 4 }`); never `cols`, which nothing reads (objectui#4001). diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index 68de90b40b..d5551ef46a 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -546,19 +546,23 @@ Same rule as above, and it governs the trail too: the breadcrumb and the buttons "children": [ { "type": "action:button", - "name": "edit_record", - "label": "Edit", - "variant": "default", - "icon": "pencil", - "actionType": "editRecord" + "properties": { + "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" + "properties": { + "name": "delete_record", + "label": "Delete", + "variant": "destructive", + "icon": "trash", + "actionType": "deleteRecord" + } } ] }, @@ -573,7 +577,10 @@ Same rule as above, and it governs the trail too: the breadcrumb and the buttons ``` A button that RUNS something is an `action:button` node, not a `button` carrying an -`onClick`. `ButtonSchema.onClick` is declared as a runtime slot for a host-supplied +`onClick`. Its props — `actionType` and the rest of the block's row — go in the node's +`properties` bag, as `@objectstack/spec` declares them; the spec's strict page component +refuses them written flat on the node, and `SchemaRenderer` hoists the bag onto the node +before the renderer reads it. `ButtonSchema.onClick` is declared as a runtime slot for a host-supplied function and the zod mirror refuses it BY NAME — JSON has no function value, and no handler key consumes a declarative action object. The refusal is not the whole cost: `onClick` is on `SDUI_DOM_PASS_THROUGH_KEYS`, so an authored string or object is diff --git a/content/docs/guide/quick-start.md b/content/docs/guide/quick-start.md index 9fca28bf4e..ca70f902fc 100644 --- a/content/docs/guide/quick-start.md +++ b/content/docs/guide/quick-start.md @@ -143,16 +143,20 @@ Open [http://localhost:5173](http://localhost:5173). You should see a card and d ### Add Actions Actions are data, not inline functions. Declare one as an `action:button` -node: `actionType` names the built-in executor the action runner dispatches to, -and the action's own keys carry that executor's arguments — `target` is the -location a `url` action navigates to: +node whose `properties` bag carries the block's props, as `@objectstack/spec` +declares them: `actionType` names the built-in executor the action runner +dispatches to, and the bag's other keys carry that executor's arguments — +`target` is the location a `url` action navigates to. Written flat on the node, +`actionType` and `target` are refused by the spec's strict page component: ```json { "type": "action:button", - "label": "Open details", - "actionType": "url", - "target": "/users/ada" + "properties": { + "label": "Open details", + "actionType": "url", + "target": "/users/ada" + } } ``` diff --git a/content/docs/guide/record-edit-modes.md b/content/docs/guide/record-edit-modes.md index 94e404f15b..1d2f42819d 100644 --- a/content/docs/guide/record-edit-modes.md +++ b/content/docs/guide/record-edit-modes.md @@ -63,19 +63,20 @@ refresh the page mid-edit (the form rehydrates from the URL `:recordId`). In addition to the implicit "click create/edit on a list" entry point, two declarative actions let you open the page-mode routes from an -`action:button` in metadata. The handler name goes in `actionType`: that -is the key the button renderer forwards to the action runner as the -action's type, and the runner dispatches to the handler registered under -it. Arguments are static values, and they go in the node's `properties` -bag as `properties.params`: +`action:button` in metadata. Every prop of the block lives in the node's +`properties` bag, as `@objectstack/spec` declares it. The handler name goes +in `properties.actionType`: that is the key the button renderer forwards to +the action runner as the action's type, and the runner dispatches to the +handler registered under it. Arguments are static values, and they go in +the same bag as `properties.params`: ```jsonc { "type": "action:button", - "label": "New Account", - "icon": "plus", - "actionType": "navigate_create", "properties": { + "label": "New Account", + "icon": "plus", + "actionType": "navigate_create", "params": { "objectName": "account" } } } @@ -89,10 +90,10 @@ names the record it sits on with `${record.id}`: ```jsonc { "type": "action:button", - "label": "Edit", - "icon": "pencil", - "actionType": "navigate_edit", "properties": { + "label": "Edit", + "icon": "pencil", + "actionType": "navigate_edit", "params": { "objectName": "account", "recordId": "${record.id}" @@ -126,8 +127,10 @@ entirely: ```jsonc { "type": "action:button", - "label": "New", - "actionType": "navigate_create" + "properties": { + "label": "New", + "actionType": "navigate_create" + } } ``` diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md index 65da7d502a..1b695520c4 100644 --- a/content/docs/guide/schema-rendering.md +++ b/content/docs/guide/schema-rendering.md @@ -307,16 +307,20 @@ Components can emit events that you handle in React: /> ``` -Reference actions in schemas: +Reference actions in schemas — the block's props go in its `properties` bag, as +`@objectstack/spec` declares them; the spec's strict page component refuses them +written flat on the node: ```json { "type": "action:button", - "name": "call_api", - "label": "Click Me", - "actionType": "api", - "endpoint": "/api/action", - "method": "POST" + "properties": { + "name": "call_api", + "label": "Click Me", + "actionType": "api", + "endpoint": "/api/action", + "method": "POST" + } } ``` diff --git a/content/docs/plugins/plugin-detail.mdx b/content/docs/plugins/plugin-detail.mdx index 02eb931e2f..1f6a018f03 100644 --- a/content/docs/plugins/plugin-detail.mdx +++ b/content/docs/plugins/plugin-detail.mdx @@ -184,7 +184,9 @@ declare const handleAddComment: (text: string) => void; `RecordActivityTimeline` is a presenter: you hand it `items`. The SDUI block `record:activity` is the schema-authored form of the same panel, and it finds -its own feed. Drop it anywhere inside a record page: +its own feed. Drop it anywhere inside a record page; its inputs go in the node's +`properties` bag, as `@objectstack/spec` declares them (the strict page component +refuses them written flat on the node): ```json { @@ -195,7 +197,7 @@ its own feed. Drop it anywhere inside a record page: "label": "Activity", "value": "activity", "children": [ - { "type": "record:activity", "limit": 20, "showCompleted": false } + { "type": "record:activity", "properties": { "limit": 20, "showCompleted": false } } ] } ] diff --git a/skills/objectui/SKILL.md b/skills/objectui/SKILL.md index 15730680a8..0e8a1d02dc 100644 --- a/skills/objectui/SKILL.md +++ b/skills/objectui/SKILL.md @@ -83,16 +83,21 @@ styles can be overridden from JSON. ### 4. The action system (interactivity) Actions are data, not functions — and a control that RUNS something is its own -node type, `action:button`. `actionType` names the executor the action runner -dispatches to; the node's own keys carry that executor's arguments: +node type, `action:button`. Its props go in the node's `properties` bag, the spec's +row for the block: `actionType` names the executor the action runner dispatches +to, and the row's other keys carry that executor's arguments. ⛔ Never flat on the +node — the spec's strict page component refuses a node-level `actionType` / +`target` as mis-layered (ADR-0089 D3a), and so does objectui's strict authoring face: ```json { "type": "action:button", - "label": "Open details", - "actionType": "url", - "target": "/users/ada" + "properties": { + "label": "Open details", + "actionType": "url", + "target": "/users/ada" + } } ``` diff --git a/skills/objectui/guides/page-builder.md b/skills/objectui/guides/page-builder.md index e9cc92fe7a..a9c817bc06 100644 --- a/skills/objectui/guides/page-builder.md +++ b/skills/objectui/guides/page-builder.md @@ -122,15 +122,19 @@ Keep custom component registrations namespaced to avoid collisions. ### 5. Use action data, not inline callback spaghetti Represent interactions as data: a control that runs something is an `action:button` -node, and `actionType` names the executor the action runner dispatches to. +node. Its props go in the `properties` bag — `actionType` names the executor the +action runner dispatches to — never flat on the node, which the spec's strict page +component refuses (ADR-0089 D3a). ```json { "type": "action:button", - "label": "Save customer", - "actionType": "url", - "target": "/customers" + "properties": { + "label": "Save customer", + "actionType": "url", + "target": "/customers" + } } ``` @@ -251,17 +255,20 @@ renderers do (`schema.objectName`, `schema.columns`, `schema.fields`, > names the field a form input writes. A grid column written as `{ "name": ... }` > names no field, so `ObjectGrid` drops it. -**Form plugin example:** +**Form plugin example** — an `object-form` takes its props in the spec's `properties` +bag, and `objectui validate` refuses the flat spelling by name: ```json { "type": "object-form", - "objectName": "customer", - "mode": "edit", - "fields": [ - { "name": "name", "label": "Name", "type": "text", "required": true }, - { "name": "email", "label": "Email", "type": "text" } - ] + "properties": { + "objectName": "customer", + "mode": "edit", + "fields": [ + { "name": "name", "label": "Name", "type": "text", "required": true }, + { "name": "email", "label": "Email", "type": "text" } + ] + } } ``` diff --git a/skills/objectui/rules/protocol.md b/skills/objectui/rules/protocol.md index 644c8247e8..d7f1815656 100644 --- a/skills/objectui/rules/protocol.md +++ b/skills/objectui/rules/protocol.md @@ -253,17 +253,23 @@ channel is objectui#4795's open question, not a taught surface. ## Rule: Actions Are Node Types, Not An Event Bag -A control that RUNS something MUST be its own node — `action:button` — with -`actionType` naming the executor the action runner dispatches to and the node's -own keys carrying that executor's arguments: +A control that RUNS something MUST be its own node — `action:button` — and its +props live in the node's `properties` bag (the spec's row for the block, hoisted +onto the node by `SchemaRenderer` as the rule above describes): `actionType` +names the executor the action runner dispatches to, and the row's other keys carry +that executor's arguments. ⛔ Not flat on the node: the spec's strict page +component refuses a node-level `actionType` / `target` as mis-layered +(ADR-0089 D3a), and objectui's strict authoring face refuses the same two keys. ```json { "type": "action:button", - "label": "Submit", - "actionType": "url", - "target": "/success" + "properties": { + "label": "Submit", + "actionType": "url", + "target": "/success" + } } ``` From f580661be67d78ef4a8120029c510ff898b547b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:38:52 +0000 Subject: [PATCH 2/2] test: the two ledgered quick-start pins read the taught action:button node in the properties bag, with a derived flat control held-public-block-arms-10872: the taught node now validates AS WRITTEN on both faces and on the spec's PageComponentSchema; the pre-move flat spelling is derived from the taught node (bag hoisted flat) and pinned as refused by exactly actionType and target on both strict faces, while the tolerant face still keeps it. registered-types-validate-ratchet-10859: lit control that the extracted node is in the bag spelling. Empty-frontmatter changeset: docs and pins only, nothing released. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg --- .../11183-action-node-properties-bag.md | 15 +++++ ...tered-types-validate-ratchet-10859.test.ts | 8 ++- .../held-public-block-arms-10872.test.ts | 56 ++++++++++++++----- 3 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 .changeset/11183-action-node-properties-bag.md diff --git a/.changeset/11183-action-node-properties-bag.md b/.changeset/11183-action-node-properties-bag.md new file mode 100644 index 0000000000..c204f453fa --- /dev/null +++ b/.changeset/11183-action-node-properties-bag.md @@ -0,0 +1,15 @@ +--- +--- + +Docs and pins only, no package released: every taught `action:*` node under `AGENTS.md`, +`skills/objectui/**` and `content/docs/guide/**` writes the block's props in the spec's +`properties` bag, as the `@objectstack/spec` `ComponentPropsMap` row declares them, instead of +flat on the node. The spec's `PageComponentSchema` is `.strict()` (ADR-0089 D3a) and refuses a +node-level `actionType` / `target` as mis-layered; objectui's strict authoring face refuses the +same two keys, and PR objectui#11069 moves `objectui validate` onto that face, so the taught node +had to move first. Same move for the `record:activity` example in the plugin-detail guide +(`limit` / `showCompleted`) and the page-builder skill's `object-form` example, which `objectui +validate` already refuses flat by name. The runtime reads both spellings through the +`SchemaRenderer` `properties` hoist, so no behaviour changes. The two ledgered pins that read the +quick-start's taught node move with it and keep a derived control that the pre-move flat spelling +is still refused by exactly `actionType` and `target` (objectui#11183). diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index c8fa47ba07..476180d005 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -371,13 +371,17 @@ describe('objectui validate — a page built from ADR-0080 public blocks (object it('validates a page with the taught `action:button` node (objectui#10872 batch 4)', async () => { // The node the quick-start's "Add Actions" section teaches — the one // AGENTS.md #4 and the handler-key refusals' own remedy point at — read - // from the page that teaches it, beside a `page:header`. + // from the page that teaches it, beside a `page:header`. Since + // objectui#11183 the page writes it in the spec's spelling, the block's + // props in `properties`, so this row passes on the strict face as well. const quickStart = readFileSync(QUICK_START, 'utf8'); const fence = /```json\n([\s\S]*?)\n```/.exec(quickStart.slice(quickStart.indexOf('### Add Actions'))); expect(fence, 'no ```json fence under "### Add Actions" in the quick-start').not.toBeNull(); const taught = JSON.parse((fence as RegExpExecArray)[1]); - // Lit control on the extraction: it is the taught node. + // Lit control on the extraction: it is the taught node, in the bag spelling. expect(taught.type).toBe('action:button'); + expect(taught.properties?.actionType).toBe('url'); + expect(taught).not.toHaveProperty('actionType'); const page = { type: 'page', title: 'Users', diff --git a/packages/types/src/__tests__/held-public-block-arms-10872.test.ts b/packages/types/src/__tests__/held-public-block-arms-10872.test.ts index 409caf98ef..02590d6265 100644 --- a/packages/types/src/__tests__/held-public-block-arms-10872.test.ts +++ b/packages/types/src/__tests__/held-public-block-arms-10872.test.ts @@ -75,6 +75,12 @@ const bagOf = (arm: z.ZodObject): z.ZodType => (arm.shape.properties as z.ZodOpt const keysOf = (schema: z.ZodType): string[] => Object.keys((schema as unknown as { shape?: Record }).shape ?? {}).sort(); +/** The taught node with its bag hoisted flat — the spelling the page taught before objectui#11183. */ +const flattened = (node: Record): Record => { + const { properties, ...rest } = node; + return { ...rest, ...(properties as Record) }; +}; + /** Every issue, union branches unfolded and paths made absolute. */ const allIssues = (issues: Issue[] | undefined, prefix: PropertyKey[] = []): Issue[] => (issues ?? []).flatMap((issue) => { @@ -223,7 +229,11 @@ describe('objectui#10872 batch 4 — the two handler keys the action renderers r const HERE = dirname(fileURLToPath(import.meta.url)); const QUICK_START = join(HERE, '..', '..', '..', '..', 'content', 'docs', 'guide', 'quick-start.md'); -/** The quick-start's "Add Actions" node — the one AGENTS.md #4 teaches, read from the page that teaches it. */ +/** + * The quick-start's "Add Actions" node — the one AGENTS.md #4 teaches, read from + * the page that teaches it. Since objectui#11183 the page writes it in the spec's + * spelling: the block's props in `properties`, nothing flat on the node. + */ function taughtActionButton(): Record { const doc = readFileSync(QUICK_START, 'utf8'); const section = doc.slice(doc.indexOf('### Add Actions')); @@ -233,10 +243,15 @@ function taughtActionButton(): Record { } describe('objectui#10872 batch 4 — a page with an `action:button` validates', () => { - it('reads the taught node (non-vacuity)', () => { + it('reads the taught node (non-vacuity) — and it is in the bag spelling', () => { const node = taughtActionButton(); expect(node.type).toBe('action:button'); - expect(node.actionType).toBe('url'); + const bag = node.properties as Record; + expect(bag.actionType).toBe('url'); + expect(bag.target).toBe('/users/ada'); + // Nothing executor-shaped is left flat on the node (objectui#11183). + expect(node).not.toHaveProperty('actionType'); + expect(node).not.toHaveProperty('target'); }); it('the taught node, in a page, passes the tolerant face `objectui validate` runs', () => { @@ -245,23 +260,34 @@ describe('objectui#10872 batch 4 — a page with an `action:button` validates', expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); }); - it.each(FACES)('%s face: the same node in the spec\'s spelling — its props in `properties` — validates', (_face, judge) => { - const { type, ...props } = taughtActionButton(); - const page = { type: 'page', children: [{ type: 'page:header', properties: { title: 'Users' } }, { type, properties: props }] }; + it.each(FACES)('%s face: the taught node AS WRITTEN — its props in `properties` — validates, in a page and alone', (_face, judge) => { + const page = { type: 'page', children: [{ type: 'page:header', properties: { title: 'Users' } }, taughtActionButton()] }; const result = judge(page); expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(judge(taughtActionButton()).success).toBe(true); + }); + + it('the spec\'s own `PageComponentSchema` accepts the taught node as written', () => { + expect(PageComponentSchema.safeParse(taughtActionButton()).success).toBe(true); }); - it('⚠️ the strict face refuses the taught node\'s FLAT props — the flat-props question objectui#10872 holds', () => { - // A prop written flat on the node is not judged against the row: `label` is - // a `BaseSchema` key, and `actionType` / `target` are undeclared there, so - // the strict face refuses exactly those two, as the spec's own - // `PageComponentSchema` does. Whether the flat spelling becomes an - // authoring channel for `action:*` is that card's call; this reading moves - // with it. - const result = StrictAnyComponentSchema.safeParse(taughtActionButton()); + it('CONTROL — the pre-objectui#11183 FLAT spelling of the same node is refused, by exactly its two executor keys', () => { + // objectui#10872's fork, ruled A on objectui#11183: the `properties` bag is + // the contract. A prop written flat on the node is not judged against the + // row — `label` is a `BaseSchema` key, `actionType` / `target` are + // undeclared there — so the strict face refuses exactly those two, as the + // spec's `PageComponentSchema` does. Derived from the taught node rather + // than transcribed, so the control moves with the page. + const flat = flattened(taughtActionButton()); + expect(flat.actionType).toBe('url'); + const result = StrictAnyComponentSchema.safeParse(flat); expect(result.success).toBe(false); expect(refusedKeys(result).sort()).toEqual(['actionType', 'target']); - expect(PageComponentSchema.safeParse(taughtActionButton()).success).toBe(false); + const spec = PageComponentSchema.safeParse(flat); + expect(spec.success).toBe(false); + expect(refusedKeys(spec as Result).sort()).toEqual(['actionType', 'target']); + // The tolerant face still keeps the flat node, which is why the page had to move + // before `objectui validate` reaches the strict face (objectui#5250). + expect(safeValidateSchema(flat).success).toBe(true); }); });