Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/10872-held-public-block-arms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@object-ui/types': minor
---

`safeValidateSchema`, and so `objectui validate`, accepts the six ADR-0080 public blocks held back until `@objectstack/spec` carried a `ComponentPropsMap` row for each: `action:button`, `action:icon`, `action:group`, `action:menu`, `element:definition-list` and `element:repeater` (objectui#10872, batch 4). Their rows arrived in `@objectstack/spec` 17.5.0, measured at the renderers' read points (objectstack-ai/objectstack#20371).

**Clause-②: yes.** The accept set of `AnyComponentSchema` widens by six `type` literals, and `@object-ui/types/zod` exports six new schemas. Nothing that parsed before is refused now.

**What it was.** Each of these types is registered and curated as platform contract by ADR-0080, and `AnyComponentSchema` carried no arm for any of them. So every document naming one was refused with `invalid_union` at `type`. That included `action:button`, the node the validator's own handler-key refusals tell an author to write instead of an `onClick`.

**What changed, in observable terms.**

- `@object-ui/types/zod` exports `ActionButtonBlockSchema`, `ActionIconBlockSchema`, `ActionGroupBlockSchema`, `ActionMenuBlockSchema`, `ElementDefinitionListBlockSchema` and `ElementRepeaterBlockSchema`, each a member of `PublicBlockComponentSchema`. The strict authoring face (`StrictAnyComponentSchema`) accepts the same documents, closed to undeclared keys like every other arm.
- Each arm's `properties` bag is the block's spec row, read by reference: its members, value types and strictness are the spec's. The rows were measured at what the renderers read, not at what the registrations publish, so the validator refuses what no renderer reads:
- a group-level `name` on `action:group`, with the spec's own prescription;
- `size: "md"` on `action:group` and `action:menu`, whose renderers hand it to the Button primitive unmapped;
- the strings `"1"` / `"2"` for `element:definition-list`'s `columns`, where the renderer compares the number `2`: write `columns: 2`;
- a `label` on an `element:repeater` `fields` entry, which the list never prints.
- `element:repeater` requires `properties.object`: its renderer never queries without one, and it does not read the node's `dataSource`.
- `action:button` and `action:icon` also refuse two keys their renderers read off the node, each by name:
- an authored `onClick`, a runtime slot for a host-supplied function (objectui#6124), as `ButtonSchema.onClick` is;
- a flat `onSuccess`, with a message naming `properties.onSuccess`, where the spec row declares the post-success `{ navigate, openIn }` block. The spec's own page component refuses the flat spelling too.
- A prop written directly on the node rather than in `properties` is treated as on every other public block. The tolerant face passes a key `BaseSchema` does not declare, unjudged; the strict face refuses it. So the taught `{ "type": "action:button", "label": "Open details", "actionType": "url", "target": "/users/ada" }` validates through `objectui validate` as it runs today, while `StrictAnyComponentSchema` refuses its flat `actionType` and `target`, as `@objectstack/spec`'s `PageComponentSchema` does. The same node with its props in `properties` passes both faces.
- `record:line_items` is still refused at `type`: the spec has no row for it yet.
2 changes: 2 additions & 0 deletions .changeset/10872-public-block-zod-arms.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@
⚠️ **Dated note, 2026-09-28 — `element:number` armed — objectui#10872 batch 2.** Later in this same release `element:number` is armed as well (`ElementNumberBlockSchema`, with the spec's `dataSource` waiver on its required `object`), so the lead sentence's "less `element:number`" and the first item of the "Not armed in this release" bullet no longer describe the release as a whole; the other held blocks are still refused at `type`. The rest of this entry is kept as the reading of this change.

⚠️ **Dated note, 2026-09-29 — `record:alert`'s flat `body` — objectui#10872 batch 3.** Later in this same release `record:alert`'s arm restates `body` with its own refusal, which names `properties.body`. So the bullet on a prop written directly on the node, where it says "`body` (`page:card`, `record:alert`) is refused on both faces by the base's objectui#6771 retirement", no longer describes the release as a whole: that holds for `page:card` only, and a flat `body` on `record:alert` is still refused on both faces, by the arm's own refusal instead. The bullet's advice, to write a `record:alert` banner's `body` inside `properties`, is what that refusal now says. The rest of this entry is kept as the reading of this change.

⚠️ **Dated note, 2026-09-30 — the six held blocks armed — objectui#10872 batch 4.** Later in this same release `@objectstack/spec` 17.5.0 carries a `ComponentPropsMap` row for `element:definition-list`, `element:repeater`, `action:button`, `action:group`, `action:menu` and `action:icon`, and each is armed from its row (`.changeset/10872-held-public-block-arms.md`). So the last two blocks of the "Not armed in this release" bullet no longer describe the release as a whole: of the held blocks, only `record:line_items` is still refused at `type`. The rest of this entry is kept as the reading of this change.
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,11 @@ const REFUSED_AT_TYPE = 73;
* The head's refused count over the NAMESPACED keys (objectui#10872 batch 1:
* 418 on `main` before it, minus the twenty ADR-0080 public blocks armed from
* their `@objectstack/spec` `ComponentPropsMap` rows; batch 2: minus
* `element:number`, armed with the spec's `dataSource` waiver). LOWER it when
* a batch arms more keys; never raise it.
* `element:number`, armed with the spec's `dataSource` waiver; batch 4: minus
* the six blocks `@objectstack/spec` 17.5.0 gave a row). LOWER it when a batch
* arms more keys; never raise it.
*/
const NAMESPACED_REFUSED_AT_TYPE = 397;
const NAMESPACED_REFUSED_AT_TYPE = 391;

/** The bare registry keys — the population the card measured. */
const BARE_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => !key.includes(':'));
Expand All @@ -100,6 +101,11 @@ const ARMED_PUBLIC_BLOCKS_10872 = [
/** The public block objectui#10872 batch 2 armed. */
const ARMED_PUBLIC_BLOCKS_10872_BATCH_2 = ['element:number'] as const;

/** The six public blocks objectui#10872 batch 4 armed, held until `@objectstack/spec` 17.5.0 carried their rows. */
const ARMED_PUBLIC_BLOCKS_10872_BATCH_4 = [
'action:button', 'action:icon', 'action:group', 'action:menu', 'element:definition-list', 'element:repeater',
] as const;

/** Is `type` unclaimed by every arm of the validator's root union? */
function refusedAtType(type: string): boolean {
const result = safeValidateSchema({ type });
Expand Down Expand Up @@ -197,6 +203,13 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet
}
});

