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
2 changes: 2 additions & 0 deletions .changeset/10735-html-tier-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ 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.

⚠️ **Dated note, 2026-09-27 — `code` has since joined the roster — objectui#10756.** At this change the roster held 47 tags and `code` was undeclared because the bare key was the `field:code` widget's fallback; `html-elements.tsx` now registers `code` as a sanitised passthrough (`ui:code`), `@object-ui/fields` registers the widget with `skipFallback` so it keeps only `field:code`, and the manifest declares `code` with `tier: 'html'` — 48 html-tier entries. `div` and `kbd` are still out. The rest of this entry is kept as the reading of this change.

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
Expand Down
51 changes: 51 additions & 0 deletions .changeset/10756-html-tier-code-element.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@object-ui/components': minor
'@object-ui/core': minor
'@object-ui/types': minor
'@object-ui/fields': minor
'@object-ui/cli': minor
---

feat(core,components): the html tier registers and declares `code` — `<code>inline</code>` 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
`<code>inline</code>` 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 `<code>` 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.
3 changes: 3 additions & 0 deletions .changeset/8499-node-slot-registered-arms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions apps/console/src/__tests__/html-tier-code-element-10756.test.tsx
Original file line number Diff line number Diff line change
@@ -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, `<code>inline</code>` 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 `<code>inline</code>` 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 `<code>` 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<typeof manifestFromConfigs>[0],
);

function renderNode(schema: Record<string, unknown>) {
return render(
<SchemaRendererProvider dataSource={dataSource}>
<SchemaRenderer schema={schema as any} />
</SchemaRendererProvider>,
);
}

const HTML_SOURCE = '<main><p>Read <code className="k">inline</code> here.</p></main>';

describe("kind:'html' — `<code>` 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: '<main><pre>block</pre><p><strong>bold</strong></p></main>',
});
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;');
});
});
30 changes: 21 additions & 9 deletions apps/console/src/__tests__/html-tier-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,6 @@ const EXCLUDED_HTML_NAMED: Record<string, string> = {
'published contract by the objectstack#20112 ruling A: the gate that reads the manifest refuses `<div>` 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.',
Expand Down Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -269,8 +279,10 @@ describe('what an html-tier author gets from the gate that reads this manifest (
expect(compile('<main><div>hi</div></main>', published).ok).toBe(false);
});

it('refuses `<code>` — the ledgered field-widget fallback is not declared', () => {
expect(codes('<p><code>x</code></p>')).toContain('forbidden-tag:code');
it('accepts `<code>` with text — declared with its child slot, so neither `forbidden-tag` nor `not-a-container` fires (objectui#10756)', () => {
expect(errors('<p>Read <code className="k">inline</code> here.</p>')).toEqual([]);
expect(codes('<p><code>x</code></p>')).not.toContain('forbidden-tag:code');
expect(codes('<p><code>x</code></p>')).not.toContain('not-a-container:code');
});

it('judges a `label` authored with children by its registration — `text` is required and there is no slot', () => {
Expand Down
6 changes: 3 additions & 3 deletions apps/console/src/__tests__/public-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading