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
94 changes: 94 additions & 0 deletions .changeset/8499-node-slot-registered-arms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
'@object-ui/types': minor
---

Give `AnyComponentSchema` arms for seven registered, live renderers that resolved in
no arm at a declared node slot (objectui#8499).

**The defect, and the direction it ran.** Nine `type` spellings sat at DECLARED node
slots in this repository's own corpora and resolved in no arm of the component union.
Eight were registered renderers with fixtures proving they draw; the ninth
(`my-component`) is the reader's own plugin component and carries a written exemption
in `scripts/check-doc-component-types.mjs`. A reader following
`content/docs/utilities/runner.mdx`'s own instruction — "copy one, wrap it in a page
document … and save it as `src/app-data/pages/index.json`" — got a document that
**renders correctly in the browser and is refused by `objectui check`**. That is the
expensive direction: the likely reaction is to stop trusting the validator.

It was invisible because `check:doc-types` judges a `type` literal against the
RENDERER REGISTRY (656 keys) and not against `AnyComponentSchema` (107 arm literals).
The two faces disagreed by construction and nothing compared them at a node slot.

**What is now authorable.** Four new arms, 47 new `type` literals, taking the union
from 107 to 154:

- `SemanticElementSchema` (`zod/layout.zod.ts`) — the seven HTML sectioning tags
`renderers/layout/semantic.tsx` registers: `aside` `main` `header` `nav` `footer`
`section` `article`.
- `HtmlElementSchema` (`zod/layout.zod.ts`) — the 37 safe flow/inline tags
`renderers/basic/html-elements.tsx` registers (`h1`…`h6`, `p`, `a`, `ul`, `img`, …),
plus the per-tag keys that module forwards to the DOM (`href`, `target`, `rel`,
`title`, `src`, `alt`, `width`, `height`, `dateTime`, `cite`).
- `InputShorthandSchema` (`zod/form.zod.ts`) — `email` / `password`, the two aliases
`renderers/form/input.tsx` registers onto the `input` renderer with `inputType`
pinned. `inputType` is deliberately NOT declared on this arm: the wrapper spreads
its own value last, so an authored one is overwritten.
- `UiCalendarSchema` (`zod/form.zod.ts`) — `ui:calendar`, the date-picker primitive
`renderers/form/calendar.tsx` registers under exactly that key (`skipFallback`,
because bare `calendar` belongs to the plugin-calendar view).

Every arm this change AUTHORS declares only keys its renderer demonstrably reads —
the `BarChartSchema` discipline objectui#6318 established for the same class of gap.
⚠️ One arm INHERITS more than that, and it is stated rather than glossed:
`UiCalendarSchema` extends `CalendarSchema`, so it carries `minDate` and `maxDate`,
which `renderers/form/calendar.tsx` reads zero times (controls, same file: `mode` 3,
`className` 4 — it reads `mode`, `value`, `defaultValue` and `className`). That is
pre-existing debt on `CalendarSchema`, not something this change introduces, and
narrowing it here would be a different card's accept-set movement; the extend is what
keeps the two spellings one schema.

**A repo-tracked metric moves, declared knowingly.**
`scripts/measure-strict-authoring-face.mjs` reports `unexportedNodeSchemas` — node
types reachable only through the union because no schema for them is exported by name
from `@object-ui/types/zod`, "so no consumer can validate one alone". The four new
arms are not in that barrel's explicit export lists, so the metric moves from
`[breadcrumb, object-tree]` to **49 node types** (the same 2, plus this change's 47
new literals). Measured at this branch's head, not estimated.

Why 49 is acceptable here where 2 was a defect: the 2 were data blocks a consumer had
a standing reason to validate on their own, and objectui#7917 (PR #8777, open at the
time of writing, and the holder of `zod/index.zod.ts`) exists to export exactly those.
The 47 added here are HTML primitives and two input aliases — they have no per-tag
consumer to serve, and exporting a `SemanticElementSchema` / `HtmlElementSchema` pair
would publish a NAMED authoring surface (`z.enum` families, not per-tag schemas) that
this card's ruling does not cover: the ruling is "arm the registered renderers", not
"add public exports to `@object-ui/types`". So the metric is left to move and said out
loud instead. ⇒ Whoever next runs that measurement should expect 49, and whoever
wants the number back down should treat naming these families as its own decision.
If PR #8777 lands first, the same movement reads `0` to `47`.

