Read inline here.
inline` on a `kind:'html'` page renders its text instead of the `field:code` editor
+
+`renderers/basic/html-elements.tsx` called `code` "registered elsewhere", but the
+only bare `code` registration was the `field:code` widget's namespace fallback: a
+code editor reading `value`, with no declared inputs and no child slot. The
+console's html-tier compile whitelists `ComponentRegistry.getKnownTypes()`, so
+`inline` on an html page compiled, resolved to the editor, and the
+authored text was dropped — while `pre`, `strong` and `em` rendered their
+children through the passthrough, and `span` through its own renderer. The
+published manifest (objectui#10735) left `code` undeclared for the same reason,
+so the objectstack gate refused the tag the renderer mis-drew.
+
+**What moves for a consumer.**
+
+- `@object-ui/components` — `code` joins the `TAGS` loop of `html-elements.tsx`:
+ registered `ui:code`, sanitised like its siblings, declaring `className` and the
+ `children` slot. An html author's `` now renders a real `code` element with
+ its text.
+- `@object-ui/fields` — **BREAKING (authoring): the bare `code` key no longer
+ resolves to the code-editor widget.** `code` joins `FIELD_TYPES_SKIP_FALLBACK`,
+ so the widget is registered `field:code` only. A node authored as bare
+ `{ "type": "code", "value": … }` now renders the html tier's `code` element (it
+ draws `children`, not `value`); in a registry without `@object-ui/components`
+ the bare key resolves to nothing. Migration: `{ "type": "code" }` becomes
+ `{ "type": "field:code" }`. Form fields of type `code` resolve through the
+ `field:` namespace and render the same editor as before. Released as `minor`
+ under objectui's version policy; a breaking change never declares `major`.
+- `@object-ui/core` — `code` joins `HTML_TIER_INTRINSICS`, so `getPublicConfigs()`
+ projects it stamped `tier: 'html'` and the regenerated `sdui.manifest.json`
+ grows by one entry (the html tier declares 48 tags where objectui#10735
+ declared 47); `div` and `kbd` stay undeclared.
+- `@object-ui/types` — `HtmlElementSchema` (zod and TS) names `code`, keeping the
+ JSON-surface declaration equal to the registration, as the objectui#8499 pin
+ requires — so `AnyComponentSchema` (and `objectui validate`) now accepts
+ `{ "type": "code" }`, which it refused before.
+- `@object-ui/cli` — `packages/cli/src/utils/known-schema-types.ts` regenerates
+ from the registrations and gains `ui:code`, so `objectui validate` and
+ `objectui check` recognise it as a known schema type (the objectui#9533 and
+ objectui#6416 precedent).
+
+The `kind:'react'` scope skips stamped entries, so no `Code` wrapper is injected
+on react pages. The framework's tracked manifest regenerates at its next
+`.objectui-sha` bump (objectstack#20112's port list); nothing there changes here.
diff --git a/.changeset/8499-node-slot-registered-arms.md b/.changeset/8499-node-slot-registered-arms.md
index 7128a6df7d..0434fec402 100644
--- a/.changeset/8499-node-slot-registered-arms.md
+++ b/.changeset/8499-node-slot-registered-arms.md
@@ -37,6 +37,9 @@ from 107 to 154:
`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`).
+ ⚠️ **Dated note, 2026-09-27 — that set has since gained `code` — objectui#10756.**
+ At this change `TAGS` and this arm both named 37 tags; both now name 38, and the
+ parity pin counts 38. The rest of this entry is kept as the reading of this change.
- `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
diff --git a/apps/console/src/__tests__/html-tier-code-element-10756.test.tsx b/apps/console/src/__tests__/html-tier-code-element-10756.test.tsx
new file mode 100644
index 0000000000..6d2c4530ee
--- /dev/null
+++ b/apps/console/src/__tests__/html-tier-code-element-10756.test.tsx
@@ -0,0 +1,132 @@
+/**
+ * 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#10756 — on a `kind:'html'` page, `inline` renders the
+ * text inside a real `code` element; the `field:code` widget still renders as
+ * the widget under its own key.
+ *
+ * ## The defect
+ *
+ * `html-elements.tsx`'s header called `code` "registered elsewhere", but the
+ * only bare `code` registration was the `field:code` widget's namespace
+ * fallback: a code EDITOR reading `value`, with no declared inputs and no child
+ * slot. The console's html-tier compile whitelists
+ * `ComponentRegistry.getKnownTypes()`, so `inline` compiled, the
+ * renderer resolved the bare key to the editor, and the authored text was
+ * dropped. The lit controls — `pre`, `strong`, `em`, `span` — rendered their
+ * children through the passthrough all along.
+ *
+ * ## The fix, and what each pin holds
+ *
+ * `code` joins `TAGS` in `html-elements.tsx` (registered `ui:code`, className +
+ * child slot), joins `HTML_TIER_INTRINSICS`, and `@object-ui/fields` registers
+ * `code` with `skipFallback` so the passthrough is the one bare claimant.
+ *
+ * 1. the html page renders the text in a `code` element (red on the base:
+ * the editor rendered, no `code` element carried the text);
+ * 2. the live compile reports nothing for `` with children (red on the
+ * base: `not-a-container` — the widget declared no slot);
+ * 3. CONTROL — `field:code` still resolves to the widget and renders it;
+ * 4. the bare key is owned by the passthrough, the namespaced key by the widget.
+ *
+ * It lives in `apps/console` because the claim is about the whole registration
+ * graph: `@object-ui/components` registers the passthrough and `plugin-form`
+ * pulls in `@object-ui/fields`, which registers the widget — the pair whose
+ * import ORDER decided the bare key before the widget stood down.
+ */
+import { describe, it, expect } from 'vitest';
+import { render, waitFor } from '@testing-library/react';
+import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+import { ComponentRegistry } from '@object-ui/core';
+import { compile, manifestFromConfigs } from '@object-ui/sdui-parser';
+
+// The full registration graph — the pair `dev/manifest-dump.tsx` builds the
+// published artifacts from, and the pair every live-path suite here reads.
+import '@object-ui/components';
+import '../register-plugins';
+
+const dataSource = {
+ find: async () => ({ data: [], total: 0, hasMore: false }),
+ findOne: async () => null,
+ create: async () => ({}),
+ update: async () => ({}),
+ delete: async () => ({}),
+ count: async () => 0,
+ getObjectSchema: async (name: string) => ({ name, label: name, fields: {} }),
+ getObjects: async () => [],
+ onMutation: () => () => {},
+} as any;
+
+/**
+ * The manifest an html-kind page validates against, built the way the renderer
+ * builds it — `ComponentRegistry.getKnownTypes()` + each type's declared
+ * `inputs`, mirroring `page.tsx`'s `getJsxManifest()`.
+ */
+const livePageManifest = () =>
+ manifestFromConfigs(
+ ComponentRegistry.getKnownTypes().map((t) => {
+ const meta = ComponentRegistry.getMeta(t);
+ return { type: t, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs };
+ }) as unknown as Parameters[0],
+ );
+
+function renderNode(schema: Record) {
+ return render(
+
+
+ ,
+ );
+}
+
+const HTML_SOURCE = 'Read inline here.
';
+
+describe("kind:'html' — `` renders its text (objectui#10756)", () => {
+ it('renders the authored text inside a `code` element, with the authored className', async () => {
+ const { container } = renderNode({ type: 'page', kind: 'html', name: 'code_page', source: HTML_SOURCE });
+ await waitFor(() => expect(container.querySelector('code')).toBeTruthy());
+ const code = container.querySelector('code')!;
+ expect(code.textContent).toBe('inline');
+ expect(code.classList.contains('k')).toBe(true);
+ expect(code.closest('p')?.textContent).toBe('Read inline here.');
+ // The editor the bare key used to resolve to is not what an html author gets.
+ expect(container.querySelector('textarea')).toBeNull();
+ });
+
+ it('compiles cleanly against the live page manifest — no `forbidden-tag`, no `not-a-container`', () => {
+ const diagnostics = compile(HTML_SOURCE, livePageManifest()).diagnostics;
+ expect(diagnostics.map((d) => `${d.code}:${d.tag ?? ''}`)).toEqual([]);
+ });
+
+ it('lit control — `pre` and `strong` render their children the same way', async () => {
+ const { container } = renderNode({
+ type: 'page',
+ kind: 'html',
+ name: 'control_page',
+ source: 'block
bold
',
+ });
+ await waitFor(() => expect(container.querySelector('pre')).toBeTruthy());
+ expect(container.querySelector('pre')!.textContent).toBe('block');
+ expect(container.querySelector('strong')!.textContent).toBe('bold');
+ });
+});
+
+describe('`field:code` still renders the widget (objectui#10756 control)', () => {
+ it('resolves to the field widget under its namespaced key, while the bare key is the passthrough', () => {
+ expect(ComponentRegistry.getConfig('field:code')?.type).toBe('field:code');
+ expect(ComponentRegistry.getConfig('code')?.type).toBe('ui:code');
+ expect(ComponentRegistry.get('code')).not.toBe(ComponentRegistry.get('field:code'));
+ // The passthrough declares what the manifest carries; the widget declares nothing.
+ expect((ComponentRegistry.getMeta('code')?.inputs ?? []).map((i) => i.name)).toEqual(['className', 'children']);
+ });
+
+ it('renders the widget — read-only, `value` comes out in the widget\'s own `pre` block', async () => {
+ const { container } = renderNode({ type: 'field:code', value: 'const answer = 42;', readonly: true });
+ // `CodeField` is lazy; its read-only branch is a `pre` carrying the value.
+ await waitFor(() => expect(container.querySelector('pre')).toBeTruthy());
+ expect(container.querySelector('pre')!.textContent).toBe('const answer = 42;');
+ });
+});
diff --git a/apps/console/src/__tests__/html-tier-manifest.test.ts b/apps/console/src/__tests__/html-tier-manifest.test.ts
index 8b7374c401..eafdadbe9b 100644
--- a/apps/console/src/__tests__/html-tier-manifest.test.ts
+++ b/apps/console/src/__tests__/html-tier-manifest.test.ts
@@ -80,11 +80,6 @@ const EXCLUDED_HTML_NAMED: Record = {
'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.',
@@ -176,13 +171,28 @@ describe('the manifest carries the html tier exactly as the registry declares it
}
});
- it('leaves `div` out, and the two ledgered look-alikes `code` and `kbd` with it', () => {
- for (const tag of ['div', 'code', 'kbd']) {
+ it('leaves `div` out, and the ledgered look-alike `kbd` with it', () => {
+ for (const tag of ['div', '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();
}
});
+
+ it('declares `code` — the element passthrough, not the `field:code` widget the bare key used to fall back to (objectui#10756)', () => {
+ // The ledger held `code` while the only bare registration was the field
+ // widget's namespace fallback. `html-elements.tsx` now registers it from
+ // `TAGS` and the widget stands down from the bare key, so the manifest
+ // carries the passthrough's own declaration: `ui`, a className, a child slot.
+ const comp = published.components.code;
+ expect(comp).toBeDefined();
+ expect(comp.tier).toBe('html');
+ expect(comp.namespace).toBe('ui');
+ expect(comp.inputs.map((i) => `${i.name}:${i.type}`)).toEqual(['className:string', 'children:slot']);
+ // The registration behind it is the passthrough, and the widget is still its own key.
+ expect(ComponentRegistry.getConfig('code')?.type).toBe('ui:code');
+ expect(ComponentRegistry.getConfig('field:code')?.type).toBe('field:code');
+ });
});
describe('the curated tier is unmoved — the JSON-surface vocabulary did not grow (objectui#10735)', () => {
@@ -269,8 +279,10 @@ describe('what an html-tier author gets from the gate that reads this manifest (
expect(compile('hi ', published).ok).toBe(false);
});
- it('refuses `` — the ledgered field-widget fallback is not declared', () => {
- expect(codes('x
')).toContain('forbidden-tag:code');
+ it('accepts `` with text — declared with its child slot, so neither `forbidden-tag` nor `not-a-container` fires (objectui#10756)', () => {
+ expect(errors('Read inline here.
')).toEqual([]);
+ expect(codes('x
')).not.toContain('forbidden-tag:code');
+ expect(codes('x
')).not.toContain('not-a-container:code');
});
it('judges a `label` authored with children by its registration — `text` is required and there is no slot', () => {
diff --git a/apps/console/src/__tests__/public-contract.test.ts b/apps/console/src/__tests__/public-contract.test.ts
index 91a04c6e4e..23088f6f0c 100644
--- a/apps/console/src/__tests__/public-contract.test.ts
+++ b/apps/console/src/__tests__/public-contract.test.ts
@@ -404,7 +404,7 @@ 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`, the deprecated `div`, and — since objectui#8499 armed
- * them — the 37 flow/inline HTML tags of `HtmlElementSchema`, not one of which
+ * them — the 38 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.
@@ -456,7 +456,7 @@ describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => {
* 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
+ * `HtmlElementSchema`'s 38 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.
*
@@ -602,7 +602,7 @@ describe('PUBLIC_BLOCKS ↔ the declared layout containers (derived, objectui#68
//
// ⚠️ 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
+ // enum arm contributes 38 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_ARM_LITERALS.filter((literals) => literals.length > 0)).toHaveLength(
diff --git a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx
index 76f8f62ca0..6bed2c87dc 100644
--- a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx
+++ b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx
@@ -37,9 +37,9 @@
* | plugin-calendar | 3 | 0 | 0 |
* | plugin-chatbot | 3 | 0 | 0 |
* | plugin-dashboard | 8 | 2 | 7 / 9 |
- * | components | 159 | 89 | 12 .. 15 |
+ * | components | 160 | 90 | 12 .. 15 |
*
- * **91 of 182 targets leak.** The `components` row is objectui#5574 and is
+ * **92 of 183 targets leak.** The `components` row is objectui#5574 and is
* covered in its own section below; the two `plugin-dashboard` rows are the
* older tail. Both are in {@link LEAK_LEDGER}:
* `plugin-dashboard:metric` and `plugin-dashboard:metric-card`, the open tail
@@ -122,7 +122,7 @@
* have failed. These two differ in exactly one thing: which namespace the extra
* widget went into.
*
- * ### The reading — 119 of 158 ON ARRIVAL, in seven shapes; 89 today
+ * ### The reading — 119 of 158 ON ARRIVAL, in seven shapes; 90 today
*
* The card named four candidates (`flex`, `stack`, `container`, `text`) and was
* careful to call them candidates. All four leaked. So did 115 others, and the
@@ -139,8 +139,8 @@
* so their rows had to go for the gate to pass — the two-way expiry below,
* working exactly once it had something to expire. Later slices took more,
* and objectui#5632's `ui:sidebar-trigger` took the last shape that named a
- * single renderer: **89 rows in FOUR shapes** remain, on a target set that has
- * itself grown to 159. What the arrival reading measured is preserved here in prose and in
+ * single renderer: **90 rows in FOUR shapes** remain, on a target set that has
+ * itself grown to 160. What the arrival reading measured is preserved here in prose and in
* the burn-down note on {@link COMPONENTS_LEAK_GROUPS}; what the gate asserts
* is always current truth, which is the whole point of not writing dates into
* a ledger.
@@ -595,7 +595,7 @@ const CALENDAR_OBJECT_EXTRAS = {
* an ordinary authored prop while doing it.
*
* Measured on the tree this landed on: 152 of 158 targets matched it.
- * Today 156 of 159 targets match it; the other three are in
+ * Today 157 of 160 targets match it; the other three are in
* {@link READY_OVERRIDE_REASONS}, each with the reason it cannot —
* a recorded limitation with its own two-way assertion below, never a quiet
* exemption (the `omitCanaries` discipline, applied to readiness).
@@ -747,7 +747,7 @@ const COMPONENTS_PLAIN_TYPES: readonly string[] = [
'protocol-placeholder:nav:menu', 'ui:a', 'ui:abbr', 'ui:accordion', 'ui:address',
'ui:alert', 'ui:app', 'ui:article', 'ui:aside', 'ui:aspect-ratio', 'ui:avatar', 'ui:b',
'ui:badge', 'ui:blockquote', 'ui:box', 'ui:br', 'ui:breadcrumb', 'ui:button', 'ui:button-group',
- 'ui:calendar', 'ui:card', 'ui:carousel', 'ui:checkbox', 'ui:cite', 'ui:collapsible',
+ 'ui:calendar', 'ui:card', 'ui:carousel', 'ui:checkbox', 'ui:cite', 'ui:code', 'ui:collapsible',
'ui:combobox', 'ui:command', 'ui:container', 'ui:context-menu', 'ui:data-table',
'ui:date-picker', 'ui:dd', 'ui:del', 'ui:div', 'ui:dl', 'ui:dt', 'ui:em', 'ui:email',
'ui:empty', 'ui:figcaption', 'ui:figure', 'ui:file-upload', 'ui:filter-builder', 'ui:flex',
@@ -884,8 +884,8 @@ const TARGETS: Readonly> = {
// key onto this package's own namespace; the renderer is the same one.
{ type: 'plugin-dashboard:dashboard', ready: '.grid.auto-rows-min' },
],
- // objectui#5574 — 159 targets, built above rather than spelled here because
- // 140 of them need nothing but the shared readiness class.
+ // objectui#5574 — 160 targets, built above rather than spelled here because
+ // 141 of them need nothing but the shared readiness class.
components: COMPONENTS_TARGETS,
};
@@ -978,7 +978,7 @@ interface LedgerEntry {
/* ── objectui#5574: the `packages/components` reading, as a LEDGER ─────────── */
/**
- * 89 of the 159 `packages/components` targets leak, and they do it in exactly
+ * 90 of the 160 `packages/components` targets leak, and they do it in exactly
* FOUR shapes (119 targets in seven shapes did on arrival; see the burn-down
* note below — and note the arrival count of shapes read `eight` here until
* objectui#5632 counted them: the groups were seven, and the card's own table
@@ -1081,8 +1081,8 @@ interface LedgerEntry {
* leaking a NINTH attribute the gate fails, because the measured set no longer
* equals the recorded one; if it stops leaking, the gate ALSO fails until the
* row goes. An allowlist has neither property. Nothing below is skipped,
- * `it.skip`-ed, quarantined or excluded from the sweep — all 159 targets render
- * and all 159 are scanned on every run, the 70 clean ones included.
+ * `it.skip`-ed, quarantined or excluded from the sweep — all 160 targets render
+ * and all 160 are scanned on every run, the 70 clean ones included.
*/
/**
@@ -1148,7 +1148,7 @@ const COMPONENTS_LEAK_GROUPS: readonly LedgerGroup[] = [
'action:bar', 'ui:a', 'ui:abbr', 'ui:accordion', 'ui:address', 'ui:alert',
'ui:article', 'ui:aside', 'ui:aspect-ratio', 'ui:avatar', 'ui:b', 'ui:badge',
'ui:blockquote', 'ui:br', 'ui:breadcrumb', 'ui:button-group', 'ui:card', 'ui:carousel',
- 'ui:cite', 'ui:collapsible', 'ui:command', 'ui:dd', 'ui:del', 'ui:div',
+ 'ui:cite', 'ui:code', 'ui:collapsible', 'ui:command', 'ui:dd', 'ui:del', 'ui:div',
'ui:dl', 'ui:dt', 'ui:em', 'ui:empty', 'ui:figcaption', 'ui:figure',
'ui:footer', 'ui:h1', 'ui:h2', 'ui:h3', 'ui:h4', 'ui:h5', 'ui:h6', 'ui:header',
'ui:hr', 'ui:html', 'ui:i', 'ui:image', 'ui:img', 'ui:ins', 'ui:kbd',
@@ -1225,7 +1225,7 @@ const LEAK_LEDGER: Readonly> = {
issue: 'objectui#4425',
},
- /* ── packages/components: 89 of 159 targets, in four measured shapes ───── */
+ /* ── packages/components: 90 of 160 targets, in four measured shapes ───── */
...Object.fromEntries(
COMPONENTS_LEAK_GROUPS.flatMap((group) =>
group.targets.map((type) => [
@@ -1805,11 +1805,11 @@ const wordForCount = (count: number): string => COUNT_WORDS[count] ?? String(cou
*/
const DOCBLOCK_COUNTS = {
/** `packages/components` targets swept: the plain types plus the specials. */
- componentsTargets: 159,
+ componentsTargets: 160,
/** Of those, the ones needing nothing but the shared readiness class. */
- componentsPlainTypes: 140,
+ componentsPlainTypes: 141,
/** Ledgered `packages/components` rows — targets with a recorded leak. */
- componentsLedgered: 89,
+ componentsLedgered: 90,
/** The complement: swept, scanned and clean. */
componentsClean: 70,
/** Measured shapes the ledgered rows fall into. */
@@ -1817,15 +1817,15 @@ const DOCBLOCK_COUNTS = {
/** Registry prefixes `packages/components` owns. */
componentsPrefixes: 5,
/** Targets the shared readiness selector reaches. */
- componentsReadyMatched: 156,
+ componentsReadyMatched: 157,
/** The rest, each with a recorded reason it cannot. */
componentsReadyOverrides: 3,
/** Attributes leaked by the shape with the most members. */
commonestShapeAttributes: 14,
/** Every target this sweep renders, all five packages. */
- allTargets: 182,
+ allTargets: 183,
/** Every ledgered row, `plugin-dashboard`'s open tail included. */
- allLedgered: 91,
+ allLedgered: 92,
} as const;
type CountName = keyof typeof DOCBLOCK_COUNTS;
diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts
index bc010c7b33..3447617c5d 100644
--- a/packages/cli/src/utils/known-schema-types.ts
+++ b/packages/cli/src/utils/known-schema-types.ts
@@ -528,6 +528,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [
'ui:carousel',
'ui:checkbox',
'ui:cite',
+ 'ui:code',
'ui:collapsible',
'ui:combobox',
'ui:command',
diff --git a/packages/components/src/__tests__/react-page-scope.test.tsx b/packages/components/src/__tests__/react-page-scope.test.tsx
index 81e613e1e5..05941770c0 100644
--- a/packages/components/src/__tests__/react-page-scope.test.tsx
+++ b/packages/components/src/__tests__/react-page-scope.test.tsx
@@ -239,6 +239,23 @@ function Page() {
expect(await findByText('React page error')).toBeTruthy();
await waitFor(() => expect(container.textContent).toContain('Img is not defined'));
});
+
+ it('`code` joined the roster without a `Code` wrapper — stamped `tier: html`, skipped by the scope (objectui#10756)', async () => {
+ // `code` is a leaf like `img`; without the stamp the container branch would
+ // inject a `` wrapper the moment the passthrough was registered.
+ const cfg = ComponentRegistry.getPublicConfigs().find((c) => c.type === 'code');
+ expect(cfg).toBeTruthy();
+ expect(cfg!.tier).toBe('html');
+ expect(cfg!.isContainer).toBeFalsy();
+
+ const source = `
+function Page() {
+ return inline;
+}`;
+ const { container, findByText } = renderReactPage(source);
+ expect(await findByText('React page error')).toBeTruthy();
+ await waitFor(() => expect(container.textContent).toContain('Code is not defined'));
+ });
});
// ---------------------------------------------------------------------------
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 5ae5bbe93e..e7887d9215 100644
--- a/packages/components/src/renderers/__tests__/container-declaration-census.test.tsx
+++ b/packages/components/src/renderers/__tests__/container-declaration-census.test.tsx
@@ -173,10 +173,10 @@ describe('the declaration is confined to what was measured (objectui#6764)', ()
it.each(['img', 'hr', 'br'])('leaves the void tag `%s` alone', async (type) => {
// The second half of the same control, inside a LOOP FACTORY. `img`/`hr`/
- // `br` come out of the same `basic/html-elements.tsx` loop as 34 tags that
+ // `br` come out of the same `basic/html-elements.tsx` loop as 35 tags that
// DO render children, and the factory skips `renderChildren` for them by
// design (`VOID_TAGS`). A census that worked at file granularity — the
- // granularity a static reader can reach — would have declared all 37
+ // granularity a static reader can reach — would have declared all 38
// together and told authors that `
` accepts children.
expect(await rendersChildren(type)).toBe(false);
expect(diagnose(withChildren(type)).map((d) => d.code)).toContain(CONTAINMENT);
diff --git a/packages/components/src/renderers/basic/html-elements.tsx b/packages/components/src/renderers/basic/html-elements.tsx
index 70c0757526..201a259de5 100644
--- a/packages/components/src/renderers/basic/html-elements.tsx
+++ b/packages/components/src/renderers/basic/html-elements.tsx
@@ -11,9 +11,13 @@
* PARSED (never executed) into the SDUI tree. For that tier to live up to its
* name, the everyday HTML tags an author reaches for — headings, paragraphs,
* lists, links, images, emphasis — must each resolve to a renderer (otherwise
- * the parser flags them `unknown-component`). div / span / table / code / label
- * and the semantic sectioning tags are registered elsewhere; this module fills
- * in the rest of the safe flow/inline set.
+ * the parser flags them `unknown-component`). div / span / table / label and
+ * the semantic sectioning tags are registered elsewhere; this module fills in
+ * the rest of the safe flow/inline set — `code` included (objectui#10756): the
+ * bare `code` key used to be the `field:code` widget's namespace fallback, so
+ * `inline` on an html page drew a code EDITOR and dropped its
+ * text. The widget now stands down from the bare key (`FIELD_TYPES_SKIP_FALLBACK`
+ * in `@object-ui/fields`) and stays reachable as `field:code`.
*
* Safety: these render real DOM, but the html tier never executes JS, so props
* are static literals from the parser. As defense-in-depth the passthrough
@@ -33,7 +37,7 @@ type AnyProps = { schema: any; className?: string; [key: string]: any };
const VOID_TAGS = new Set(['img', 'hr', 'br']);
// The safe set we own here. Deliberately excludes anything already registered
-// (div, span, table, code, label, kbd, the semantic sectioning tags, html) and
+// (div, span, table, label, kbd, the semantic sectioning tags, html) and
// anything that can execute or escape (script, style, iframe, object, embed,
// link, meta, form, input, button — button is the shadcn component).
//
@@ -44,7 +48,7 @@ const VOID_TAGS = new Set(['img', 'hr', 'br']);
// any tag added here that another renderer already registers.
const TAGS = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
- 'p', 'a', 'blockquote', 'pre',
+ 'p', 'a', 'blockquote', 'pre', 'code',
'strong', 'em', 'b', 'i', 'u', 'small', 'mark', 'sub', 'sup', 'del', 'ins', 'abbr',
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'figure', 'figcaption', 'img', 'hr', 'br', 'time', 'address', 'cite', 'q',
diff --git a/packages/core/src/registry/__tests__/html-tier-intrinsics.test.ts b/packages/core/src/registry/__tests__/html-tier-intrinsics.test.ts
index 65027496ca..0775543027 100644
--- a/packages/core/src/registry/__tests__/html-tier-intrinsics.test.ts
+++ b/packages/core/src/registry/__tests__/html-tier-intrinsics.test.ts
@@ -83,11 +83,13 @@ describe('HTML_TIER_INTRINSICS — the roster (objectui#10735)', () => {
expect(HTML_TIER_INTRINSIC_SET.has('box')).toBe(false);
});
- it('leaves `code` and `kbd` out — a field-widget fallback and an unruled component, not element renderers', () => {
- // `code` is the bare fallback of the `field:code` widget (no inputs, no
- // child slot); `kbd` is a `ui` component reading `keys` / `label`. Both are
- // recorded in the console test's exclusion ledger with their reasons.
- expect(HTML_TIER_INTRINSIC_SET.has('code')).toBe(false);
+ it('carries `code` and leaves `kbd` out — an element passthrough since objectui#10756, and an unruled component', () => {
+ // `code` used to be the bare fallback of the `field:code` widget (no inputs,
+ // no child slot) and was ledgered out; `html-elements.tsx` now registers it
+ // from `TAGS` and the widget stands down from the bare key. `kbd` is a `ui`
+ // component reading `keys` / `label`, still recorded in the console test's
+ // exclusion ledger with its reason.
+ expect(HTML_TIER_INTRINSIC_SET.has('code')).toBe(true);
expect(HTML_TIER_INTRINSIC_SET.has('kbd')).toBe(false);
});
});
@@ -139,12 +141,36 @@ describe('getPublicConfigs — the html tier rides the contract read, stamped (o
expect(types).toEqual(['span']);
});
- it('does not admit a field widget squatting on an html tag name', () => {
+ it('does not admit a field widget squatting on an html tag name that is not on the roster', () => {
+ // `summary` is the console's live instance: the `field:summary` widget's
+ // bare fallback shares its name with the HTML element and is ledgered out.
const r = new Registry();
- r.register('code', C, { namespace: 'field' });
+ r.register('summary', C, { namespace: 'field' });
expect(r.getPublicConfigs()).toEqual([]);
});
+ it('projects a roster tag from whatever holds its bare key — why `field:code` stands down (objectui#10756)', () => {
+ // The roster names the TAG; the projection copies the registration the bare
+ // key resolves to. Were the field widget still the bare `code` owner, the
+ // manifest would declare a code editor under the tag — the defect this card
+ // closes — so `@object-ui/fields` registers `code` with `skipFallback` and
+ // `html-elements.tsx`'s `ui:code` is the one bare claimant. Both halves,
+ // in one registry:
+ const r = new Registry();
+ r.register('code', C, { namespace: 'field', skipFallback: true });
+ expect(r.getPublicConfigs()).toEqual([]); // the widget declined the bare key: nothing to project
+ const Passthrough = () => null;
+ r.register('code', Passthrough, {
+ namespace: 'ui',
+ inputs: [{ name: 'className', type: 'string' }, { name: 'children', type: 'slot' }],
+ });
+ const cfg = r.getPublicConfigs().find((c) => c.type === 'code');
+ expect(cfg?.tier).toBe('html');
+ expect(cfg?.namespace).toBe('ui');
+ expect(cfg?.component).toBe(Passthrough);
+ expect(r.getConfig('field:code')?.component).toBe(C); // the widget is still its own key
+ });
+
it('resolves a lazily registered roster tag as a stamped stub', () => {
const r = new Registry();
r.registerLazy('p', () => Promise.resolve(), { namespace: 'ui' });
diff --git a/packages/core/src/registry/html-tier-intrinsics.ts b/packages/core/src/registry/html-tier-intrinsics.ts
index 6956ea3939..2eb3564f67 100644
--- a/packages/core/src/registry/html-tier-intrinsics.ts
+++ b/packages/core/src/registry/html-tier-intrinsics.ts
@@ -52,11 +52,6 @@
* page. The renderer still registers it and its declared deprecation still
* names only the `json` surface (objectui#4000); reconciling that runtime
* exemption with this contract is a separate card, not a roster edit.
- * - `code` — the ruling's list names it as "registered elsewhere", but the
- * bare `code` key the registry holds is the `field:code` widget's
- * namespace fallback (a code editor / `pre` block reading `value`, with no
- * declared `inputs` and no child slot), not an html element renderer.
- * Declaring it would teach a tag whose children the runtime drops.
* - `kbd` — a `ui` component under an HTML tag name (renders `keys` /
* `label`), not named by the ruling. Admitting it is additive and cheap on
* a named need; retracting a declared tag is a narrowing.
@@ -68,6 +63,15 @@
* - `button`, `text`, `image`, `html` — HTML tag names that are ALREADY
* curated in `PUBLIC_BLOCKS`; they reach the manifest from there, unmarked.
*
+ * `code` was on that list when the roster landed: the ruling named it
+ * "registered elsewhere", but the only bare `code` registration was the
+ * `field:code` widget's namespace fallback — a code editor reading `value`, no
+ * child slot — so an html author's `inline` drew the editor and lost
+ * its text. objectui#10756 registers `code` as a sanitised passthrough in
+ * `html-elements.tsx`'s `TAGS` and has the widget stand down from the bare key
+ * (`FIELD_TYPES_SKIP_FALLBACK` in `@object-ui/fields`; it stays `field:code`),
+ * so the tag is declared here exactly as its siblings are.
+ *
* The console's `html-tier-manifest` test derives the population of
* HTML-named registrations from `@types/react`'s `IntrinsicElements` and
* holds every member to one of: this roster, `PUBLIC_BLOCKS`, or its exclusion
@@ -83,7 +87,7 @@
export const HTML_TIER_INTRINSICS: readonly string[] = [
// ── `renderers/basic/html-elements.tsx` `TAGS` — the safe flow/inline set ──
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
- 'p', 'a', 'blockquote', 'pre',
+ 'p', 'a', 'blockquote', 'pre', 'code',
'strong', 'em', 'b', 'i', 'u', 'small', 'mark', 'sub', 'sup', 'del', 'ins', 'abbr',
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'figure', 'figcaption', 'img', 'hr', 'br', 'time', 'address', 'cite', 'q',
diff --git a/packages/fields/src/__tests__/registry-collision.test.ts b/packages/fields/src/__tests__/registry-collision.test.ts
index 25251c069f..ccea217591 100644
--- a/packages/fields/src/__tests__/registry-collision.test.ts
+++ b/packages/fields/src/__tests__/registry-collision.test.ts
@@ -10,12 +10,14 @@
* Registry collision guard.
*
* `@object-ui/components` registers display widgets (`text`, `image`,
- * `avatar`, `html`, `grid`) under their bare names. `@object-ui/fields`
+ * `avatar`, `html`, `grid`) under their bare names, and the html tier's
+ * inline `code` element passthrough (objectui#10756). `@object-ui/fields`
* registers form-input widgets that — by name — collide with those.
*
- * The fix: field registrations for these 5 types pass `skipFallback: true`,
- * so the bare lookup keeps returning the display widget while the field
- * input is still reachable via the `field:` namespace.
+ * The fix: field registrations for these types pass `skipFallback: true`,
+ * so the bare lookup keeps returning the display widget (or, for `code`, the
+ * element passthrough) while the field input is still reachable via the
+ * `field:` namespace.
*
* Regression contract:
* 1. `FIELD_TYPES_SKIP_FALLBACK` must contain every colliding name.
@@ -35,7 +37,7 @@ import { ComponentRegistry } from '@object-ui/core';
import '@object-ui/components';
import { registerAllFields } from '../index';
-const COLLIDING_TYPES = ['text', 'html', 'image', 'avatar', 'grid'] as const;
+const COLLIDING_TYPES = ['text', 'html', 'image', 'avatar', 'grid', 'code'] as const;
describe('registry collision (fields ↔ components)', () => {
beforeAll(() => {
diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx
index 6fdf855a20..b899f552bd 100644
--- a/packages/fields/src/index.tsx
+++ b/packages/fields/src/index.tsx
@@ -4137,6 +4137,11 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([
'slider',
// Display renderer owned by `plugin-markdown:markdown`.
'markdown',
+ // The html tier's inline `code` element passthrough, owned by `ui:code` in
+ // `renderers/basic/html-elements.tsx` (objectui#10756). Before this line the
+ // bare `code` key was THIS widget's fallback, so `inline` on a
+ // `kind:'html'` page drew a code editor and dropped its text.
+ 'code',
// No other package owns the bare `time`/`address` key, but `registerField`
// wraps each call in a fresh `React.lazy(...)`, so re-registration (HMR,
// re-import) fails the registry's identity check every time and logs the
diff --git a/packages/types/src/__tests__/node-slot-registered-arms-8499.test.ts b/packages/types/src/__tests__/node-slot-registered-arms-8499.test.ts
index f0a9721622..d079dbb1cf 100644
--- a/packages/types/src/__tests__/node-slot-registered-arms-8499.test.ts
+++ b/packages/types/src/__tests__/node-slot-registered-arms-8499.test.ts
@@ -181,7 +181,8 @@ describe('objectui#8499 — the controls that keep the acceptances honest', () =
...literalsOf(InputShorthandSchema),
...literalsOf(UiCalendarSchema),
];
- expect(added.length).toBe(7 + 37 + 2 + 1);
+ // 38 flow/inline tags: the 37 this card armed plus `code` (objectui#10756).
+ expect(added.length).toBe(7 + 38 + 2 + 1);
expect(new Set(added).size).toBe(added.length);
const all = literalsOf(AnyComponentSchema);
expect(new Set(all).size).toBe(all.length);
@@ -209,7 +210,7 @@ describe('objectui#8499 — the family arms are compared against their registrat
it('`HtmlElementSchema` names exactly the tags `html-elements.tsx` registers', () => {
const tags = sourceArray(read(HTML_RENDERER), 'const TAGS = ');
- expect(tags.length, 'the source read went vacuous — check the declaration name').toBe(37);
+ expect(tags.length, 'the source read went vacuous — check the declaration name').toBe(38);
expect([...literalsOf(HtmlElementSchema)].sort()).toEqual([...tags].sort());
});
@@ -220,19 +221,20 @@ describe('objectui#8499 — the family arms are compared against their registrat
// touching a registration. Both halves below run the REAL instrument.
//
// A real mismatch, from a real registration site: `html-elements.tsx`
- // registers 37 tags and `SemanticElementSchema` arms none of them, so the
+ // registers 38 tags and `SemanticElementSchema` arms none of them, so the
// equality the two legs above perform must come out false here.
const htmlTags = sourceArray(read(HTML_RENDERER), 'const TAGS = ');
const semanticArmed = [...literalsOf(SemanticElementSchema)].sort();
- expect(htmlTags.length, 'the registration read went vacuous').toBe(37);
+ expect(htmlTags.length, 'the registration read went vacuous').toBe(38);
expect(semanticArmed.length, 'the arm read went vacuous').toBe(7);
expect(semanticArmed).not.toEqual([...htmlTags].sort());
// And the reader must be able to come back EMPTY rather than fabricate a
- // pass — which is what makes the `toBe(7)` / `toBe(37)` guards above real
+ // pass — which is what makes the `toBe(7)` / `toBe(38)` guards above real
// guards. A declaration name that is not in the file throws rather than
// quietly yielding [], so the non-vacuity checks cannot be satisfied by a
- // reader that has stopped reading.
+ // reader that has stopped reading. (The `toBe(38)` guards read 37 when this
+ // card landed; `code` joined `TAGS` and this arm together in objectui#10756.)
expect(() => sourceArray(read(SEMANTIC_RENDERER), 'const notADeclaration = ')).toThrow(
/declaration not found/,
);
diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts
index 2d2b1dc05d..9e0276e109 100644
--- a/packages/types/src/layout.ts
+++ b/packages/types/src/layout.ts
@@ -1706,7 +1706,7 @@ export interface SemanticElementSchema extends BaseSchema {
export interface HtmlElementSchema extends BaseSchema {
type:
| 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
- | 'p' | 'a' | 'blockquote' | 'pre'
+ | 'p' | 'a' | 'blockquote' | 'pre' | 'code'
| 'strong' | 'em' | 'b' | 'i' | 'u' | 'small' | 'mark' | 'sub' | 'sup' | 'del' | 'ins' | 'abbr'
| 'ul' | 'ol' | 'li' | 'dl' | 'dt' | 'dd'
| 'figure' | 'figcaption' | 'img' | 'hr' | 'br' | 'time' | 'address' | 'cite' | 'q';
diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts
index a69b99b43d..b23db79a32 100644
--- a/packages/types/src/zod/layout.zod.ts
+++ b/packages/types/src/zod/layout.zod.ts
@@ -941,7 +941,7 @@ export const SemanticElementSchema = BaseSchema.extend({
export const HtmlElementSchema = BaseSchema.extend({
type: z.enum([
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
- 'p', 'a', 'blockquote', 'pre',
+ 'p', 'a', 'blockquote', 'pre', 'code',
'strong', 'em', 'b', 'i', 'u', 'small', 'mark', 'sub', 'sup', 'del', 'ins', 'abbr',
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'figure', 'figcaption', 'img', 'hr', 'br', 'time', 'address', 'cite', 'q',
diff --git a/scripts/body-dialect-census.mjs b/scripts/body-dialect-census.mjs
index 32392eaa4b..c83b721c56 100644
--- a/scripts/body-dialect-census.mjs
+++ b/scripts/body-dialect-census.mjs
@@ -153,7 +153,7 @@ export const BODY_ONLY_UNRULED = ['tooltip'];
const SEMANTIC_TAGS = ['aside', 'main', 'header', 'nav', 'footer', 'section', 'article'];
const HTML_ELEMENT_TAGS = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
- 'p', 'a', 'blockquote', 'pre',
+ 'p', 'a', 'blockquote', 'pre', 'code',
'strong', 'em', 'b', 'i', 'u', 'small', 'mark', 'sub', 'sup', 'del', 'ins', 'abbr',
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'figure', 'figcaption', 'time', 'address', 'cite', 'q',