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
17 changes: 17 additions & 0 deletions .changeset/17108-element-text-variant-published-nine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/spec': minor
---

`element:text.variant` accepts the nine values objectui's text node publishes — `h1`–`h6`, `body`, `caption`, `overline` — and still accepts `heading` and `subheading` (#17108).

Clause-②: yes (widening)

Release 1 of 2 for the objectui#7450 convergence (director batch #71, 2026-09-07, maintainer verbatim 「其他同意」), split across two releases by the maintainer's decision of 2026-09-09, option B. This release is **additive only**: the accepted set grows by seven and nothing is refused that was accepted before, so an out-of-repo author can converge on a released pin before any spelling stops working.

Measured on the 17.3.0 declaration, per value, through `ElementTextPropsSchema.safeParse`: `h1`–`h6` and `overline` were refused with `invalid_value`; they are accepted now. `heading`, `subheading`, `body` and `caption` were accepted and are accepted now. A value outside the eleven — `small` — is still refused with `invalid_value` at path `variant`, so the enum remains a closed set rather than having stopped judging `variant` at all.

- **`.optional().default('body')` is kept, deliberately.** An `element:text` node parsed without a `variant` still materialises `variant: 'body'`, exactly as before. Absence is the one thing a widening must not move, and the `ui:text` side of the platform deliberately does *not* synthesise `body` for an absent `variant` (objectui#6942) — that asymmetry is pre-existing and is left where it was.
- **⛔ Nothing is retired.** `heading` and `subheading` become named refusals carrying migration hints in **release 2**, which is a separate card and is blocked on a value-level retirement mechanism that does not exist yet: `retiredKey()` and ADR-0087 D2 retire a *key*, not a *value*. Authors who want to move early can write `h2` for `heading` and `h3` for `subheading`; neither spelling stops working in this release.
- **No renderer changes here.** `element:text`'s renderer, its designer inspector options and its i18n rows are objectui's, on the released pin, and land on objectui's side of the sequence.

Generated projections follow the declaration: `api-surface-declarations/ui.txt` gains the seven members on `ElementTextPropsSchema` and on `ComponentPropsMap['element:text']`, and the `content/docs/references/ui/component.mdx` property table widens. `check:api-surface` reports nothing removed or narrowed.
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ Sort field and direction pair
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **content** | `string \| Record<string, string>` | ✅ | Text or Markdown content — a plain string, or an inline locale map |
| **variant** | `Enum<'heading' \| 'subheading' \| 'body' \| 'caption'>` | optional (default: `"body"`) | Text style variant |
| **variant** | `Enum<'h1' \| 'h2' \| 'h3' \| 'h4' \| 'h5' \| 'h6' \| 'body' \| 'caption' \| 'overline' \| 'heading' \| 'subheading'>` | optional (default: `"body"`) | Text style variant |
| **align** | `Enum<'left' \| 'center' \| 'right'>` | optional (default: `"left"`) | Text alignment |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |

Expand Down
14 changes: 14 additions & 0 deletions packages/spec/api-surface-declarations/ui.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3666,6 +3666,13 @@ declare const ComponentPropsMap: {
heading: "heading";
caption: "caption";
subheading: "subheading";
h1: "h1";
h2: "h2";
h3: "h3";
h4: "h4";
h5: "h5";
h6: "h6";
overline: "overline";
}>>>;
align: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
left: "left";
Expand Down Expand Up @@ -7265,6 +7272,13 @@ declare const ElementTextPropsSchema: z.ZodObject<{
heading: "heading";
caption: "caption";
subheading: "subheading";
h1: "h1";
h2: "h2";
h3: "h3";
h4: "h4";
h5: "h5";
h6: "h6";
overline: "overline";
}>>>;
align: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
left: "left";
Expand Down
52 changes: 47 additions & 5 deletions packages/spec/src/ui/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1369,11 +1369,53 @@ describe('ElementTextPropsSchema', () => {
expect(props.align).toBe('center');
});

it('should accept all variants', () => {
const variants = ['heading', 'subheading', 'body', 'caption'] as const;
variants.forEach(variant => {
expect(() => ElementTextPropsSchema.parse({ content: 'Test', variant })).not.toThrow();
});
/**
* The accept set, measured rather than described. Release 1 of the
* objectui#7450 convergence (maintainer 2026-09-09, option B) is additive
* only, so the assertion has two halves and BOTH are load-bearing: the nine
* published values are accepted, and the two legacy spellings are STILL
* accepted. A pin that only checked the nine would stay green through the
* release-2 retirement this card explicitly does not carry.
*/
const PUBLISHED_NINE = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body', 'caption', 'overline'] as const;
const STILL_ACCEPTED = ['heading', 'subheading'] as const;

it.each(PUBLISHED_NINE)('accepts the published variant %s', variant => {
const parsed = ElementTextPropsSchema.safeParse({ content: 'Test', variant });
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.variant).toBe(variant);
});

it.each(STILL_ACCEPTED)('release 1 refuses nothing — %s is still accepted', variant => {
const parsed = ElementTextPropsSchema.safeParse({ content: 'Test', variant });
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.variant).toBe(variant);
});

/**
* The lit control for the two tests above: the enum is still a CLOSED set,
* so a zero-refusal reading on the eleven is a reading and not a schema that
* stopped judging `variant` at all.
*/
it('still refuses a value outside the eleven, with invalid_value', () => {
const parsed = ElementTextPropsSchema.safeParse({ content: 'Test', variant: 'small' });
expect(parsed.success).toBe(false);
expect(parsed.success ? [] : parsed.error.issues.map(issue => issue.code)).toContain('invalid_value');
expect(parsed.success ? [] : parsed.error.issues.map(issue => issue.path.join('.'))).toContain('variant');
});

/**
* Absence is the one thing this widening must not move (objectui#6942 keeps
* the `ui:text` side from synthesising `body`; the spec side always has).
* `.optional().default('body')` is kept deliberately, so an absent `variant`
* still materialises `'body'` — pinned here as well as in the minimal-props
* test above, because that test would keep passing if the default moved to
* some other member of the widened enum.
*/
it('leaves absence exactly where it was — no variant materialises body', () => {
const parsed = ElementTextPropsSchema.safeParse({ content: 'Test' });
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.variant).toBe('body');
});