it('counts the six public blocks objectui#10872 batch 4 armed', () => {
for (const key of ARMED_PUBLIC_BLOCKS_10872_BATCH_4) {
expect(NAMESPACED_KEYS, key).toContain(key);
expect(refusedAtType(key), key).toBe(false);
}
});

it('counts `cloud:plan-status` armed — it registered WITH its arm (objectui#10919)', () => {
// One registry key (`skipFallback: true`, so no bare `plan-status`), armed in
// `@object-ui/types/zod` in the same change, so the pin above did not move.
Expand All @@ -211,6 +224,7 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet
/** Rooted on this file, never on `process.cwd()`. */
const HERE = dirname(fileURLToPath(import.meta.url));
const PLUGIN_AI_README = join(HERE, '..', '..', '..', 'plugin-ai', 'README.md');
const QUICK_START = join(HERE, '..', '..', '..', '..', 'content', 'docs', 'guide', 'quick-start.md');

/** See `validate-root-path-line.test.ts` — the escape byte is never spelled. */
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
Expand Down Expand Up @@ -347,4 +361,70 @@ describe('objectui validate — a page built from ADR-0080 public blocks (object
expect(text).toContain('dataSource.object');
expect(exitCodes).toEqual([1]);
});

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`.
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.
expect(taught.type).toBe('action:button');
const page = {
type: 'page',
title: 'Users',
children: [{ type: 'page:header', properties: { title: 'Users' } }, taught],
};
const file = join(dir, 'action-button-page.json');
writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8');

await validate(file);

const text = out.join('\n').replace(ANSI, '');
expect(text).not.toContain('Schema validation failed');
expect(text).toContain('Schema is valid');
expect(exitCodes).toEqual([0]);
});

it('validates a page of the six batch-4 blocks in the spec\'s `properties` spelling', async () => {
const page = {
type: 'page',
children: [
{ type: 'action:button', properties: { label: 'Open details', actionType: 'url', target: '/users/ada' } },
{ type: 'action:icon', properties: { icon: 'pencil', label: 'Edit', actionType: 'url', target: '/users/ada/edit' } },
{ type: 'action:group', properties: { display: 'dropdown', actions: [{ name: 'archive', label: 'Archive' }] } },
{ type: 'action:menu', properties: { actions: [{ name: 'delete', label: 'Delete' }] } },
{ type: 'element:definition-list', properties: { columns: 2, items: [{ term: 'Owner', description: 'Ada' }] } },
{ type: 'element:repeater', properties: { object: 'task', fields: ['subject'], limit: 5 } },
],
};
const file = join(dir, 'held-blocks-page.json');
writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8');

await validate(file);

const text = out.join('\n').replace(ANSI, '');
expect(text).not.toContain('Schema validation failed');
expect(text).toContain('Schema is valid');
expect(exitCodes).toEqual([0]);
});

it('still judges an `action:button` bag — an undeclared prop is refused and named (batch 4)', async () => {
// The control that keeps the two rows above from passing for the wrong reason.
const page = {
type: 'page',
children: [{ type: 'action:button', properties: { label: 'Go', inventedProp10872b4: true } }],
};
const file = join(dir, 'action-button-page-refused.json');
writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8');

await validate(file);

const text = out.join('\n').replace(ANSI, '');
expect(text).toContain('Schema validation failed');
expect(text).toContain('inventedProp10872b4');
expect(exitCodes).toEqual([1]);
});
});
Loading
Loading