**Accept-set movement is widening only.** Nothing that parsed green parses red. Each
arm still judges VALUES: `{ type: 'img', width: true }`, `{ type: 'password',
required: 'yes' }` and `{ type: 'ui:calendar', mode: 'agenda' }` are all refused, and
a `type` nothing registers (`stat-card`, `metric-card`, `h1ZZ`) is refused at the root
and at every node slot exactly as before.

**Every one of these types becomes legal at EVERY node slot** — measured: 249
arm-level node slots before this change and 257 after, plus 36 on nested schemas,
all spelled with the one `SchemaNodeSchema`. A slot-constrained shape is not expressible by adding arms: a
discriminated union selects its arm from the authored literal alone, so a slot has no
say. That is a per-slot vocabulary programme, not a variant of this change.

**`line-chart` is deliberately NOT armed.** The card lists it among the eight as a
live renderer; measured here it is not. `apps/console/src/register-plugins.ts`
registers it as a lazy stub pointing at `@object-ui/plugin-charts`, and that package
never registers the key, so it resolves to nothing at render time. Arming it would
invent a capability rather than name one. `__tests__/node-slot-registered-arms-8499.test.ts`
pins the absence together with its reason, so registering the key for real turns red.

**Downstream.** `objectui check` stops reporting these documents. Its
`check-validity-recogniser` suite measured 658 registered types against 102 arm
literals when it was written; re-measured here it is 656 against 154, so the
"registered but not modelled" bucket goes from 558 to 505 and its fixture moved off
the HTML primitives (they are modelled now) onto `metric-card`, whose absence from the
union is ruled rather than pending.
152 changes: 134 additions & 18 deletions apps/console/src/__tests__/public-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,9 +397,11 @@ describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => {
*
* Scope, stated plainly: this covers the CONTAINER half of the layout
* vocabulary. The non-container arms (`span`, `separator`, `scroll-area`,
* `resizable`, `page`, and the deprecated `div`) are outside it because
* curating any of them is an unruled question of its own, and a ledger is a
* forcing function, not a place to park four of those at once.
* `resizable`, `page`, the deprecated `div`, and — since objectui#8499 armed
* them — the 37 flow/inline HTML tags of `HtmlElementSchema`, not one of which
* declares containment) are outside it because curating any of them is an
* unruled question of its own, and a ledger is a forcing function, not a place
* to park four of those at once.
*
* ── THE MEASUREMENT THIS CARD WAS DISPATCHED TO MAKE, RECORDED ──────────────
*
Expand Down Expand Up @@ -443,16 +445,39 @@ describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => {
/**
* The JSON layout vocabulary, read from the zod union's own arms.
*
* A discriminated union member declares its `type` literal to Zod, so the arm
* list IS the vocabulary. Filtering to real strings is deliberate: a zod release
* that moved this accessor would otherwise yield a population of `undefined` and
* make every assertion below vacuously true, which is why the first case
* compares the resolved count against the raw arm count.
* A discriminated union member declares its `type` to Zod, so the arm list IS
* the vocabulary — but an arm declares it in one of TWO shapes, and reading only
* the first is what objectui#8499 broke here. A `z.literal` arm carries one
* spelling on `.value`; a `z.enum` arm carries a whole registered family on
* `.options` — `SemanticElementSchema`'s seven sectioning tags, and
* `HtmlElementSchema`'s 37 flow/inline tags. A `.value`-only read resolved
* neither, and the anti-vacuity case below reported it as 19 arms yielding 17
* literals, which is precisely the job that case exists to do.
*
* The switch is on zod's own `def.type` discriminator, NOT on which accessor
* happens to be present. A fallback chain (`.value`, else `.options`, else …)
* would paper over exactly the accessor drift the first case is meant to
* report: an arm whose declaration is neither shape resolves NOTHING here, and
* the count comparison below turns that silence into a failure.
*/
const LAYOUT_UNION_ARMS = (LayoutSchema as unknown as { options: unknown[] }).options;
const LAYOUT_VOCABULARY: string[] = LAYOUT_UNION_ARMS.map(
(arm) => (arm as { shape?: { type?: { value?: unknown } } }).shape?.type?.value,
).filter((value): value is string => typeof value === 'string' && value.length > 0);

type TypeDeclaration = { def?: { type?: string }; value?: unknown; options?: unknown[] };

/** Every `type` spelling ONE arm can take: a literal's single value, or an enum's whole set. */
const armLiterals = (arm: unknown): string[] => {
const declared = (arm as { shape?: { type?: TypeDeclaration } }).shape?.type;
const isSpelling = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0;
if (declared?.def?.type === 'literal') {
return isSpelling(declared.value) ? [declared.value] : [];
}
if (declared?.def?.type === 'enum') return (declared.options ?? []).filter(isSpelling);
return [];
};

const LAYOUT_ARM_LITERALS: string[][] = LAYOUT_UNION_ARMS.map(armLiterals);
const LAYOUT_VOCABULARY: string[] = LAYOUT_ARM_LITERALS.flat();

/** …of those, the ones the registry DECLARES it renders a child list for. */
const DECLARED_LAYOUT_CONTAINERS = LAYOUT_VOCABULARY.filter(
Expand Down Expand Up @@ -480,7 +505,60 @@ const specCarried = (type: string): boolean =>
* below by ABSENCE — which is the property `box` needed and did not have. An
* entry here is the deliberate, reviewable alternative to curating, never a
* silent one, and it must name an issue that resolves it.
*
* ⭐ The seven sectioning tags arrived here BY ABSENCE — the mechanism working,
* not failing. They have declared `isContainer: true` since objectui#6764;
* objectui#8499 armed them as `SemanticElementSchema`, and becoming arms of the
* layout union is what first pulled already-declared containers into the
* intersection this file derives. ⛔ The alternative on offer — housing that arm
* outside `LayoutSchema` — was refused deliberately: it would have removed seven
* genuine layout containers from this population without changing anything about
* what they are, which is a gate that stops looking rather than a gate that
* passes. Ledgering them is the reviewable option; objectui#8775 holds the
* decision itself, which is NOT this card's to take.
*
* ⚠️ What an entry here COSTS, stated once so no entry has to re-argue it, and
* corrected because objectui#8499 first shipped it wrong. Curating a ledgered
* container widens the AI-authoring vocabulary, `sdui.manifest.json` and the
* generated intrinsics, and turns the census pin in
* `renderers/__tests__/container-declaration-census.test.tsx` red BY DESIGN — a
* deliberate re-opening, which is the whole point of pinning it. It does ⛔ NOT
* remove the tag from any `kind:'react'` page. `renderers/layout/react-page.tsx`
* builds that scope with `if (!tag || cfg.isContainer) continue;`, so it skips
* EVERY container config; a ledgered container already carries the flag, so
* promotion changes which list the config comes from and nothing about whether
* the loop keeps it. Measured on `main`: 46 injected identifiers today, 46 with
* `main` promoted, `Main` absent from both. The deletion reading is real but
* runs the OTHER way — objectui#6764's direction, where declaring containment on
* an already-public block removes an identifier that existed — and reading that
* docblock forwards is how it got here.
*/

/**
* The seven are ONE registration — a single loop factory in
* `renderers/layout/semantic.tsx` over one `tags` array — so they share one
* reason rather than seven paraphrases of it.
*/
const SECTIONING_TAG_UNRULED =
'NOT YET RULED, either way — and promoting it is not a roster edit. One of the seven HTML ' +
'sectioning tags the single loop factory in `renderers/layout/semantic.tsx` registers with ' +
'`category: layout` and `isContainer: true` (objectui#6764). objectui#8499 armed them as ' +
'`SemanticElementSchema`, which is what first brought already-declared containers into the ' +
'population this file derives — none of them is newly a container, and none is newly ' +
'authorable. What curating one WOULD move, measured rather than reasoned: it widens the ' +
'AI-authoring vocabulary, `sdui.manifest.json` and the generated intrinsics, and it turns the ' +
'"none of the eight is in the curated public contract" pin in ' +
'`container-declaration-census.test.tsx` RED BY DESIGN — which is precisely what ' +
'`semantic.tsx` means by re-opening the question THERE. ⛔ It does NOT delete the tag from ' +
'any react page, and an earlier revision of this entry said it did: `react-page.tsx` skips ' +
'EVERY container config (`if (!tag || cfg.isContainer) continue;`) and these seven already ' +
'carry `isContainer: true`, so a promoted config is skipped on exactly the same line an ' +
'unlisted one never reaches — measured at 46 injected identifiers before and 46 after ' +
'simulating the promotion of `main`, with `Main` absent from both. A lowercase `main` in a ' +
'react page is a DOM intrinsic either way, because `react-runtime` never reads the registry. ' +
'objectui#8775 measured the population and holds the decision: curate the family or a named ' +
'subset and carry that consequence, or refuse on stated merits and replace this text with them.';

const UNCURATED_LAYOUT_CONTAINERS: Record<string, string> = {
'aspect-ratio':
'NOT YET RULED, either way — and that is the entry, stated honestly rather than dressed as merits. ' +
Expand All @@ -490,18 +568,56 @@ const UNCURATED_LAYOUT_CONTAINERS: Record<string, string> = {
'`PUBLIC_BLOCKS`, so the react-page scope builder never saw this tag"), never a reason. objectui#6879 ' +
'measured the population and filed the decision as objectui#8628 — curate it, or refuse it on stated ' +
'merits and replace this text with them. Until then it stays visible here instead of invisible in a gap.',
article: SECTIONING_TAG_UNRULED,
aside: SECTIONING_TAG_UNRULED,
footer: SECTIONING_TAG_UNRULED,
header: SECTIONING_TAG_UNRULED,
main: SECTIONING_TAG_UNRULED,
nav: SECTIONING_TAG_UNRULED,
section: SECTIONING_TAG_UNRULED,
};

describe('PUBLIC_BLOCKS ↔ the declared layout containers (derived, objectui#6879)', () => {
it('reads the vocabulary off the union rather than restating it', () => {
// The anti-vacuity case, and it comes first. Every arm must resolve a real
// `type` literal: a population of `undefined`s would make the ledger pin
// below pass while asserting nothing at all.
// The anti-vacuity case, and it comes first. EVERY arm must resolve at least
// one real `type` spelling: an arm that resolved none would make the ledger
// pin below pass while asserting nothing at all about that arm.
//
// ⚠️ Counting literals against arms — what this line did before
// objectui#8499 — is NOT the same assertion and cannot be restored: a single
// enum arm contributes 37 spellings, so the two numbers are no longer meant
// to match. What still holds one-for-one is that no arm contributes ZERO.
expect(LAYOUT_UNION_ARMS.length).toBeGreaterThan(0);
expect(LAYOUT_VOCABULARY).toHaveLength(LAYOUT_UNION_ARMS.length);
// And it must be the vocabulary we think it is — the minted type this card
// is about, plus two of its long-standing siblings.
expect(LAYOUT_VOCABULARY).toEqual(expect.arrayContaining(['box', 'flex', 'container']));
expect(LAYOUT_ARM_LITERALS.filter((literals) => literals.length > 0)).toHaveLength(
LAYOUT_UNION_ARMS.length,
);
// And it must be the vocabulary we think it is: the type this card was built
// around, two of its long-standing siblings, and one spelling out of EACH
// enum arm — so a reader that silently stopped resolving enums, which is the
// regression this file caught, cannot pass this case either.
expect(LAYOUT_VOCABULARY).toEqual(
expect.arrayContaining(['box', 'flex', 'container', 'main', 'h1']),
);
// ⭐ The SECOND ACCESSOR PATH, and the reason the sample above is not the
// only guard. `propValues` is zod's OWN discriminator index, built when the
// union was constructed; `armLiterals` is this file's switch over `def`.
// Two readers, one schema — so they must agree as sets, and the day someone
// "simplifies" `armLiterals` back to a `.value`-only read (exactly the
// objectui#8499 regression) the two stop agreeing here rather than five
// sampled spellings later.
//
// ⚠️ Scope, stated so nobody over-reads it: this pins the READER, not the
// schema. Both paths read the same union, so a spelling genuinely deleted
// from an enum arm (`q`, say) leaves both sides equal and this case green;
// that direction is pinned against the REGISTRATION SOURCE elsewhere, which
// is where it belongs.
const zodPropValues = (
LayoutSchema as unknown as { _zod?: { propValues?: { type?: Set<unknown> } } }
)._zod?.propValues?.type;
expect(zodPropValues, "zod exposes no `propValues.type` index for this union").toBeDefined();
expect([...(zodPropValues ?? [])].filter((v) => typeof v === 'string').sort()).toEqual(
[...LAYOUT_VOCABULARY].sort(),
);
});

it('derives a non-empty container population holding the objectui#6764 control set', () => {
Expand Down
Loading
Loading