it('should reject without content', () => {
Expand Down
47 changes: 46 additions & 1 deletion packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1817,7 +1817,52 @@ export const ElementTextPropsSchema = lazySchema(() => strictObject({
* pages.
*/
content: I18nLabelSchema.describe('Text or Markdown content — a plain string, or an inline locale map'),
variant: z.enum(['heading', 'subheading', 'body', 'caption'])
/**
* Text style variant, declared as the PUBLISHED NINE plus the two spellings
* this declaration has always accepted.
*
* objectui#7450's ruling (director batch #71, 2026-09-07, maintainer
* verbatim 「其他同意」) converges `element:text` on the nine values
* `@object-ui/types` publishes for its text node — `h1`-`h6`, `body`,
* `caption`, `overline` — with `heading` / `subheading` becoming named
* refusals carrying migration hints. The maintainer then split the landing
* (2026-09-09, option B): release 1 widens and refuses NOTHING, so
* out-of-repo authors converge on a released pin before any spelling stops
* working; release 2 carries the refusals and waits on a value-level
* retirement mechanism that does not exist yet (`retiredKey()` / ADR-0087 D2
* retire a KEY, not a VALUE). This entry is release 1. So the accepted set
* GROWS by seven and loses nothing: `h1`-`h6` and `overline` were refused
* here with `invalid_value` on the 17.3.0 pin, measured, and `heading` /
* `subheading` stay accepted.
*
* Why the widening is authored HERE rather than in objectui: this
* declaration is the authoring gate, and it already refused the seven. The
* accurate statement of the defect the ruling names is 「the renderer
* swallows what the authoring gate already refuses」 — objectui declaring
* the nine against a spec that refuses them is the consumer-side widening
* AGENTS.md #0.1 bans, and objectui's own per-PR registry↔spec parity gate
* catches it.
*
* ⚠️ `.optional().default('body')` is KEPT, deliberately, not inherited.
* Absence is the one thing a widening must not move: a parsed
* `element:text` node with no `variant` materialises `variant: 'body'`
* today, and it still does — identical bytes in, identical bytes out. The
* `ui:text` side of the platform deliberately does NOT synthesise `body`
* for an absent `variant` (objectui#6942, protecting unannotated corpus
* nodes); that asymmetry is pre-existing, is not this card's to resolve,
* and is left exactly where it was. Removing the default here would refuse
* nothing and break nothing at the door, but it WOULD change what every
* downstream reader sees for an absent key — a silent behaviour change
* wearing an additive changeset, which is what the ruling's split exists to
* prevent.
*/
variant: z.enum([
// The published nine (`@object-ui/types` `TextProps['variant']`).
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body', 'caption', 'overline',
// Accepted since this shape was declared; release 2 turns these two into
// named refusals with migration hints, ⛔ not release 1.
'heading', 'subheading',
])
.optional().default('body').describe('Text style variant'),
align: z.enum(['left', 'center', 'right'])
.optional().default('left').describe('Text alignment'),
Expand Down
Loading