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 */}
- {renderChildren(schema.trigger || { type: 'text', value: "Right click here" })} + {renderChildren(schema.trigger || { type: 'text', content: "Right click here" })}
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')