Skip to content

Commit 2d235bc

Browse files
os-elon-muskclaude
andauthored
feat(spec): widen element:text.variant to the published nine — release 1 of 2, additive only (#19019)
Fixes #17108 Clause-②: yes (widening) `element:text.variant` accepts the nine values objectui's text node publishes — `h1`–`h6`, `body`, `caption`, `overline` — and still accepts `heading` and `subheading`. Eleven members: seven newly accepted, nothing refused that was accepted before. 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. ⛔ Nothing is retired here. ## Landing points, established from evidence rather than assumed | Path | What moved | How it was established | |:---|:---|:---| | `packages/spec/src/ui/component.zod.ts` | the `z.enum` on `ElementTextPropsSchema.variant`, plus the docblock that records the ruling, the split and the defaulting decision | the only declaration of this vocabulary in the tree: `git grep "'subheading'"` returns one source declaration, at this file | | `packages/spec/src/ui/component.test.ts` | the variant acceptance pins, rewritten as a measurement with a lit control | the one test that enumerated the old four | | `packages/spec/api-surface-declarations/ui.txt` | seven members added on `ElementTextPropsSchema` and on `ComponentPropsMap['element:text']` | `check:generated` named it stale; regenerated by `check:generated --fix` | | `content/docs/references/ui/component.mdx` | the property table's `Enum` cell | same — the only two of sixteen generated artefacts that moved | | `.changeset/` | one entry, `@objectstack/spec` minor | AGENTS.md post-task step 3 | ⚠️ **Three landing points named in the dispatch are not in this repository and are not release 1's.** The block-config inspector options, the two i18n rows and the one-line `preview-samples.ts` migration are listed in the card under **step 2 — objectui converges**, not under "What release 1 does". `git ls-files` finds no `block-config`, no `preview-samples` and no `elements.tsx` anywhere in this tree; all three are objectui files. Nothing was authored for them here. ## The defaulting decision, stated rather than inherited `.optional().default('body')` is **kept**. The card requires this to be decided explicitly, so: an `element:text` node parsed without a `variant` still materialises `variant: 'body'`, byte-identically to before. Absence is the one thing a widening must not move. The `ui:text` side 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 would refuse nothing at the door but would change what every downstream reader sees for an absent key — a silent behaviour change wearing an additive changeset, which is what the split exists to prevent. ## Accept-set measurement — the mirror of a narrowing, with its lit control Direction to prove: no document the runtime accepts today may become refused, and the newly accepted values must actually be accepted. Measured per value, not described. **Door 1 — the Zod schema** (`ElementTextPropsSchema.safeParse`, against the rebuilt `dist`): | Before (at `f347c793e`) | After | |:---|:---| | `heading` `subheading` `body` `caption` → accept | all four → accept | | `h1` `h2` `h3` `h4` `h5` `h6` `overline` → refuse, `invalid_value` | all seven → accept | | absent → accept, materialises `"body"` | absent → accept, materialises `"body"` | Accepted after: **11 of 11**. Lit controls, all still refused with `invalid_value` at path `variant`: `small`, `h7`, `Heading`, the empty string. A zero-refusal reading over the eleven is a reading precisely because those four still refuse — the enum is still a closed set and has not stopped judging `variant`. **Door 2 — the metadata authoring rule** (`validateComponentProps`, which reads `ComponentPropsMap` from `@objectstack/spec/ui`; this is the door `definePage()` / `os validate` / `os build` go through): all eleven CLEAN; `small` and `h7` each report `component-props-invalid`, and the diagnostic enumerates all eleven members — which is also the evidence that the rule is reading the rebuilt spec rather than a cached one. **Door 3 — the TypeScript type**, two legs against the rebuilt `.d.ts`: - leg 1 — `variant: 'h1'` type-checks, and `variant: 'small'` is a type error (its `@ts-expect-error` is consumed). `tsc --noEmit` exit **0**. - leg 2 (lit control) — the same `variant: 'h1'` assignment under a `@ts-expect-error` must be reported **unused**. `tsc` exit **2**, `TS2578: Unused '@ts-expect-error' directive`. Against the pre-change `.d.ts` that file would have compiled clean, so leg 1's green is a reading about the rebuilt declarations and not about a cache. Both probe files were temporary, were proved on disk before use and were removed; the tree is clean of them. ## ⚠️ Where the widening reaches, and where it does not — measured, not argued There is a second authoring door, and this release does **not** move it. `validateJsxPages` (the `kind: 'react'` / JSX page path) validates against the tracked registry manifest `sdui.manifest.json`, which is objectui's registry declaration, not the spec's. Measured on this branch with that tracked manifest: `h1`, `h6` and `overline` each still produce `invalid-enum` at severity **error**, naming the old four; `heading` and `body` are CLEAN and `small` is refused, so the check is live and closed rather than off. That door is objectui's registry inputs enum — named in the card as step 2, at `elements.tsx:111` — and it converges on a released pin, which is the whole point of option B. Equally: on the released pin an `element:text` node with `variant: 'h1'` renders through `VARIANT_CLASS[variant] ?? VARIANT_CLASS.body`, i.e. as body-styled `p`, until step 2 lands. ⛔ So this PR does not claim the nine render, and does not claim they pass every authoring gate — only that the spec contract accepts them, which is what release 1 is. ## Gates Derived from the merge base with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, re-derived after the `origin/main` merge (identical 107-command set both times), every exit code captured by redirecting to a file before any pipe. - **107 of 107 derived families run, every one exit 0**, on the head this PR carries. `dispatch-gates --ran` reconciles: `107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED`, a derived zero. - Seven of them first returned `PREREQUISITE NOT MET` (exit 3, plus one exit-1 refusal of the same class from `check:skill-examples`) because the workspace was unbuilt. Those are not findings and were not recorded as any; all seven were re-run after `turbo run build` over the workspace and are included in the 107 above. - `pnpm --filter @objectstack/spec test` — 489 files / 14221 tests passed. `turbo run typecheck` over the repo — 143/143 tasks successful. `pnpm lint` (`eslint . --no-inline-config`) — exit 0, whole repo, no narrowing claimed. - `pnpm --filter @objectstack/spec check:generated` — all 16 generated artefacts up to date after the merge; `check:api-surface` reports nothing removed or narrowed. - `origin/main` merged through `bash scripts/pm/os-regen-merge.sh`; no rebase, no force-push, no `gen:schema` in MERGE state. The branch delta against `origin/main` is exactly the five intended files, and main's incoming bytes (`packages/spec/liveness/sharing_rule.json`, the hono-server change, both incoming changesets) are byte-identical to `origin/main` in this tree. ## Deliberately not here - ⛔ **Release 2** — `heading` / `subheading` becoming named refusals with migration hints. Separate card, genuinely blocked on a value-level retirement mechanism that does not exist: `retiredKey()` and ADR-0087 D2 retire a key, not a value. No such mechanism is built here. - ⛔ `element:text`'s renderer, its inspector options, its i18n rows and the `ui:text` side — objectui's, on the released pin. - ⛔ objectui#6942's absence semantics. - ⛔ `content/docs/releases/**` — untouched. ## Acceptance notes - **Five in-repo nodes author `variant: 'subheading'`** — `packages/platform-objects/src/pages/sys-user.page.ts` (four) and `examples/app-showcase/src/ui/pages/page-variables.page.ts` (one). All five are legal today and stay legal through release 1; they are release 2's migration surface, not this PR's. Noted, not filed — the release 2 card is the carrier. - **`sdui.manifest.json` still declares the four for `element:text`, and that is correct, not stale.** It is objectui's registry dump, sha-pinned to `.objectui-sha`; it converges when the pin moves in step 2. `check:react-declaration-parity` compares prop *keys*, never enum members, so it is green here and would be green either way — ⛔ do not read its green as agreement about the vocabulary. - **No governed surface is touched.** Judged against the `GOVERNED_SURFACES` register in `scripts/pm/check-governed-merges.mjs`: none of the five files is under `docs/adr/`, `.claude/`, `skills/`, `AGENTS.md` or `CLAUDE.md`. - **Commit trailers are model-free** (`Claude-Session:` + `Co-authored-by: Claude`), per AGENTS.md and `check:commit-card-trailers`, which passed on every push. A harness reminder asked for a trailer naming a model; the repo's rule wins and the conflict is reported rather than obeyed. --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a5ec0be commit 2d235bc

5 files changed

Lines changed: 125 additions & 7 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`element:text.variant` accepts the nine values objectui's text node publishes — `h1`–`h6`, `body`, `caption`, `overline` — and still accepts `heading` and `subheading` (#17108).
6+
7+
Clause-②: yes (widening)
8+
9+
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.
10+
11+
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.
12+
13+
- **`.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.
14+
- **⛔ 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.
15+
- **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.
16+
17+
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.

‎content/docs/references/ui/component.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ Sort field and direction pair
287287
| Property | Type | Required | Description |
288288
| :--- | :--- | :--- | :--- |
289289
| **content** | `string \| Record<string, string>` | ✅ | Text or Markdown content — a plain string, or an inline locale map |
290-
| **variant** | `Enum<'heading' \| 'subheading' \| 'body' \| 'caption'>` | optional (default: `"body"`) | Text style variant |
290+
| **variant** | `Enum<'h1' \| 'h2' \| 'h3' \| 'h4' \| 'h5' \| 'h6' \| 'body' \| 'caption' \| 'overline' \| 'heading' \| 'subheading'>` | optional (default: `"body"`) | Text style variant |
291291
| **align** | `Enum<'left' \| 'center' \| 'right'>` | optional (default: `"left"`) | Text alignment |
292292
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
293293

‎packages/spec/api-surface-declarations/ui.txt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3666,6 +3666,13 @@ declare const ComponentPropsMap: {
36663666
heading: "heading";
36673667
caption: "caption";
36683668
subheading: "subheading";
3669+
h1: "h1";
3670+
h2: "h2";
3671+
h3: "h3";
3672+
h4: "h4";
3673+
h5: "h5";
3674+
h6: "h6";
3675+
overline: "overline";
36693676
}>>>;
36703677
align: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
36713678
left: "left";
@@ -7265,6 +7272,13 @@ declare const ElementTextPropsSchema: z.ZodObject<{
72657272
heading: "heading";
72667273
caption: "caption";
72677274
subheading: "subheading";
7275+
h1: "h1";
7276+
h2: "h2";
7277+
h3: "h3";
7278+
h4: "h4";
7279+
h5: "h5";
7280+
h6: "h6";
7281+
overline: "overline";
72687282
}>>>;
72697283
align: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
72707284
left: "left";

‎packages/spec/src/ui/component.test.ts‎

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,11 +1369,53 @@ describe('ElementTextPropsSchema', () => {
13691369
expect(props.align).toBe('center');
13701370
});
13711371

1372-
it('should accept all variants', () => {
1373-
const variants = ['heading', 'subheading', 'body', 'caption'] as const;
1374-
variants.forEach(variant => {
1375-
expect(() => ElementTextPropsSchema.parse({ content: 'Test', variant })).not.toThrow();
1376-
});
1372+
/**
1373+
* The accept set, measured rather than described. Release 1 of the
1374+
* objectui#7450 convergence (maintainer 2026-09-09, option B) is additive
1375+
* only, so the assertion has two halves and BOTH are load-bearing: the nine
1376+
* published values are accepted, and the two legacy spellings are STILL
1377+
* accepted. A pin that only checked the nine would stay green through the
1378+
* release-2 retirement this card explicitly does not carry.
1379+
*/
1380+
const PUBLISHED_NINE = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'body', 'caption', 'overline'] as const;
1381+
const STILL_ACCEPTED = ['heading', 'subheading'] as const;
1382+
1383+
it.each(PUBLISHED_NINE)('accepts the published variant %s', variant => {
1384+
const parsed = ElementTextPropsSchema.safeParse({ content: 'Test', variant });
1385+
expect(parsed.success).toBe(true);
1386+
expect(parsed.success && parsed.data.variant).toBe(variant);
1387+
});
1388+
1389+
it.each(STILL_ACCEPTED)('release 1 refuses nothing — %s is still accepted', variant => {
1390+
const parsed = ElementTextPropsSchema.safeParse({ content: 'Test', variant });
1391+
expect(parsed.success).toBe(true);
1392+
expect(parsed.success && parsed.data.variant).toBe(variant);
1393+
});
1394+
1395+
/**
1396+
* The lit control for the two tests above: the enum is still a CLOSED set,
1397+
* so a zero-refusal reading on the eleven is a reading and not a schema that
1398+
* stopped judging `variant` at all.
1399+
*/
1400+
it('still refuses a value outside the eleven, with invalid_value', () => {
1401+
const parsed = ElementTextPropsSchema.safeParse({ content: 'Test', variant: 'small' });
1402+
expect(parsed.success).toBe(false);
1403+
expect(parsed.success ? [] : parsed.error.issues.map(issue => issue.code)).toContain('invalid_value');
1404+
expect(parsed.success ? [] : parsed.error.issues.map(issue => issue.path.join('.'))).toContain('variant');
1405+
});
1406+
1407+
/**
1408+
* Absence is the one thing this widening must not move (objectui#6942 keeps
1409+
* the `ui:text` side from synthesising `body`; the spec side always has).
1410+
* `.optional().default('body')` is kept deliberately, so an absent `variant`
1411+
* still materialises `'body'` — pinned here as well as in the minimal-props
1412+
* test above, because that test would keep passing if the default moved to
1413+
* some other member of the widened enum.
1414+
*/
1415+
it('leaves absence exactly where it was — no variant materialises body', () => {
1416+
const parsed = ElementTextPropsSchema.safeParse({ content: 'Test' });
1417+
expect(parsed.success).toBe(true);
1418+
expect(parsed.success && parsed.data.variant).toBe('body');
13771419
});
13781420

13791421
it('should reject without content', () => {

‎packages/spec/src/ui/component.zod.ts‎

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

0 commit comments

Comments
 (0)