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
69 changes: 69 additions & 0 deletions .changeset/21015-element-text-variant-heading-retired.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered element-text-variant-heading-levels, element-text-variant-heading-subheading-retired -->
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,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<'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<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |

Expand Down
2 changes: 1 addition & 1 deletion examples/app-showcase/src/ui/pages/page-variables.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const PageVariablesPage = definePage({
visibleWhen: "page.selectedProjectId != ''",
properties: {
content: '✓ Project selected',
variant: 'subheading',
variant: 'h3',
},
},
{
Expand Down
8 changes: 4 additions & 4 deletions packages/platform-objects/src/pages/sys-user.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ export const SysUserDetailPage: Page = {
{
type: 'element:text',
properties: {
variant: 'subheading',
variant: 'h3',
content: {
en: 'Password & Sign-in',
'zh-CN': '密码与登录',
Expand Down Expand Up @@ -398,7 +398,7 @@ export const SysUserDetailPage: Page = {
{
type: 'element:text',
properties: {
variant: 'subheading',
variant: 'h3',
content: {
en: 'Two-Factor Authentication',
'zh-CN': '两步验证',
Expand Down Expand Up @@ -431,7 +431,7 @@ export const SysUserDetailPage: Page = {
{
type: 'element:text',
properties: {
variant: 'subheading',
variant: 'h3',
content: {
en: 'Email Verification',
'zh-CN': '邮箱验证',
Expand Down Expand Up @@ -464,7 +464,7 @@ export const SysUserDetailPage: Page = {
{
type: 'element:text',
properties: {
variant: 'subheading',
variant: 'h3',
content: {
en: 'Danger Zone',
'zh-CN': '危险操作',
Expand Down
142 changes: 142 additions & 0 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = { 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`
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
@@ -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'],
};
Loading
Loading