diff --git a/.changeset/21015-element-text-variant-heading-retired.md b/.changeset/21015-element-text-variant-heading-retired.md new file mode 100644 index 00000000000..05d995172c8 --- /dev/null +++ b/.changeset/21015-element-text-variant-heading-retired.md @@ -0,0 +1,69 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': patch +--- + +feat(spec)!: `element:text` `variant` refuses `heading` / `subheading` by name — the vocabulary is the nine `ui:text` publishes, and `os migrate meta` rewrites them to `h2` / `h3` (#21015) + +**BREAKING** — `heading` and `subheading` leave `ElementTextPropsSchema.variant` (an +`element:text` page component's `properties.variant`). This is the second release of +the ruled two-release convergence on the nine values `ui:text` publishes — `h1`-`h6`, +`body`, `caption`, `overline`. 17.5.0 added the nine and refused nothing; 17.6.0 was +the full release in which both vocabularies parsed; this release refuses the two old +spellings. A heading is a document level, not a text style: `heading` and +`subheading` named a style and left the renderer to pick the level. + +### FROM → TO + +| removed | what to write instead | +| --- | --- | +| `variant: 'heading'` | `variant: 'h2'` — the heading element `heading` always rendered — or the level the page outline means. | +| `variant: 'subheading'` | `variant: 'h3'` — the heading element `subheading` always rendered — or the level the page outline means. | + +**The one-line fix: `heading` → `h2`, `subheading` → `h3`.** +`os migrate meta --from 17` lists the mechanical edits for existing sources. + +The rewrite keeps the heading ELEMENT (so the document outline is unchanged) but not +the size: `heading` drew in the `h3` style and `subheading` in a medium-weight small +heading style, and `h2` / `h3` draw their own, larger styles. Where the old look +mattered more than the level, pick the level whose style you want. + +Each retired spelling is refused at parse with a prescription naming the level to +write, and in `tsc` (the two members are gone from the input type). Any other unknown +value keeps zod's own message. An `element:text` with no `variant` still parses to +`body`. + +### The retirement kit + +- **Value-level retirement.** The enum is declared through `enumWithRetiredValues` + (`shared/retired-key.ts`), with the two prescriptions module-private. No authorable + KEY and no def changed, so nothing lands in `RETIRED_KEYS_BY_MAJOR` and the four + surface ratchets (`api-surface`, `authorable-surface`, `json-schema.manifest`, + `api-surface-signatures`) are byte-identical; the generated component reference + page drops the two values. +- **D2 conversion `element-text-variant-heading-levels`** (step 18, retired from the + load path): `heading` → `h2` and `subheading` → `h3` on every `element:text` page + component — regions, named slots and container nesting. Stored `sys_metadata` page + rows replay it at rehydration; one notice per rewritten block. +- **D3 entry `element-text-variant-heading-subheading-retired`** carries the judgement + the conversion cannot make: whether the rewritten level is the one the page means. +- **No further deprecation window**: 17.6.0 was the window the ruling asked for. + +### Producers moved in this repository + +- `@objectstack/platform-objects`: the four section headings on the `sys_user` record + page's Security tab (`Password & Sign-in`, `Two-Factor Authentication`, `Email + Verification`, `Danger Zone`) move from `subheading` to `h3`. They render the same + h3 element, in the `h3` style. +- `examples/app-showcase`: the `page-variables` detail heading moves to `h3`. + +⚠️ **The out-of-repo author population is NOT MEASURED.** `@objectstack/spec` is +published, and tenant-authored pages were not measured. In this repository the five +writers above were the only ones outside `packages/spec`. objectui at `main` authors +neither value; its `element:text` renderer, registry `inputs` enum, html tier and the +published `sdui.manifest.json` still list the two, and drop them once this release is +installable there (the objectui follow-up). + +Clause-②: no (narrowing) + + diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 7531d2b515c..9030761053a 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -436,7 +436,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **content** | `string \| Record` | ✅ | Text or Markdown content — a plain string, or an inline locale map | -| **variant** | `Enum<'h1' \| 'h2' \| 'h3' \| 'h4' \| 'h5' \| 'h6' \| 'body' \| 'caption' \| 'overline' \| 'heading' \| 'subheading'>` | optional (default: `"body"`) | Text style variant | +| **variant** | `Enum<'h1' \| 'h2' \| 'h3' \| 'h4' \| 'h5' \| 'h6' \| 'body' \| 'caption' \| 'overline'>` | optional (default: `"body"`) | Text style variant | | **align** | `Enum<'left' \| 'center' \| 'right'>` | optional (default: `"left"`) | Text alignment | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/examples/app-showcase/src/ui/pages/page-variables.page.ts b/examples/app-showcase/src/ui/pages/page-variables.page.ts index 60ed40928a1..74369973156 100644 --- a/examples/app-showcase/src/ui/pages/page-variables.page.ts +++ b/examples/app-showcase/src/ui/pages/page-variables.page.ts @@ -87,7 +87,7 @@ export const PageVariablesPage = definePage({ visibleWhen: "page.selectedProjectId != ''", properties: { content: '✓ Project selected', - variant: 'subheading', + variant: 'h3', }, }, { diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 0773ca0b303..3a525f41786 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -365,7 +365,7 @@ export const SysUserDetailPage: Page = { { type: 'element:text', properties: { - variant: 'subheading', + variant: 'h3', content: { en: 'Password & Sign-in', 'zh-CN': '密码与登录', @@ -398,7 +398,7 @@ export const SysUserDetailPage: Page = { { type: 'element:text', properties: { - variant: 'subheading', + variant: 'h3', content: { en: 'Two-Factor Authentication', 'zh-CN': '两步验证', @@ -431,7 +431,7 @@ export const SysUserDetailPage: Page = { { type: 'element:text', properties: { - variant: 'subheading', + variant: 'h3', content: { en: 'Email Verification', 'zh-CN': '邮箱验证', @@ -464,7 +464,7 @@ export const SysUserDetailPage: Page = { { type: 'element:text', properties: { - variant: 'subheading', + variant: 'h3', content: { en: 'Danger Zone', 'zh-CN': '危险操作', diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 5fc79cb7a9a..2d89e2846e3 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7718,6 +7718,147 @@ const translationComponentSubmitLabelRemoved: MetadataConversion = { }, }; +/** + * `element:text` `variant`'s two pre-convergence spellings become the heading + * levels they always rendered — `heading` → `h2`, `subheading` → `h3` + * (protocol 18, #21015: release 2 of objectui#7450's ruling B, maintainer + * 「其他同意」 2026-09-07, split across two releases 2026-09-09). + * + * Release 1 (#17108, 17.5.0) widened the enum to the nine values `ui:text` + * publishes — `h1`-`h6`, `body`, `caption`, `overline` — and refused nothing; + * 17.6.0 was the one full release in which both vocabularies parsed. This + * release refuses the two old spellings by name (`enumWithRetiredValues`, + * ui/component.zod.ts), and this entry carries the ruled migration hint as the + * mechanical edit. + * + * **The rewrite keeps the heading element, not the look.** Measured at the + * `.objectui-sha` pin `89cad75d55` (`renderers/basic/elements.tsx`), the + * `element:text` renderer drew `heading` as an h2 element and `subheading` as an + * h3 element, so the document outline a screen reader walks is unchanged. The + * size is not: `heading` drew `h3`'s style and `subheading` a medium-weight + * `text-lg`, while `h2` and `h3` draw their own, larger ones. The level IS the + * ruled meaning ("or pick the level you mean"), so the edit follows the + * element; the D3 entry `element-text-variant-heading-subheading-retired` + * carries the judgement the chain cannot make — whether this page wanted that + * level, or another. + * + * **One reach: every page component of type `element:text`**, through + * {@link mapPageComponents} — regions, named slots and container nesting, the + * positions the component-props gate judges. `variant` on any other component + * type is that component's own vocabulary and is never read here. + * + * `retiredFromLoadPath`: an author is refused at parse with the prescription + * rather than silently rewritten; stored rows replay it at rehydration + * (`applyConversionsToStoredItem`) and `os migrate meta` lists the edit for + * existing sources. Idempotent by construction: the rewrite's output is + * outside its own input set. + */ +const elementTextVariantHeadingLevels: MetadataConversion = { + id: 'element-text-variant-heading-levels', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.6.0', + surface: 'page.component.element:text.variant', + summary: + "element:text 'variant' spellings 'heading' → 'h2' and 'subheading' → 'h3' (the vocabulary converged " + + "on the nine values ui:text publishes; each old spelling already rendered that heading element, " + + 'so the outline is unchanged and the heading takes that level\'s style)', + apply(stack, emit) { + const VARIANT_REWRITE: Readonly> = { heading: 'h2', subheading: 'h3' }; + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'element:text') return component; + const properties = component.properties; + if (!isDict(properties)) return component; + const variant = properties.variant; + if (typeof variant !== 'string' || !Object.prototype.hasOwnProperty.call(VARIANT_REWRITE, variant)) { + return component; + } + const to = VARIANT_REWRITE[variant]!; + emit({ from: variant, to, path: `${path}.properties.variant` }); + return { ...component, properties: { ...properties, variant: to } }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'text_variant_levels', + regions: [ + { + name: 'main', + components: [ + { type: 'element:text', properties: { content: 'Overview', variant: 'heading', align: 'center' } }, + { type: 'element:text', properties: { content: 'Details', variant: 'subheading' } }, + // A published level and an absent `variant` ride through. + { type: 'element:text', properties: { content: 'Body copy', variant: 'h3' } }, + { type: 'element:text', properties: { content: 'Default body' } }, + // `variant` on another component type is that type's own + // vocabulary, untouched here. + { type: 'element:button', properties: { label: 'Go', variant: 'heading' } }, + // Nested one container down. + { + type: 'page:card', + properties: { + title: 'Card', + children: [{ type: 'element:text', properties: { content: 'In a card', variant: 'subheading' } }], + }, + }, + ], + }, + ], + }, + // A slotted page's named slot — the same component, the other authoring shape. + { + name: 'text_variant_levels_slotted', + kind: 'slotted', + regions: [], + slots: { + details: [{ type: 'element:text', properties: { content: 'Title', variant: 'heading' } }], + }, + }, + ], + }, + after: { + pages: [ + { + name: 'text_variant_levels', + regions: [ + { + name: 'main', + components: [ + { type: 'element:text', properties: { content: 'Overview', variant: 'h2', align: 'center' } }, + { type: 'element:text', properties: { content: 'Details', variant: 'h3' } }, + { type: 'element:text', properties: { content: 'Body copy', variant: 'h3' } }, + { type: 'element:text', properties: { content: 'Default body' } }, + { type: 'element:button', properties: { label: 'Go', variant: 'heading' } }, + { + type: 'page:card', + properties: { + title: 'Card', + children: [{ type: 'element:text', properties: { content: 'In a card', variant: 'h3' } }], + }, + }, + ], + }, + ], + }, + { + name: 'text_variant_levels_slotted', + kind: 'slotted', + regions: [], + slots: { + details: [{ type: 'element:text', properties: { content: 'Title', variant: 'h2' } }], + }, + }, + ], + }, + // One per rewritten `variant`: the region pair, the nested card child and + // the slotted one — the published level, the absent key and the + // other component type are untouched. + expectedNotices: 4, + }, +}; + /** * The inline grid column's one mechanical respelling, shared by both of its * carriers — a relationship field's `inlineColumns` @@ -14439,6 +14580,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: elementFilterRemoved, order: 4 }, { conversion: elementFormRemoved, order: 5 }, { conversion: elementInputTargetVariableRemoved, order: 3 }, + { conversion: elementTextVariantHeadingLevels, order: 59 }, { conversion: fieldColumnListsCanonicalized, order: 6 }, { conversion: fieldMalformedScalePrecisionRemoved, order: 1 }, { conversion: fieldReferenceToAlias, order: 18 }, diff --git a/packages/spec/src/migrations/entries/semantic/18.element-text-variant-heading-subheading-retired.ts b/packages/spec/src/migrations/entries/semantic/18.element-text-variant-heading-subheading-retired.ts new file mode 100644 index 00000000000..af054947adf --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.element-text-variant-heading-subheading-retired.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21015 — release 2 of objectui#7450's ruling B: `element:text` `variant` +// refuses the pre-convergence spellings `heading` and `subheading` by name +// (`enumWithRetiredValues`). The family's one D3 entry; the D2 half is +// `element-text-variant-heading-levels`, which rewrites each to the heading +// element it always rendered. This entry carries the judgement the chain +// cannot make — whether that level is the one the page means, now that it +// draws in that level's style. +export const entry: SemanticMigration = { + id: 'element-text-variant-heading-subheading-retired', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + 'page components of type element:text — properties.variant authored as heading or subheading ' + + '(ElementTextPropsSchema.variant)', + replacement: + "one of the nine values `ui:text` publishes: `h1`-`h6`, `body`, `caption` or `overline`. 'heading' " + + "→ 'h2' and 'subheading' → 'h3' (the heading element each one always rendered), or the level the " + + 'page outline means', + reason: + 'The ruling converged `element:text` on the vocabulary `ui:text` already publishes, because a ' + + 'heading is a document level, not a text style: `heading` and `subheading` named a style and ' + + 'left the renderer to pick a level. It landed in two releases so authors outside this repository ' + + 'could move first — 17.5.0 added the nine and refused nothing, and 17.6.0 was a full release in ' + + 'which both vocabularies parsed. The D2 conversion `element-text-variant-heading-levels` makes ' + + 'the ruled edit: `heading` → `h2`, `subheading` → `h3`. That keeps the heading element (the ' + + 'renderer drew `heading` as an h2 element and `subheading` as an h3 element), so the document ' + + 'outline a screen reader walks is unchanged, but not the size: `heading` drew in the `h3` style ' + + 'and `subheading` in a medium-weight small heading style, and `h2` / `h3` draw their own, larger ' + + 'styles. Whether the page wanted that level is the author\'s call — a heading placed for its ' + + 'size rather than its place in the outline may want a deeper level. Nothing is dropped at rest: ' + + 'a stored page replays the rewrite at rehydration; a page component\'s `properties` is not ' + + 'parsed on the save path, and the component-props gate reports an old spelling as an advisory ' + + '`component-props-invalid` finding, carrying the prescription, on `os validate`, `os build` and ' + + '`os lint`. ADR-0087', + acceptanceCriteria: + 'No `element:text` page component carries `variant` `heading` or `subheading`; `os validate` ' + + 'reports no `component-props-invalid` finding under `properties.variant` for these blocks. For ' + + 'each rewritten block, open the page and check the heading: it renders the same heading element ' + + 'as before, in its level\'s style. Where the old, smaller look mattered more than the level, ' + + 'pick the level whose style you want and confirm the outline still reads in order. A block that ' + + 'omits `variant` still renders as `body`.', + conversionIds: ['element-text-variant-heading-levels'], +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2aa79d5154c..4a8ddfdec03 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5518,6 +5518,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'key from old sources (pure lossless delete — it never had an effect to lose); ' + 'the tombstone\'s prescription says how to declare the binding that works.', }, + { + id: 'element-text-variant-heading-subheading-retired', + order: 68, + text: + 'It also completes the `element:text` `variant` convergence (#21015, the second release of ' + + 'the ruled two-release split): the enum is the nine values `ui:text` publishes — `h1`-`h6`, ' + + '`body`, `caption`, `overline` — and the pre-convergence spellings `heading` and `subheading`, ' + + 'which every release since the nine were added still accepted, are refused by name with a ' + + 'prescription naming the level to write. The D2 conversion `element-text-variant-heading-levels` ' + + 'rewrites `heading` to `h2` and `subheading` to `h3` on every `element:text` page component — ' + + 'the heading element each one always rendered, so the outline is unchanged and the heading ' + + 'takes that level\'s style. The `body` default for an absent `variant` is unchanged.', + }, { id: 'field-inline-and-related-list-columns-closed', order: 9, @@ -10893,6 +10906,49 @@ const step18: MigrationStep = { + 'the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — ' + 'a console-side change filed in the objectui repository, blocked on that release.', }, + // #21015 — release 2 of objectui#7450's ruling B: `element:text` `variant` + // refuses the pre-convergence spellings `heading` and `subheading` by name + // (`enumWithRetiredValues`). The family's one D3 entry; the D2 half is + // `element-text-variant-heading-levels`, which rewrites each to the heading + // element it always rendered. This entry carries the judgement the chain + // cannot make — whether that level is the one the page means, now that it + // draws in that level's style. + { + id: 'element-text-variant-heading-subheading-retired', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + 'page components of type element:text — properties.variant authored as heading or subheading ' + + '(ElementTextPropsSchema.variant)', + replacement: + "one of the nine values `ui:text` publishes: `h1`-`h6`, `body`, `caption` or `overline`. 'heading' " + + "→ 'h2' and 'subheading' → 'h3' (the heading element each one always rendered), or the level the " + + 'page outline means', + reason: + 'The ruling converged `element:text` on the vocabulary `ui:text` already publishes, because a ' + + 'heading is a document level, not a text style: `heading` and `subheading` named a style and ' + + 'left the renderer to pick a level. It landed in two releases so authors outside this repository ' + + 'could move first — 17.5.0 added the nine and refused nothing, and 17.6.0 was a full release in ' + + 'which both vocabularies parsed. The D2 conversion `element-text-variant-heading-levels` makes ' + + 'the ruled edit: `heading` → `h2`, `subheading` → `h3`. That keeps the heading element (the ' + + 'renderer drew `heading` as an h2 element and `subheading` as an h3 element), so the document ' + + 'outline a screen reader walks is unchanged, but not the size: `heading` drew in the `h3` style ' + + 'and `subheading` in a medium-weight small heading style, and `h2` / `h3` draw their own, larger ' + + 'styles. Whether the page wanted that level is the author\'s call — a heading placed for its ' + + 'size rather than its place in the outline may want a deeper level. Nothing is dropped at rest: ' + + 'a stored page replays the rewrite at rehydration; a page component\'s `properties` is not ' + + 'parsed on the save path, and the component-props gate reports an old spelling as an advisory ' + + '`component-props-invalid` finding, carrying the prescription, on `os validate`, `os build` and ' + + '`os lint`. ADR-0087', + acceptanceCriteria: + 'No `element:text` page component carries `variant` `heading` or `subheading`; `os validate` ' + + 'reports no `component-props-invalid` finding under `properties.variant` for these blocks. For ' + + 'each rewritten block, open the page and check the heading: it renders the same heading element ' + + 'as before, in its level\'s style. Where the old, smaller look mattered more than the level, ' + + 'pick the level whose style you want and confirm the outline still reads in order. A block that ' + + 'omits `variant` still renders as `body`.', + conversionIds: ['element-text-variant-heading-levels'], + }, { id: 'engine-dotted-filter-refused', surface: diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index b7704d84dca..50d48892e0e 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -1757,23 +1757,22 @@ describe('ElementTextPropsSchema', () => { it('should accept full text props', () => { const props = ElementTextPropsSchema.parse({ content: '# Welcome', - variant: 'heading', + variant: 'h2', align: 'center', }); - expect(props.variant).toBe('heading'); + expect(props.variant).toBe('h2'); expect(props.align).toBe('center'); }); /** - * 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. + * The accept set, measured rather than described. Release 2 of the + * objectui#7450 convergence (#21015; maintainer 2026-09-09, option B) is the + * narrowing, so the assertion has two halves and BOTH are load-bearing: the + * nine published values are accepted, and the two pre-convergence spellings + * are refused BY NAME with their prescription. A pin that only checked the + * nine would stay green if the retirement were reverted. */ 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 }); @@ -1781,31 +1780,70 @@ describe('ElementTextPropsSchema', () => { 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 envelope a schema refusal carries: a ZodError issue with `code` and + * `path`. There is no ADR-0112 `status` here — that envelope belongs to the + * API error surface — so these pin the code, the path naming the position, + * the prescription's first sentence (the FROM → TO an upgrading author greps + * for) and the house `os migrate meta` sentence. + */ + const MIGRATE_SENTENCE = + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + + it.each([ + ['heading', 'h2'], + ['subheading', 'h3'], + ] as const)('refuses `variant: %s` by name, naming `%s`', (retired, level) => { + const parsed = ElementTextPropsSchema.safeParse({ content: 'Test', variant: retired }); + expect(parsed.success).toBe(false); + const issues = parsed.success ? [] : parsed.error.issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('invalid_value'); + expect(issues[0]!.path).toEqual(['variant']); + const message = issues[0]!.message; + expect(message.split(' — ')[0]).toBe( + `\`${retired}\` was removed from \`element:text\` \`variant\` (\`ElementTextPropsSchema.variant\`) in @objectstack/spec 17.7.0`, + ); + expect(message).toContain(`Write \`${level}\``); + expect(message.endsWith(MIGRATE_SENTENCE)).toBe(true); + }); + + it('refuses a retired spelling through the `ComponentPropsMap` row the props gate parses', () => { + const parsed = ComponentPropsMap['element:text'].safeParse({ content: 'Test', variant: 'subheading' }); + expect(parsed.success).toBe(false); + expect(parsed.success ? [] : parsed.error.issues.map(issue => issue.code)).toEqual(['invalid_value']); + }); + + it('tsc refuses each retired spelling at its typed position', () => { + // @ts-expect-error — `heading` left `element:text` `variant`. + const heading: z.input['variant'] = 'heading'; + // @ts-expect-error — `subheading` left `element:text` `variant`. + const subheading: z.input['variant'] = 'subheading'; + // The parse half of the same fact, so neither local is unused. + expect(ElementTextPropsSchema.safeParse({ content: 'Test', variant: heading }).success).toBe(false); + expect(ElementTextPropsSchema.safeParse({ content: 'Test', variant: subheading }).success).toBe(false); }); /** - * 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. + * The lit control for the refusals above: a value that was never legal keeps + * zod's own message (which lists the legal tokens) — telling its author the + * value "was removed" would misinform. */ - it('still refuses a value outside the eleven, with invalid_value', () => { + it('still refuses a value outside the nine, with zod\'s own invalid_value message', () => { 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'); + expect(parsed.success ? '' : parsed.error.issues[0]!.message).not.toContain('was removed'); }); /** - * Absence is the one thing this widening must not move (objectui#6942 keeps + * Absence is the one thing neither release may 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. + * some other member of the enum. */ it('leaves absence exactly where it was — no variant materialises body', () => { const parsed = ElementTextPropsSchema.safeParse({ content: 'Test' }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 2594178cfc5..c0882cf1f3c 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -58,7 +58,7 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; import { lazySchema } from '../shared/lazy-schema'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { evaluatedExpressionUnionRefusal } from '../shared/evaluated-slot-union'; -import { retiredKey } from '../shared/retired-key'; +import { enumWithRetiredValues, retiredKey } from '../shared/retired-key'; // The retired page-component TYPES' prescriptions — one string per type, three // doors (#14159): the enum's error map and the `PageComponentSchema.type` check // in page.zod.ts, and the kept `ComponentPropsMap` rows below. @@ -2373,6 +2373,41 @@ export const PageAccordionProps = strictObject({ * ---------------------------------------------------------------------- */ +// `element:text` `variant` — the two pre-convergence spellings, retired in +// release 2 of objectui#7450's ruling B (#21015). A VALUE-level retirement +// (`enumWithRetiredValues`, shared/retired-key.ts): the members left the enum, +// so `tsc` refuses them, and the parse answers each with the prescription +// below instead of zod's anonymous enum message. The ruled hints are +// `heading` → `h2` and `subheading` → `h3`, or the level the author means: each +// is the heading ELEMENT the value always rendered (objectui +// `renderers/basic/elements.tsx` `VARIANT_TAG`, measured at the `.objectui-sha` +// pin `89cad75d55`), while the size changes — `heading` drew `h3`'s style and +// `subheading` a medium-weight `text-lg`, and `h2` / `h3` draw their own. The +// ADR-0087 conversion `element-text-variant-heading-levels` rewrites stored +// rows and lists the edit for existing sources. No ADR is cited in the text: +// the retirement rests on the maintainer's ruling, not on an enforce-or-remove +// verdict — both values were rendered. +// +// Module-private and written with `//`, never `/** */`: prose an enum's error +// map consumes, not documented surface — an export with no reader is a +// published surface the next narrowing must keep. +const ELEMENT_TEXT_VARIANT_VOCABULARY = + 'a heading is a document level, not a text style, and `variant` speaks the nine values `ui:text` ' + + 'publishes: `h1`-`h6`, `body`, `caption` and `overline`.'; + +const ELEMENT_TEXT_VARIANT_RETIRED = { + heading: + '`heading` was removed from `element:text` `variant` (`ElementTextPropsSchema.variant`) in ' + + `@objectstack/spec 17.7.0 — ${ELEMENT_TEXT_VARIANT_VOCABULARY} Write \`h2\` — the heading element ` + + '`heading` always rendered, now drawn in the `h2` style — or the level the page outline means. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + subheading: + '`subheading` was removed from `element:text` `variant` (`ElementTextPropsSchema.variant`) in ' + + `@objectstack/spec 17.7.0 — ${ELEMENT_TEXT_VARIANT_VOCABULARY} Write \`h3\` — the heading element ` + + '`subheading` always rendered, now drawn in the `h3` style — or the level the page outline means. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', +} as const; + export const ElementTextPropsSchema = lazySchema(() => strictObject({ surface: 'this `element:text`', history: PROPS_HISTORY, @@ -2392,24 +2427,31 @@ export const ElementTextPropsSchema = lazySchema(() => strictObject({ */ content: I18nLabelSchema.describe('Text or Markdown content — a plain string, or an inline locale map'), /** - * Text style variant, declared as the PUBLISHED NINE plus the two spellings - * this declaration has always accepted. + * Text style variant: the PUBLISHED NINE, and nothing else. * * 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. + * (2026-09-09, option B) across two releases. Release 1 (#17108, shipped in + * 17.5.0) widened by seven and refused NOTHING, so out-of-repo authors could + * converge on a released pin before any spelling stopped working; 17.6.0 was + * the one full release in which the nine were accepted and the two old + * spellings still parsed (triage's window). + * + * This is release 2 (#21015): `heading` and `subheading` leave the enum + * through the value-level retirement mechanism (`enumWithRetiredValues`, + * #17109 — ⛔ not a one-off refinement on this enum), so `tsc` refuses them + * and the parse answers each with its prescription + * ({@link ELEMENT_TEXT_VARIANT_RETIRED}) instead of zod's anonymous enum + * message. The ADR-0087 conversion `element-text-variant-heading-levels` + * (conversions/registry.ts) rewrites them to `h2` / `h3` — the heading + * element each one rendered as — for stored rows and `os migrate meta`; the + * D3 entry `element-text-variant-heading-subheading-retired` carries the + * judgement left to the author (the level the document means). * - * Why the widening is authored HERE rather than in objectui: this + * Why release 1's widening was 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 @@ -2418,25 +2460,24 @@ export const ElementTextPropsSchema = lazySchema(() => strictObject({ * catches it. * * ⚠️ `.optional().default('body')` is KEPT, deliberately, not inherited. - * Absence is the one thing a widening must not move: a parsed + * Absence is the one thing neither release may 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. + * retired members were never the default, so absence never meets the + * refusal (`enumWithRetiredValues` composes with `.default(…)` unchanged). + * 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 no retirement changeset announces. */ - variant: z.enum([ + variant: enumWithRetiredValues( // 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', - ]) + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body', 'caption', 'overline'], + ELEMENT_TEXT_VARIANT_RETIRED, + ) .optional().default('body').describe('Text style variant'), align: z.enum(['left', 'center', 'right']) .optional().default('left').describe('Text alignment'), diff --git a/packages/spec/src/ui/element-text-variant-heading-retirement.test.ts b/packages/spec/src/ui/element-text-variant-heading-retirement.test.ts new file mode 100644 index 00000000000..58f7c11761f --- /dev/null +++ b/packages/spec/src/ui/element-text-variant-heading-retirement.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `element:text` `variant`'s `heading` / `subheading` RETIRED (#21015) — + * release 2 of objectui#7450's ruling B: the vocabulary is the nine values + * `ui:text` publishes, and the two pre-convergence spellings are refused by + * name (`enumWithRetiredValues`), with the ADR-0087 D2 conversion + * `element-text-variant-heading-levels` rewriting them to `h2` / `h3`. + * + * This file is the TREE-SCOPED half of the retirement — it reads outside the + * package, so it runs in the `repo` project (`vitest.repo-tests.json`). The + * refusal, its prescription and the `tsc` channel are pinned in + * `component.test.ts` (`ElementTextPropsSchema`); the conversion's fixture is + * replayed by the conversion suite. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps only TYPED authoring sites, and a page component's `properties` +// is an open bag, so `tsc` does not reach an `element:text` authored through +// `definePage`/`defineStack` at all — the five writers this retirement moved +// were found by grep, not by the compiler. This walk covers every text file +// under the five repo roots `scripts/cross-package-test-inputs.mjs` declares +// for `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example +// apps' own `src/` trees. +// +// The matcher judges the AUTHORING SHAPE, never a mention: `variant` in key +// position with the string value `heading` or `subheading` (TS / JS / JSON, +// and YAML, quoted or bare). It is deliberately not narrowed to +// `element:text`: no other component in this contract declares either value, +// so any `variant: 'heading'` is this retirement being undone — if a schema +// ever declares one, narrow the matcher to `element:text` then, never exclude +// the new file. Prose mentions are spelled in inline code in this repo, and +// inline code is stripped before judging. The bound, stated: a value that is +// not a string literal, a computed key, and `docs/**`, `.claude/**`, +// `.github/**` and the repo-root files are outside what this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors an element:text heading / subheading variant', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const AUTHORING = /(^|[^\w.$])["']?variant["']?[ \t]*:[ \t]*(["'])(heading|subheading)\2|(^|\n)[ \t]*variant:[ \t]*(heading|subheading)[ \t]*(#.*)?$/m; + + /** Inline code spans are prose; newline-bounded, so a fenced example is still judged. */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => AUTHORING.exec(stripInlineCode(text)); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired value. + */ + const EXCLUDED = new Set([ + // This pin authors the values in its anti-vacuity cases. + THIS_FILE, + // The row's pin authors the values to assert their refusal. + 'packages/spec/src/ui/component.test.ts', + // The helper's own contract suite, over a SYNTHETIC `heading` / `subheading` + // vocabulary that is not this enum's (shared/retired-key.ts, #17109). + 'packages/spec/src/shared/retired-key.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement spellings on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `component.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention and the kit (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("{ type: 'element:text', properties: { content: 'Overview', variant: 'heading' } }")).not.toBeNull(); + expect(judge(" properties: {\n variant: 'subheading',\n },")).not.toBeNull(); + expect(judge('{ "type": "element:text", "properties": { "variant": "subheading" } }')).not.toBeNull(); + expect(judge(' properties:\n variant: heading\n')).not.toBeNull(); + expect(judge(" properties:\n variant: 'subheading'\n")).not.toBeNull(); + expect(judge("Prose.\n\n```ts\ndefinePage({ regions: [{ components: [{ properties: { variant: 'heading' } }] }] });\n```\n")).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge('a block that said `variant: \'subheading\'` now says `h3`')).toBeNull(); + expect(judge("{ type: 'element:text', properties: { variant: 'h2' } }")).toBeNull(); + expect(judge("aliases: { heading: 'title', subheading: 'subtitle' },")).toBeNull(); + expect(judge("variant: 'headings',")).toBeNull(); + expect(judge('properties.variant authored as heading or subheading')).toBeNull(); + }); + + it('no heading / subheading variant authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree and the example apps' sources. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'a heading / subheading variant authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 708dfdaa4e2..a89a951d4fb 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -900,7 +900,7 @@ describe('Page end-to-end', () => { components: [ { type: 'element:text', - properties: { content: '# Order Dashboard', variant: 'heading' }, + properties: { content: '# Order Dashboard', variant: 'h2' }, }, { type: 'element:number', diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index bc93eb46351..7392a247e8c 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -45,6 +45,7 @@ "src/system/constants/platform-object-names.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", + "src/ui/element-text-variant-heading-retirement.test.ts", "src/ui/form-field-public-picker-retirement.test.ts", "src/ui/object-grid-resizable-columns-retirement.test.ts", "src/ui/page-header-breadcrumb-retirement.test.ts",