` because the roster
+ * leaves it out;
+ * - `generateBlockList` sections the html tier under its own count instead
+ * of reading it as curated blocks;
+ * - `generateDts` emits the tags into `JSX.IntrinsicElements`, which is the
+ * authoring surface a `kind:'html'` page type-checks against.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { compile, generateBlockList, generateDts, manifestFromConfigs, type RegistryConfigLike } from '../index.js';
+
+const configs: RegistryConfigLike[] = [
+ { type: 'flex', namespace: 'ui', isContainer: true, inputs: [{ name: 'gap', type: 'number' }, { name: 'children', type: 'slot' }] },
+ { type: 'my-widget', namespace: 'x', tier: 'public', inputs: [{ name: 'label', type: 'string' }] },
+ { type: 'studio-admin', namespace: 'app-shell', tier: 'internal', inputs: [] },
+ { type: 'p', namespace: 'ui', tier: 'html', inputs: [{ name: 'className', type: 'string' }, { name: 'children', type: 'slot' }] },
+ { type: 'a', namespace: 'ui', tier: 'html', inputs: [{ name: 'className', type: 'string' }, { name: 'href', type: 'string' }, { name: 'children', type: 'slot' }] },
+ { type: 'img', namespace: 'ui', tier: 'html', inputs: [{ name: 'src', type: 'string' }, { name: 'alt', type: 'string' }] },
+ { type: 'main', namespace: 'ui', tier: 'html', isContainer: true, inputs: [{ name: 'className', type: 'string' }, { name: 'children', type: 'slot' }] },
+];
+
+const manifest = manifestFromConfigs(configs);
+/** What a consumer reads back off disk — `JSON.stringify` drops every `undefined`. */
+const published = JSON.parse(JSON.stringify(manifest)) as typeof manifest;
+
+describe('manifestFromConfigs carries the html-tier stamp, and only that stamp', () => {
+ it('writes `tier: html` on a stamped config', () => {
+ for (const tag of ['p', 'a', 'img', 'main']) {
+ expect(published.components[tag].tier, `${tag} lost its stamp`).toBe('html');
+ }
+ });
+
+ it('publishes NO `tier` key for a curated, an opted-in or an internal config — byte-identical to before', () => {
+ for (const tag of ['flex', 'my-widget', 'studio-admin']) {
+ expect(Object.prototype.hasOwnProperty.call(published.components[tag], 'tier'), `${tag} grew a tier key`).toBe(false);
+ }
+ });
+
+ it('keeps every other field exactly as the registration declared it', () => {
+ expect(published.components.a.inputs.map((i) => i.name)).toEqual(['className', 'href', 'children']);
+ expect(published.components.main.isContainer).toBe(true);
+ expect(published.components.img.inputs.map((i) => i.name)).toEqual(['src', 'alt']);
+ });
+
+ it('`publicOnly` keeps the curated tier alone — the html tier is not `public`', () => {
+ expect(Object.keys(manifestFromConfigs(configs, { publicOnly: true }).components)).toEqual(['my-widget']);
+ });
+});
+
+describe('compile reads the manifest as a whitelist — the stamp changes nothing for that reader', () => {
+ it('accepts an html-tier tag with its declared inputs and child slot', () => {
+ const r = compile('
', published);
+ expect(r.diagnostics.filter((d) => d.severity === 'error')).toEqual([]);
+ expect(r.ok).toBe(true);
+ expect(r.tree?.type).toBe('main');
+ });
+
+ it('refuses `
` — the roster leaves it out, so it is `forbidden-tag` like any unknown tag', () => {
+ const r = compile('
hi
', published);
+ expect(r.ok).toBe(false);
+ expect(r.diagnostics).toEqual(
+ expect.arrayContaining([expect.objectContaining({ severity: 'error', code: 'forbidden-tag', tag: 'div' })]),
+ );
+ // The lit control: the same source with `p` in place of `div` is clean.
+ expect(compile('
hi
', published).ok).toBe(true);
+ });
+
+ it('warns `not-a-container` on a void tag authored with children — the slot is declared per tag, honestly', () => {
+ const r = compile('
stray', published);
+ expect(r.diagnostics).toEqual(
+ expect.arrayContaining([expect.objectContaining({ severity: 'warning', code: 'not-a-container', tag: 'img' })]),
+ );
+ });
+});
+
+describe('generateBlockList sections the html tier under its own count', () => {
+ it('counts curated blocks in the title and lists the html tier separately', () => {
+ const list = generateBlockList(published);
+ // Curated title counts `flex`, `my-widget`, `studio-admin` — not the four html-tier rows.
+ expect(list).toContain('# SDUI public blocks (3)');
+ expect(list).toContain('## html tier intrinsic elements (4)');
+ // Rows land in the right section: the html rows come AFTER the section heading.
+ const heading = list.indexOf('## html tier intrinsic elements');
+ for (const tag of ['p', 'a', 'img', 'main']) {
+ expect(list.indexOf(`| \`${tag}\` |`), `${tag} row is missing or above the html section`).toBeGreaterThan(heading);
+ }
+ for (const tag of ['flex', 'my-widget']) {
+ expect(list.indexOf(`| \`${tag}\` |`), `${tag} row landed in the html section`).toBeLessThan(heading);
+ }
+ });
+
+ it('emits no html section for a manifest without html-tier entries — the curated list is unchanged', () => {
+ const curatedOnly = manifestFromConfigs(configs.filter((c) => c.tier !== 'html'));
+ const list = generateBlockList(curatedOnly);
+ expect(list).toContain('# SDUI public blocks (3)');
+ expect(list).not.toContain('html tier intrinsic elements');
+ });
+});
+
+describe('generateDts publishes the html tier into JSX.IntrinsicElements', () => {
+ it('emits an interface and an intrinsic entry per html-tier tag, beside the curated ones', () => {
+ const dts = generateDts(published);
+ expect(dts).toContain('"p": PProps;');
+ expect(dts).toContain('"a": AProps;');
+ expect(dts).toContain('export interface AProps extends SduiBaseProps {\n className?: string;\n href?: string;\n}');
+ expect(dts).toContain('"flex": FlexProps;');
+ });
+});
diff --git a/packages/sdui-parser/src/codegen.ts b/packages/sdui-parser/src/codegen.ts
index 76a7d654f1f..895d403f5a8 100644
--- a/packages/sdui-parser/src/codegen.ts
+++ b/packages/sdui-parser/src/codegen.ts
@@ -165,23 +165,40 @@ export function propsName(type: string): string {
/**
* Generate the human-facing PUBLIC block list (the curated "清单") from a
* manifest — a Markdown table. Derived, never hand-maintained (ADR-0046).
+ *
+ * The html tier's intrinsic elements (`tier: 'html'`, objectui#10735) are
+ * listed in a section of their own, under their own count: they are in the
+ * manifest because a `kind:'html'` page may author them, not because they are
+ * curated blocks, and a single table would read them as the latter.
*/
export function generateBlockList(manifest: Manifest): string {
- const rows = Object.values(manifest.components)
- .sort((a, b) => a.type.localeCompare(b.type))
- .map((c) => {
- const req = c.inputs.filter((i) => i.required).map((i) => i.name);
- const binds = c.inputs.filter((i) => i.binding).map((i) => `${i.name}:${i.binding}`);
- return `| \`${c.type}\` | ${c.namespace ?? '—'} | ${c.isContainer ? '✓' : ''} | ${req.join(', ') || '—'} | ${binds.join(', ') || '—'} |`;
- });
+ const sorted = Object.values(manifest.components).sort((a, b) => a.type.localeCompare(b.type));
+ const curated = sorted.filter((c) => c.tier !== 'html');
+ const htmlTier = sorted.filter((c) => c.tier === 'html');
+ const row = (c: ManifestComponent): string => {
+ const req = c.inputs.filter((i) => i.required).map((i) => i.name);
+ const binds = c.inputs.filter((i) => i.binding).map((i) => `${i.name}:${i.binding}`);
+ return `| \`${c.type}\` | ${c.namespace ?? '—'} | ${c.isContainer ? '✓' : ''} | ${req.join(', ') || '—'} | ${binds.join(', ') || '—'} |`;
+ };
+ const header = ['| block | plugin | container | required props | bindings |', '|---|---|---|---|---|'];
return [
- `# SDUI public blocks (${Object.keys(manifest.components).length})`,
+ `# SDUI public blocks (${curated.length})`,
'',
'> Auto-generated from the registry `tier:\'public\'` set (ADR-0080). Do not edit by hand.',
'',
- '| block | plugin | container | required props | bindings |',
- '|---|---|---|---|---|',
- ...rows,
+ ...header,
+ ...curated.map(row),
+ ...(htmlTier.length
+ ? [
+ '',
+ `## html tier intrinsic elements (${htmlTier.length})`,
+ '',
+ "> The intrinsic HTML tags a `kind:'html'` page may author (ADR-0081 §2), declared by the registry's `HTML_TIER_INTRINSICS` roster and marked `tier: 'html'` in the manifest. Not part of the curated vocabulary above.",
+ '',
+ ...header,
+ ...htmlTier.map(row),
+ ]
+ : []),
'',
].join('\n');
}
diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts
index 2ea35795b9c..78bc31e5edd 100644
--- a/packages/sdui-parser/src/index.ts
+++ b/packages/sdui-parser/src/index.ts
@@ -68,8 +68,14 @@ export interface RegistryConfigLike {
type: string;
namespace?: string;
isContainer?: boolean;
- /** ADR-0080 contract tier — only 'public' configs form the AI/contract surface. */
- tier?: 'public' | 'internal';
+ /**
+ * ADR-0080 contract tier — only 'public' configs form the AI/contract
+ * surface under `publicOnly`. `'html'` is the projection-only stamp
+ * `ComponentRegistry.getPublicConfigs()` puts on the html tier's intrinsic
+ * elements (objectui#10735); it is the one value {@link manifestFromConfigs}
+ * carries into `ManifestComponent.tier`.
+ */
+ tier?: 'public' | 'internal' | 'html';
label?: string;
category?: string;
inputs?: Array<{
@@ -131,6 +137,11 @@ export function manifestFromConfigs(
type: c.type,
namespace: c.namespace,
isContainer: c.isContainer,
+ // The html tier's stamp, and ONLY that stamp (objectui#10735): a
+ // registration's `'public'` / `'internal'` is registry mechanics the
+ // manifest never carried, and `undefined` is dropped by `JSON.stringify`,
+ // so every curated entry serialises exactly as before this key existed.
+ tier: c.tier === 'html' ? 'html' : undefined,
inputs: (c.inputs ?? []).map((i) => ({
name: i.name,
type: canonicalizeInputType(i.type),
diff --git a/packages/sdui-parser/src/types.ts b/packages/sdui-parser/src/types.ts
index 97739b9dd7a..cd37a78c654 100644
--- a/packages/sdui-parser/src/types.ts
+++ b/packages/sdui-parser/src/types.ts
@@ -172,6 +172,23 @@ export interface ManifestComponent {
* ⛔ Not "accepts children" — this tier's containment check does not read it.
*/
isContainer?: boolean;
+ /**
+ * The authoring tier this entry belongs to (objectui#10735).
+ *
+ * Absent = the curated PUBLIC tier (ADR-0080 §4), the JSON-surface
+ * AI-authoring vocabulary. `'html'` = one of the intrinsic HTML elements a
+ * `kind:'html'` page may author (ADR-0081 §2), declared by the registry's
+ * `HTML_TIER_INTRINSICS` roster and carried here so the ONE flat `components`
+ * map can serve both of its readers: a whitelist reader (`compile`, the
+ * objectstack gate) takes every key and needs no change; a reader that means
+ * the curated vocabulary — the `kind:'react'` scope, the block list, a census
+ * of curated blocks — filters this key out.
+ *
+ * ⛔ Not the registration's `tier` copied through. `manifestFromConfigs`
+ * writes exactly `'html'` or nothing, so every entry published before this
+ * key existed serialises byte-identically.
+ */
+ tier?: 'html';
}
export interface Manifest {
diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json
index e66fbb6331f..61a72c008df 100644
--- a/scripts/sdui-manifest.record.json
+++ b/scripts/sdui-manifest.record.json
@@ -16,7 +16,7 @@
"modulesRoot": ".cache/objectui-dd3f7e1be356/apps/console",
"objectuiWorkspaceVersion": "17.6.0",
"generator": "scripts/gen-sdui-manifest-node.mjs",
- "generatedAt": "2026-09-28",
- "sha256": "4073897dcdc1cd68966f12f1868d37c2c25a750ea044760ce58e181fce6f21bc",
+ "generatedAt": "2026-09-29",
+ "sha256": "d0666ac585db8fa974471d9ebf33b906be776d77a170e7e5ecac774095d7b26a",
"components": 107
}
diff --git a/sdui.manifest.json b/sdui.manifest.json
index 4365e978a0c..05053a86d42 100644
--- a/sdui.manifest.json
+++ b/sdui.manifest.json
@@ -2667,6 +2667,7 @@
"h1": {
"type": "h1",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2681,6 +2682,7 @@
"h2": {
"type": "h2",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2695,6 +2697,7 @@
"h3": {
"type": "h3",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2709,6 +2712,7 @@
"h4": {
"type": "h4",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2723,6 +2727,7 @@
"h5": {
"type": "h5",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2737,6 +2742,7 @@
"h6": {
"type": "h6",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2751,6 +2757,7 @@
"p": {
"type": "p",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2765,6 +2772,7 @@
"a": {
"type": "a",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2795,6 +2803,7 @@
"blockquote": {
"type": "blockquote",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2813,6 +2822,7 @@
"pre": {
"type": "pre",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2827,6 +2837,7 @@
"code": {
"type": "code",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2841,6 +2852,7 @@
"strong": {
"type": "strong",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2855,6 +2867,7 @@
"em": {
"type": "em",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2869,6 +2882,7 @@
"b": {
"type": "b",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2883,6 +2897,7 @@
"i": {
"type": "i",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2897,6 +2912,7 @@
"u": {
"type": "u",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2911,6 +2927,7 @@
"small": {
"type": "small",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2925,6 +2942,7 @@
"mark": {
"type": "mark",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2939,6 +2957,7 @@
"sub": {
"type": "sub",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2953,6 +2972,7 @@
"sup": {
"type": "sup",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2967,6 +2987,7 @@
"del": {
"type": "del",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2981,6 +3002,7 @@
"ins": {
"type": "ins",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -2995,6 +3017,7 @@
"abbr": {
"type": "abbr",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3013,6 +3036,7 @@
"ul": {
"type": "ul",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3027,6 +3051,7 @@
"ol": {
"type": "ol",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3041,6 +3066,7 @@
"li": {
"type": "li",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3055,6 +3081,7 @@
"dl": {
"type": "dl",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3069,6 +3096,7 @@
"dt": {
"type": "dt",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3083,6 +3111,7 @@
"dd": {
"type": "dd",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3097,6 +3126,7 @@
"figure": {
"type": "figure",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3111,6 +3141,7 @@
"figcaption": {
"type": "figcaption",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3125,6 +3156,7 @@
"img": {
"type": "img",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3155,6 +3187,7 @@
"hr": {
"type": "hr",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3165,6 +3198,7 @@
"br": {
"type": "br",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3175,6 +3209,7 @@
"time": {
"type": "time",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3193,6 +3228,7 @@
"address": {
"type": "address",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3207,6 +3243,7 @@
"cite": {
"type": "cite",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3221,6 +3258,7 @@
"q": {
"type": "q",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3239,6 +3277,7 @@
"span": {
"type": "span",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3254,6 +3293,7 @@
"table": {
"type": "table",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "caption",
@@ -3282,6 +3322,7 @@
"label": {
"type": "label",
"namespace": "ui",
+ "tier": "html",
"inputs": [
{
"name": "text",
@@ -3298,6 +3339,7 @@
"type": "aside",
"namespace": "ui",
"isContainer": true,
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3313,6 +3355,7 @@
"type": "main",
"namespace": "ui",
"isContainer": true,
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3328,6 +3371,7 @@
"type": "header",
"namespace": "ui",
"isContainer": true,
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3343,6 +3387,7 @@
"type": "nav",
"namespace": "ui",
"isContainer": true,
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3358,6 +3403,7 @@
"type": "footer",
"namespace": "ui",
"isContainer": true,
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3373,6 +3419,7 @@
"type": "section",
"namespace": "ui",
"isContainer": true,
+ "tier": "html",
"inputs": [
{
"name": "className",
@@ -3388,6 +3435,7 @@
"type": "article",
"namespace": "ui",
"isContainer": true,
+ "tier": "html",
"inputs": [
{
"name": "className",