diff --git a/.changeset/10735-html-tier-manifest.md b/.changeset/10735-html-tier-manifest.md
new file mode 100644
index 0000000000..2bdcd4e0d3
--- /dev/null
+++ b/.changeset/10735-html-tier-manifest.md
@@ -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 `
` 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 `
` 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.
diff --git a/.changeset/6740-flex-is-container.md b/.changeset/6740-flex-is-container.md
index 251aae955c..c151c0f144 100644
--- a/.changeset/6740-flex-is-container.md
+++ b/.changeset/6740-flex-is-container.md
@@ -35,6 +35,8 @@ wrote `` moves from silently swallowing its children to the page-level
error panel naming the identifier, with that page's documented remedy being
real HTML: `
`.
+⚠️ **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.
diff --git a/apps/console/src/__tests__/html-tier-manifest.test.ts b/apps/console/src/__tests__/html-tier-manifest.test.ts
new file mode 100644
index 0000000000..8b7374c401
--- /dev/null
+++ b/apps/console/src/__tests__/html-tier-manifest.test.ts
@@ -0,0 +1,299 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#10735 — the published manifest declares the html tier's registered
+ * intrinsic elements, exactly as the registry declares them, and `div` stays out
+ * (the maintainer's ruling A on objectstack#20112).
+ *
+ * Built the way both manifest producers build it — `getPublicConfigs()` through
+ * `manifestFromConfigs`, over the console's REAL registration graph
+ * (`@object-ui/components` plus `src/register-plugins.ts`, the pair
+ * `dev/manifest-dump.tsx` loads) — so what is asserted here is what
+ * `sdui.manifest.json` will carry, not what a fixture was shaped to say.
+ *
+ * Three things are pinned:
+ *
+ * 1. COVERAGE — every roster tag reaches the manifest stamped `tier: 'html'`,
+ * with its registered `inputs` and its child slot declared exactly where
+ * the registration declares it (the void tags declare none);
+ * 2. THE CURATED TIER IS UNMOVED — the entries WITHOUT the stamp are exactly
+ * the registered `PUBLIC_BLOCKS`, so the JSON-surface vocabulary did not
+ * grow by a single tag;
+ * 3. THE CENSUS — every bare registration whose key is an HTML element name
+ * is on one of the two rosters or in the exclusion ledger below, WITH a
+ * reason. The population is derived from `@types/react`'s
+ * `JSX.IntrinsicElements` (the one machine-readable list of element names
+ * this repo already depends on), so a registration that newly squats on a
+ * tag name lands here by absence rather than by memory.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { dirname, join } from 'node:path';
+import { LayoutSchema } from '@object-ui/types/zod';
+import { compile, generateDts, manifestFromConfigs, type RegistryConfigLike } from '@object-ui/sdui-parser';
+import {
+ ComponentRegistry,
+ HTML_TIER_INTRINSICS,
+ HTML_TIER_INTRINSIC_SET,
+ PUBLIC_BLOCKS,
+ PUBLIC_BLOCK_SET,
+} from '@object-ui/core';
+// The layout/content primitives (Tier B) and every html-tier renderer …
+import '@object-ui/components';
+// … and the console's own plugin layer, from the module main.tsx boots from.
+import '../register-plugins';
+
+/** Exactly the generators' read (`dev/manifest-dump.tsx`, `sdui-parser/scripts/gen-manifest.ts`). */
+const manifest = manifestFromConfigs(ComponentRegistry.getPublicConfigs() as unknown as RegistryConfigLike[]);
+/** What a consumer reads back off disk — `JSON.stringify` drops every `undefined`. */
+const published = JSON.parse(JSON.stringify(manifest)) as typeof manifest;
+const entries = Object.values(published.components);
+
+const inputNames = (tag: string): string[] => (ComponentRegistry.getMeta(tag)?.inputs ?? []).map((i) => i.name);
+const declaresSlot = (tag: string): boolean =>
+ (ComponentRegistry.getMeta(tag)?.inputs ?? []).some((i) => i.name === 'children');
+
+/**
+ * The tags `html-elements.tsx` renders WITHOUT a child list (`VOID_TAGS`
+ * there) — asserted against the registration rather than restated, below.
+ */
+const VOID_TAGS = ['img', 'hr', 'br'];
+
+/**
+ * HTML-named bare registrations that are on NEITHER roster, each with the
+ * reason. A forcing function, not an allowlist: a registration that squats on
+ * an element name and is absent from both rosters AND from here fails the
+ * census by absence, and a stale entry — one no longer registered, or one
+ * since admitted to a roster — fails the liveness pin.
+ */
+const EXCLUDED_HTML_NAMED: Record = {
+ div:
+ 'deprecated on the JSON surface in favour of `box` (objectui#3965, PR objectui#6878) and kept out of the ' +
+ 'published contract by the objectstack#20112 ruling A: the gate that reads the manifest refuses `
` on ' +
+ 'an html page. The renderer still registers it with a `json`-only deprecation (objectui#4000); reconciling ' +
+ 'that runtime exemption with this contract is a separate card, not a roster edit.',
+ code:
+ 'the bare fallback key of the `field:code` widget (namespace `field`, no declared inputs, no child slot) — a ' +
+ 'code editor reading `value`, not an html element renderer. The ruling listed `code` as "registered elsewhere"; ' +
+ 'measured, there is no element registration to declare, and declaring the widget would teach a tag whose ' +
+ 'children the runtime drops.',
+ summary: 'the bare fallback key of the `field:summary` widget — a field, not an element renderer.',
+ object: 'the bare fallback key of the `field:object` widget — a field, not an element renderer.',
+ view: 'the bare `view` registration of `@object-ui/plugin-view`, which happens to share its name with the SVG element.',
+ kbd:
+ 'a `ui` component rendering `keys` / `label` under an HTML tag name; not named by the objectstack#20112 ruling. ' +
+ 'Admitting it is additive on a named need, while retracting a declared tag is a narrowing — so it waits.',
+ form: 'a JSON-surface form component; the html tier excludes form controls by design (the exclusion list in `html-elements.tsx`).',
+ input: 'a JSON-surface field component; the html tier excludes form controls by design (the exclusion list in `html-elements.tsx`).',
+ textarea: 'a JSON-surface field component; the html tier excludes form controls by design (the exclusion list in `html-elements.tsx`).',
+ select: 'a JSON-surface field component; the html tier excludes form controls by design (the exclusion list in `html-elements.tsx`).',
+ switch: 'a JSON-surface field component; the html tier excludes form controls by design (the exclusion list in `html-elements.tsx`).',
+ progress: 'a `ui` component reading `value` under an HTML tag name, not an element passthrough; not named by the ruling.',
+ dialog: 'a `ui` overlay component under an HTML tag name, not an element passthrough; not named by the ruling.',
+};
+
+/**
+ * Every element name React knows — read off `@types/react`'s
+ * `JSX.IntrinsicElements`, the one declaration of the HTML/SVG tag set this
+ * repo already depends on. Resolved through the package's `package.json`
+ * because its `exports` map does not expose `index.d.ts` as a subpath.
+ */
+function reactIntrinsicNames(): Set {
+ const pkg = createRequire(import.meta.url).resolve('@types/react/package.json');
+ const dts = readFileSync(join(dirname(pkg), 'index.d.ts'), 'utf8');
+ const block = dts.match(/interface IntrinsicElements \{([\s\S]*?)\n\s*\}/)?.[1] ?? '';
+ const names = new Set();
+ for (const line of block.split('\n')) {
+ const m = line.match(/^\s*"?([a-zA-Z][a-zA-Z0-9-]*)"?\s*:/);
+ if (m) names.add(m[1]);
+ }
+ return names;
+}
+
+/**
+ * The two tag families the JSON layout union declares by `z.enum` — the
+ * flow/inline set (holding `p`) and the sectioning set (holding `main`). Read
+ * off `LayoutSchema`'s arms, because `HtmlElementSchema` and
+ * `SemanticElementSchema` are deliberately not exported (objectui#9067).
+ */
+type EnumTypeArm = { shape?: { type?: { def?: { type?: string }; options?: readonly string[] } } };
+const enumArms = (): string[][] =>
+ (LayoutSchema as unknown as { options: EnumTypeArm[] }).options
+ .filter((arm) => arm.shape?.type?.def?.type === 'enum')
+ .map((arm) => [...(arm.shape?.type?.options ?? [])]);
+const flowInlineTags = (): string[] => enumArms().find((arm) => arm.includes('p')) ?? [];
+const sectioningTags = (): string[] => enumArms().find((arm) => arm.includes('main')) ?? [];
+
+describe('the manifest carries the html tier exactly as the registry declares it (objectui#10735)', () => {
+ it('every roster tag is registered under `ui` and reaches the manifest stamped `tier: html`', () => {
+ for (const tag of HTML_TIER_INTRINSICS) {
+ const comp = published.components[tag];
+ expect(comp, `\`${tag}\` is on the roster and absent from the manifest`).toBeDefined();
+ expect(comp.tier, `\`${tag}\` reached the manifest unstamped`).toBe('html');
+ expect(comp.namespace, `\`${tag}\` is not a \`ui\` registration`).toBe('ui');
+ expect(comp.type).toBe(tag);
+ }
+ });
+
+ it('carries each tag with its registered inputs — the manifest copies the declaration, verbatim', () => {
+ for (const tag of HTML_TIER_INTRINSICS) {
+ expect(published.components[tag].inputs.map((i) => i.name), `\`${tag}\` inputs drifted`).toEqual(inputNames(tag));
+ }
+ });
+
+ it('declares the child slot exactly where the registration renders a child list', () => {
+ expect(enumArms()).toHaveLength(2);
+ const flowInline = flowInlineTags();
+ const sectioning = sectioningTags();
+ expect(flowInline.length).toBeGreaterThan(30);
+ expect(sectioning).toHaveLength(7);
+
+ const slotDeclared = (tag: string): boolean =>
+ published.components[tag].inputs.some((i) => i.name === 'children' && i.type === 'slot');
+
+ // The void tags declare NO slot — ` ` taking children would be the lie
+ // in the other direction (objectui#9910) — and every other flow/inline tag,
+ // every sectioning tag and `span` declare one.
+ for (const tag of VOID_TAGS) {
+ expect(slotDeclared(tag), `void tag \`${tag}\` declares a child slot`).toBe(false);
+ }
+ for (const tag of [...flowInline.filter((t) => !VOID_TAGS.includes(t)), ...sectioning, 'span']) {
+ expect(slotDeclared(tag), `\`${tag}\` declares no child slot`).toBe(true);
+ }
+ // `table` and `label` are declared as registered: no slot, because their
+ // renderers read `columns` / `data` and `text`, not a child list.
+ for (const tag of ['table', 'label']) {
+ expect(slotDeclared(tag), `\`${tag}\` grew a child slot its renderer does not read`).toBe(false);
+ expect(declaresSlot(tag)).toBe(false);
+ }
+ });
+
+ it('leaves `div` out, and the two ledgered look-alikes `code` and `kbd` with it', () => {
+ for (const tag of ['div', 'code', 'kbd']) {
+ // Registered — the tier resolves them at runtime — and deliberately undeclared.
+ expect(ComponentRegistry.getKnownTypes(), `\`${tag}\` is no longer registered; re-read its ledger entry`).toContain(tag);
+ expect(published.components[tag], `\`${tag}\` reached the manifest`).toBeUndefined();
+ }
+ });
+});
+
+describe('the curated tier is unmoved — the JSON-surface vocabulary did not grow (objectui#10735)', () => {
+ it('the unstamped entries are exactly the registered PUBLIC_BLOCKS, in roster order', () => {
+ const curated = entries.filter((c) => c.tier !== 'html').map((c) => c.type);
+ expect(curated).toEqual(PUBLIC_BLOCKS.filter((tag) => tag in published.components));
+ // Anti-vacuity: the curated tier resolved, and to the vocabulary it is.
+ expect(curated.length).toBeGreaterThan(50);
+ expect(curated).toEqual(expect.arrayContaining(['object-grid', 'box', 'html']));
+ });
+
+ it('the stamped entries are exactly the roster, in roster order, and nothing else', () => {
+ expect(entries.filter((c) => c.tier === 'html').map((c) => c.type)).toEqual([...HTML_TIER_INTRINSICS]);
+ });
+
+ it('no entry is on both tiers, and every entry is on one of them', () => {
+ for (const c of entries) {
+ const onCurated = PUBLIC_BLOCK_SET.has(c.type) || ComponentRegistry.getMeta(c.type)?.tier === 'public';
+ const onHtml = HTML_TIER_INTRINSIC_SET.has(c.type);
+ expect(onCurated !== onHtml, `\`${c.type}\` is on ${onCurated && onHtml ? 'both tiers' : 'neither tier'}`).toBe(true);
+ expect(c.tier === 'html', `\`${c.type}\` is stamped against its roster`).toBe(onHtml);
+ }
+ });
+});
+
+describe('the census — every HTML-named bare registration is accounted for (objectui#10735)', () => {
+ const intrinsicNames = reactIntrinsicNames();
+ const bareKnown = ComponentRegistry.getKnownTypes().filter((k) => !k.includes(':'));
+ const htmlNamed = bareKnown.filter((k) => intrinsicNames.has(k)).sort();
+
+ it('reads a real element-name population and a real registration population', () => {
+ // Lit controls, both sides: the instrument resolved React's element list
+ // (HTML and SVG), and the registry resolved this console's bare keys.
+ expect(intrinsicNames.size).toBeGreaterThan(100);
+ expect(intrinsicNames.has('div')).toBe(true);
+ expect(intrinsicNames.has('p')).toBe(true);
+ expect(intrinsicNames.has('svg')).toBe(true);
+ expect(intrinsicNames.has('object-grid')).toBe(false);
+ expect(bareKnown).toEqual(expect.arrayContaining(['object-grid', 'box', 'div', 'p']));
+ // The intersection is the population this file is about, and it is not empty.
+ expect(htmlNamed.length).toBeGreaterThan(40);
+ });
+
+ it('places every HTML-named registration on a roster or in the ledger — none unaccounted', () => {
+ const unaccounted = htmlNamed.filter(
+ (tag) => !PUBLIC_BLOCK_SET.has(tag) && !HTML_TIER_INTRINSIC_SET.has(tag) && !(tag in EXCLUDED_HTML_NAMED),
+ );
+ expect(
+ unaccounted,
+ `HTML-named registration(s) on neither roster and not in the ledger: ${unaccounted.join(', ')}. ` +
+ 'Admit each to HTML_TIER_INTRINSICS (an element renderer the html tier should declare), to PUBLIC_BLOCKS ' +
+ '(a curated block), or record it in EXCLUDED_HTML_NAMED with the reason.',
+ ).toEqual([]);
+ });
+
+ it('keeps every ledger entry live — registered, HTML-named, on neither roster, with a reason', () => {
+ for (const [tag, reason] of Object.entries(EXCLUDED_HTML_NAMED)) {
+ expect(htmlNamed, `\`${tag}\` is no longer an HTML-named registration; delete its entry`).toContain(tag);
+ expect(PUBLIC_BLOCK_SET.has(tag) || HTML_TIER_INTRINSIC_SET.has(tag), `\`${tag}\` is now on a roster; delete its entry`).toBe(false);
+ expect(reason.length, `\`${tag}\` states no reason`).toBeGreaterThan(40);
+ }
+ });
+});
+
+describe('what an html-tier author gets from the gate that reads this manifest (objectui#10735)', () => {
+ const errors = (src: string) => compile(src, published).diagnostics.filter((d) => d.severity === 'error');
+ const codes = (src: string) => compile(src, published).diagnostics.map((d) => `${d.code}:${d.tag ?? ''}`);
+
+ it('accepts the everyday tags with their declared attributes', () => {
+ expect(
+ errors(
+ '
', published).ok).toBe(false);
+ });
+
+ it('refuses `` — the ledgered field-widget fallback is not declared', () => {
+ expect(codes('
x
')).toContain('forbidden-tag:code');
+ });
+
+ it('judges a `label` authored with children by its registration — `text` is required and there is no slot', () => {
+ // Declared "exactly as the registry declares it": the renderer reads `text`
+ // (required) and never a child list, so an html author who writes the
+ // HTML-shaped `` is told both facts instead of getting
+ // a green save and a blank label.
+ const src = '';
+ expect(codes(src)).toEqual(expect.arrayContaining(['missing-required-prop:label', 'not-a-container:label']));
+ expect(errors(src).map((d) => d.code)).toEqual(['missing-required-prop']);
+ // The registered spelling is clean.
+ expect(errors('')).toEqual([]);
+ });
+});
+
+describe('the generated intrinsics type the html tier (objectui#10735)', () => {
+ it('emits every roster tag into JSX.IntrinsicElements with its declared attributes', () => {
+ const dts = generateDts(published);
+ for (const tag of HTML_TIER_INTRINSICS) {
+ expect(dts, `\`${tag}\` missing from the intrinsics`).toContain(`"${tag}": `);
+ }
+ expect(dts).toContain('export interface AProps extends SduiBaseProps {');
+ expect(dts).toContain(' href?: string;');
+ expect(dts).toContain('"object-grid": ObjectGridProps;');
+ });
+});
diff --git a/apps/console/src/__tests__/public-contract.test.ts b/apps/console/src/__tests__/public-contract.test.ts
index b00b32bdce..91a04c6e4e 100644
--- a/apps/console/src/__tests__/public-contract.test.ts
+++ b/apps/console/src/__tests__/public-contract.test.ts
@@ -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
@@ -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 ' +
diff --git a/content/docs/guide/plugin-development.md b/content/docs/guide/plugin-development.md
index dad028c3f3..dabccd8d2c 100644
--- a/content/docs/guide/plugin-development.md
+++ b/content/docs/guide/plugin-development.md
@@ -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:
diff --git a/content/docs/guide/react-pages.md b/content/docs/guide/react-pages.md
index 57b554ab1c..a609b74611 100644
--- a/content/docs/guide/react-pages.md
+++ b/content/docs/guide/react-pages.md
@@ -93,7 +93,10 @@ derived by splitting the registry type on `-`, `_` and `:` and PascalCasing each
part: `object-grid` → ``, `record:details` → ``. A
`kind:'html'` page writes the registry type itself instead — ``,
``. 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 `
` is React's own element, and no `
` 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,
@@ -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 ``, ``,
+every container and every html-tier entry
+(`if (!tag || cfg.isContainer || cfg.tier === 'html') continue;`), so ``, ``,
`` 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: `
`.
diff --git a/packages/components/src/__tests__/layout-containers-declare-containment.test.tsx b/packages/components/src/__tests__/layout-containers-declare-containment.test.tsx
index 1cabe4f21a..da4f4f1dea 100644
--- a/packages/components/src/__tests__/layout-containers-declare-containment.test.tsx
+++ b/packages/components/src/__tests__/layout-containers-declare-containment.test.tsx
@@ -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`.
diff --git a/packages/components/src/__tests__/react-page-scope.test.tsx b/packages/components/src/__tests__/react-page-scope.test.tsx
index 05e4200aaa..81e613e1e5 100644
--- a/packages/components/src/__tests__/react-page-scope.test.tsx
+++ b/packages/components/src/__tests__/react-page-scope.test.tsx
@@ -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 ``
+ // 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 `` is React's own intrinsic here; `` 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 ;
+}`;
+ 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
// ---------------------------------------------------------------------------
diff --git a/packages/components/src/renderers/__tests__/container-declaration-census.test.tsx b/packages/components/src/renderers/__tests__/container-declaration-census.test.tsx
index 90e9805848..5ae5bbe93e 100644
--- a/packages/components/src/renderers/__tests__/container-declaration-census.test.tsx
+++ b/packages/components/src/renderers/__tests__/container-declaration-census.test.tsx
@@ -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 `