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
49 changes: 49 additions & 0 deletions .changeset/10735-html-tier-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
'@object-ui/core': minor
'@object-ui/sdui-parser': minor
'@object-ui/components': minor
---

feat(core,sdui-parser): the published manifest declares the html tier's registered intrinsic elements, marked `tier: 'html'` — `div` stays out

A `kind:'html'` page may author the everyday HTML tags its renderer registers —
the flow/inline set of `html-elements.tsx` (h1–h6, p, a, lists, emphasis,
figure/img/hr/br, time, address, cite, q), `span`, `table`, `label` and the seven
sectioning tags — and the console has rendered such pages all along. The
published `sdui.manifest.json` never said so: it was the curated `PUBLIC_BLOCKS`
vocabulary alone, so the objectstack CLI gate, which whitelists an html page's
tags from that manifest, refused every plain HTML tag as `forbidden-tag` on
pages the renderer accepts (200 ledgered findings over the three shipped showcase
pages). The maintainer ruled A on objectstack#20112: the producer declares the
set, exactly as the registry declares it, and `div` stays deprecated.

**What moves for a consumer.** `ComponentRegistry.getPublicConfigs()` — the
read both manifest producers serialise — now returns the curated tier AND the
html tier's intrinsic elements, the latter from the new `HTML_TIER_INTRINSICS`
roster (`@object-ui/core`) and each stamped `tier: 'html'` in the projection.
`manifestFromConfigs` carries that one value into a new optional
`ManifestComponent.tier` (`@object-ui/sdui-parser`), so the regenerated
`sdui.manifest.json` grows by the roster (47 entries, each with its registered
`inputs` and its child slot declared exactly where the registration renders a
child list — the void tags `img` / `hr` / `br` declare none) and
`sdui-intrinsics.d.ts` types them. A reader that whitelists tags by key — the
objectstack gate — needs no change and accepts the tags. A reader that means the
CURATED vocabulary filters on the stamp: `generateBlockList` now sections the
html tier under its own count, and the `kind:'react'` JSX scope skips stamped
entries (`@object-ui/components`, `react-page.tsx`), so no `P` / `A` / `Img`
wrapper is injected into react pages — on that tier a lowercase `<p>` is React's
own element.

**What does not move.** `PUBLIC_BLOCKS` is unchanged and the two rosters are
pinned disjoint; every curated manifest entry serialises byte-identically
(`tier` is written as exactly `'html'` or omitted). `div` is not declared: the
gate keeps refusing `<div>` on an html page, and `box` remains the JSON
surface's replacement. `code` is not declared either — the bare `code` key is
the `field:code` widget's namespace fallback, not an element renderer — nor is
`kbd`; both are recorded, with reasons, in the console's exclusion ledger.
No registration changes; `html-elements.tsx` is untouched.

The manifest the framework ships regenerates from objectui's built tree at the
pin (its `gen-sdui-manifest-node.mjs`); its lockstep copy of `manifestFromConfigs`
must take this port for the stamp to reach that file — until then the tags are
declared there without the marker, which the gate treats identically.
2 changes: 2 additions & 0 deletions .changeset/6740-flex-is-container.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ wrote `<Flex>` moves from silently swallowing its children to the page-level
error panel naming the identifier, with that page's documented remedy being
real HTML: `<div style={{ display: 'flex', gap: 16 }}>`.

⚠️ **Dated note, 2026-09-27 — the skip line quoted above has since gained a third arm — objectui#10735.** At this change the line read `if (!tag || cfg.isContainer) continue;`; it now reads `if (!tag || cfg.isContainer || cfg.tier === 'html') continue;`, because `getPublicConfigs()` also returns the html tier's intrinsic elements stamped `tier: 'html'` and the scope skips those too. The container arm this entry is about is unchanged; the rest of this entry is kept as the reading of this change.

Pinned over the family rather than over `flex` alone: the defect's shape was
"three declare it and one does not", and a pin covering only the one that was
missing would let the next registration rot the same way.
299 changes: 299 additions & 0 deletions apps/console/src/__tests__/html-tier-manifest.test.ts

