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
78 changes: 78 additions & 0 deletions .changeset/6951-text-value-retired.md
Original file line number Diff line number Diff line change
@@ -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`).
2 changes: 1 addition & 1 deletion content/docs/components/basic/span.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions content/docs/components/basic/text.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,26 @@ 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
className?: string; // Additional CSS classes
}
```

> **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
Expand Down
4 changes: 2 additions & 2 deletions content/docs/core/enhanced-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
}
}
}
Expand Down Expand Up @@ -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.'
}
}
},
Expand Down
4 changes: 2 additions & 2 deletions content/docs/guide/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,10 +397,10 @@ Use expressions for dynamic content:
<!-- doc-snippet: fragment — a good/bad contrast pair of two bare schema object literals -->
```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
Expand Down
4 changes: 2 additions & 2 deletions content/docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
}
}
Expand Down Expand Up @@ -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..." }
]
}
}
Expand Down
10 changes: 5 additions & 5 deletions content/docs/guide/schema-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function App() {
const schema = {
type: "page",
title: "My Dashboard",
body: { type: "text", value: "Hello" }
body: { type: "text", content: "Hello" }
}

return <SchemaRenderer schema={schema} />
Expand Down Expand Up @@ -133,7 +133,7 @@ Schemas can be nested to create complex UIs:
"title": "Card 1",
"body": {
"type": "text",
"value": "Nested content"
"content": "Nested content"
}
},
{
Expand All @@ -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" }
]
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"children": [
{
"type": "text",
"value": "Press"
"content": "Press"
},
{
"type": "kbd",
Expand All @@ -16,7 +16,7 @@
},
{
"type": "text",
"value": "to save"
"content": "to save"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
{
"type": "text",
"value": "Please wait"
"content": "Please wait"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
{
"type": "text",
"value": "to"
"content": "to"
},
{
"type": "date-picker",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
},
"content": {
"type": "text",
"value": "This is the hover card content"
"content": "This is the hover card content"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
"description": "Sheet description goes here.",
"content": {
"type": "text",
"value": "Sheet content"
"content": "Sheet content"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
"title": "Left Sheet",
"content": {
"type": "text",
"value": "Content"
"content": "Content"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
"title": "Right Sheet",
"content": {
"type": "text",
"value": "Content"
"content": "Content"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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);
});
});
Expand All @@ -156,7 +156,7 @@ describe('objectui#6939 — the keys the renderers read are DECLARED, not passth
const shape = (TooltipSchema as unknown as { shape: Record<string, unknown> }).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', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const schema = {
title: 'Welcome',
body: {
type: 'text',
value: 'Hello from Object UI!'
content: 'Hello from Object UI!'
}
}

Expand Down
12 changes: 9 additions & 3 deletions packages/components/src/__tests__/basic-renderers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
23 changes: 12 additions & 11 deletions packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SchemaRenderer schema={schema} />);
Expand All @@ -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(<SchemaRenderer schema={schema} />);
Expand All @@ -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}
/>,
);
Expand Down
Loading
Loading