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/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 62215ec366..ef9919128d 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/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); }); }); 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" + } } ```