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
17 changes: 17 additions & 0 deletions .changeset/10872-public-block-zod-arms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/types': minor
---

`safeValidateSchema` — and so `objectui validate` — accepts twenty of the ADR-0080 public blocks, the ones whose props `@objectstack/spec` declares as a `ComponentPropsMap` row less `element:number` (below): `page:header`, `page:tabs`, `page:card`, `page:accordion`, `page:section`, `page:footer`, `page:sidebar`, `record:details`, `record:highlights`, `record:related_list`, `record:path`, `record:activity`, `record:discussion`, `record:history`, `record:quick_actions`, `record:reference_rail`, `record:alert`, `element:text`, `element:button` and `element:divider` (objectui#10872, batch 1).

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

**What it was.** Every one of these types is registered, curated as platform contract by ADR-0080 (`PUBLIC_BLOCKS` in `@object-ui/core`) and declared by the spec, while `AnyComponentSchema` carried no arm for any of them — so every document naming one was refused with `invalid_union` at `type`, and `objectui validate` reported "Schema validation failed" for any page built from the public vocabulary, down to `{ "type": "record:details" }`.

**What changed, in observable terms.**

- `@object-ui/types/zod` exports one arm per block — `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema`, `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema`, `RecordDetailsBlockSchema`, `RecordHighlightsBlockSchema`, `RecordRelatedListBlockSchema`, `RecordPathBlockSchema`, `RecordActivityBlockSchema`, `RecordDiscussionBlockSchema`, `RecordHistoryBlockSchema`, `RecordQuickActionsBlockSchema`, `RecordReferenceRailBlockSchema`, `RecordAlertBlockSchema`, `ElementTextBlockSchema`, `ElementButtonBlockSchema`, `ElementDividerBlockSchema` — and the category union `PublicBlockComponentSchema`, which `AnyComponentSchema` now lists. The strict authoring face (`StrictAnyComponentSchema`) derives from `AnyComponentSchema` and accepts the same blocks, closed to undeclared keys like every other arm.
- Each arm declares the block's props where the spec puts them: `properties`, whose value is the block's `ComponentPropsMap` row, read by reference. A bag is judged only when present, as the spec's own props gate judges it, and it is judged by the spec's row — its members, value types, retired-key refusals and strictness — so `{ "type": "page:header", "properties": { "title": "Account", "inventedProp": true } }` is refused, naming `inventedProp`, on both faces. `element:divider` is the one row restated rather than read, because the spec exports it under no name; it declares no prop, as the row does.
- A prop written directly on the node rather than inside `properties` is not judged against the block's row. A key the shared node base (`BaseSchema`) does not declare, such as `{ "type": "record:details", "columns": "2" }`, passes the tolerant face and is refused by the strict face. A key the base does declare is judged by the base instead. `children` (`page:card`, `page:section`, `page:footer`, `page:sidebar`), `label` and `disabled` (`element:button`) and `visible` (`record:alert`) are judged against the base's own types. `body` (`page:card`, `record:alert`) is refused on both faces by the base's objectui#6771 retirement: write a `record:alert` banner's `body` inside `properties`.
- `page:tabs` also refuses an authored `onTabChange`, by name: it is the callback `@object-ui/app-shell` injects onto the node to write `?tab=` back, a runtime slot a React host fills (objectui#6124), and a JSON value there was never a working handler — delete the key.
- Not armed in this release, and still refused at `type`: `element:number` (its row's required `object` is waived by the spec when the node binds through `dataSource`, which the row alone would refuse), `record:line_items` (the spec has no row for it yet), `element:definition-list` and `element:repeater` (the spec's `element:` vocabulary does not declare them), and `action:button`, `action:group`, `action:menu` and `action:icon` (no spec row yet; one is being measured from the renderers' read points upstream, objectstack-ai/objectstack#20371).
6 changes: 6 additions & 0 deletions .changeset/8344-node-recursion-point-redirect.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,9 @@ make the redirect apply.
release `zod/ai.zod.ts` joins the category modules `AnyComponentSchema` is built from and imports
`zod/base.zod.ts` too, so both counts in the mechanical note above are one higher; the reason the arm
cannot be an import is unchanged. The rest of this entry is kept as the reading of this change.

⚠️ **Dated note, 2026-09-28 — a second category module — objectui#10872.** Later still in this same
release `zod/public-blocks.zod.ts` joins them as well and imports `zod/base.zod.ts` too, so both counts
in the mechanical note above are two higher, not the one the objectui#10859 note says; the reason the
arm cannot be an import is unchanged. The rest of this entry, and that note, are kept as their
readings.
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,21 @@
* `retirementTombstone()`, which is why the retired direction below
* cannot be measured without it.
*
* A block may resolve on both (`object-grid`, `object-form`, `object-kanban`),
* on one (`grid` — node only; every `page:*` / `record:*` / `element:*` spec
* type — spec only), or on neither. Neither is an EXPLICIT exemption with a
* reason and a card, never a silent skip — the idiom is
* ⚠️ An arm is judged at the LEVEL its block's props live on. Most
* arms declare them at the node's top level; the ADR-0080 public-block
* arms (objectui#10872) declare them the spec's way, as one
* `properties` object member whose value is the block's
* `ComponentPropsMap` row. The designer edits a block's `properties`
* (`block-config.ts`'s header), so such an arm is judged against that
* member's shape — {@link propsLevelOf} derives the level from the arm
* itself, ⛔ never from a list of types.
*
* A block may resolve on both (`object-grid`, `object-form`, `object-kanban`,
* and, since objectui#10872, the `page:*` / `record:*` / `element:*` public
* blocks that carry a spec row — their node face judged at `properties`), on
* one (`grid` — node only; a spec type no arm declares yet, such as
* `element:number` — spec only), or on neither. Neither is an EXPLICIT exemption
* with a reason and a card, never a silent skip — the idiom is
* `check-designer-field-key-parity.mjs`'s, copied rather than reinvented.
*
* ## Both directions, because "is it declared?" is not the question
Expand Down Expand Up @@ -119,11 +130,26 @@ function nodeOracles(): Record<string, unknown> {
(typeMember?._def?.values as unknown[] | undefined)?.[0] ??
typeMember?._def?.value ??
typeMember?.value;
if (typeof literal === 'string' && !(literal in out)) out[literal] = exported;
if (typeof literal === 'string' && !(literal in out)) out[literal] = propsLevelOf(exported);
}
return out;
}

/**
* The schema a designer field of this arm is judged against: the arm's
* `properties` member when that member unwraps to an OBJECT shape — the
* ADR-0080 public-block arms (objectui#10872), whose props live in the spec's
* bag — and the arm itself otherwise. Read off the arm, so an arm that adopts
* the bag later is judged at the bag without an edit here, and an arm without
* one (or with a non-object `properties`) keeps its top-level reading.
*/
function propsLevelOf(arm: unknown): unknown {
const member = resolvePropsShape(arm)?.properties as { unwrap?: () => unknown } | undefined;
if (!member) return arm;
const inner = typeof member.unwrap === 'function' ? member.unwrap() : member;
return resolvePropsShape(inner) ? inner : arm;
}

const NODE_ORACLES = nodeOracles();
const SPEC_ORACLES = ComponentPropsMap as unknown as Record<string, unknown>;

Expand Down Expand Up @@ -273,6 +299,21 @@ describe('BLOCK_CONFIG ↔ node-schema parity — the instruments (objectui#8216
}
});

it('judges a bag-carrying arm AT its `properties` bag, and every other arm at its top level (objectui#10872)', () => {
// The descent is derived, so its non-vacuity is measured on both sides: a
// public-block arm resolves to its bag — the designer's `title` control is
// a declared member there, and the bag still carries the spec's tombstone
// for `icon` — while a top-level arm is untouched by it.
const header = NODE_ORACLES['page:header'];
expect(header, 'no node arm resolved for page:header').toBeTruthy();
expect(listedShapeKeys(header)).toContain('title');
expect(listedShapeKeys(header), 'judged at the node, not at its bag').not.toContain('properties');
expect(judge(header, 'title')).toBeUndefined();
expect(judge(header, 'icon')?.kind).toBe('RETIRED');
expect(listedShapeKeys(NODE_ORACLES['object-kanban'])).toContain('groupBy');
expect(listedShapeKeys(NODE_ORACLES['object-kanban'])).toContain('type');
});

it('the MISSING probe can say no — and yes', () => {
expect(judge(SPEC_ORACLES['page:header'], 'zzzNotAKey')?.kind).toBe('MISSING');
expect(judge(SPEC_ORACLES['page:header'], 'title')).toBeUndefined();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@
* with the refused keys listed, which is where the next author finds out.
* ⛔ Do not raise the constant to make it pass — arm the type, or take the
* registration's authorability to the card.
*
* ## The namespaced half (objectui#10872)
*
* The same list carries the NAMESPACED keys (`view:grid`, `record:details`,
* `action:button`), and the same union refuses them the same way. They are
* counted separately, against `NAMESPACED_REFUSED_AT_TYPE`, because they are a
* separate family card with its own batches — objectui#10872 — and a single
* total would let a batch on one card mask a regression on the other. Same
* rule, same direction: the pin is the head's count, it falls with each batch,
* and ⛔ it never rises.
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -60,9 +70,31 @@ import { validate } from '../commands/validate.js';
*/
const REFUSED_AT_TYPE = 76;

/**
* 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). LOWER it when a batch
* arms more keys; never raise it.
*/
const NAMESPACED_REFUSED_AT_TYPE = 398;

/** The bare registry keys — the population the card measured. */
const BARE_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => !key.includes(':'));

/** The namespaced registry keys — objectui#10872's population. */
const NAMESPACED_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => key.includes(':'));

/**
* The public blocks objectui#10872 batch 1 armed — named, so the row below
* says WHICH keys left the refused set rather than only that the count fell.
*/
const ARMED_PUBLIC_BLOCKS_10872 = [
'page:header', 'page:tabs', 'page:card', 'page:accordion', 'page:section', 'page:footer', 'page:sidebar',
'record:details', 'record:highlights', 'record:related_list', 'record:path', 'record:activity',
'record:discussion', 'record:history', 'record:quick_actions', 'record:reference_rail', 'record:alert',
'element:text', 'element:button', 'element:divider',
] 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 @@ -114,6 +146,39 @@ describe('registered component types refused at `type` — a ratchet (objectui#1
});
});

