diff --git a/.changeset/theme-inert-token-scales-removed.md b/.changeset/theme-inert-token-scales-removed.md new file mode 100644 index 0000000000..1df37437f6 --- /dev/null +++ b/.changeset/theme-inert-token-scales-removed.md @@ -0,0 +1,66 @@ +--- +"@objectstack/spec": major +--- + +**BREAKING (theme):** retire the nine theme token groups that were emitted and read by nobody (#5021, ADR-0049 enforce-or-remove). + +`ThemeSchema` declared a full design-token vocabulary — a type scale, a weight +scale, line-height and letter-spacing scales, a motion scale and a z-index scale. +objectui's theme engine turned every one of them into CSS custom properties, +faithfully and for years. What never existed was a **reader**: measured against +objectui `main` on 2026-08-04, `--font-size-*`, `--font-weight-*`, +`--line-height-*`, `--letter-spacing-*`, `--duration-*`, `--timing-*`, `--z-*`, +`--font-heading` and `--font-mono` have **zero** consumers across objectui's +components and stylesheets, while `--font-sans`, `--radius*`, `--shadow*` and the +colour variables come back live in the same run. So a declared type scale was +real CSS that styled nothing, and an overlay you "lifted" with `zIndex` still +stacked by document order. + +This is why the earlier theme sweep (#3494) left them standing: its criterion was +*"the engine never emits it"*, and these are emitted. ADR-0049's criterion — +emitted, but consumed by nobody — is what reaches them. + +FROM → TO: + +| Removed | Replace with | +|---|---| +| `theme.typography.fontSize` | `theme.customVars: { "font-size-lg": "1.125rem" }` | +| `theme.typography.fontWeight` | `theme.customVars: { "font-weight-semibold": "600" }` | +| `theme.typography.lineHeight` | `theme.customVars: { "line-height-relaxed": "1.75" }` | +| `theme.typography.letterSpacing` | `theme.customVars: { "letter-spacing-wide": "0.025em" }` | +| `theme.typography.fontFamily.heading` | `theme.customVars: { "font-heading": "Georgia, serif" }` | +| `theme.typography.fontFamily.mono` | `theme.customVars: { "font-mono": "ui-monospace, monospace" }` | +| `theme.animation` | `theme.customVars: { "duration-fast": "150ms", "timing-ease": "ease" }` | +| `theme.zIndex` | `theme.customVars: { "z-modal": "1050" }` | + +The one-line fix: **delete the key; re-declare under `customVars` only the +variables your own stylesheets actually read.** `customVars` emits each entry +verbatim as `--: `, so every retired variable is reproducible byte +for byte — no capability is lost. Run `os migrate meta --from 16` to strip the +keys automatically; it emits one notice per key so you can see what you were +declaring before deciding what to keep. + +`colors`, `borderRadius`, `shadows` and `typography.fontFamily.base` have live +consumers and are **unchanged**. + +The retirement kit: + +- **Schema** — each key is a `retiredKey()` tombstone, so authoring one is both + a `tsc` error (the input type is `never`) and a parse error carrying the + prescription above. `AnimationSchema` and `ZIndexSchema` were deleted outright + along with the `Animation` / `ZIndex` types: each had exactly one consumer — + the key now tombstoned — and an exported schema with no consumer reads as a + capability to whoever finds it (#3950). +- **Aliases** — the five that pointed at `animation`/`zIndex` and the seven that + pointed into the retired typography scales were deleted with their targets + rather than re-pointed. Keeping them would answer an author with *"did you mean + `zIndex`?"* and then reject `zIndex` — a rename into a second rejection. +- **Migration** — `theme-inert-token-scales-removed` (ADR-0087 D2), wired into + the protocol-17 chain step and retired from the load path, so a live parse + rejects loudly and only `os migrate meta` rewrites sources. It **deletes** the + keys rather than auto-populating `customVars`: a rewrite would hand back two + dozen variables that still nothing reads, turning a dead semantic slot into a + dead literal one. +- **Baselines** — `authorable-surface.json` gains eight `[RETIRED]` markers and + loses the ten `ui/Animation:*` / `ui/ZIndex:*` lines under the #4650 deletion + check's whole-def proof; `json-schema.manifest.json` drops the two defs. diff --git a/content/docs/references/ui/theme.mdx b/content/docs/references/ui/theme.mdx index d17db85982..7c5005d73a 100644 --- a/content/docs/references/ui/theme.mdx +++ b/content/docs/references/ui/theme.mdx @@ -16,25 +16,13 @@ Defines brand colors and their variants. ## TypeScript Usage ```typescript -import { AnimationSchema, BorderRadiusSchema, ColorPaletteSchema, ShadowSchema, ThemeSchema, ThemeModeSchema, TypographySchema, ZIndexSchema } from '@objectstack/spec/ui'; -import type { Animation, BorderRadius, ColorPalette, Shadow, Theme, ThemeMode, Typography, ZIndex } from '@objectstack/spec/ui'; +import { BorderRadiusSchema, ColorPaletteSchema, ShadowSchema, ThemeSchema, ThemeModeSchema, TypographySchema } from '@objectstack/spec/ui'; +import type { BorderRadius, ColorPalette, Shadow, Theme, ThemeMode, Typography } from '@objectstack/spec/ui'; // Validate data -const result = AnimationSchema.parse(data); +const result = BorderRadiusSchema.parse(data); ``` ---- - -## Animation - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **duration** | `{ fast?: string; base?: string; slow?: string }` | optional | | -| **timing** | `{ linear?: string; ease?: string; ease_in?: string; ease_out?: string; … }` | optional | | - - --- ## BorderRadius @@ -111,11 +99,11 @@ const result = AnimationSchema.parse(data); | **description** | `string` | optional | Theme description | | **mode** | `Enum<'light' \| 'dark' \| 'auto'>` | ✅ | Theme mode (light, dark, or auto) | | **colors** | `{ primary: string; secondary?: string; accent?: string; success?: string; … }` | ✅ | Color palette configuration | -| **typography** | `{ fontFamily?: object; fontSize?: object; fontWeight?: object; lineHeight?: object; … }` | optional | Typography settings | +| **typography** | `{ fontFamily?: object; fontSize?: any; fontWeight?: any; lineHeight?: any; … }` | optional | Typography settings | | **borderRadius** | `{ none?: string; sm?: string; base?: string; md?: string; … }` | optional | Border radius scale | | **shadows** | `{ none?: string; sm?: string; base?: string; md?: string; … }` | optional | Box shadow effects | -| **animation** | `{ duration?: object; timing?: object }` | optional | Animation settings | -| **zIndex** | `{ base?: number; dropdown?: number; sticky?: number; fixed?: number; … }` | optional | Z-index scale for layering | +| **animation** | `any` | optional | [REMOVED] `theme.animation` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — unlike the #3494 props above, the engine DID emit `--duration-*` and `--timing-*`, faithfully and for years; what never existed was a reader. No first-party component or stylesheet has ever consumed one, so every transition ran at the renderer default whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "duration-fast": "150ms", "timing-ease_in": "cubic-bezier(0.4, 0, 1, 1)" }` emits exactly the same properties). Run `os migrate meta --from 16` to rewrite it automatically. | +| **zIndex** | `any` | optional | [REMOVED] `theme.zIndex` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--z-base` … `--z-tooltip` and nothing read one, so an overlay you "lifted" still stacked by document order. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "z-modal": "1050" }` emits exactly the same `--z-modal`). Run `os migrate meta --from 16` to rewrite it automatically. | | **customVars** | `Record` | optional | Custom CSS variables (key-value pairs) | | **extends** | `string` | optional | Base theme to extend from | @@ -139,29 +127,11 @@ const result = AnimationSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **fontFamily** | `{ base?: string; heading?: string; mono?: string }` | optional | | -| **fontSize** | `{ xs?: string; sm?: string; base?: string; lg?: string; … }` | optional | | -| **fontWeight** | `{ light?: number; normal?: number; medium?: number; semibold?: number; … }` | optional | | -| **lineHeight** | `{ tight?: string; normal?: string; relaxed?: string; loose?: string }` | optional | | -| **letterSpacing** | `{ tighter?: string; tight?: string; normal?: string; wide?: string; … }` | optional | | - - ---- - -## ZIndex - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **base** | `number` | optional | Base z-index (e.g., 0) | -| **dropdown** | `number` | optional | Dropdown z-index (e.g., 1000) | -| **sticky** | `number` | optional | Sticky z-index (e.g., 1020) | -| **fixed** | `number` | optional | Fixed z-index (e.g., 1030) | -| **modalBackdrop** | `number` | optional | Modal backdrop z-index (e.g., 1040) | -| **modal** | `number` | optional | Modal z-index (e.g., 1050) | -| **popover** | `number` | optional | Popover z-index (e.g., 1060) | -| **tooltip** | `number` | optional | Tooltip z-index (e.g., 1070) | +| **fontFamily** | `{ base?: string; heading?: any; mono?: any }` | optional | | +| **fontSize** | `any` | optional | [REMOVED] `theme.typography.fontSize` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-size-xs` … `--font-size-4xl` faithfully and NO first-party component or stylesheet has ever read one, so a declared type scale was real CSS that styled nothing. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-size-lg": "1.125rem" }` emits exactly the same `--font-size-lg`). Run `os migrate meta --from 16` to rewrite it automatically. | +| **fontWeight** | `any` | optional | [REMOVED] `theme.typography.fontWeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-weight-*` and nothing read it, so text rendered at the inherited weight whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-weight-semibold": "600" }` emits exactly the same `--font-weight-semibold`). Run `os migrate meta --from 16` to rewrite it automatically. | +| **lineHeight** | `any` | optional | [REMOVED] `theme.typography.lineHeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--line-height-*` and nothing read it, so every block kept its inherited leading. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "line-height-relaxed": "1.75" }` emits exactly the same `--line-height-relaxed`). Run `os migrate meta --from 16` to rewrite it automatically. | +| **letterSpacing** | `any` | optional | [REMOVED] `theme.typography.letterSpacing` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--letter-spacing-*` and nothing read it, so tracking never moved. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "letter-spacing-wide": "0.025em" }` emits exactly the same `--letter-spacing-wide`). Run `os migrate meta --from 16` to rewrite it automatically. | --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index b9de033020..dd04185294 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 484 | +| Object sites in them | 476 | | Still-open (strip) sites | 221 | | Files carrying at least one | 36 | @@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 200 | 120 | 5 | 0 | 75 | +| `ui/` | 192 | 112 | 5 | 0 | 75 | | `data/` | 162 | 54 | 1 | 0 | 107 | | `automation/` | 75 | 49 | 0 | 0 | 26 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **484** | **257** | **6** | **0** | **221** | +| **total** | **476** | **249** | **6** | **0** | **221** | ## File-level triage — site counts @@ -77,11 +77,11 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `report.zod.ts` | 3 | | `responsive.zod.ts` | 4 | | `sharing.zod.ts` | 2 | -| `theme.zod.ts` | 14 | +| `theme.zod.ts` | 6 | | `touch.zod.ts` | 7 | | `view.zod.ts` | 51 | | `widget.zod.ts` | 9 | -| **total** | **200** | +| **total** | **192** | ### `data/` — sites @@ -161,7 +161,7 @@ over it is here. ### `ui/` — open -**75 strip of 200**, in 13 file(s). +**75 strip of 192**, in 13 file(s). | File | Strip | Sites | |---|---|---| @@ -178,7 +178,7 @@ over it is here. | `touch.zod.ts` | 7 | 7 | | `view.zod.ts` | 5 | 51 | | `widget.zod.ts` | 9 | 9 | -| **total** | **75** | **200** | +| **total** | **75** | **192** | | Bucket | Sites | |---|---| diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index fc55982722..3455d25b26 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -622,7 +622,7 @@ sites left to be a verdict about. | `view.zod.ts` | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | | `bulk-action.zod.ts` | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open** and both now `.passthrough()` — the param because objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so passthrough is the honest mirror and strictness would reject valid config (same call as `dashboard.zod.ts`'s widget `config`); the OPTION ENTRY on separate measured evidence, since its objectui type is closed and only the runtime path is open — `bulkParamToField` spreads each entry (`plugin-grid/src/components/bulkParamToField.ts:131`) into `SelectOptionMetadata` (`types/src/field-types.ts:288`), which declares and reads `color` / `icon` / `disabled` / `visibleWhen`. **This row said "both deliberately open" while only the parent was `passthrough`** — one intent, two postures, caught by the 2026-08-03 re-measure and closed by the ruling's verdict A (make the code match the prose). The lesson is the campaign's own: prose in this ledger is not a posture reading, which is why the remaining-strip map is gated and this column is not. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached | | `component.zod.ts` | ~~authorable (p)~~ **no gate** | **no parse anywhere (measured, #4001 批 17)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`. ⚠️ The #5056 bridge defect does not touch this row: it makes the derived-clone bridge report dead shapes as REACHABLE, the opposite direction, and nothing here rests on that bridge — all six positive controls resolve `root-graph` and all 52 targets miss BOTH `root-graph` and `derived-clone`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. Recorded in three places (file header, `component.test.ts` pin incl. a standing assertion that goes red the day `properties` gets a typed dispatch, this row) | -| `theme.zod.ts` | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all (#4988) — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. ⚠️ **Separately filed, not answered here**: `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read). That is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live | +| `theme.zod.ts` | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all (#4988) — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. ⚠️ **Separately filed — and ANSWERED at #5021, which is why this row's site count fell 14 → 6.** 批 15 recorded that `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read), and refused to act on it inside a strictness batch: that is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live. The refusal was correct and the separation is what made the follow-up answerable. #5021 re-measured against objectui `main` (2026-08-04) with `--font-sans`/`--radius`/`--shadow`/`--primary` as positive controls **in the same run**, the maintainer ruled RETIRE over both alternatives (wire consumers / bless as a public token surface — the latter rejected as a stability promise attached to a slot the platform's own UI ignores, the #4583 shape), and `typography.fontSize`/`.fontWeight`/`.lineHeight`/`.letterSpacing`, `typography.fontFamily.heading`/`.mono`, `animation` and `zIndex` are now `retiredKey()` tombstones prescribing `customVars`. **Note what this row's arithmetic does NOT say**: the eight sites left `ui/` from the `strict` column (120 → 112), and `strip` is unchanged at 75 — a retirement removes closed doors, so it cannot move this ratchet's open-site debt in either direction. The two campaigns stayed disjoint to the end. ⚠️ The prescription is `customVars` **because it was measured live**, not because it is the nearest-looking slot: the engine emits each entry as `--: ` verbatim, so every retired variable is reproducible byte for byte and the retirement removes no capability — the distinction from `touchTarget`/`keyboardNavigation` two sentences up, which got NO replacement precisely because theirs would have been a guess. The five aliases pointing at the retired keys (`animations`/`motion`/`transitions` → `animation`, `layers`/`stacking` → `zIndex`) and the seven pointing into the retired typography scales were **deleted with their targets**, not re-pointed — leaving them would answer an author with "did you mean `zIndex`?" and then reject `zIndex`, finding 7's exact shape, and this file has now signposted that failure mode three times | | `app.zod.ts` | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first | | `dashboard.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction. ⚠️ **#5010 retired four more widget keys and moved this row's posture by nothing, which is the point.** The `#4956` drill gave `DashboardWidgetSchema`'s 22 widget-level keys their first per-key verdicts and found six dead; `actionUrl`/`actionType`/`actionIcon` (a per-widget action BUTTON no renderer in either repo has ever drawn — all 14 `actionUrl` reads in `DashboardRenderer` are scoped to `header.actions[]`) and `aria` (ARIA attributes that never reached the DOM — the dashboard-level `aria` the #3896 sweep removed, one level down) are now `retiredKey` tombstones beside `responsive`. **Strip sites remain 0 and the strictness verdict is untouched**, because a retirement is ADR-0049 work and this ratchet is not: closing a door makes a *dropped* key loud, it cannot make a *declared* one live — the same boundary `theme.zod.ts` records two rows up, met here from the other side. The removal also settled a second-order cost the strictness campaign could never have reached: `packages/lint`'s dashboard action-ref rule enforced ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key "the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so an author could FAIL A BUILD because a control that cannot render pointed at an action that also did not — an enforcement gate sustaining the very false affordance ADR-0049 wrote it to delete. That widget branch is gone, pinned. ⚠️ **`colorVariant`, the fifth dead key, is deliberately NOT retired here and this row must not be read as closing it**: the rewrite target the #4956 triage assumed (`options.colorVariant`) measured dead too — `options` only reaches a renderer through `componentSchema` on the INLINE path, and `dataset` is required on this schema, so every spec-authorable widget is dataset-bound and renders through `DatasetWidget`, which has no colour affordance at all. Moving the key there would relocate 16 authored sites from one dead slot to another and mint a second inert key. Returned for adjudication; `chartConfig`'s dashboard-face inertness (11 of 12 keys, #5175) is the same shape on the neighbouring slot | | `widget.zod.ts` | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 16)** — the `(p)` resolved NEGATIVE for the whole file, the second such run after 批 13's five. Three independent measurements on 2026-08-04: (1) nothing under `packages/spec/src` imports this module except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for a widget shape — `field.widget` is a `z.string()` naming a registered *component* and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack` (4 766 nodes) reaches none of the six shapes, while `PageSchema` / `ObjectListViewSchema` resolve in the same run, a fresh `z.object` and a deliberate look-alike both resolve unreachable, and a synthetic carrier flips all six to reachable; (3) zero `.parse()` / `.safeParse()` in `objectstack`, `objectui` or `cloud` outside this file's own tests — objectui re-exports the inferred TYPES only and under different names (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161), and a `cloud` code search returns 0 for every symbol against a working index (`"@objectstack/spec"` → 345). ADR-0049 enforce-or-remove is **#5055**. ⚠️ **The campaign's own BFS said REACHABLE on the first run** — a false positive in the derived-clone bridge, filed as **#5056**: zod's `.describe()` returns a clone that SHARES the original `_zod.def`, so `WidgetManifestSchema.name` / `.label` (a described `SnakeCaseIdentifierSchema` / `I18nLabelSchema`) are def-identical to the same leaves on live schemas, and a bridge firing on ANY one shared property links two unrelated shapes. 2 shared keys of 20. The error is one-directional — it can only manufacture a door, i.e. it can only make a batch tighten something dead. Corrected to whole-shape overlap in `ui/door-reachability.testkit.ts` and pinned in `widget.test.ts` | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 096328cd59..fc179cbae0 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -200,6 +200,8 @@ The same widget drill retires four more keys (#5010): the action trio `actionUrl ⚠️ One protocol-17 change turns metadata ON rather than off, and it is the one to read first: declarative `apis:` endpoints EXECUTE from 17 (#5040). The surface used to be inert end to end — no route mounted, no matcher, every key including `authRequired` parsed and enforced nothing — which is why #4936 refused a non-empty `apis:` outright. 17 ships the executor and narrows that refusal to a per-endpoint publish gate, so an endpoint that passes the gate is MOUNTED and serves traffic the moment it is published. Any historical `apis:` block therefore changes meaning without changing a byte. Review every entry before upgrading, and pay particular attention to an explicit `authRequired: false`: the schema default is `true`, so an omission is safe, and only that explicit `false` opens anonymous access — which ADR-0121 D6 now pairs with a mandatory armed `rateLimit` (`enabled: true`; the key defaults to `false`, so a budget written without it meters nothing). Paths also move under the namespace carve-out `/api/v1/apps//` (ADR-0121 D1/D2). The full checklist is the `declarative-apis-endpoints-live` semantic entry below; it is a security review, not a rename, so nothing about it is applied for you. +Finally, the theme token scales retire (#5021, ADR-0049): `typography.fontSize`, `typography.fontWeight`, `typography.lineHeight`, `typography.letterSpacing`, `typography.fontFamily.heading`, `typography.fontFamily.mono`, `animation` and `zIndex`. These are the reverse of the usual inert key and the distinction is the point: the theme engine DID emit them — `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--duration-*`, `--timing-*`, `--z-*`, `--font-heading`, `--font-mono` all reached the document exactly as authored — and no first-party component or stylesheet has ever read one, so a declared type scale was real CSS that styled nothing. That is why the earlier theme sweep (#3494) left them standing: its criterion was "never emitted", and these are emitted. `colors`, `borderRadius`, `shadows` and `typography.fontFamily.base` have live consumers and are untouched. The prescription is `customVars`, which emits `--: ` verbatim — so a tenant stylesheet that really was reading `--z-modal` reproduces it byte for byte and loses no capability. The conversion DELETES the keys and emits a notice per key rather than auto-populating `customVars`: a rewrite would hand back two dozen variables that still nothing reads, turning a dead semantic slot into a dead literal one. Deciding which of them you actually consume is yours to make; the notice names each one. Retired from the load path with the other keys that misdescribed themselves. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -245,6 +247,7 @@ The same widget drill retires four more keys (#5010): the action trio `actionUrl | `object-enable-trash-mru-removed` | `object.enable.trash / object.enable.mru` | object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no recycle bin and no MRU tracking ever ran; both default-true flags gated nothing) | retired — `migrate meta` only | | `hook-body-crypto-hash-removed` | `hook.body.capabilities / action.body.capabilities` | script-body capability token 'crypto.hash' removed (#4391 — the sandbox never installed ctx.crypto.hash, so the token granted a call that always threw; the CLI inferred it too) | retired — `migrate meta` only | | `connector-rate-limit-config-removed` | `connector.rateLimitConfig` | connector key 'rateLimitConfig' removed (#4911 — no outbound rate-limiting engine exists; the runtime's only token bucket limits INBOUND requests, so every knob here was inert while reading like a configured cap. The whole ConnectorRateLimitConfig shape went with it) | retired — `migrate meta` only | +| `theme-inert-token-scales-removed` | `theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndex` | theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', 'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed (#5021, ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono faithfully, and no first-party component or stylesheet has ever read one. Re-declare any variable you actually consume under customVars, which emits it verbatim) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 776470ed2c..e083eb3fb8 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3155,8 +3155,6 @@ "ActionType (const)", "AddRecordConfig (type)", "AddRecordConfigSchema (const)", - "Animation (type)", - "AnimationSchema (const)", "AnimationTrigger (type)", "AnimationTriggerSchema (const)", "App (type)", @@ -3520,8 +3518,6 @@ "WidgetPropertySchema (const)", "WidgetSource (type)", "WidgetSourceSchema (const)", - "ZIndex (type)", - "ZIndexSchema (const)", "actionForm (const)", "appForm (const)", "chartAggregateCategoryKey (function)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 5703c2b88a..bb9f3c6626 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -6923,8 +6923,6 @@ "ui/AddRecordConfig:formView", "ui/AddRecordConfig:mode", "ui/AddRecordConfig:position", - "ui/Animation:duration", - "ui/Animation:timing", "ui/App:_lock", "ui/App:_lockDocsUrl", "ui/App:_lockReason", @@ -7894,7 +7892,7 @@ "ui/SyncConfig:maxRetries", "ui/SyncConfig:retryInterval", "ui/SyncConfig:strategy", - "ui/Theme:animation", + "ui/Theme:animation [RETIRED]", "ui/Theme:borderRadius", "ui/Theme:colors", "ui/Theme:customVars", @@ -7905,7 +7903,7 @@ "ui/Theme:name", "ui/Theme:shadows", "ui/Theme:typography", - "ui/Theme:zIndex", + "ui/Theme:zIndex [RETIRED]", "ui/TimelineConfig:colorField", "ui/TimelineConfig:endDateField", "ui/TimelineConfig:groupByField", @@ -7933,10 +7931,10 @@ "ui/TreeConfig:labelField", "ui/TreeConfig:parentField", "ui/Typography:fontFamily", - "ui/Typography:fontSize", - "ui/Typography:fontWeight", - "ui/Typography:letterSpacing", - "ui/Typography:lineHeight", + "ui/Typography:fontSize [RETIRED]", + "ui/Typography:fontWeight [RETIRED]", + "ui/Typography:letterSpacing [RETIRED]", + "ui/Typography:lineHeight [RETIRED]", "ui/UrlNavItem:badge", "ui/UrlNavItem:badgeVariant", "ui/UrlNavItem:icon", @@ -8037,14 +8035,6 @@ "ui/WidgetProperty:name", "ui/WidgetProperty:required", "ui/WidgetProperty:type", - "ui/WidgetProperty:validation", - "ui/ZIndex:base", - "ui/ZIndex:dropdown", - "ui/ZIndex:fixed", - "ui/ZIndex:modal", - "ui/ZIndex:modalBackdrop", - "ui/ZIndex:popover", - "ui/ZIndex:sticky", - "ui/ZIndex:tooltip" + "ui/WidgetProperty:validation" ] } diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index b3a1946f84..122ce232bc 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1461,7 +1461,6 @@ "ui/ActionParam", "ui/ActionType", "ui/AddRecordConfig", - "ui/Animation", "ui/AnimationTrigger", "ui/App", "ui/AppBranding", @@ -1640,7 +1639,6 @@ "ui/WidgetLifecycle", "ui/WidgetManifest", "ui/WidgetProperty", - "ui/WidgetSource", - "ui/ZIndex" + "ui/WidgetSource" ] } diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index f1ccc960b2..2a34c461f1 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -313,6 +313,12 @@ "to": "connector key 'rateLimitConfig' removed (#4911 — no outbound rate-limiting engine exists; the runtime's only token bucket limits INBOUND requests, so every knob here was inert while reading like a configured cap. The whole ConnectorRateLimitConfig shape went with it)", "conversionId": "connector-rate-limit-config-removed", "toMajor": 17 + }, + { + "surface": "theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndex", + "to": "theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', 'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed (#5021, ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono faithfully, and no first-party component or stylesheet has ever read one. Re-declare any variable you actually consume under customVars, which emits it verbatim)", + "conversionId": "theme-inert-token-scales-removed", + "toMajor": 17 } ], "migrated": [ @@ -988,6 +994,12 @@ "to": "connector key 'rateLimitConfig' removed (#4911 — no outbound rate-limiting engine exists; the runtime's only token bucket limits INBOUND requests, so every knob here was inert while reading like a configured cap. The whole ConnectorRateLimitConfig shape went with it)", "conversionId": "connector-rate-limit-config-removed", "toMajor": 17 + }, + { + "surface": "theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndex", + "to": "theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', 'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed (#5021, ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono faithfully, and no first-party component or stylesheet has ever read one. Re-declare any variable you actually consume under customVars, which emits it verbatim)", + "conversionId": "theme-inert-token-scales-removed", + "toMajor": 17 } ], "migrated": [ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 498444d36d..ff7bc00827 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -3959,6 +3959,140 @@ const connectorRateLimitConfigRemoved: MetadataConversion = { }, }; +/** + * The nine theme token groups that were EMITTED and read by nobody (#5021, + * ADR-0049). + * + * The distinguishing fact, and the reason #3494 could not reach these: that + * round's criterion was "the theme engine never emits a variable for this key", + * which retired `spacing` / `breakpoints` / `density` / `wcagContrast` and five + * more. These eight keys pass that test — objectui's `generateThemeVars` walks + * every one of them and puts `--font-size-*`, `--font-weight-*`, + * `--line-height-*`, `--letter-spacing-*`, `--duration-*`, `--timing-*`, + * `--z-*`, `--font-heading` and `--font-mono` on the document, exactly as + * declared. What does not exist is a READER: measured against objectui `main` + * on 2026-08-04, all nine groups have zero consumers across `packages/**`, + * while `--font-sans`, `--radius*`, `--shadow*` and the colour variables come + * back live in the same run. So the author's type scale was real CSS that + * styled nothing. + * + * A CSS custom property is genuinely weaker evidence than a spec key here — a + * tenant's own stylesheet CAN read a variable the platform ignores — and that + * is precisely why the prescription is `customVars` rather than a bare "sorry". + * `customVars` emits `--: ` verbatim, so every one of these + * variables is reproducible byte for byte. The retirement removes a semantic + * vocabulary the platform never honoured; it removes no capability. + * + * ⚠️ This STRIPS rather than rewriting into `customVars`, and that is the + * deliberate half of the design. A mechanical rewrite is possible (stringify + * the numbers, reconstruct `--z-` / `--timing-` names) and was + * rejected: it would hand back ~25 `customVars` entries that still nothing + * reads, converting a dead semantic slot into a dead literal one and making the + * retirement invisible — the #4583 shape. The notice tells the author exactly + * which keys went and the tombstone tells them where to put back the ones they + * actually consume, which is a decision only they can make. Measured input to + * that choice: `examples/**` and `apps/**` author ZERO of these keys today (the + * showcase's two themes declare `colors` only), so there is no in-repo body of + * authored config a rewrite would have saved. + * + * `retiredFromLoadPath`: the schema tombstones each key with its prescription, + * so a live parse rejects loudly and only `os migrate meta --from 16` rewrites + * sources. Absorbing these at load would let an author keep believing they had + * configured a type scale. + */ +const themeInertTokenScalesRemoved: MetadataConversion = { + id: 'theme-inert-token-scales-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: + 'theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight' + + ' / theme.typography.letterSpacing / theme.typography.fontFamily.heading' + + ' / theme.typography.fontFamily.mono / theme.animation / theme.zIndex', + summary: + "theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', " + + "'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed " + + '(#5021, ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, ' + + '--letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono ' + + 'faithfully, and no first-party component or stylesheet has ever read one. ' + + 'Re-declare any variable you actually consume under customVars, which emits it verbatim)', + apply(stack, emit) { + return mapCollection(stack, 'themes', (theme, path) => { + let next = stripKeys(theme, ['animation', 'zIndex'], emit, path); + + // `typography` is one level down, and `fontFamily` two — `stripKeys` is + // top-level only, so each nesting level is walked explicitly and + // copy-on-write is preserved at every level (an untouched theme keeps its + // identity, which is what `mapCollection` tests for). + const typography = next.typography; + if (typography && typeof typography === 'object' && !Array.isArray(typography)) { + const typo = typography as Record; + let nextTypo = stripKeys( + typo, + ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'], + emit, + `${path}.typography`, + ); + + const fontFamily = nextTypo.fontFamily; + if (fontFamily && typeof fontFamily === 'object' && !Array.isArray(fontFamily)) { + const nextFamily = stripKeys( + fontFamily as Record, + ['heading', 'mono'], + emit, + `${path}.typography.fontFamily`, + ); + if (nextFamily !== fontFamily) nextTypo = { ...nextTypo, fontFamily: nextFamily }; + } + + if (nextTypo !== typo) next = { ...next, typography: nextTypo }; + } + + return next; + }); + }, + fixture: { + before: { + themes: [ + { + name: 'corporate', + label: 'Corporate', + colors: { primary: '#7C3AED' }, + typography: { + fontFamily: { base: 'Inter, sans-serif', heading: 'Georgia, serif', mono: 'ui-monospace' }, + fontSize: { base: '1rem', lg: '1.125rem' }, + fontWeight: { normal: 400, bold: 700 }, + lineHeight: { normal: '1.5' }, + letterSpacing: { wide: '0.025em' }, + }, + animation: { duration: { fast: '150ms' }, timing: { ease: 'cubic-bezier(0.4, 0, 0.2, 1)' } }, + zIndex: { modal: 1050 }, + }, + // A theme that authored none of them keeps its identity — the + // copy-on-write contract `stripKeys` / `mapCollection` are built on. + { name: 'minimal', label: 'Minimal', colors: { primary: '#000000' } }, + ], + }, + after: { + themes: [ + { + name: 'corporate', + label: 'Corporate', + colors: { primary: '#7C3AED' }, + // `fontFamily` SURVIVES with `base` alone: it is the one font-family + // key with a live consumer (`--font-sans`). The block is not removed, + // only narrowed — which is why the fixture keeps it rather than + // dropping `typography` wholesale. + typography: { fontFamily: { base: 'Inter, sans-serif' } }, + }, + { name: 'minimal', label: 'Minimal', colors: { primary: '#000000' } }, + ], + }, + // One notice per KEY, not per stop and not per theme: eight keys authored + // on the first theme, zero on the second. + expectedNotices: 8, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -4008,6 +4142,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly/' + '` (ADR-0121 D1/D2). The full checklist is the `declarative-apis-endpoints-live` ' + 'semantic entry below; it is a security review, not a rename, so nothing about it is ' - + 'applied for you.', + + 'applied for you.\n\n' + + 'Finally, the theme token scales retire (#5021, ADR-0049): `typography.fontSize`, ' + + '`typography.fontWeight`, `typography.lineHeight`, `typography.letterSpacing`, ' + + '`typography.fontFamily.heading`, `typography.fontFamily.mono`, `animation` and `zIndex`. ' + + 'These are the reverse of the usual inert key and the distinction is the point: the theme ' + + 'engine DID emit them — `--font-size-*`, `--font-weight-*`, `--line-height-*`, ' + + '`--letter-spacing-*`, `--duration-*`, `--timing-*`, `--z-*`, `--font-heading`, ' + + '`--font-mono` all reached the document exactly as authored — and no first-party component ' + + 'or stylesheet has ever read one, so a declared type scale was real CSS that styled ' + + 'nothing. That is why the earlier theme sweep (#3494) left them standing: its criterion was ' + + '"never emitted", and these are emitted. `colors`, `borderRadius`, `shadows` and ' + + '`typography.fontFamily.base` have live consumers and are untouched. The prescription is ' + + '`customVars`, which emits `--: ` verbatim — so a tenant stylesheet that really ' + + 'was reading `--z-modal` reproduces it byte for byte and loses no capability. The ' + + 'conversion DELETES the keys and emits a notice per key rather than auto-populating ' + + '`customVars`: a rewrite would hand back two dozen variables that still nothing reads, ' + + 'turning a dead semantic slot into a dead literal one. Deciding which of them you actually ' + + 'consume is yours to make; the notice names each one. Retired from the load path with the ' + + 'other keys that misdescribed themselves.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -936,6 +954,7 @@ const step17: MigrationStep = { 'dashboard-widget-responsive-removed', 'dashboard-widget-action-aria-removed', 'dashboard-widget-compareto-converged', + 'theme-inert-token-scales-removed', ], semantic: [ { diff --git a/packages/spec/src/ui/theme.test.ts b/packages/spec/src/ui/theme.test.ts index 080b534c3b..64214defa8 100644 --- a/packages/spec/src/ui/theme.test.ts +++ b/packages/spec/src/ui/theme.test.ts @@ -79,46 +79,23 @@ describe('TypographySchema', () => { expect(() => TypographySchema.parse(typography)).not.toThrow(); }); - it('should accept complete typography settings', () => { - const typography = { - fontFamily: { - base: 'Inter, system-ui, sans-serif', - heading: 'Poppins, sans-serif', - mono: 'Fira Code, monospace', - }, - fontSize: { - xs: '0.75rem', - sm: '0.875rem', - base: '1rem', - lg: '1.125rem', - xl: '1.25rem', - '2xl': '1.5rem', - '3xl': '1.875rem', - '4xl': '2.25rem', - }, - fontWeight: { - light: 300, - normal: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - lineHeight: { - tight: '1.25', - normal: '1.5', - relaxed: '1.75', - loose: '2', - }, - letterSpacing: { - tighter: '-0.05em', - tight: '-0.025em', - normal: '0', - wide: '0.025em', - wider: '0.05em', - }, - }; - - expect(() => TypographySchema.parse(typography)).not.toThrow(); + // REPLACED at #5021 (fixture triage, disposition 3). The fixture that used to + // sit here authored all four retired scales plus `fontFamily.heading`/`.mono` + // and asserted `not.toThrow()` — i.e. it pinned exactly the limbs the + // retirement deletes. Re-spelling it was not an option (there is no canonical + // spelling to move to) and keeping it would have made the whole surviving + // block untested, so it is replaced by a fixture the narrowed schema really + // reads. The rejection side is pinned in the #5021 block at the bottom. + it('accepts the whole SURVIVING typography surface — which is `fontFamily.base`', () => { + const typography = { fontFamily: { base: 'Inter, system-ui, sans-serif' } }; + + const parsed = TypographySchema.parse(typography); + expect(parsed.fontFamily?.base).toBe('Inter, system-ui, sans-serif'); + // Nothing else is left to WRITE: `base` emits `--font-sans`, the one + // typography variable objectui actually reads. (The four retired scales are + // still in the shape as tombstones, but they accept nothing, so they never + // appear on a parsed value.) + expect(Object.keys(parsed)).toEqual(['fontFamily']); }); }); @@ -206,16 +183,13 @@ describe('ThemeSchema', () => { textSecondary: '#6C757D', border: '#DEE2E6', }, + // #5021 fixture triage, disposition 1 (re-spell): this fixture merely + // USED `fontFamily.heading`/`.mono` and a `fontSize` scale, so it drops + // them and keeps the live `base`. It is still a "complete" theme — the + // completeness that matters is the set of blocks with live consumers. typography: { fontFamily: { base: 'Inter, sans-serif', - heading: 'Poppins, sans-serif', - mono: 'Fira Code, monospace', - }, - fontSize: { - base: '1rem', - lg: '1.125rem', - xl: '1.25rem', }, }, borderRadius: { @@ -307,51 +281,54 @@ describe('ThemeSchema', () => { expect(() => ThemeSchema.parse(theme)).not.toThrow(); }); - it('should accept theme with z-index configuration', () => { + // The `zIndex` and `animation` acceptance fixtures that sat here were + // REPLACED at #5021 (fixture triage, disposition 3) — they pinned the two + // keys the retirement deletes, and an `expect(…).not.toThrow()` on a deleted + // key has no honest re-spelling. Their replacements are the layering and + // motion fixtures below, which express the SAME intent through the door that + // survived, plus the rejection pins in the #5021 block at the bottom. + + it('a layering scale is still expressible — through `customVars`, the live door', () => { const theme: Theme = { name: 'layered_theme', label: 'Layered Theme', - colors: { - primary: '#007BFF', - }, - zIndex: { - base: 0, - dropdown: 1000, - sticky: 1020, - fixed: 1030, - modalBackdrop: 1040, - modal: 1050, - popover: 1060, - tooltip: 1070, + colors: { primary: '#007BFF' }, + // The variable names are spelled out because that is what the engine used + // to derive from the `zIndex` key names. Byte for byte the same custom + // properties reach the document — which is the whole basis on which the + // retirement claims to remove no capability. + customVars: { + 'z-base': '0', + 'z-dropdown': '1000', + 'z-sticky': '1020', + 'z-fixed': '1030', + 'z-modal-backdrop': '1040', + 'z-modal': '1050', + 'z-popover': '1060', + 'z-tooltip': '1070', }, }; - expect(() => ThemeSchema.parse(theme)).not.toThrow(); + const parsed = ThemeSchema.parse(theme); + expect(parsed.customVars?.['z-modal']).toBe('1050'); }); - it('should accept theme with animation settings', () => { + it('a motion scale is still expressible — same door, same variables', () => { const theme: Theme = { name: 'animated_theme', label: 'Animated Theme', - colors: { - primary: '#007BFF', - }, - animation: { - duration: { - fast: '150ms', - base: '300ms', - slow: '500ms', - }, - timing: { - ease: 'cubic-bezier(0.4, 0, 0.2, 1)', - ease_in: 'cubic-bezier(0.4, 0, 1, 1)', - ease_out: 'cubic-bezier(0, 0, 0.2, 1)', - ease_in_out: 'cubic-bezier(0.4, 0, 0.2, 1)', - }, + colors: { primary: '#007BFF' }, + customVars: { + 'duration-fast': '150ms', + 'duration-base': '300ms', + 'duration-slow': '500ms', + 'timing-ease': 'cubic-bezier(0.4, 0, 0.2, 1)', + 'timing-ease_in': 'cubic-bezier(0.4, 0, 1, 1)', }, }; - expect(() => ThemeSchema.parse(theme)).not.toThrow(); + const parsed = ThemeSchema.parse(theme); + expect(parsed.customVars?.['duration-fast']).toBe('150ms'); }); }); @@ -377,10 +354,11 @@ describe('Real-World Theme Examples', () => { textSecondary: '#718096', border: '#E2E8F0', }, + // #5021 fixture triage, disposition 1 (re-spell): `heading` dropped, the + // live `base` kept. typography: { fontFamily: { base: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', - heading: 'Poppins, sans-serif', }, }, }; @@ -425,26 +403,44 @@ describe('Real-World Theme Examples', () => { // ============================================================================ // Issue #6: Easing naming unified to snake_case in theme animation tokens +// +// RETIRED at #5021. `AnimationSchema` no longer exists, so the snake_case-vs- +// camelCase question this block settled is moot at the schema — there is no +// declared easing vocabulary left to be inconsistent about. The old fixture +// (fixture triage, disposition 3) pinned `animation.timing` directly. +// +// Kept as a pin rather than deleted, because the useful half of #6's finding +// SURVIVES the retirement and would otherwise go untested: an author who wants +// snake_case easing variables can still emit them, and now spells the variable +// name in full instead of relying on the engine's key-to-variable derivation — +// which is the one thing that actually changed for them. // ============================================================================ -describe('AnimationSchema - snake_case timing keys', () => { - it('should accept snake_case easing keys', () => { +describe('easing tokens after the #5021 retirement', () => { + it('the snake_case easing vocabulary is still emittable through `customVars`', () => { const theme = ThemeSchema.parse({ name: 'snake_case_timing', label: 'Snake Case Timing', colors: { primary: '#000' }, - animation: { - timing: { - linear: 'linear', - ease: 'ease', - ease_in: 'ease-in', - ease_out: 'ease-out', - ease_in_out: 'ease-in-out', - }, + customVars: { + 'timing-linear': 'linear', + 'timing-ease': 'ease', + 'timing-ease_in': 'ease-in', + 'timing-ease_out': 'ease-out', + 'timing-ease_in_out': 'ease-in-out', }, }); - expect(theme.animation?.timing?.ease_in).toBe('ease-in'); - expect(theme.animation?.timing?.ease_out).toBe('ease-out'); - expect(theme.animation?.timing?.ease_in_out).toBe('ease-in-out'); + expect(theme.customVars?.['timing-ease_in']).toBe('ease-in'); + expect(theme.customVars?.['timing-ease_in_out']).toBe('ease-in-out'); + }); + + it('`animation` itself is gone — the key no longer exists on the parsed theme', () => { + const theme = ThemeSchema.parse({ + name: 'no_animation', + label: 'No Animation', + colors: { primary: '#000' }, + }); + expect(theme).not.toHaveProperty('animation'); + expect(theme).not.toHaveProperty('zIndex'); }); }); @@ -514,31 +510,23 @@ describe('#4001 批 15 — ThemeSchema unknown-key strictness', () => { expect(reject({ ...base, colors: { primary: '#000', notAColor: '#fff' } })).toContain('notAColor'); }); + // #5021 shrank this list from fourteen sites to six. The `animation`, + // `zIndex`, `typography.fontSize`/`.fontWeight`/`.lineHeight`/ + // `.letterSpacing` and `animation.duration`/`.timing` rows were not + // re-spelled — those schemas no longer exist, so an unknown-key probe against + // them has nothing to probe. What replaced them is the retirement pin at the + // bottom of this file: writing the BLOCK is now the rejection, which is + // strictly stronger than rejecting one bad key inside it. it.each([ ['typography', { notATypographyKey: 1 }], ['borderRadius', { notARadius: '1px' }], ['shadows', { notAShadow: 'x' }], - ['animation', { notAnAnimationKey: 1 }], - ['zIndex', { notALayer: 1 }], ])('rejects an undeclared key in `%s`', (block, value) => { expect(reject({ ...base, [block]: value })).toContain(Object.keys(value)[0]); }); - it.each([ - ['fontFamily', { notAFamily: 'x' }], - ['fontSize', { notASize: 'x' }], - ['fontWeight', { notAWeight: 1 }], - ['lineHeight', { notALeading: 'x' }], - ['letterSpacing', { notATracking: 'x' }], - ])('rejects an undeclared key in the nested `typography.%s` scale', (block, value) => { - expect(reject({ ...base, typography: { [block]: value } })).toContain(Object.keys(value)[0]); - }); - - it.each([ - ['duration', { notADuration: 'x' }], - ['timing', { notATiming: 'x' }], - ])('rejects an undeclared key in the nested `animation.%s` block', (block, value) => { - expect(reject({ ...base, animation: { [block]: value } })).toContain(Object.keys(value)[0]); + it('rejects an undeclared key in the nested `typography.fontFamily` block', () => { + expect(reject({ ...base, typography: { fontFamily: { notAFamily: 'x' } } })).toContain('notAFamily'); }); // ---- 3. curation ---------------------------------------------------- @@ -553,28 +541,24 @@ describe('#4001 批 15 — ThemeSchema unknown-key strictness', () => { expect(msg).toContain('`destructive` → `error`'); }); - it('renames `md` onto `base` in the font-size scale — the same-file scale disagreement', () => { - // `borderRadius` and `shadows` declare `md`; `fontSize` jumps sm → base → lg. - expect(reject({ ...base, typography: { fontSize: { md: '1rem' } } })).toContain('`md` → `base`'); - // …and the mirror: three scales spell the middle stop `base`, fontWeight - // spells it `normal`. - expect(reject({ ...base, typography: { fontWeight: { base: 400 } } })).toContain('`base` → `normal`'); - }); - - it('renames camelCase easing onto the snake_case keys this one block uses', () => { - // The file's single snake_case vocabulary, against Prime Directive #3 — - // so `easeIn` is an author obeying the repo's naming rule, not a typo. - const msg = reject({ ...base, animation: { timing: { easeIn: 'x', easeInOut: 'y' } } }); - expect(msg).toContain('`easeIn` → `ease_in`'); - expect(msg).toContain('`easeInOut` → `ease_in_out`'); - }); + // The `md` → `base` (font-size), `base` → `normal` (font-weight) and + // `easeIn` → `ease_in` (animation timing) curation tests lived here until + // #5021. All three graded aliases INSIDE a retired scale, so they went with + // their schemas rather than being re-spelled — there is no surviving surface + // on which `md` or `easeIn` means anything. This is the honest reading of the + // fixture-triage rule: a fixture whose subject was deleted is replaced by one + // the surviving rule reads, not kept alive on a technicality. it('renames onto the camelCase targets the distance fallback is weak on (#4990)', () => { // `findClosestMatches` lowercases the input but not the candidates, so a // capital costs an edit. These land through the explicit table instead. - expect(reject({ ...base, zIndex: { backdrop: 10 } })).toContain('`backdrop` → `modalBackdrop`'); + // + // The `backdrop` → `modalBackdrop` case that used to anchor this test was + // on `zIndex` and retired with it (#5021); `radius` and `cssVars` carry the + // same property on surfaces that are still live. expect(reject({ ...base, radius: {} })).toContain('`radius` → `borderRadius`'); expect(reject({ ...base, cssVars: {} })).toContain('`cssVars` → `customVars`'); + expect(reject({ ...base, customProperties: {} })).toContain('`customProperties` → `customVars`'); }); it('renames `inset` onto `inner` — CSS\'s word for what this scale calls inner', () => { @@ -611,8 +595,213 @@ describe('#4001 批 15 — ThemeSchema unknown-key strictness', () => { // The `triggerPhrases` lesson in `shared/strict-object.ts`: never point an // author at a key that will reject them a second time. Walk the whole // alias table and prove each target parses. - const targets = ['colors', 'typography', 'borderRadius', 'shadows', 'animation', 'zIndex', 'customVars', 'extends', 'label', 'name', 'mode']; + // + // `animation` and `zIndex` left this list at #5021 — WITH the five aliases + // that pointed at them (`animations`/`motion`/`transitions`/`layers`/ + // `stacking`). That pairing is the point of the test, not bookkeeping: had + // the aliases stayed, this assertion would be the thing that caught it. + const targets = ['colors', 'typography', 'borderRadius', 'shadows', 'customVars', 'extends', 'label', 'name', 'mode']; const shape = (ThemeSchema as unknown as { _zod: { def: { shape: Record } } })._zod.def.shape; for (const t of targets) expect(Object.keys(shape), `alias target "${t}" must be declared`).toContain(t); }); }); + +// ============================================================================ +// #5021 — the nine emitted-but-unread token groups, RETIRED (ADR-0049 D2) +// +// The measurement (objectui `main`, re-confirmed 2026-08-04): `--font-size-*`, +// `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--duration-*`, +// `--timing-*`, `--z-*`, `--font-heading` and `--font-mono` have ZERO consumers +// across objectui's `packages/**`, while `--font-sans`, `--radius*`, +// `--shadow*` and the colour variables are read — the positive controls that +// make the zero mean something. +// +// Route: STRICT REMOVAL + guidance map, not a `retiredKey()` tombstone. Both +// channels are still covered and it is worth being explicit about which does +// what, because the two routes' evidence looks different: +// * `tsc` — the key is gone from the inferred input type, so authoring one +// fails to compile. (A `retiredKey()` would type it `never`; deleting it +// from a `.strict()` shape is the same outcome by a different mechanism.) +// * the parse — `strictObject`'s `guidance` map carries the prescription, so +// the rejection is the upgrade instruction rather than a bare +// "unrecognized key". That is what these tests pin. +// ============================================================================ +describe('#5021 — retired theme token scales', () => { + const base = { name: 'demo_theme', label: 'Demo', colors: { primary: '#000' } }; + const reject = (input: unknown): string => { + const r = ThemeSchema.safeParse(input); + expect(r.success, 'fixture must be REJECTED — a passing parse means the key is still live').toBe(false); + return r.error!.issues.map((i) => i.message).join('\n'); + }; + + // ---- 1. the control: this suite fails closed ------------------------ + it('the surviving theme parses — these tests reject specific keys, not everything', () => { + expect(ThemeSchema.safeParse({ + ...base, + typography: { fontFamily: { base: 'Inter' } }, + borderRadius: { base: '0.25rem' }, + shadows: { base: '0 1px 2px rgba(0,0,0,.1)' }, + customVars: { 'z-modal': '1050' }, + }).success).toBe(true); + }); + + // ---- 2. every retired key rejects, at its own path ------------------- + it('rejects `animation` and `zIndex` at the theme top level', () => { + expect(reject({ ...base, animation: { duration: { fast: '150ms' } } })).toContain('`theme.animation` was removed'); + expect(reject({ ...base, zIndex: { modal: 1050 } })).toContain('`theme.zIndex` was removed'); + }); + + it.each(['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'])( + 'rejects the retired `typography.%s` scale', + (key) => { + const msg = reject({ ...base, typography: { [key]: {} } }); + expect(msg).toContain('`theme.typography.' + key + '` was removed'); + }, + ); + + it.each(['heading', 'mono'])('rejects the retired `typography.fontFamily.%s`', (key) => { + const msg = reject({ ...base, typography: { fontFamily: { base: 'Inter', [key]: 'Georgia' } } }); + expect(msg).toContain('`theme.typography.fontFamily.' + key + '` was removed'); + }); + + // ---- 3. the prescription is the payload ----------------------------- + it('every prescription names `customVars`, the door measured to have real consumers', () => { + for (const [input, key] of [ + [{ ...base, animation: {} }, 'animation'], + [{ ...base, zIndex: {} }, 'zIndex'], + [{ ...base, typography: { fontSize: {} } }, 'typography.fontSize'], + [{ ...base, typography: { fontWeight: {} } }, 'typography.fontWeight'], + [{ ...base, typography: { lineHeight: {} } }, 'typography.lineHeight'], + [{ ...base, typography: { letterSpacing: {} } }, 'typography.letterSpacing'], + [{ ...base, typography: { fontFamily: { heading: 'x' } } }, 'typography.fontFamily.heading'], + [{ ...base, typography: { fontFamily: { mono: 'x' } } }, 'typography.fontFamily.mono'], + ] as const) { + const msg = reject(input); + expect(msg, `${key} must prescribe customVars`).toContain('customVars'); + expect(msg, `${key} must name the migration command`).toContain('os migrate meta --from 16'); + expect(msg, `${key} must cite the issue`).toContain('#5021'); + } + }); + + it('gives each retired key its OWN sentence — a shared string prints N times (批 10)', () => { + const r = ThemeSchema.safeParse({ ...base, animation: {}, zIndex: {} }); + expect(r.success).toBe(false); + const msg = r.error!.issues.map((i) => i.message).join('\n'); + expect(msg).toContain('every transition ran at the renderer default'); + expect(msg).toContain('still stacked by document order'); + // Two keys, two DISTINCT issues. Note the shape difference from the #4001 + // block above: a `guidance` prescription arrives as ONE unrecognized-key + // issue carrying N bullets, whereas a `retiredKey()` raises its own issue + // per key — so this counts issues, not bullets. + expect(r.error!.issues).toHaveLength(2); + }); + + // ---- 4. finding 7: no suggestion may point at a retired key ---------- + it('the five aliases that pointed at `animation`/`zIndex` are GONE, not re-pointed', () => { + // Had `layers: 'zIndex'` survived, an author writing `layers` would be told + // "did you mean `zIndex`?" and then rejected for writing `zIndex` — walked + // out of one rejection into a second. The ledger's finding 7, which this + // file's own header has now signposted three times. + for (const alias of ['animations', 'motion', 'transitions', 'layers', 'stacking']) { + const msg = reject({ ...base, [alias]: {} }); + expect(msg, `${alias} must not be renamed onto a retired key`).not.toContain('→ `animation`'); + expect(msg, `${alias} must not be renamed onto a retired key`).not.toContain('→ `zIndex`'); + } + }); + + it('the typography aliases that pointed at retired scales are GONE too', () => { + for (const alias of ['sizes', 'size', 'weights', 'weight', 'tracking', 'leading']) { + const msg = reject({ ...base, typography: { [alias]: {} } }); + for (const dead of ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']) { + expect(msg, `${alias} must not be renamed onto retired \`${dead}\``).not.toContain('→ `' + dead + '`'); + } + } + for (const alias of ['headings', 'display', 'monospace', 'code']) { + const msg = reject({ ...base, typography: { fontFamily: { base: 'Inter', [alias]: 'x' } } }); + expect(msg, `${alias} must not be renamed onto retired \`heading\``).not.toContain('→ `heading`'); + expect(msg, `${alias} must not be renamed onto retired \`mono\``).not.toContain('→ `mono`'); + } + }); + + it('the retired keys are DECLARED-but-unwritable, not deleted — the tombstone route', () => { + // The distinction that decides how `authorable-surface.json` moves: a + // tombstoned key STAYS in the walked shape (gaining a `[RETIRED]` marker + // in the baseline) rather than vanishing from it. Assert the mechanism + // directly, so a future "tidy-up" that deletes these keys outright fails + // here rather than in `gen:schema`'s #4650 deletion check. + const shapeOf = (s: unknown) => + (s as { _zod: { def: { shape: Record } } })._zod.def.shape; + + const themeShape = shapeOf(ThemeSchema); + const typoShape = shapeOf(TypographySchema); + expect(Object.keys(themeShape)).toContain('animation'); + expect(Object.keys(themeShape)).toContain('zIndex'); + expect(Object.keys(typoShape)).toEqual( + expect.arrayContaining(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']), + ); + + // …and every one of them accepts NOTHING, which is what makes it a + // tombstone rather than a live key. + for (const [shape, keys] of [ + [themeShape, ['animation', 'zIndex']], + [typoShape, ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']], + ] as const) { + for (const k of keys) { + const inner = (shape[k] as { _zod: { def: { innerType?: { _zod: { def: { type: string } } } } } }) + ._zod.def.innerType; + expect(inner?._zod.def.type, `${k} must be a never-typed tombstone`).toBe('never'); + } + } + }); + + // ---- 5. the capability survives ------------------------------------- + it('`customVars` reproduces every retired variable by name — capability is not lost', () => { + // This is the claim the whole retirement rests on, so it is pinned rather + // than asserted in prose: the engine emits `customVars` as `--: + // ` verbatim, so each retired variable has an exact spelling here. + const parsed = ThemeSchema.parse({ + ...base, + customVars: { + 'font-size-lg': '1.125rem', + 'font-weight-bold': '700', + 'line-height-relaxed': '1.75', + 'letter-spacing-wide': '0.025em', + 'duration-fast': '150ms', + 'timing-ease_in': 'cubic-bezier(0.4, 0, 1, 1)', + 'z-modal': '1050', + 'font-heading': 'Georgia, serif', + 'font-mono': 'ui-monospace, monospace', + }, + }); + // One entry per retired GROUP — all nine the issue measured. + expect(Object.keys(parsed.customVars ?? {})).toHaveLength(9); + }); + + // ---- 6. the live blocks are untouched ------------------------------- + it('the blocks with live consumers still parse — the retirement is scoped, not a sweep', () => { + const parsed = ThemeSchema.parse({ + ...base, + colors: { primary: '#7C3AED', surface: '#F8F9FA', text: '#1F2937' }, + typography: { fontFamily: { base: 'Inter, sans-serif' } }, + borderRadius: { none: '0', base: '0.25rem', full: '9999px' }, + shadows: { base: '0 1px 3px rgba(0,0,0,.1)', inner: 'inset 0 2px 4px rgba(0,0,0,.06)' }, + }); + expect(parsed.colors.surface).toBe('#F8F9FA'); + expect(parsed.typography?.fontFamily?.base).toBe('Inter, sans-serif'); + expect(parsed.borderRadius?.full).toBe('9999px'); + expect(parsed.shadows?.inner).toBe('inset 0 2px 4px rgba(0,0,0,.06)'); + }); + + // ---- 7. the authoring DOORS carry the rejection ---------------------- + it('both authoring doors reject a retired key — `defineTheme` and `defineStack`', () => { + expect(() => defineTheme({ ...base, zIndex: { modal: 1 } } as never)).toThrow(/`theme\.zIndex` was removed/s); + + const stack = ObjectStackSchema.safeParse({ + name: 'demo', label: 'Demo', + themes: [{ ...base, typography: { fontSize: { base: '1rem' } } }], + }); + expect(stack.success).toBe(false); + expect(stack.error!.issues.map((i) => i.message).join('\n')) + .toContain('`theme.typography.fontSize` was removed'); + }); +}); diff --git a/packages/spec/src/ui/theme.zod.ts b/packages/spec/src/ui/theme.zod.ts index b2c7a1bfcd..26714eb622 100644 --- a/packages/spec/src/ui/theme.zod.ts +++ b/packages/spec/src/ui/theme.zod.ts @@ -52,31 +52,55 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; // * `colors` / `borderRadius` / `shadows` / `typography.fontFamily` are read // through FIXED maps (`COLOR_TO_CSS_MAP`, the radius and shadow maps) or by // named property. An undeclared key there is read by nothing, ever. -// * `typography.fontSize` / `.fontWeight` / `.lineHeight` / `.letterSpacing`, -// `animation.duration` / `.timing` and `zIndex` are read with -// `Object.entries(...)`, emitting `--font-size-`, `--duration-`, -// `--z-` … for whatever they are handed. That IS the #4909 open shape -// at the runtime — but it is not an author-reachable extension point -// through this door, because `.strip` already discarded the extra key -// before the engine ever saw it. Closing the block therefore changes what -// the author is TOLD, not what the renderer receives. +// * The token SCALES that used to sit beside them — `typography.fontSize` / +// `.fontWeight` / `.lineHeight` / `.letterSpacing`, `animation.duration` / +// `.timing` and `zIndex` — were read with `Object.entries(...)`, emitting +// `--font-size-`, `--duration-`, `--z-` … for whatever they +// were handed. That WAS the #4909 open shape at the runtime. It is moot +// now: all of them were RETIRED at #5021 (see the block below). // * And the escape hatch it might otherwise have removed already exists and // is declared: `customVars` emits an arbitrary CSS custom property by name. // An author who wants `--font-size-huge` writes it there. Openness in the // token scales would only add a second, undocumented way to do the same // thing — one whose typos are indistinguishable from intent. // -// ⚠️ SEPARATE, FILED, NOT ANSWERED HERE: several of these blocks emit CSS -// variables no first-party consumer reads (`--font-size-*`, `--font-weight-*`, -// `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, -// `--timing-*`, `--font-heading`, `--font-mono` all have ZERO consumers across -// objectui's components and stylesheets; only the colour variables, -// `--radius*`, `--shadow*` and `--font-sans` are consumed). That is an -// ADR-0049 enforce-or-remove question about LIVENESS, and it must not be -// confused with this one: strictness makes a dropped key loud, it cannot make -// a slot live. Filed rather than answered here — a theme variable is also -// readable by a tenant's own CSS, so "no in-repo consumer" is weaker evidence -// for a CSS custom property than it is for a spec key. +// ✅ ANSWERED AT #5021 (ADR-0049 enforce-or-remove) — this file's own filed +// question, now closed. The block above used to end "SEPARATE, FILED, NOT +// ANSWERED HERE", because 批 15 correctly refused to decide a LIVENESS question +// inside a STRICTNESS batch: strictness makes a dropped key loud, it cannot +// make a slot live. The measurement it filed on (re-confirmed against objectui +// `main` on 2026-08-04, with `--font-sans` / `--radius` / `--shadow` / +// `--primary` as positive controls in the same run) was that `--font-size-*`, +// `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, +// `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO +// first-party consumers, while only the colour variables, `--radius*`, +// `--shadow*` and `--font-sans` are read. +// +// The maintainer's ruling (2026-08-04) is RETIRE, not "add consumers" and not +// "promise them as a public token surface": theme-driven typography is not a +// near-term product capability, so wiring shadcn/Tailwind to read these would +// build a feature nobody asked for, and stamping a stability guarantee onto a +// surface that changes nothing in the platform's own UI is a promise attached +// to an inert slot (the #4583 shape — a precisely validated dead slot is the +// more convincing lie). +// +// ⚠️ The counter-argument that kept these alive through #3494 was answered, not +// ignored: a CSS custom property differs from an ordinary spec key because a +// TENANT'S OWN STYLESHEET can read it once it lands on the document, so +// "zero in-repo consumers" is weaker evidence here than it is elsewhere. That +// is exactly why the prescription is `customVars` rather than a bare deletion. +// `customVars` emits `--: ` verbatim, so a tenant who really was +// reading `--z-modal` or `--font-size-lg` from their own CSS reproduces every +// one of these variables BYTE FOR BYTE. Capability lost: none. What is lost is +// a semantic vocabulary that the platform itself never honoured — which is the +// thing ADR-0049 exists to delete. +// +// `colors`, `borderRadius`, `shadows` and `typography.fontFamily.base` have +// live consumers and are UNTOUCHED. objectui's `ThemeEngine` still emits the +// retired groups until its own stop-emitting follow-up lands (filed on the +// objectui queue); that is NOT a correctness dependency in either direction — +// an emitted variable nothing reads is inert, and after this change no author +// can put a value into one, so the emitter has nothing left to emit. // --------------------------------------------------------------------------- /** @@ -84,6 +108,7 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; * Defines brand colors and their variants. */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; // Competing vocabulary, MEASURED not guessed: objectui's `COLOR_TO_CSS_MAP` @@ -139,23 +164,50 @@ export const ColorPaletteSchema = lazySchema(() => strictObject( /** * Typography Settings Schema - * Font families, sizes, weights, and line heights. + * Base font family. The size / weight / line-height / letter-spacing scales + * were retired at #5021 and are `retiredKey()` tombstones below. + * + * ⚠️ WHY `retiredKey()` AND NOT A `guidance` ENTRY, on a shape that is already + * `.strict()`. The two routes are not interchangeable here and the build is + * what settles it. `guidance` is consulted for `unrecognized_keys`, which + * requires the key to be ABSENT from the shape — and a key absent from the + * shape is absent from `authorable-surface.json`, which is a ratcheted + * baseline. Deleting a live line from it fails `gen:schema`'s #4650 deletion + * check (`the entry at was LIVE (never tombstoned)`), and rightly: the + * ratchet cannot see that THIS parent happens to be strict, and the class of + * mistake it guards — a key deleted from a non-strict shape, silently stripped + * forever after — is indistinguishable at the file level. + * + * A tombstone satisfies both: the key stays in the walked shape (so the + * baseline gains a `[RETIRED]` marker instead of losing a line), and it is + * strictly LOUDER than the strict shell, because it carries its own + * prescription instead of a generic unknown-key message. `strictObject` already + * anticipates the combination — its `acceptsNothing()` helper exists precisely + * to keep a tombstoned key out of the "did you mean" candidates. + * + * Every prescription points at `customVars`, and that is a byte-for-byte + * replacement rather than a consolation prize: the engine emits `customVars` as + * `--: ` verbatim, so `customVars: { 'font-size-lg': '1.125rem' }` + * puts the SAME `--font-size-lg` on the document this block used to. What the + * author loses is a semantic vocabulary the platform never honoured; what they + * keep is every variable they were actually shipping. */ export const TypographySchema = lazySchema(() => strictObject( { surface: 'this theme typography block', history: 'Until #4001 an undeclared typography key was dropped at parse and the type scale silently stayed at the defaults.', - // `tracking` / `leading` are Tailwind's utility names for the two scales - // this block spells `letterSpacing` / `lineHeight`, and the block's own - // `.describe()` values are Tailwind's numbers — so an author arriving from - // the utility side writes the utility name. + // ⚠️ The `sizes`/`size`, `weights`/`weight`, `tracking`/`spacing` and + // `leading` aliases were DELETED at #5021, not re-pointed. They named + // Tailwind's utility words for scales this block no longer declares, so + // keeping them would answer an author with "did you mean `fontSize`?" and + // then reject `fontSize` — walking them out of one rejection into a second + // one, which is the ledger's finding 7 (this campaign signposting the way + // into the failure mode it exists to kill). A key that no longer exists + // gets no suggestion at all; the four canonical spellings still get their + // full prescription through `guidance` below. aliases: { fonts: 'fontFamily', font: 'fontFamily', family: 'fontFamily', fontFamilies: 'fontFamily', - sizes: 'fontSize', size: 'fontSize', - weights: 'fontWeight', weight: 'fontWeight', - tracking: 'letterSpacing', spacing: 'letterSpacing', - leading: 'lineHeight', }, }, { @@ -165,91 +217,40 @@ export const TypographySchema = lazySchema(() => strictObject( history: 'Until #4001 an undeclared font-family key was dropped at parse and the element kept the system font stack.', // MEASURED: the engine emits `base` as `--font-sans` (the only one of - // the three any objectui stylesheet reads), so the rendered variable - // name and the authorable key name disagree — `sans` is what an author - // reading the output writes back, four edits away from `base`. - aliases: { sans: 'base', body: 'base', default: 'base', headings: 'heading', display: 'heading', monospace: 'mono', code: 'mono' }, + // the three that any objectui stylesheet ever read — which is why it is + // the only one of the three that survived #5021), so the rendered + // variable name and the authorable key name disagree; `sans` is what an + // author reading the output writes back, four edits away from `base`. + // + // `headings`/`display` → `heading` and `monospace`/`code` → `mono` were + // DELETED at #5021 with their targets, for the finding-7 reason spelled + // out on the parent block. + aliases: { sans: 'base', body: 'base', default: 'base' }, }, { base: z.string().optional().describe('Base font family (default: system fonts)'), - heading: z.string().optional().describe('Heading font family'), - mono: z.string().optional().describe('Monospace font family for code'), - }, - ).optional(), - fontSize: strictObject( - { - surface: 'this theme font-size scale', - history: - 'Until #4001 an undeclared size stop was dropped at parse — the scale reported valid and the text rendered at the default size.', - // MEASURED same-file inconsistency: `borderRadius` and `shadows` below - // both declare `md`; this scale jumps `sm` → `base` → `lg`. An author - // who has just written `borderRadius: { md }` writes `fontSize: { md }` - // next, and got nothing. - aliases: { md: 'base', medium: 'base', normal: 'base', default: 'base', small: 'sm', large: 'lg' }, - }, - { - xs: z.string().optional().describe('Extra small font size (e.g., 0.75rem)'), - sm: z.string().optional().describe('Small font size (e.g., 0.875rem)'), - base: z.string().optional().describe('Base font size (e.g., 1rem)'), - lg: z.string().optional().describe('Large font size (e.g., 1.125rem)'), - xl: z.string().optional().describe('Extra large font size (e.g., 1.25rem)'), - '2xl': z.string().optional().describe('2X large font size (e.g., 1.5rem)'), - '3xl': z.string().optional().describe('3X large font size (e.g., 1.875rem)'), - '4xl': z.string().optional().describe('4X large font size (e.g., 2.25rem)'), - }, - ).optional(), - - fontWeight: strictObject( - { - surface: 'this theme font-weight scale', - history: - 'Until #4001 an undeclared weight stop was dropped at parse and the text rendered at the inherited weight.', - // The mirror of the `fontSize` entry above: three sibling scales in - // this file name their middle stop `base`, this one names it `normal`. - aliases: { base: 'normal', regular: 'normal', default: 'normal', extrabold: 'bold', black: 'bold', heavy: 'bold', thin: 'light' }, - }, - { - light: z.number().optional().describe('Light weight (default: 300)'), - normal: z.number().optional().describe('Normal weight (default: 400)'), - medium: z.number().optional().describe('Medium weight (default: 500)'), - semibold: z.number().optional().describe('Semibold weight (default: 600)'), - bold: z.number().optional().describe('Bold weight (default: 700)'), + heading: retiredKey( + '`theme.typography.fontFamily.heading` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — it emitted `--font-heading`, which no objectui component or stylesheet reads, so headings always rendered in the base font stack. `base` is the ONE font-family key with a live consumer (it emits `--font-sans`) and is unchanged. Delete the key; if your own CSS reads the variable, declare it under `customVars` (`{ "font-heading": "Georgia, serif" }` emits exactly the same `--font-heading`). Run `os migrate meta --from 16` to rewrite it automatically.', + ), + mono: retiredKey( + '`theme.typography.fontFamily.mono` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — it emitted `--font-mono`, which no objectui component or stylesheet reads, so code always rendered in the browser default monospace. `base` is the ONE font-family key with a live consumer (it emits `--font-sans`) and is unchanged. Delete the key; if your own CSS reads the variable, declare it under `customVars` (`{ "font-mono": "ui-monospace, monospace" }` emits exactly the same `--font-mono`). Run `os migrate meta --from 16` to rewrite it automatically.', + ), }, ).optional(), - lineHeight: strictObject( - { - surface: 'this theme line-height scale', - history: - 'Until #4001 an undeclared line-height stop was dropped at parse and the block kept the inherited leading.', - // `snug` is a real Tailwind `leading-*` stop this scale does not have. - aliases: { base: 'normal', default: 'normal', snug: 'tight' }, - }, - { - tight: z.string().optional().describe('Tight line height (e.g., 1.25)'), - normal: z.string().optional().describe('Normal line height (e.g., 1.5)'), - relaxed: z.string().optional().describe('Relaxed line height (e.g., 1.75)'), - loose: z.string().optional().describe('Loose line height (e.g., 2)'), - }, - ).optional(), - - letterSpacing: strictObject( - { - surface: 'this theme letter-spacing scale', - history: - 'Until #4001 an undeclared tracking stop was dropped at parse and the text kept its default letter spacing.', - // `widest` / `tightest` are real Tailwind `tracking-*` stops absent here. - aliases: { base: 'normal', default: 'normal', widest: 'wider', tightest: 'tighter' }, - }, - { - tighter: z.string().optional().describe('Tighter letter spacing (e.g., -0.05em)'), - tight: z.string().optional().describe('Tight letter spacing (e.g., -0.025em)'), - normal: z.string().optional().describe('Normal letter spacing (e.g., 0)'), - wide: z.string().optional().describe('Wide letter spacing (e.g., 0.025em)'), - wider: z.string().optional().describe('Wider letter spacing (e.g., 0.05em)'), - }, - ).optional(), + fontSize: retiredKey( + '`theme.typography.fontSize` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-size-xs` … `--font-size-4xl` faithfully and NO first-party component or stylesheet has ever read one, so a declared type scale was real CSS that styled nothing. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-size-lg": "1.125rem" }` emits exactly the same `--font-size-lg`). Run `os migrate meta --from 16` to rewrite it automatically.', + ), + fontWeight: retiredKey( + '`theme.typography.fontWeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--font-weight-*` and nothing read it, so text rendered at the inherited weight whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "font-weight-semibold": "600" }` emits exactly the same `--font-weight-semibold`). Run `os migrate meta --from 16` to rewrite it automatically.', + ), + lineHeight: retiredKey( + '`theme.typography.lineHeight` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--line-height-*` and nothing read it, so every block kept its inherited leading. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "line-height-relaxed": "1.75" }` emits exactly the same `--line-height-relaxed`). Run `os migrate meta --from 16` to rewrite it automatically.', + ), + letterSpacing: retiredKey( + '`theme.typography.letterSpacing` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--letter-spacing-*` and nothing read it, so tracking never moved. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "letter-spacing-wide": "0.025em" }` emits exactly the same `--letter-spacing-wide`). Run `os migrate meta --from 16` to rewrite it automatically.', + ), }, )); @@ -304,84 +305,6 @@ export const ShadowSchema = lazySchema(() => strictObject( }, )); -/** - * Animation Schema - * Animation timing and duration settings. - */ -export const AnimationSchema = lazySchema(() => strictObject( - { - surface: 'this theme animation block', - history: - 'Until #4001 an undeclared animation key was dropped at parse and the transition ran at the renderer default.', - aliases: { - durations: 'duration', transition: 'duration', transitions: 'duration', - easing: 'timing', easings: 'timing', timingFunction: 'timing', timingFunctions: 'timing', - }, - }, - { - duration: strictObject( - { - surface: 'this theme animation-duration scale', - history: - 'Until #4001 an undeclared duration stop was dropped at parse and the transition ran at the renderer default.', - aliases: { normal: 'base', medium: 'base', default: 'base', quick: 'fast' }, - }, - { - fast: z.string().optional().describe('Fast animation (e.g., 150ms)'), - base: z.string().optional().describe('Base animation (e.g., 300ms)'), - slow: z.string().optional().describe('Slow animation (e.g., 500ms)'), - }, - ).optional(), - - timing: strictObject( - { - surface: 'this theme animation-timing block', - history: - 'Until #4001 an undeclared easing key was dropped at parse and the transition fell back to the renderer default curve.', - // This block is the file's one snake_case vocabulary — against - // AGENTS.md Prime Directive #3 (TS config keys are camelCase) and - // against every sibling key in this file. So `easeIn` is not a typo, it - // is an author following the repo's own naming rule, and it must land - // on `ease_in` rather than on whatever the distance fallback picks. - aliases: { easeIn: 'ease_in', easeOut: 'ease_out', easeInOut: 'ease_in_out', default: 'ease' }, - }, - { - linear: z.string().optional().describe('Linear timing function'), - ease: z.string().optional().describe('Ease timing function'), - ease_in: z.string().optional().describe('Ease-in timing function'), - ease_out: z.string().optional().describe('Ease-out timing function'), - ease_in_out: z.string().optional().describe('Ease-in-out timing function'), - }, - ).optional(), - }, -)); - -/** - * Z-Index Scale Schema - * Layering and stacking order. - */ -export const ZIndexSchema = lazySchema(() => strictObject( - { - surface: 'this theme z-index scale', - history: - 'Until #4001 an undeclared layer was dropped at parse — the overlay it was meant to lift kept the default stacking order and rendered behind its own backdrop.', - // `modalBackdrop` is camelCase, so the distance fallback is systematically - // weak on it (#4990: the helper lowercases the input but not the - // candidates, spending one edit per capital). These land explicitly. - aliases: { backdrop: 'modalBackdrop', overlay: 'modalBackdrop', dialog: 'modal', menu: 'dropdown', default: 'base' }, - }, - { - base: z.number().optional().describe('Base z-index (e.g., 0)'), - dropdown: z.number().optional().describe('Dropdown z-index (e.g., 1000)'), - sticky: z.number().optional().describe('Sticky z-index (e.g., 1020)'), - fixed: z.number().optional().describe('Fixed z-index (e.g., 1030)'), - modalBackdrop: z.number().optional().describe('Modal backdrop z-index (e.g., 1040)'), - modal: z.number().optional().describe('Modal z-index (e.g., 1050)'), - popover: z.number().optional().describe('Popover z-index (e.g., 1060)'), - tooltip: z.number().optional().describe('Tooltip z-index (e.g., 1070)'), - }, -)); - /** * Theme Mode Schema */ @@ -390,9 +313,10 @@ export const ThemeModeSchema = lazySchema(() => z.enum(['light', 'dark', 'auto'] /** @deprecated Use ThemeModeSchema instead */ export const ThemeMode = ThemeModeSchema; -// Tombstones for the eight props #3494 removed. Each carries its OWN sentence: -// `guidance` prints one bullet per rejected key verbatim, so a shared string -// prints the same paragraph N times (批 10's `join`/`joinGateway` lesson). +// Tombstones for the eight props #3494 removed, plus the two blocks #5021 +// retired. Each carries its OWN sentence: `guidance` prints one bullet per +// rejected key verbatim, so a shared string prints the same paragraph N times +// (批 10's `join`/`joinGateway` lesson). // // Two of them are deliberately worded NOT to hand the author a replacement // slot. `touchTarget` and `keyboardNavigation` read like they should point at @@ -401,6 +325,12 @@ export const ThemeMode = ThemeModeSchema; // would walk an author out of a loud rejection and into a silent one. That is // the ledger's finding 7, and this campaign has now signposted its own failure // mode twice; it does not get to do it a third time. +// +// The #5021 pair is the OPPOSITE case and it is worth keeping the distinction +// visible: `animation` and `zIndex` DO get a replacement slot, because +// `customVars` is measured live (the engine emits every entry verbatim) rather +// than merely plausible. That is the whole difference between a prescription +// and a signpost into a second rejection. const THEME_RETIRED_KEY_GUIDANCE: Readonly> = { spacing: '`spacing` was removed in #3494 — the theme engine (objectui `generateThemeVars`) never emitted a spacing variable, so authoring it was a silent no-op. Emit your own scale through `customVars` (e.g. `{ "space-4": "1rem" }`).', @@ -420,6 +350,20 @@ const THEME_RETIRED_KEY_GUIDANCE: Readonly> = { '`keyboardNavigation` was removed in #3494 — keyboard behaviour is not a CSS variable and was never emitted; no theme key can switch it on or off.', }; +/** + * The two theme-level keys #5021 retired. + * + * These are `retiredKey()` tombstones rather than `guidance` entries — unlike + * the eight above, which predate the `authorable-surface.json` ratchet and are + * long gone from it. See the note on `TypographySchema` for why a strict shell + * does not make the tombstone redundant. + */ +const THEME_ANIMATION_RETIRED = + '`theme.animation` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — unlike the #3494 props above, the engine DID emit `--duration-*` and `--timing-*`, faithfully and for years; what never existed was a reader. No first-party component or stylesheet has ever consumed one, so every transition ran at the renderer default whatever you declared. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "duration-fast": "150ms", "timing-ease_in": "cubic-bezier(0.4, 0, 1, 1)" }` emits exactly the same properties). Run `os migrate meta --from 16` to rewrite it automatically.'; + +const THEME_ZINDEX_RETIRED = + '`theme.zIndex` was removed in @objectstack/spec 17.0.0 (#5021, ADR-0049 D2) — the engine emitted `--z-base` … `--z-tooltip` and nothing read one, so an overlay you "lifted" still stacked by document order. Delete the key; if your own CSS reads those variables, declare them under `customVars` (`{ "z-modal": "1050" }` emits exactly the same `--z-modal`). Run `os migrate meta --from 16` to rewrite it automatically.'; + /** * Theme Configuration Schema * Complete theme definition for brand customization. @@ -430,6 +374,14 @@ const THEME_RETIRED_KEY_GUIDANCE: Readonly> = { * authoring them was a silent no-op (liveness audit #1878/#1893). Since #4001 * writing one is a loud rejection carrying its replacement, instead of a silent * strip that made the removal indistinguishable from the bug it fixed. + * + * #5021 removed `animation` and `zIndex` (and, inside `typography`, the + * `fontSize` / `fontWeight` / `lineHeight` / `letterSpacing` scales and + * `fontFamily.heading` / `.mono`). #3494's criterion could not reach these: + * it was "the engine never emits it", and the engine emitted every one of + * these. The criterion that reaches them is ADR-0049's — emitted, but read by + * nobody. `colors`, `borderRadius`, `shadows` and `fontFamily.base` have live + * consumers and stay. */ export const ThemeSchema = lazySchema(() => strictObject( { @@ -441,8 +393,19 @@ export const ThemeSchema = lazySchema(() => strictObject( fonts: 'typography', font: 'typography', type: 'typography', radius: 'borderRadius', borderRadii: 'borderRadius', radii: 'borderRadius', shadow: 'shadows', boxShadow: 'shadows', elevation: 'shadows', - animations: 'animation', motion: 'animation', transitions: 'animation', - layers: 'zIndex', stacking: 'zIndex', + // `animations`/`motion`/`transitions` → `animation` and + // `layers`/`stacking` → `zIndex` were DELETED at #5021 rather than + // re-pointed at `customVars`: an alias renames one key to another, and + // this is not a rename — a scale object would have to become a flat + // string map with the CSS variable names spelled out and the numbers + // stringified. Suggesting the retired key would hand the author a second + // rejection (finding 7); suggesting `customVars` would imply the value + // transfers unchanged, which it does not. The prescription in `guidance` + // states the shape change; the D2 conversion DELETES the key and emits a + // notice rather than auto-populating `customVars`, because a rewrite + // would silently reconstitute ~25 variables nothing reads as + // live-looking config — the retirement would be invisible in exactly the + // way ADR-0049 objects to. cssVars: 'customVars', variables: 'customVars', vars: 'customVars', customProperties: 'customVars', tokens: 'customVars', extend: 'extends', parent: 'extends', inherits: 'extends', basedOn: 'extends', title: 'label', displayName: 'label', @@ -471,13 +434,27 @@ export const ThemeSchema = lazySchema(() => strictObject( /** Shadows */ shadows: ShadowSchema.optional().describe('Box shadow effects'), - /** Animation */ - animation: AnimationSchema.optional().describe('Animation settings'), - - /** Z-Index */ - zIndex: ZIndexSchema.optional().describe('Z-index scale for layering'), - - /** Custom CSS variables */ + /** @deprecated REMOVED at #5021 — see {@link THEME_ANIMATION_RETIRED}. */ + animation: retiredKey(THEME_ANIMATION_RETIRED), + + /** @deprecated REMOVED at #5021 — see {@link THEME_ZINDEX_RETIRED}. */ + zIndex: retiredKey(THEME_ZINDEX_RETIRED), + + // `AnimationSchema` and `ZIndexSchema` were DELETED outright rather than + // left standing beside these tombstones: an exported value schema with no + // consumer reads as a capability to whoever finds it (#3950), and each had + // exactly one consumer — the key now tombstoned above. Their own + // `authorable-surface` lines leave with the def, which is the #4650 + // deletion check's third proof (whole def no longer emitted) and is + // adjudicated by the json-schema.manifest.json ratchet (#2978) instead. + + /** + * Custom CSS variables. + * + * The declared door for any custom property, and — since #5021 — the ONLY + * one. Each entry is emitted verbatim as `--: `, so this is + * where a `--z-modal` or a `--font-size-lg` goes now. + */ customVars: z.record(z.string(), z.string()).optional().describe('Custom CSS variables (key-value pairs)'), /** Extends another theme */ @@ -501,6 +478,6 @@ export type ColorPalette = z.infer; export type Typography = z.infer; export type BorderRadius = z.infer; export type Shadow = z.infer; -export type Animation = z.infer; -export type ZIndex = z.infer; +// `Animation` and `ZIndex` were exported here until #5021, alongside the two +// schemas they were inferred from. export type ThemeMode = z.infer;