Large diffs are not rendered by default.

21 changes: 14 additions & 7 deletions apps/console/src/__tests__/public-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,13 +525,16 @@ const specCarried = (type: string): boolean =>
*
* ⚠️ 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
* container widens the curated AI-authoring vocabulary (and, for a container
* the html tier does not already declare through `HTML_TIER_INTRINSICS`,
* `sdui.manifest.json` and the generated intrinsics too — the seven sectioning
* tags are already there as html-tier entries since objectui#10735), 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
* builds that scope with `if (!tag || cfg.isContainer || cfg.tier === 'html') 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
Expand All @@ -552,12 +555,16 @@ const SECTIONING_TAG_UNRULED =
'`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 ' +
'curated AI-authoring vocabulary 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 ' +
'`semantic.tsx` means by re-opening the question THERE. (Since objectui#10735 the seven are ' +
'ALREADY in `sdui.manifest.json` and the generated intrinsics, as html-tier entries stamped ' +
'`tier: \'html\'` from `HTML_TIER_INTRINSICS`, so a `kind:\'html\'` page may author them; ' +
'curating one would move it onto the curated tier, unstamped — a different fact from ' +
'declaring it.) ⛔ 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 ' +
"EVERY container config (`if (!tag || cfg.isContainer || cfg.tier === 'html') 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 ' +
Expand Down
2 changes: 1 addition & 1 deletion content/docs/guide/plugin-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ export interface BoardSchema extends BaseSchema {
}
```

Declare `ComponentInput` entries when registering: they are what the published manifest (`sdui.manifest.json`) and the JSX-page compiler's diagnostics read. Each entry carries the seven keys the manifest forwards — `name`, `type`, `of`, `required`, `enum`, `binding`, `description`; a default belongs in the renderer's own fallback read and, for the author, in `description` (`label`, `defaultValue` and `advanced` are retired keys — nothing ever read them).
Declare `ComponentInput` entries when registering: they are what the published manifest (`sdui.manifest.json`) and the JSX-page compiler's diagnostics read. Each entry carries the seven keys the manifest forwards — `name`, `type`, `of`, `required`, `enum`, `binding`, `description`; a default belongs in the renderer's own fallback read and, for the author, in `description` (`label`, `defaultValue` and `advanced` are retired keys — nothing ever read them). At the component level the manifest carries one marker of its own: `tier: 'html'` on the intrinsic HTML elements a `kind:'html'` page may author (`HTML_TIER_INTRINSICS` in `@object-ui/core`). An entry without it is a curated block; a reader that means the curated vocabulary filters on it, a reader that whitelists tags takes every key.

`of` is the coarse kind of an `array`'s elements, or of the values of an `object` used as a map. Declare it whenever the contract admits exactly one kind there: `type: 'array'` alone says a value is a list and stops, so a member of the wrong shape reaches the renderer with nothing anywhere reporting it. With `of` declared, the parser reports a member no declared kind accepts, and the generated `sdui-intrinsics.d.ts` types the elements (`string[]` rather than `unknown[]`). Like `type`, it names a KIND and never a domain: `of: 'object'` says the elements are objects, not which keys they carry — spell that out in `description`, and let `os validate` judge the values. A member contract that genuinely accepts several kinds takes the array form (`of: ['string', 'object']`), or no `of` at all:

Expand Down
8 changes: 6 additions & 2 deletions content/docs/guide/react-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ derived by splitting the registry type on `-`, `_` and `:` and PascalCasing each
part: `object-grid` → `<ObjectGrid>`, `record:details` → `<RecordDetails>`. A
`kind:'html'` page writes the registry type itself instead — `<object-grid>`,
`<record:details>`. Blocks registered lazily are in scope too — you never wait
on a plugin chunk to reference one.
on a plugin chunk to reference one. The intrinsic HTML elements the `html` tier
declares (`h1`, `p`, `a`, the sectioning tags, … — `HTML_TIER_INTRINSICS`, carried
in `sdui.manifest.json` as `tier: 'html'`) are **not** injected here: on this tier
a lowercase `<p>` is React's own element, and no `<P>` wrapper exists.

**The authored contract** is the much smaller set that has *published props* —
checked by `os validate` and generated into the reference an author, human or AI,
Expand Down Expand Up @@ -138,7 +141,8 @@ If you want the whole record-page composition, author the page as `type:'record'
instead — that is the page kind that mounts the context these blocks render from.

**Layout containers are deliberately not injected.** The scope builder skips
every container (`if (!tag || cfg.isContainer) continue;`), so `<flex>`, `<grid>`,
every container and every html-tier entry
(`if (!tag || cfg.isContainer || cfg.tier === 'html') continue;`), so `<flex>`, `<grid>`,
`<card>` and friends have no injected wrapper. In react mode you compose layout
with real HTML, which React is better at than a schema-children renderer — styled
inline, not with Tailwind: `<div style={{ display: 'flex', gap: 16 }}>`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe('the declaration reaches the consumers that read it (objectui#6740)', (
it.each(LAYOUT_CONTAINERS)('`%s` reports as a container on the public tier', (type) => {
// The flag's consumer, and the reason it is pinned rather than left
// implicit: `renderers/layout/react-page.tsx` builds the JSX scope of
// every `kind:'react'` page with `if (!tag || cfg.isContainer) continue;`,
// every `kind:'react'` page with `if (!tag || cfg.isContainer || cfg.tier === 'html') continue;`,
// reading THIS predicate off `getPublicConfigs()` — not off `getMeta()`.
// Since objectui#9910 this is the flag's ONE meaning — layout containment —
// so this block is about the react-page scope, not about `validateTree`.
Expand Down
29 changes: 29 additions & 0 deletions packages/components/src/__tests__/react-page-scope.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,35 @@ function Page() {
});
});

// ---------------------------------------------------------------------------
// 3b. The html tier rides the same read and must NOT be injected (objectui#10735)
// ---------------------------------------------------------------------------

describe('kind:\'react\' scope — html-tier intrinsics are not injected', () => {
it('`img` is in the contract read, stamped `tier: html`, and is not a container', () => {
// The premise: ONLY the stamp keeps it out. `img` is a leaf (no
// `isContainer`), so the container branch would have injected an `<Img>`
// wrapper the moment the html tier joined `getPublicConfigs()`.
const cfg = ComponentRegistry.getPublicConfigs().find((c) => c.type === 'img');
expect(cfg).toBeTruthy();
expect(cfg!.tier).toBe('html');
expect(cfg!.isContainer).toBeFalsy();
});

it('a PascalCased html-tier tag is an unknown identifier on this tier', async () => {
// A lowercase `<img>` is React's own intrinsic here; `<Img>` must not
// resolve to an injected wrapper. The failure shape is the same loud
// ReferenceError the negative control below pins.
const source = `
function Page() {
return <Img src="/x.png" alt="x" />;
}`;
const { container, findByText } = renderReactPage(source);
expect(await findByText('React page error')).toBeTruthy();
await waitFor(() => expect(container.textContent).toContain('Img is not defined'));
});
});

// ---------------------------------------------------------------------------
// 4. Negative control — absence must be loud
// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ describe('the declaration is confined to what was measured (objectui#6764)', ()
describe('the premise that made this change safe on the SECOND consumer (objectui#6764)', () => {
it('none of the eight is in the curated public contract', () => {
// `renderers/layout/react-page.tsx` builds the JSX scope of every
// `kind:'react'` page with `if (!tag || cfg.isContainer) continue;`, so each
// declaration also REMOVES that tag as an injected identifier — the
// `kind:'react'` page with `if (!tag || cfg.isContainer || cfg.tier === 'html') continue;`,
// so each declaration also REMOVES that tag as an injected identifier — the
// consequence objectui#6764 recorded as unmeasured. It reads
// `getPublicConfigs()`, not the whole registry, and none of these eight is
// in it: there is no `<Aside>` / `<Main>` / `<AspectRatio>` wrapper for the
Expand All @@ -219,17 +219,33 @@ describe('the premise that made this change safe on the SECOND consumer (objectu
// Pinned rather than left as a comment so that promoting one of these into
// `PUBLIC_BLOCKS` re-opens the question HERE, instead of silently dropping a
// tag from every react page.
const publicTags = new Set(
(ComponentRegistry.getPublicConfigs() as Array<{ type: string }>).map((c) => c.type),
);
//
// objectui#10735 re-opened it, deliberately, and answered it: the seven
// sectioning tags now ride `getPublicConfigs()` — the published manifest
// must whitelist them for `kind:'html'` pages — but as HTML-TIER entries,
// stamped `tier: 'html'`, never as curated blocks. `react-page.tsx` skips
// them on that stamp as well as on the container flag, so the premise this
// pin guards ("no injected wrapper exists for the flag to delete") holds,
// and the CURATED contract is read here as the unstamped entries.
const contract = ComponentRegistry.getPublicConfigs() as Array<{ type: string; tier?: string }>;
const curatedTags = new Set(contract.filter((c) => c.tier !== 'html').map((c) => c.type));
const htmlTierTags = new Set(contract.filter((c) => c.tier === 'html').map((c) => c.type));

// Direction control first. Without it, "none of the eight is public" is
// indistinguishable from "the public tier is empty / this reader broke",
// and the assertion below would be green for nothing.
expect(publicTags.size).toBeGreaterThan(0);
expect(publicTags.has('flex')).toBe(true);
expect(publicTags.has('button')).toBe(true);
expect(curatedTags.size).toBeGreaterThan(0);
expect(curatedTags.has('flex')).toBe(true);
expect(curatedTags.has('button')).toBe(true);

expect(DECLARED_HERE.filter((t) => publicTags.has(t))).toEqual([]);
expect(DECLARED_HERE.filter((t) => curatedTags.has(t))).toEqual([]);

// The seven sectioning tags are declared for the html tier and nowhere
// else; `aspect-ratio` is on neither roster (refused on stated merits,
// objectui#8628).
expect(DECLARED_HERE.filter((t) => htmlTierTags.has(t)).sort()).toEqual(
['article', 'aside', 'footer', 'header', 'main', 'nav', 'section'],
);
expect(htmlTierTags.has('aspect-ratio')).toBe(false);
});
});
10 changes: 8 additions & 2 deletions packages/components/src/renderers/layout/react-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,15 @@ function buildComponentScope(dataSource: unknown): Record<string, React.Componen
// Some data blocks read their dataSource from props (e.g. `list-view`), others
// from the SchemaRenderer context (e.g. `object-form`). We inject it as a prop
// here AND wrap the page in a SchemaRendererProvider below, so both kinds work.
for (const cfg of ComponentRegistry.getPublicConfigs() as Array<{ type: string; isContainer?: boolean }>) {
for (const cfg of ComponentRegistry.getPublicConfigs() as Array<{ type: string; isContainer?: boolean; tier?: string }>) {
const tag = cfg.type;
if (!tag || cfg.isContainer) continue;
// The html tier's intrinsic elements ride `getPublicConfigs()` stamped
// `tier: 'html'` so the published manifest can whitelist them for
// `kind:'html'` pages (objectui#10735). They are NOT curated blocks and
// they are DOM intrinsics in real JSX already — a lowercase `<p>` on this
// tier is React's own — so injecting `P` / `A` / `Img` wrappers would widen
// this scope for nothing. Skipped by the stamp, not by name.
if (!tag || cfg.isContainer || cfg.tier === 'html') continue;
const name = toPascal(tag);
if (seen.has(name)) continue;
seen.add(name);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
export type { SchemaNode, ComponentRendererProps } from './types/index.js';
export * from './registry/Registry.js';
export * from './registry/public-blocks.js';
export * from './registry/html-tier-intrinsics.js';
export * from './registry/PluginSystem.js';
export * from './registry/PluginScopeImpl.js';
export * from './registry/WidgetRegistry.js';
Expand Down
Loading
Loading