describe('registered NAMESPACED component types refused at `type` — a ratchet (objectui#10872)', () => {
it('the refused count equals the pin, and only ever falls', () => {
const refused = NAMESPACED_KEYS.filter(refusedAtType);
expect(
refused.length,
[
`\`objectui validate\` refuses ${refused.length} registered namespaced key(s) at \`type\`; the pin is ${NAMESPACED_REFUSED_AT_TYPE}.`,
refused.length < NAMESPACED_REFUSED_AT_TYPE
? `Fewer than the pin — an arm landed. LOWER \`NAMESPACED_REFUSED_AT_TYPE\` to ${refused.length} in this same change (objectui#10872).`
: 'MORE than the pin — a registered key lost its arm or a registration landed without one. '
+ 'Arm it in `@object-ui/types/zod` (or take its authorability to objectui#10872); ⛔ never raise the pin.',
`Refused: ${refused.join(', ')}`,
].join('\n'),
).toBe(NAMESPACED_REFUSED_AT_TYPE);
});

it('reads the whole generated namespaced population (non-vacuity)', () => {
expect(NAMESPACED_KEYS.length).toBeGreaterThan(400);
// Lit control: the one namespaced key armed before objectui#10872.
expect(NAMESPACED_KEYS).toContain('ui:calendar');
expect(refusedAtType('ui:calendar')).toBe(false);
// And a namespaced key no arm claims IS refused there.
expect(refusedAtType('no-such-namespace:component-10872')).toBe(true);
});

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

/* ── End to end: the README document through `objectui validate` ─────────── */

/** Rooted on this file, never on `process.cwd()`. */
Expand Down Expand Up @@ -174,3 +239,50 @@ describe('objectui validate — the plugin-ai README document (objectui#10859)',
expect(exitCodes).toEqual([0]);
});
});

