diff --git a/.changeset/6951-text-value-retired.md b/.changeset/6951-text-value-retired.md
new file mode 100644
index 0000000000..2aebdb2f93
--- /dev/null
+++ b/.changeset/6951-text-value-retired.md
@@ -0,0 +1,78 @@
+---
+'@object-ui/types': minor
+'@object-ui/components': minor
+'@object-ui/plugin-dashboard': patch
+---
+
+**Breaking for authored metadata:** `TextSchema.value` is RETIRED (objectui#6951,
+maintainer ruling A1 of 2026-09-04; objectui#7016; ADR-0049 enforce-or-remove).
+A `text` node that authors `value` no longer validates: the parse fails loudly on
+the `value` path with the explanation in the message, the TS member is a
+`?: never` tombstone so the same document is refused at compile time, and the
+renderer no longer reads the key. Write `content`.
+
+**What was measured, on this branch's base.** `TextSchema` declared two spellings
+for its one content slot — `content` (read first) and `value` (the fallback limb
+of `{schema.content || schema.value}` at `renderers/basic/text.tsx:162` and
+`:167`) — both declared by objectui#6150, whose docblock called the pair "a
+dialect, not a design" and deferred the choice. The ruling's premise, that
+`value` is the minority spelling, was measured before any edit over the four
+roots it named: **776 `content`-only `text` nodes, 25 `value`-only, 0 authoring
+both** across `examples/` (674 / 13), `apps/` (59 / 0), the `examples/`
+directories under `packages/` (0 / 1) and `content/docs/**` (43 / 11) — a
+thirty-to-one majority for `content`, so the retirement went ahead as ruled.
+(A further 14 `{ value, label, type: "text" }` objects in the filter-builder
+catalog entries are field descriptors whose `type` is a field type, not `text`
+nodes, and were excluded by kind.)
+
+**Who is affected — a `value` authored on a `text` node:**
+
+```json
+{ "type": "text",
+ "value": "Hello" } // ← was tolerated (rendered as the fallback)
+```
+
+now fails validation with:
+
+> RETIRED (objectui#6951) — `value` is no longer part of TextSchema; write
+> `content`. It was a second spelling of the one content slot, read only as the
+> fallback limb of `schema.content || schema.value`, and was retired under
+> ADR-0049 enforce-or-remove with no deprecation window (maintainer ruling A1,
+> 2026-09-04). The renderer reads `content` alone now, so an authored `value`
+> would render nothing. Rename the key; the string is unchanged.
+
+**Two published faces, one retirement.** The TypeScript interface `TextSchema`
+(`@object-ui/types`, `layout.ts`) declares `value?: never`; the Zod mirror
+`TextSchema` (`@object-ui/types/zod`, `layout.zod.ts`) declares `value` as a
+`retirementTombstone()`, so the key stays DECLARED and is refused BY NAME —
+a plain deletion would have let an authored `value` ride `BaseSchema`'s
+`.passthrough()` into a silent blank, which is worse than the tolerated
+fallback it replaces. The `value?: string` members of `TextSpanSchema` and
+`TabsSchema` in the same file are other schemas' contracts and are unchanged.
+
+**`@object-ui/components`** — the `text` renderer renders `{schema.content}` at
+both arms (the `|| schema.value` limb is gone from each), and the `context-menu`
+renderer's built-in fallback trigger node now spells `content`. Nothing else in
+the package moves. **`@object-ui/plugin-dashboard`** — its three placeholder
+`text` nodes ("chart type is not supported yet", "Custom widget — set
+`component`…", the retired-widget notice) spell `content` so they keep rendering;
+their wording is unchanged and still pinned.
+
+**Who is NOT affected.** A document that already wrote `content` is untouched;
+`content`, `variant`, `align` and `className` are unchanged; `absent` stays
+valid (`{ "type": "text" }` still parses). Every in-repo document that authored
+`value` on a `text` node was rewritten to `content` in the same change: nine
+`examples/schema-catalog` entries, `packages/types/examples/zod-validation-example.ts`,
+eleven doc fences under `content/docs/`, and the `@object-ui/components`,
+`@object-ui/react` and `@object-ui/types/zod` README samples; the catalog is now
+pinned tree-wide against the retired spelling.
+
+**Migration:** rename `value` to `content` on every `text` node; the string is
+unchanged. If a document authored both, `content` was already the value that
+rendered — delete `value`.
+
+Graded `minor`, not `patch`: this narrows the accepted input set, which is
+breaking for any author who wrote the tolerated spelling. It is not `major` per
+this repo's fixed-group convention (objectui's own breaking changes ship as
+`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
+mechanically enforced by `scripts/check-changeset-no-major.mjs`).
diff --git a/content/docs/components/basic/span.mdx b/content/docs/components/basic/span.mdx
index 9cd56086e7..14b0d69f4e 100644
--- a/content/docs/components/basic/span.mdx
+++ b/content/docs/components/basic/span.mdx
@@ -64,7 +64,7 @@ interface SpanSchema {
value?: string; // Text content
children?: SchemaNode | SchemaNode[]; // Child components (the child key this component reads)
// Both keys are read. When both are authored, child content wins and `value`
- // is ignored — the same precedence `text` uses for `content` over `value`.
+ // is ignored (the richer key renders, the scalar does not).
// Styling
className?: string; // Tailwind CSS classes
diff --git a/content/docs/components/basic/text.mdx b/content/docs/components/basic/text.mdx
index 8934c5c02c..7585927525 100644
--- a/content/docs/components/basic/text.mdx
+++ b/content/docs/components/basic/text.mdx
@@ -33,8 +33,7 @@ rather than ignored.
```plaintext
interface TextSchema {
type: 'text';
- content: string; // Text content to display
- value?: string; // Alias for content
+ content: string; // Text content to display — the one spelling
variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'body' | 'caption' | 'overline';
align?: 'left' | 'center' | 'right' | 'justify';
color?: string; // Tailwind color class
@@ -42,6 +41,18 @@ interface TextSchema {
}
```
+> **Retired: `value`** (objectui#6951, ADR-0049 enforce-or-remove). `value` was a
+> second spelling of the one content slot — the renderer read it only as the
+> fallback of `content || value` — and it is no longer part of `TextSchema` on
+> either face. A `text` node that authors `value` now fails validation with:
+>
+> > RETIRED (objectui#6951) — `value` is no longer part of TextSchema; write
+> > `content`. …
+>
+> Rename the key to `content`; the string is unchanged. Measured before the
+> retirement: 776 `content`-only `text` nodes against 25 `value`-only across
+> `examples/`, `apps/`, `packages/*/examples` and `content/docs/**`.
+
## Examples
### Colored Text
diff --git a/content/docs/core/enhanced-actions.mdx b/content/docs/core/enhanced-actions.mdx
index ebdc488ea4..208716ebb5 100644
--- a/content/docs/core/enhanced-actions.mdx
+++ b/content/docs/core/enhanced-actions.mdx
@@ -328,7 +328,7 @@ const actionWithCallbacks: ActionSchema = {
title: 'Submission Failed',
content: {
type: 'text',
- value: 'Please try again or contact support.'
+ content: 'Please try again or contact support.'
}
}
}
@@ -458,7 +458,7 @@ const complexAction: ActionSchema = {
title: 'Order Processing Failed',
content: {
type: 'text',
- value: 'Unable to process order. Please try again.'
+ content: 'Unable to process order. Please try again.'
}
}
},
diff --git a/content/docs/guide/architecture.md b/content/docs/guide/architecture.md
index d5cf1f4a60..9bdb84df90 100644
--- a/content/docs/guide/architecture.md
+++ b/content/docs/guide/architecture.md
@@ -397,10 +397,10 @@ Use expressions for dynamic content:
```tsx
// ❌ Bad - hardcoded
-{ type: 'text', value: 'Hello, John!' }
+{ type: 'text', content: 'Hello, John!' }
// ✅ Good - dynamic
-{ type: 'text', value: 'Hello, ${user.name}!' }
+{ type: 'text', content: 'Hello, ${user.name}!' }
```
## Plugin Development
diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md
index fb2b71c788..7a8c7511c2 100644
--- a/content/docs/guide/layout.md
+++ b/content/docs/guide/layout.md
@@ -137,7 +137,7 @@ The `Page` component provides a consistent wrapper for individual pages with opt
"body": {
"type": "container",
"children": [
- { "type": "text", "value": "User list goes here" }
+ { "type": "text", "content": "User list goes here" }
]
}
}
@@ -533,7 +533,7 @@ Omit `sidebar` and the content fills the width under the top bar.
"body": {
"type": "card",
"children": [
- { "type": "text", "value": "Record details..." }
+ { "type": "text", "content": "Record details..." }
]
}
}
diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md
index 7e2c35cc08..70cdb10ae0 100644
--- a/content/docs/guide/schema-rendering.md
+++ b/content/docs/guide/schema-rendering.md
@@ -31,7 +31,7 @@ function App() {
const schema = {
type: "page",
title: "My Dashboard",
- body: { type: "text", value: "Hello" }
+ body: { type: "text", content: "Hello" }
}
return
@@ -133,7 +133,7 @@ Schemas can be nested to create complex UIs:
"title": "Card 1",
"body": {
"type": "text",
- "value": "Nested content"
+ "content": "Nested content"
}
},
{
@@ -158,9 +158,9 @@ Use arrays for multiple items:
{
"type": "container",
"body": [
- { "type": "text", "value": "First item" },
- { "type": "text", "value": "Second item" },
- { "type": "text", "value": "Third item" }
+ { "type": "text", "content": "First item" },
+ { "type": "text", "content": "Second item" },
+ { "type": "text", "content": "Third item" }
]
}
```
diff --git a/examples/schema-catalog/src/schemas/components-data-display-kbd/inline-usage.json b/examples/schema-catalog/src/schemas/components-data-display-kbd/inline-usage.json
index 695a9a0774..4a6b189396 100644
--- a/examples/schema-catalog/src/schemas/components-data-display-kbd/inline-usage.json
+++ b/examples/schema-catalog/src/schemas/components-data-display-kbd/inline-usage.json
@@ -5,7 +5,7 @@
"children": [
{
"type": "text",
- "value": "Press"
+ "content": "Press"
},
{
"type": "kbd",
@@ -16,7 +16,7 @@
},
{
"type": "text",
- "value": "to save"
+ "content": "to save"
}
]
}
diff --git a/examples/schema-catalog/src/schemas/components-feedback-spinner/loading-button.json b/examples/schema-catalog/src/schemas/components-feedback-spinner/loading-button.json
index badd09118e..9ae099c6b2 100644
--- a/examples/schema-catalog/src/schemas/components-feedback-spinner/loading-button.json
+++ b/examples/schema-catalog/src/schemas/components-feedback-spinner/loading-button.json
@@ -9,7 +9,7 @@
},
{
"type": "text",
- "value": "Please wait"
+ "content": "Please wait"
}
]
}
diff --git a/examples/schema-catalog/src/schemas/components-form-date-picker/date-range-selector.json b/examples/schema-catalog/src/schemas/components-form-date-picker/date-range-selector.json
index 84c7dbf57c..592b9ed299 100644
--- a/examples/schema-catalog/src/schemas/components-form-date-picker/date-range-selector.json
+++ b/examples/schema-catalog/src/schemas/components-form-date-picker/date-range-selector.json
@@ -9,7 +9,7 @@
},
{
"type": "text",
- "value": "to"
+ "content": "to"
},
{
"type": "date-picker",
diff --git a/examples/schema-catalog/src/schemas/components-form-input-otp/verification-form.json b/examples/schema-catalog/src/schemas/components-form-input-otp/verification-form.json
index 5cae4182d6..f866fa8f58 100644
--- a/examples/schema-catalog/src/schemas/components-form-input-otp/verification-form.json
+++ b/examples/schema-catalog/src/schemas/components-form-input-otp/verification-form.json
@@ -14,7 +14,7 @@
},
{
"type": "text",
- "value": "We sent a code to your email",
+ "content": "We sent a code to your email",
"className": "text-sm text-muted-foreground"
}
]
diff --git a/examples/schema-catalog/src/schemas/components-overlay-hover-card/basic-hover-card.json b/examples/schema-catalog/src/schemas/components-overlay-hover-card/basic-hover-card.json
index fc2391cdf0..214615b817 100644
--- a/examples/schema-catalog/src/schemas/components-overlay-hover-card/basic-hover-card.json
+++ b/examples/schema-catalog/src/schemas/components-overlay-hover-card/basic-hover-card.json
@@ -7,6 +7,6 @@
},
"content": {
"type": "text",
- "value": "This is the hover card content"
+ "content": "This is the hover card content"
}
}
diff --git a/examples/schema-catalog/src/schemas/components-overlay-sheet/basic-sheet.json b/examples/schema-catalog/src/schemas/components-overlay-sheet/basic-sheet.json
index d1f9617c70..92e2770fd6 100644
--- a/examples/schema-catalog/src/schemas/components-overlay-sheet/basic-sheet.json
+++ b/examples/schema-catalog/src/schemas/components-overlay-sheet/basic-sheet.json
@@ -8,6 +8,6 @@
"description": "Sheet description goes here.",
"content": {
"type": "text",
- "value": "Sheet content"
+ "content": "Sheet content"
}
}
diff --git a/examples/schema-catalog/src/schemas/components-overlay-sheet/left-side.json b/examples/schema-catalog/src/schemas/components-overlay-sheet/left-side.json
index b5b923ecdc..3e9d9e076c 100644
--- a/examples/schema-catalog/src/schemas/components-overlay-sheet/left-side.json
+++ b/examples/schema-catalog/src/schemas/components-overlay-sheet/left-side.json
@@ -8,6 +8,6 @@
"title": "Left Sheet",
"content": {
"type": "text",
- "value": "Content"
+ "content": "Content"
}
}
diff --git a/examples/schema-catalog/src/schemas/components-overlay-sheet/right-side.json b/examples/schema-catalog/src/schemas/components-overlay-sheet/right-side.json
index b8cb260c79..022d8185a0 100644
--- a/examples/schema-catalog/src/schemas/components-overlay-sheet/right-side.json
+++ b/examples/schema-catalog/src/schemas/components-overlay-sheet/right-side.json
@@ -8,6 +8,6 @@
"title": "Right Sheet",
"content": {
"type": "text",
- "value": "Content"
+ "content": "Content"
}
}
diff --git a/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx b/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx
index a7d85204d7..f992216515 100644
--- a/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx
+++ b/examples/schema-catalog/test/overlay-trigger-mirror-6939.test.tsx
@@ -115,12 +115,12 @@ describe('objectui#6939 — and the repair moved the validator, not the renderer
describe('objectui#6939 — `children` is no longer required on either member', () => {
it('a tooltip with no children validates', () => {
- expect(reasons({ type: 'tooltip', trigger: { type: 'text', value: 'x' }, content: 'y' })).toEqual([]);
+ expect(reasons({ type: 'tooltip', trigger: { type: 'text', content: 'x' }, content: 'y' })).toEqual([]);
expect(TooltipSchema.safeParse({ type: 'tooltip' }).success).toBe(true);
});
it('a context menu with no children validates', () => {
- expect(reasons({ type: 'context-menu', trigger: { type: 'text', value: 'x' }, items: [{ label: 'a' }] })).toEqual([]);
+ expect(reasons({ type: 'context-menu', trigger: { type: 'text', content: 'x' }, items: [{ label: 'a' }] })).toEqual([]);
expect(ContextMenuSchema.safeParse({ type: 'context-menu', items: [] }).success).toBe(true);
});
@@ -135,7 +135,7 @@ describe('objectui#6939 — `children` is no longer required on either member',
expect(ContextMenuSchema.safeParse({
type: 'context-menu',
items: [{ label: 'Copy' }],
- children: { type: 'text', value: 'Right-click here' },
+ children: { type: 'text', content: 'Right-click here' },
}).success).toBe(true);
});
});
@@ -156,7 +156,7 @@ describe('objectui#6939 — the keys the renderers read are DECLARED, not passth
const shape = (TooltipSchema as unknown as { shape: Record }).shape;
expect(Object.keys(shape)).toEqual(expect.arrayContaining(['trigger', 'content', 'body']));
expect(TooltipSchema.safeParse({ type: 'tooltip', content: 'text only' }).success).toBe(true);
- expect(TooltipSchema.safeParse({ type: 'tooltip', body: { type: 'text', value: 'rich only' } }).success).toBe(true);
+ expect(TooltipSchema.safeParse({ type: 'tooltip', body: { type: 'text', content: 'rich only' } }).success).toBe(true);
});
it('context-menu declares triggerClassName / contentClassName / modal', () => {
diff --git a/packages/components/README.md b/packages/components/README.md
index 047b08e66b..681f8d644f 100644
--- a/packages/components/README.md
+++ b/packages/components/README.md
@@ -94,7 +94,7 @@ const schema = {
title: 'Welcome',
body: {
type: 'text',
- value: 'Hello from Object UI!'
+ content: 'Hello from Object UI!'
}
}
diff --git a/packages/components/src/__tests__/basic-renderers.test.tsx b/packages/components/src/__tests__/basic-renderers.test.tsx
index 42b7fb6fff..cd18f6a833 100644
--- a/packages/components/src/__tests__/basic-renderers.test.tsx
+++ b/packages/components/src/__tests__/basic-renderers.test.tsx
@@ -53,13 +53,19 @@ describe('Basic Renderers - Display Issue Detection', () => {
expect(domCheck).toBeDefined();
});
- it('should support value property as alias', () => {
+ it('no longer reads `value` — RETIRED (objectui#6951, ADR-0049), `content` is the one spelling', () => {
+ // Before objectui#6951 this case pinned `value` as an alias for `content`
+ // (`{schema.content || schema.value}`). The alias is retired on both
+ // published faces and the renderer reads `content` alone, so an authored
+ // `value` reaches the DOM as NOTHING — the enforce-or-remove half this
+ // pin now guards. The refusal itself is pinned in `@object-ui/types`
+ // (`text-value-retired-6951.test.ts`); this leg is the read side.
const { container } = renderComponent({
type: 'text',
value: 'Test Value',
- });
+ } as never);
- expect(container.textContent).toContain('Test Value');
+ expect(container.textContent).not.toContain('Test Value');
});
it('should render with designer props correctly', () => {
diff --git a/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx b/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
index bd53807b3d..456ce07d52 100644
--- a/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
+++ b/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
@@ -81,18 +81,19 @@ describe('objectui#4795 — declared text keys are evaluated AND read back, thro
describe('objectui#4795 — the undeclared half stays inert, through real renderers', () => {
/**
- * `basic/text.tsx` renders `schema.content || schema.value`, so `text.value`
- * IS a top-level read-back site — and `text` has no row in the spec's
- * carriage map, so the memo must not evaluate it. This assertion therefore
- * pins a KNOWN, reported gap rather than a desired behaviour: the literal on
- * screen is what an author writing the form the expressions guide teaches
- * gets today, and closing it is a spec-side row (objectstack), not a
- * renderer-side inference here. If a row is ever added upstream, this is the
- * test that will go red and say so.
+ * `text.value` is RETIRED (objectui#6951, ADR-0049 enforce-or-remove):
+ * `basic/text.tsx` renders `{schema.content}` alone, so `value` is no longer
+ * a read-back site at all — neither evaluated (the spec's carriage map never
+ * had a `text` row for it, objectstack#13670 ruled `content` the sole
+ * channel) nor rendered as a literal. Before the retirement this case pinned
+ * the literal `${data.total}` on screen as a known gap; now the pin is that
+ * NOTHING from the retired key reaches the DOM. The refusal at the authoring
+ * boundary is pinned in `@object-ui/types` (`text-value-retired-6951.test.ts`).
*/
- it('`text.value` is still not evaluated — no spec row (reported upstream)', () => {
- renderNode({ type: 'text', value: '${data.total}' });
- expect(screen.getByText('${data.total}')).toBeTruthy();
+ it('`text.value` is retired — neither evaluated nor read back', () => {
+ const { container } = renderNode({ type: 'text', value: '${data.total}' });
+ expect(container.textContent).not.toContain('${data.total}');
+ expect(container.textContent).not.toContain('99');
});
it('a key outside the component\'s declared row stays inert (`card.value`)', () => {
diff --git a/packages/components/src/__tests__/span-children-rendering.test.tsx b/packages/components/src/__tests__/span-children-rendering.test.tsx
index 2360072ece..e8540369a6 100644
--- a/packages/components/src/__tests__/span-children-rendering.test.tsx
+++ b/packages/components/src/__tests__/span-children-rendering.test.tsx
@@ -69,7 +69,7 @@ describe('span renders its canonical child key (#5027)', () => {
const schema: TextSpanSchema = {
type: 'span',
className: 'json-authored',
- children: [{ type: 'text', value: 'inline from children' }],
+ children: [{ type: 'text', content: 'inline from children' }],
};
const { container } = render();
@@ -83,7 +83,7 @@ describe('span renders its canonical child key (#5027)', () => {
const schema: TextSpanSchema = {
type: 'span',
className: 'single-child',
- children: { type: 'text', value: 'lone child' },
+ children: { type: 'text', content: 'lone child' },
};
const { container } = render();
@@ -101,7 +101,7 @@ describe('span renders its canonical child key (#5027)', () => {
schema={{
type: 'span',
className: 'alias-probe',
- body: [{ type: 'text', value: 'must not render' }],
+ body: [{ type: 'text', content: 'must not render' }],
} as never}
/>,
);
diff --git a/packages/components/src/__tests__/span-value-fallback.test.tsx b/packages/components/src/__tests__/span-value-fallback.test.tsx
index 57dd01c04f..c576729567 100644
--- a/packages/components/src/__tests__/span-value-fallback.test.tsx
+++ b/packages/components/src/__tests__/span-value-fallback.test.tsx
@@ -26,9 +26,10 @@
* Ruled 2026-08-17: wire it. Both declared faces stay as written, and the
* renderer makes them true, rather than the declarations being retracted.
*
- * PRECEDENCE, and why it is not a bespoke rule: `basic/text.tsx` renders
- * `schema.content || schema.value`, so on the sibling type in the same family
- * the richer key already wins and the scalar is already the fallback. `span`
+ * PRECEDENCE, and why it is not a bespoke rule: at the time `basic/text.tsx`
+ * rendered `schema.content || schema.value` (that fallback limb was retired by
+ * objectui#6951; `content` is now `text`'s one spelling), so on the sibling type
+ * in the same family the richer key already won and the scalar was the fallback. `span`
* follows that, with `children` (its canonical child key, #5027) in the winning
* position. `body` stays refused — see `span-children-rendering.test.tsx`; it is
* declared nowhere for this type, whereas `value` is declared twice.
@@ -66,15 +67,16 @@ describe('span reads its declared `value` key (#5050)', () => {
});
it('lets child content win when both `children` and `value` are authored', () => {
- // The precedence half of the ruling. `text`'s `content || value` is the
- // family precedent: the richer key renders, the scalar does not — and the
+ // The precedence half of the ruling. `text`'s `content || value` was the
+ // family precedent (its `value` limb is retired since objectui#6951): the
+ // richer key renders, the scalar does not — and the
// scalar must not be appended either, which is what the second assertion
// rules out (a `+` instead of a `||` would keep the first one green).
const schema: TextSpanSchema = {
type: 'span',
className: 'both-keys',
value: 'value must not render',
- children: [{ type: 'text', value: 'children win' }],
+ children: [{ type: 'text', content: 'children win' }],
};
const { container } = render();
@@ -93,7 +95,7 @@ describe('span reads its declared `value` key (#5050)', () => {
type: 'span',
className: 'single-child-wins',
value: 'value must not render',
- children: { type: 'text', value: 'lone child wins' },
+ children: { type: 'text', content: 'lone child wins' },
};
const { container } = render();
diff --git a/packages/components/src/renderers/basic/text.tsx b/packages/components/src/renderers/basic/text.tsx
index 1a66d9e745..a7a7b9eb73 100644
--- a/packages/components/src/renderers/basic/text.tsx
+++ b/packages/components/src/renderers/basic/text.tsx
@@ -159,12 +159,12 @@ ComponentRegistry.register('text',
style={style}
className={className}
>
- {schema.content || schema.value}
+ {schema.content}
);
}
- return <>{schema.content || schema.value}>;
+ return <>{schema.content}>;
},
{
namespace: 'ui',
diff --git a/packages/components/src/renderers/overlay/context-menu.tsx b/packages/components/src/renderers/overlay/context-menu.tsx
index 83e2f7084d..7796d71d32 100644
--- a/packages/components/src/renderers/overlay/context-menu.tsx
+++ b/packages/components/src/renderers/overlay/context-menu.tsx
@@ -92,7 +92,7 @@ ComponentRegistry.register('context-menu',
{/* Usually a Right Click area */}
diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx
index f8c660e419..1db59997dc 100644
--- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx
+++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx
@@ -359,7 +359,7 @@ export const DashboardGridLayout: React.FC = ({
if (dispatch.family === 'unsupported') {
return {
type: 'text',
- value: `「${widgetType}」chart type is not supported yet`,
+ content: `「${widgetType}」chart type is not supported yet`,
variant: 'caption',
align: 'center',
className: 'flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-muted-foreground',
diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx
index c3c855cf9a..af6ee14b43 100644
--- a/packages/plugin-dashboard/src/DashboardRenderer.tsx
+++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx
@@ -789,7 +789,7 @@ const DashboardRendererInner = forwardRef
+ ((schema as { shape: Record }).shape[key])?.description;
+
+/** Flatten a union refusal so the arm-level issues are addressable by path. */
+type Issue = { code: string; path: PropertyKey[]; message: string; expected?: string; errors?: Issue[][] };
+const flatIssues = (issues: Issue[]): Issue[] =>
+ issues.flatMap((i) => (i.code === 'invalid_union' && i.errors ? i.errors.flat().flatMap((e) => flatIssues([e])) : [i]));
+
+/* ── the Zod half: refused BY NAME, with the guidance in the message ─────── */
+
+describe('TextSchema.value is RETIRED — the Zod half of the tombstone (objectui#6951)', () => {
+ it.each(RETIRED_VALUES.map((v) => [JSON.stringify(v), v] as const))(
+ 'REFUSES `value: %s`, naming the retired key in the path — every value, not one spelling',
+ (_label, value) => {
+ // The pin. Before the retirement this document parsed GREEN (`value` was
+ // `z.string().optional()`, measured ACCEPTED on the retiring PR's base).
+ // Asserting the ENVELOPE — not merely `success:false` — so the pin cannot
+ // be satisfied by an unrelated rejection.
+ const result = TextSchema.safeParse({ type: 'text', value });
+ expect(result.success, `an authored \`value: ${JSON.stringify(value)}\` was ACCEPTED`).toBe(false);
+ if (result.success) return;
+
+ const issue = result.error.issues.find((i) => i.path[0] === 'value');
+ expect(issue, 'parse failed, but not on the `value` path').toBeTruthy();
+ // The accept-set contract: same address, same code a bare `z.never()`
+ // reports — `retirementTombstone()` customises the MESSAGE only.
+ expect(issue?.code).toBe('invalid_type');
+ expect((issue as { expected?: string } | undefined)?.expected).toBe('never');
+ expect(issue?.path).toEqual(['value']);
+ },
+ );
+
+ it('the refusal CARRIES the guidance — it names `content`, not zod\'s generic message', () => {
+ const result = TextSchema.safeParse({ type: 'text', value: 'Hello' });
+ expect(result.success).toBe(false);
+ if (result.success) return;
+
+ const issue = result.error.issues.find((i) => i.path[0] === 'value');
+ expect(issue?.message).not.toContain('Invalid input: expected never, received ');
+ expect(issue?.message).toContain(PRESCRIPTIVE);
+ expect(issue?.message).toContain('write `content`');
+ expect(issue?.message).toBe(GUIDANCE);
+ // ONE string, BOTH channels — asserted derived, so the parse message and
+ // the generated-docs metadata cannot drift apart (objectui#6931).
+ expect(issue?.message).toBe(describeOf(TextSchema, 'value'));
+ });
+
+ it('is refused through `safeValidateSchema` too — the `AnyComponentSchema` union arm carries the tombstone', () => {
+ // The entry point a validating host actually calls. `AnyComponentSchema`
+ // is a plain `z.union`, so a document every arm refuses surfaces as
+ // `invalid_union`; the `TextSchema` arm's own issue — path `value`, the
+ // guidance — must be inside it, otherwise the refusal an author reads
+ // through this door would not say what to write.
+ const result = safeValidateSchema({ type: 'text', value: 'Hello' });
+ expect(result.success, 'a `text` node authoring `value` validated GREEN through the union').toBe(false);
+ if (result.success) return;
+
+ const named = flatIssues(result.error.issues as Issue[]).find((i) => i.path[0] === 'value');
+ expect(named, 'the union refusal does not name the `value` path').toBeTruthy();
+ expect(named?.message).toBe(GUIDANCE);
+
+ // Positive control on the same door: the migrated document validates.
+ expect(safeValidateSchema({ ...VALID_TEXT }).success).toBe(true);
+ });
+
+ it('keeps the key DECLARED — a tombstone, not a deletion', () => {
+ // The route guard. `BaseSchema` is `.passthrough()`, so removing the key
+ // from the mirror would make the authored spelling parse green again — and
+ // with the renderer no longer reading it, render as a BLANK.
+ expect(
+ Object.keys(TextSchema.shape),
+ 'value left the mirror — under .passthrough() the retired key becomes a silent blank',
+ ).toContain('value');
+ expect(describeOf(TextSchema, 'value')).toContain('RETIRED (objectui#6951)');
+ });
+});
+
+/* ── the inside of the boundary: everything else is untouched ────────────── */
+
+describe('the retirement narrows exactly `value` and nothing else (objectui#6951)', () => {
+ it('`content` still parses and its value SURVIVES the parse', () => {
+ const result = TextSchema.safeParse(VALID_TEXT);
+ expect(result.success ? null : result.error.issues).toBe(null);
+ if (result.success) expect(result.data.content).toBe('Hello');
+ });
+
+ it('a document that never wrote `value` parses GREEN — `absent` stays valid', () => {
+ // `.optional()` on the tombstone. A bare `{ type: "text" }` was legal
+ // before (#6150's own control document) and stays legal.
+ const result = TextSchema.safeParse({ type: 'text' });
+ expect(result.success ? null : result.error.issues).toBe(null);
+ });
+
+ it('still REFUSES a wrong-typed `content` — the mirror did not stop validating', () => {
+ // Counter-probe in the other direction: the schema is not `z.any()` in
+ // disguise, so the green results above are readings.
+ const result = TextSchema.safeParse({ type: 'text', content: 42 });
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ expect(result.error.issues.find((i) => i.path[0] === 'content')).toBeTruthy();
+ });
+
+ it('control: `TextSpanSchema.value` — the sibling member at `layout.ts:66` — is still ACCEPTED and survives', () => {
+ // `value?: string` appears on three interfaces in `layout.ts`; only
+ // `TextSchema`'s is retired. The `span` renderer still reads its own
+ // `value` (`basic/span.tsx`), so this member must keep parsing — the pin
+ // that the retirement was located by SYMBOL, not by grep hit.
+ const result = TextSpanSchema.safeParse({ type: 'span', value: 'inline' });
+ expect(result.success ? null : result.error.issues).toBe(null);
+ if (result.success) expect(result.data.value).toBe('inline');
+ });
+
+ it('an UNDECLARED key still rides `.passthrough()` — the DELETED row, measured live', () => {
+ // This is the contrast that justifies `?: never` over deletion, pinned
+ // rather than argued: a key the mirror does not declare is neither refused
+ // nor stripped, it is KEPT. Had `value` been deleted instead of tombstoned,
+ // an authored value would sit exactly where this one sits — green, kept,
+ // and read by nothing.
+ const result = TextSchema.safeParse({ ...VALID_TEXT, notAKeyAtAll: 'anything' });
+ expect(result.success).toBe(true);
+ if (result.success) expect(result.data).toHaveProperty('notAKeyAtAll', 'anything');
+ });
+});
+
+/* ── the corpus: no shipped fixture authors the retired spelling ─────────── */
+
+/**
+ * Keys whose arrays hold DESCRIPTORS, not component nodes: a filter-builder's
+ * `fields[]` entry is `{ value, label, type: "text" }`, where `type` is a FIELD
+ * type and `value` is the field key — the same three keys as a retired `text`
+ * node and a different vocabulary. The census that sized this retirement
+ * excluded them by kind (14 in the catalog); the walk below excludes them by
+ * position, so the pin measures `text` NODES and not every object that spells
+ * `type: "text"`.
+ */
+const DESCRIPTOR_SLOTS = new Set(['fields', 'columns', 'filters', 'options']);
+
+/** Every `text` node (an object whose OWN `type` is `"text"`, reached through a node slot) in a parsed JSON document. */
+function* textNodes(node: unknown, path: string): Generator<[string, Record]> {
+ if (Array.isArray(node)) {
+ for (let i = 0; i < node.length; i++) yield* textNodes(node[i], `${path}[${i}]`);
+ return;
+ }
+ if (!node || typeof node !== 'object') return;
+ const obj = node as Record;
+ if (obj.type === 'text') yield [path, obj];
+ for (const [k, v] of Object.entries(obj)) {
+ if (DESCRIPTOR_SLOTS.has(k)) continue;
+ yield* textNodes(v, `${path}.${k}`);
+ }
+}
+
+function* jsonFiles(dir: string): Generator {
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ if (statSync(full).isDirectory()) yield* jsonFiles(full);
+ else if (entry.endsWith('.json')) yield full;
+ }
+}
+
+describe('no shipped JSON fixture authors `text.value` any more (objectui#6951) — tree-scoped', () => {
+ // Tree-scoped on purpose: a file-scoped pin sees only the files its author
+ // knew about, and the nine catalog entries rewritten by the retiring PR
+ // (`components-data-display-kbd/inline-usage` ×2, `components-feedback-spinner/
+ // loading-button`, `components-form-date-picker/date-range-selector`,
+ // `components-form-input-otp/verification-form`, `components-overlay-hover-card/
+ // basic-hover-card`, `components-overlay-sheet/{basic-sheet,left-side,right-side}`)
+ // were found by census, not by memory. Nested nodes are validated per node
+ // because `SchemaNodeSchema` does not descend into `AnyComponentSchema`.
+ const CATALOG = resolve(ROOT, 'examples/schema-catalog/src/schemas');
+ const TYPES_EXAMPLES = resolve(ROOT, 'packages/types/examples');
+
+ it('every `text` node in the catalog and the types examples spells `content`, and parses green', () => {
+ const offenders: string[] = [];
+ let seen = 0;
+ for (const dir of [CATALOG, TYPES_EXAMPLES]) {
+ for (const file of jsonFiles(dir)) {
+ const doc = JSON.parse(readFileSync(file, 'utf8')) as unknown;
+ for (const [path, node] of textNodes(doc, '$')) {
+ seen++;
+ if ('value' in node) offenders.push(`${file.slice(ROOT.length + 1)} ${path}`);
+ const result = TextSchema.safeParse(node);
+ if (!result.success) offenders.push(`${file.slice(ROOT.length + 1)} ${path}: ${JSON.stringify(result.error.issues)}`);
+ }
+ }
+ }
+ // Non-vacuity: the catalog carried 688 `text` nodes at the retirement; a
+ // walk that finds none is a broken walk, not a clean corpus.
+ expect(seen).toBeGreaterThan(500);
+ expect(offenders).toEqual([]);
+ });
+});
+
+/* ── the renderer half: the retired key is no longer READ ────────────────── */
+
+describe('the `text` renderer no longer reads `schema.value` (objectui#6951, enforce-or-remove)', () => {
+ it('both read sites render `{schema.content}` alone — the read set, off disk', () => {
+ // Enforce-or-remove: a retired key must stop being READ, not only stop
+ // being declared. Both arms of `text.tsx` — the wrapped element and the
+ // bare fragment — rendered `{schema.content || schema.value}`; the
+ // fallback limb is gone from both. Read off disk so a renderer-side
+ // re-widening cannot pass while the schema faces still refuse.
+ const src = readFileSync(resolve(ROOT, 'packages/components/src/renderers/basic/text.tsx'), 'utf8');
+ expect(src.match(/schema\.value\b/g)).toBeNull();
+ expect(src.match(/\{schema\.content\}/g)).toHaveLength(2);
+ });
+
+ it('the one in-package producer of the dialect spells `content` too (`context-menu` fallback trigger)', () => {
+ // `renderers/overlay/context-menu.tsx` authors a `text` node itself as the
+ // default trigger; under the retirement a `value` there would render a
+ // blank click area. Pinned so the producer cannot drift back.
+ const src = readFileSync(resolve(ROOT, 'packages/components/src/renderers/overlay/context-menu.tsx'), 'utf8');
+ expect(src).toContain("{ type: 'text', content: \"Right click here\" }");
+ expect(src).not.toMatch(/type: 'text', value:/);
+ });
+});
+
+/* ── the TS half: the `tsc` channel ──────────────────────────────────────── */
+
+describe('TextSchema.value is RETIRED — the TS half of the tombstone (objectui#6951)', () => {
+ it('refuses the retired key at compile time', () => {
+ // On the pre-fix tree `value` is `string | undefined`, so the assignment
+ // is LEGAL, the directive below is unused, and `tsc` fails the build with
+ // TS2578 naming the key — this leg is red before the fix in `type-check`,
+ // not in vitest, which strips types.
+
+ // @ts-expect-error — `value` is RETIRED (objectui#6951): declared `?: never`, so no value is authorable.
+ const retired: TextSchemaTS['value'] = 'Hello';
+
+ // Counter-probe on the same surface: the live sibling still accepts its
+ // value, so the directive above pins the KEY's retirement and not a
+ // blanket narrowing of the interface.
+ const sibling: TextSchemaTS['content'] = 'Hello';
+
+ expect([retired, sibling]).toHaveLength(2);
+ });
+
+ it('refuses the retired key in the form authors actually write', () => {
+ // The leg that proves the tombstone survives `BaseSchema`'s
+ // `[key: string]: any`: if the index signature won, `value` would widen
+ // back to `any` here and the directive would go unused (TS2578).
+ const retiredDocument: TextSchemaTS = {
+ type: 'text',
+ // @ts-expect-error — `value` is RETIRED (objectui#6951); write `content`.
+ value: 'Hello',
+ };
+
+ // The migrated document — the key renamed — still type-checks.
+ const migratedDocument: TextSchemaTS = {
+ type: 'text',
+ content: 'Hello',
+ variant: 'body',
+ };
+
+ expect([retiredDocument, migratedDocument]).toHaveLength(2);
+ });
+
+ it('refuses it through a WIDENED value too — the half a deletion would have missed', () => {
+ // Excess-property checking only reaches a FRESH literal (objectui#7654
+ // measured the contrast): a deleted key would ride a widened value
+ // silently. The declared `never` makes the assignment itself ill-typed,
+ // so freshness stops mattering.
+ const raw = { type: 'text' as const, value: 'Hello' };
+ // @ts-expect-error — `value` is RETIRED (objectui#6951), reached through a non-fresh value.
+ const document: TextSchemaTS = raw;
+ expect(document.type).toBe('text');
+ });
+
+ it('control: `TextSpanSchema.value` (`layout.ts:66`) still type-checks — retired by symbol, not by grep', () => {
+ const span: TextSpanSchemaTS = { type: 'span', value: 'inline' };
+ expect(span.value).toBe('inline');
+ });
+});
diff --git a/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts b/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts
index 0fff7f0fba..a834907f8c 100644
--- a/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts
+++ b/packages/types/src/__tests__/undeclared-but-consumed-keys-6150.test.ts
@@ -147,7 +147,7 @@ interface Case {
readText: string;
}
-const NODE: SchemaNode = { type: 'text', value: 'x' } as SchemaNode;
+const NODE: SchemaNode = { type: 'text', content: 'x' } as SchemaNode;
const TEXT_CONTROL = { type: 'text' };
const CAROUSEL_CONTROL = { type: 'carousel', items: [] };
const FILTER_CONTROL = { type: 'filter-builder', fields: [] };
@@ -162,7 +162,7 @@ const R = 'packages/components/src/renderers/';
const CASES: Case[] = [
{ type: 'TextSchema', key: 'content', mirror: TextSchema as never, control: TEXT_CONTROL,
legal: 'hello', illegal: 42,
- reader: R + 'basic/text.tsx', readText: '{schema.content || schema.value}' },
+ reader: R + 'basic/text.tsx', readText: '{schema.content}' },
{ type: 'CarouselSchema', key: 'opts', mirror: CarouselSchema as never, control: CAROUSEL_CONTROL,
legal: { loop: true, align: 'start' }, illegal: 'not-an-option-bag',
diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts
index 32f9784fda..7b94727e4e 100644
--- a/packages/types/src/base.ts
+++ b/packages/types/src/base.ts
@@ -474,7 +474,7 @@ export interface BaseSchema {
* @example
* ```typescript
* const nodes: SchemaNode[] = [
- * { type: 'text', value: 'Hello' },
+ * { type: 'text', content: 'Hello' },
* 'Plain string',
* { type: 'button', label: 'Click' }
* ]
diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts
index 92225fa263..933639a613 100644
--- a/packages/types/src/layout.ts
+++ b/packages/types/src/layout.ts
@@ -76,32 +76,41 @@ export interface TextSpanSchema extends BaseSchema {
export interface TextSchema extends BaseSchema {
type: 'text';
/**
- * Text content to display — the spelling the renderer reads FIRST.
+ * Text content to display — the ONE content spelling `text` reads.
*
- * READ SITE: `packages/components/src/renderers/basic/text.tsx:51` (the
- * wrapped `span` arm, taken when the node carries a designer id or a
- * className) and `:56` (the bare fragment arm), both as
- * `{schema.content || schema.value}`. `content` therefore WINS over
- * {@link TextSchema.value} whenever both are authored.
+ * READ SITE: `packages/components/src/renderers/basic/text.tsx:162` (the
+ * wrapped element arm, taken when the node carries a designer id, a
+ * typography class or a className) and `:167` (the bare fragment arm), both
+ * as `{schema.content}`.
*
* Declared by objectui#6150 (undeclared-but-consumed census). Before that
* card the renderer read this key through `BaseSchema`'s
* `[key: string]: any` (objectui#5155) and no shipped type mentioned it —
- * the docs page was the only record of a capability that works.
- *
- * ⚠️ Two spellings for one slot is a dialect, not a design. Retiring one of
- * them is an ADR-0049 enforce-or-remove question and is deliberately NOT
- * decided here; this declaration records what the renderer does today.
+ * the docs page was the only record of a capability that works. #6150
+ * declared it next to a `value` fallback spelling and recorded that choosing
+ * between the two was an ADR-0049 question it did not decide; objectui#6951
+ * (maintainer ruling A1, 2026-09-04) decided it: `content` is the one
+ * spelling, and {@link TextSchema.value} is the tombstone below.
*/
content?: string;
/**
- * Text content — the fallback spelling, read only when
- * {@link TextSchema.content} is absent or falsy.
+ * RETIRED (objectui#6951 / objectui#7016, ADR-0049 enforce-or-remove) — the
+ * second spelling of the one content slot, read only as the fallback limb of
+ * `schema.content || schema.value`. Maintainer ruling A1 (2026-09-04): retire
+ * `value`, keep `content`, immediately and with no deprecation window
+ * (「项目在创业阶段,用户也很少,短期不考虑渐进。」). Measured before the
+ * retirement, on the four roots the ruling named (`examples/`, `apps/`, the
+ * `examples/` directories under `packages/`, `content/docs/**`): 776 `content`-only `text`
+ * nodes, 25 `value`-only, none authoring both — so the retired limb was the
+ * minority spelling by thirty to one.
*
- * READ SITE: `renderers/basic/text.tsx:51,56`, the right-hand side of
- * `{schema.content || schema.value}`.
+ * The renderer no longer reads it (`text.tsx` renders `{schema.content}` at
+ * both arms), so an authored `value` would be a silent blank; the tombstone
+ * turns it into a `tsc` error here and a named refusal on the Zod mirror
+ * (`../zod/layout.zod.ts`) instead. Write `content`; the string is unchanged.
+ * @deprecated Not part of this contract — write `content`.
*/
- value?: string;
+ value?: never;
/**
* Text variant/style
* @default 'body'
diff --git a/packages/types/src/overlay.ts b/packages/types/src/overlay.ts
index 9df1af15e1..b19507f9be 100644
--- a/packages/types/src/overlay.ts
+++ b/packages/types/src/overlay.ts
@@ -595,7 +595,7 @@ export interface ContextMenuSchema extends BaseSchema {
* The right-clickable area's content.
*
* READ SITE: `packages/components/src/renderers/overlay/context-menu.tsx:95`
- * — `renderChildren(schema.trigger || { type: 'text', value: 'Right click here' })`
+ * — `renderChildren(schema.trigger || { type: 'text', content: 'Right click here' })`
* inside `ContextMenuTrigger`. ⚠️ Note the renderer renders `trigger`, NOT
* `children` — which this member used to sit beside as a REQUIRED key and
* which no read site consumes (objectui#6939 dropped that requirement;
diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md
index 28916d4964..568d4c197a 100644
--- a/packages/types/src/zod/README.md
+++ b/packages/types/src/zod/README.md
@@ -269,7 +269,7 @@ const cardWithChildren = CardSchema.parse({
type: 'card',
title: 'My Card',
children: [
- { type: 'text', value: 'Hello' },
+ { type: 'text', content: 'Hello' },
{ type: 'button', label: 'Click' }
]
});
diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts
index c934c77369..f3fbf81a9b 100644
--- a/packages/types/src/zod/layout.zod.ts
+++ b/packages/types/src/zod/layout.zod.ts
@@ -17,7 +17,7 @@
*/
import { z } from 'zod';
-import { handlerKeyRefusal } from './tombstone.zod.js';
+import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js';
import {
PageSchema as SpecPageSchema,
PageTypeSchema as SpecPageTypeSchema,
@@ -61,9 +61,20 @@ export const TextSpanSchema = BaseSchema.extend({
export const TextSchema = BaseSchema.extend({
type: z.literal('text'),
content: z.string().optional()
- .describe("Text content, read FIRST at renderers/basic/text.tsx:51,56 — `schema.content || schema.value`, so it wins over `value` (objectui#6150)"),
- value: z.string().optional()
- .describe('Text content, read as the fallback limb of `schema.content || schema.value` at renderers/basic/text.tsx:51,56'),
+ .describe('Text content — the one content spelling `text` reads, at renderers/basic/text.tsx:162,167 as `{schema.content}` (declared by objectui#6150; its `value` fallback spelling was retired by objectui#6951)'),
+ // ADR-0049 RETIREMENT TOMBSTONE (objectui#6951 / objectui#7016, maintainer
+ // ruling A1 of 2026-09-04). `value` was the second spelling of the one
+ // content slot; the renderer now reads `content` alone, so a plain deletion
+ // here would let an authored `value` ride `BaseSchema.passthrough()` into a
+ // silent blank. The tombstone refuses it BY NAME instead — one string, both
+ // channels (parse-time message and `.describe()`), see `./tombstone.zod.ts`.
+ value: retirementTombstone(
+ 'RETIRED (objectui#6951) — `value` is no longer part of TextSchema; write `content`. It was a second '
+ + 'spelling of the one content slot, read only as the fallback limb of `schema.content || schema.value`, '
+ + 'and was retired under ADR-0049 enforce-or-remove with no deprecation window (maintainer ruling A1, '
+ + '2026-09-04). The renderer reads `content` alone now, so an authored `value` would render nothing. '
+ + 'Rename the key; the string is unchanged.',
+ ),
variant: z.enum(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body', 'caption', 'overline'])
.optional()
.default('body')