/* ── End to end: a page of public blocks through `objectui validate` ─────── */

describe('objectui validate — a page built from ADR-0080 public blocks (objectui#10872)', () => {
it('validates a page of `page:header`, `record:details` and `element:text`', async () => {
// Each block in the spelling the platform's own producers write — the
// spec's `{ type, properties }` bag (`buildDefaultPageSchema`'s
// `componentNode`, the page designer) — with one bare node, which is the
// synthesizer's shape for a block it configures nothing on.
const page = {
type: 'page',
title: 'Account',
children: [
{ type: 'page:header', properties: { title: 'Account', subtitle: 'Customer' } },
{ type: 'record:details' },
{ type: 'element:text', properties: { content: 'Recent activity' } },
],
};
const file = join(dir, 'public-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 a public block\'s bag — an undeclared prop is refused and named', async () => {
// The control that keeps the row above from passing for the wrong reason:
// the same page with one invented prop on a nested block fails.
const page = {
type: 'page',
children: [{ type: 'page:header', properties: { title: 'Account', inventedProp10872: true } }],
};
const file = join(dir, 'public-blocks-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('inventedProp10872');
expect(exitCodes).toEqual([1]);
});
});
40 changes: 40 additions & 0 deletions packages/types/src/__tests__/imported-defaults-8317.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,24 @@ import {
ChartDrillDownSchema as SpecChartDrillDownSchema,
UserFilterFieldSchema as SpecUserFilterFieldSchema,
ViewFilterRuleSchema as SpecViewFilterRuleSchema,
// objectui#10872 — the `ComponentPropsMap` rows the public-block arms read.
PageHeaderProps as SpecPageHeaderProps,
PageTabsProps as SpecPageTabsProps,
PageCardProps as SpecPageCardProps,
PageAccordionProps as SpecPageAccordionProps,
PageContainerProps as SpecPageContainerProps,
RecordDetailsProps as SpecRecordDetailsProps,
RecordHighlightsProps as SpecRecordHighlightsProps,
RecordRelatedListProps as SpecRecordRelatedListProps,
RecordPathProps as SpecRecordPathProps,
RecordActivityProps as SpecRecordActivityProps,
RecordChatterProps as SpecRecordChatterProps,
RecordHistoryProps as SpecRecordHistoryProps,
RecordQuickActionsProps as SpecRecordQuickActionsProps,
RecordReferenceRailProps as SpecRecordReferenceRailProps,
RecordAlertProps as SpecRecordAlertProps,
ElementTextPropsSchema as SpecElementTextPropsSchema,
ElementButtonPropsSchema as SpecElementButtonPropsSchema,
objectNavTargetExclusivity,
checkListViewCalendarVisualization,
checkPageSourceCompleteness,
Expand Down Expand Up @@ -210,6 +228,28 @@ const IMPORTED: Array<readonly [string, z.ZodType]> = [
// `listViews` record, crossed through this boundary, so the container is
// measured here like every other crossing.
['ViewSchema', SpecViewSchema],
// objectui#10872: each ADR-0080 public-block arm's `properties` is the
// block's `ComponentPropsMap` row, crossed through this boundary, so every
// row is measured here like every other crossing (`page:section`,
// `page:footer` and `page:sidebar` share `PageContainerProps`, and
// `record:discussion` reads the `record:chatter` row, so one entry each).
['PageHeaderProps', SpecPageHeaderProps],
['PageTabsProps', SpecPageTabsProps],
['PageCardProps', SpecPageCardProps],
['PageAccordionProps', SpecPageAccordionProps],
['PageContainerProps', SpecPageContainerProps],
['RecordDetailsProps', SpecRecordDetailsProps],
['RecordHighlightsProps', SpecRecordHighlightsProps],
['RecordRelatedListProps', SpecRecordRelatedListProps],
['RecordPathProps', SpecRecordPathProps],
['RecordActivityProps', SpecRecordActivityProps],
['RecordChatterProps', SpecRecordChatterProps],
['RecordHistoryProps', SpecRecordHistoryProps],
['RecordQuickActionsProps', SpecRecordQuickActionsProps],
['RecordReferenceRailProps', SpecRecordReferenceRailProps],
['RecordAlertProps', SpecRecordAlertProps],
['ElementTextPropsSchema', SpecElementTextPropsSchema],
['ElementButtonPropsSchema', SpecElementButtonPropsSchema],
] as const;

/** The subset that actually carries an imported default — where the strip does work. */
Expand Down
Loading
Loading