diff --git a/.changeset/unknown-key-strictness-ui-batch15.md b/.changeset/unknown-key-strictness-ui-batch15.md new file mode 100644 index 0000000000..2d538a4c32 --- /dev/null +++ b/.changeset/unknown-key-strictness-ui-batch15.md @@ -0,0 +1,52 @@ +--- +'@objectstack/spec': major +--- + +**未知键收紧:`ui/theme.zod.ts` 全部 14 个站点 + `ui/chart.zod.ts` 5 / 7 个站点(#4001 批 15)** + +作者写进主题和图表配置里的未声明键,过去被 zod 默认的 `.strip` 静默丢弃、解析照常成功。现在是一条点名该键、并在能识别时给出正确拼法的报错。 + +**先测门,再收紧。** 两个文件在账本上都标着 `authorable (p)`,`(p)` 是待验证: + +- `theme.zod.ts` —— 门是真的,而且有两道:`stack.zod.ts` 声明 `themes: z.array(ThemeSchema)`(所以 `defineStack()` 在每次启动和 `objectstack build` 时解析每个主题),`defineTheme()` 直接解析一个。从 24 个 metadata-type root 加 `ObjectStackSchema` 做 BFS,文件内每个 schema 都可达。**14/14 收紧。** +- `chart.zod.ts` —— 5 个可达(`DashboardWidget.chartConfig` / `ReportChartSchema`),已收紧;**`ChartAggregateSchema` 与 `ChartGroupBySchema` 的 object 分支不可达,故意保持原样**,见下。 + +## 破坏性变更 · 迁移 + +以下写法过去被静默接受(键被丢弃),现在报错。括号内是新报错直接给出的处方。 + +**主题(`defineStack({ themes })` / `defineTheme()`)** + +| 旧写法 | 改成 | +|---|---| +| `colors: { card, foreground, mutedForeground, muted, destructive }` | `surface` / `text` / `textSecondary` / `disabled` / `error` | +| `typography.fontSize: { md }` | `base`(`borderRadius`/`shadows` 有 `md`,字号阶梯没有) | +| `typography.fontWeight: { base }` | `normal` | +| `animation.timing: { easeIn, easeOut, easeInOut }` | `ease_in` / `ease_out` / `ease_in_out` | +| `shadows: { inset }` | `inner` | +| `zIndex: { backdrop, overlay }` | `modalBackdrop` | +| 顶层 `palette` / `radius` / `shadow` / `animations` / `cssVars` / `extend` | `colors` / `borderRadius` / `shadows` / `animation` / `customVars` / `extends` | +| #3494 删除的 8 个 prop(`spacing` / `breakpoints` / `logo` / `density` / `wcagContrast` / `rtl` / `touchTarget` / `keyboardNavigation`) | 各自带独立墓碑处方;多数指向 `customVars`,`logo` 指向 app 的 `branding.logo` | + +**图表(dashboard widget 的 `chartConfig` / report 的 `chart`)** + +| 旧写法 | 改成 | +|---|---| +| `chartType` | `type`(`chartType` 是内部拼写,从来不是作者契约) | +| `legend` / `dataLabels` | `showLegend` / `showDataLabels` | +| `interactions` / `annotation` | `interaction` / `annotations`(同一个块里一个单数一个复数) | +| axis 上的 `name` / `label` / `dataKey` | `field` / `title` | +| series 上的 `field` / `title` / `stackId` / `strokeDasharray` | `name` / `label` / `stack` / `dashArray` | +| annotation 上的 `from` / `to` | `value` / `endValue` | +| `interaction.zoom` / `interaction.clickAction`(#3752 已删) | `brush: true` / `onSegmentClick`、`ReportSchema.drilldown`、widget 的 `options` 袋 | +| `width` / `stacked` / `dataset` / `objectName` / `aggregate` / `options` | 都是层级放错,报错点名正确的那一层(`layout.w`、`series[].stack`、widget 自己的键、react prop) | + +⚠️ **严格性会顺着 `.extend()` 传到 `ReportChartSchema`**(`ChartConfigSchema.extend(...)`)。这是有意的,并有测试钉住:report chart 只是把 `xAxis`/`yAxis` 收窄成 dataset 维度/度量名,不新增键,所以继承的键集正好。 + +## 两个站点故意没收紧 + +`ChartAggregateSchema` 和 `ChartGroupBySchema` 的 object 分支**有活的承载键**(react 层 `< ObjectChart objectName aggregate={…} >`,objectui 的 `ObjectChart` 真的读它跑查询),**但没有任何 parse**:两者从所有 metadata-type root 都不可达,三个仓库里除单测外无人 `.parse()`,而唯一审查它的 react 页发布 lint 是手写重推规则、从不检查未知键。 + +`.strict()` 是 parse 的属性,这里没有 parse —— 收紧只会让文件看起来完成,并留下*一个被精确校验的死槽位*(#4583)。账本因此新增第四类 **`no gate`**(承载键活、parse 缺),与批 13 的 `no door`(承载键本身不存在)并列:两者处方相反,前者该接闸门,后者该走 ADR-0049 退役。已归档为独立 issue。 + +主题里那些**发出后无人读取**的 CSS 变量(`--font-size-*` / `--z-*` / `--duration-*` …)是 ADR-0049 的 liveness 题目,不是未知键题目,同样单独归档 —— 收紧能让被丢弃的键变响,不能让一个槽位变活。 diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index bcc6059ac3..caea1abe38 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -21,6 +21,7 @@ One question decides the class: **who writes this schema's input?** | **wire** | Another machine: server responses, connector payloads, runtime envelopes, persisted runtime state | stay tolerant (`.strip` / `.passthrough`); strictness here turns an upstream *addition* into our parse crash | | **open** | Deliberately schemaless user data (record bodies, per-node-type `config`, React props) | stay open; a *sibling* contract validates it (e.g. a node executor's `configSchema`, #4027/#4040) | | **no door** | **Nobody — nothing parses it.** The shape is exported and typed, but no schema declares a carrier key for it, so it is unreachable from every metadata-type root and from `defineStack`. Added at 批 13, when the first run of files resolved its `(p)` this way | **out of this ratchet's scope.** `.strict()` is a property of a PARSE; with no parse it enforces nothing and only makes a dead slot look load-bearing (#4583). The live question is ADR-0049 enforce-or-remove — retire the vocabulary or give it a carrier — so a row here points at an issue, never at a batch | +| **no gate** | **An author — through a carrier this protocol does not PARSE.** The carrier key exists and is live (authors write it, a renderer reads it), but no `.parse()` sits between them; whatever checking exists re-derives the schema's rules by hand. Added at 批 15 on `ChartAggregateSchema` (``) | **out of this ratchet's scope, for the opposite reason.** Same absent parse, so closing it still enforces nothing — but the vocabulary is ALIVE, so the fix is to wire the parse at the carrier's own gate, not to retire anything. A row here points at that wiring issue | A fourth answer to "who writes this input" is **nobody**, and it is only reachable by measurement rather than by reading the file: `no door` was added at @@ -28,6 +29,26 @@ reachable by measurement rather than by reading the file: `no door` was added at empty on five `ui/` files at once. Reading a schema's exports and JSDoc cannot distinguish it from `authorable` — which is exactly why the `(p)` exists. +批 15 then found that the answer splits again, and that the split decides the +follow-up. Both `no door` and `no gate` fail the same measurement — no parse, so +no strictness — but they fail it from opposite directions: + +| | carrier key | parse | right next step | +|---|---|---|---| +| **`no door`** | absent | absent | ADR-0049 enforce-or-remove — there is no author to protect | +| **`no gate`** | **live** | absent | wire the parse at the carrier's own gate — retiring it would break a working feature | + +Read the wrong one and the prescribed action is not merely wasteful but +destructive: retiring a `no gate` vocabulary deletes something authors use and +renderers run. So the measurement has to report the CARRIER and the PARSE +separately; "unreachable from the metadata roots" alone cannot tell them apart, +because a react-tier prop is a real authoring door that no metadata BFS can see. + +Both are verdicts, not TODOs. What they share is the discipline that produced +them: a verification step's correct output includes "this was never the ratchet's +job", and a batch unable to return that answer will close things to look +finished. + Mixed files carry both — classify per schema, not per file. A **response-side extension of an authoring schema** (e.g. `EffectiveObjectPermissionSchema`) must explicitly `.strip()` back, because `.extend()` inherits `.strict()`. @@ -497,12 +518,13 @@ not verdicts). | `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | | `bulk-action.zod.ts` | 3 | 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` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) | -| `theme.zod.ts` | 14 | authorable (p) | authored themes | +| `theme.zod.ts` | 14 | 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 | | `app.zod.ts` | 18 | 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` | 11 | authorable | partially strict | | `widget.zod.ts` | 9 | authorable (p) | | | `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) | -| `chart.zod.ts` / `i18n.zod.ts` | 7+6 | authorable (p) | i18n label shapes are wide-open records by design — verify. **`chart` 6 → 7 at the re-measurement** — again no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | +| `chart.zod.ts` | 7 | **mixed — 5 authorable, 2 no gate** | **5 strict as of #4001 批 15**; 2 deliberately left open. `ChartConfigSchema` / `ChartAxis` / `ChartSeries` / `ChartAnnotation` / `ChartInteraction` are `root-graph`-reachable from the `dashboard` and `report` metadata roots (`DashboardWidget.chartConfig`, `ReportChartSchema`), so they are judged on the stored-metadata path and are now closed. **`ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are NOT**, and this is the batch's real finding. They are not 批 13's no-door case — their carrier is LIVE: `aggregate` is a real authorable prop on the react tier's `` (ADR-0081), published in the generated react-blocks contract, and objectui's `ObjectChart` reads `schema.aggregate` to run the query. What is missing is the PARSE: neither schema is reachable from any metadata-type root or from `ObjectStackSchema` (both `UNREACHABLE` in the run where the five above come back `root-graph`), nothing in the three repos calls `.parse()` on them outside this file's unit tests, and the gate that DOES judge an authored `aggregate` — the react-page publish lint — re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field requirement, the result-column naming) and never checks unknown keys. `react-blocks.ts` publishes the prop as a hand-written TYPE STRING; the Zod schema beside it is not what the contract is generated from. So `groupby` / `dateGranularty` are silently dropped today and would go on being silently dropped after a `strictObject` here — `.strict()` is a property of a parse. A fourth class, **`no gate`**: carrier live, parse absent. Distinct from `no door` (批 13), where the carrier itself does not exist. The contract-first fix is to make the publish gate PARSE the schema instead of re-deriving it — a `packages/lint` change, filed rather than smuggled into a spec strictness batch. Recorded in three places (schema-adjacent comment, test pin incl. a standing BFS assertion that goes red the day a carrier key appears, this row). ⚠️ One correction shipped with the tightening: the `clickAction` migration text #3752 wrote into this file prescribed **`drillDown`, which is not a key this protocol declares anywhere** — it is an untyped `(schema as any).drillDown` read inside objectui's `ObjectChart`. Promoting that sentence into a strict rejection would have handed an author the platform's authority for a key the same gate then rejects: finding 7, third occurrence, this time caught before shipping. The prose and the tombstone now name `onSegmentClick` / `ReportSchema.drilldown` / the widget's `options` bag, all of which exist. Filed separately. **`chart` 6 → 7 at the re-measurement** — no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | +| `i18n.zod.ts` | 6 | authorable (p) | i18n label shapes are wide-open records by design — verify | | `responsive.zod.ts` | 4 | authorable | **strict as of #4001 批 13** — all four sites (`ResponsiveConfig`, `ResponsiveStyles`, and the two per-breakpoint maps). This is the one file of batch 13's six whose `(p)` resolved POSITIVE, and it resolved on the graph rather than on the file's face: `page.components[].responsive` / `.responsiveStyles` put both shapes inside the `page` metadata-type root (`dashboard.widgets[].responsive` was the second carrier until #4876 retired it, same day). What the closure bought is the batch's whole argument in one parse — **`PageComponentSchema` has been `.strict()` since ADR-0089 D3a and that never reached these blocks**, so `{ type:'element:text', responsiveStyles: { lg: {…} }, responsive: { colums: {…}, hideOn: [] } }` parsed CLEAN and returned `responsiveStyles: {}, responsive: {}` — every styling and layout instruction the author wrote, gone, reported valid. A strict shell over strip-mode children is a closed surface's silhouette, not a closed surface. The curation is the file's real hazard rather than typos: it carries TWO breakpoint vocabularies sixteen lines apart on the same component (`responsiveStyles`' `large`/`medium`/`small`/`xsmall`, ADR-0065, against `responsive`'s Tailwind `xs`…`2xl`), so the aliases run BOTH ways between them and are anchored to the named sibling, not to edit distance — batch 12's method, and the only thing that can answer `lg` → `large`. Two entries had to be measured rather than reasoned: `{ columns: { large: 4, lg: 3 } }` used to keep HALF the map (the node laid out, at the wrong width, on breakpoints the author never named — worse than a total loss, which is at least visible); and `hideOn` → `hiddenOn` needed a hand-written alias because the distance fallback provably cannot reach it — it lowercases the input but not the candidates, so a capital in a declared key costs an extra edit against a budget of 2, and the all-lowercase `hiddenon` resolves while the correctly-cased `hideOn` does not. That asymmetry is general to camelCase keys, i.e. to most of the spec, and is filed as **#4990**. `StyleMapSchema` stays deliberately OPEN (its key space is every CSS property; objectui's `declarations()` emits whatever it is handed) — recorded in the schema JSDoc, in a test pin, and in this row | | `dataset.zod.ts` | 4 | authorable (p) | analytics dimension/measure config | | `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` / `offline.zod.ts` | 4+4+4+7+3 | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 13)** — the `(p)` resolved NEGATIVE and the row is kept only so the arithmetic stays complete. Three independent measurements on 2026-08-03: (1) nothing under `packages/spec/src` imports these modules except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for them; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650 deletion check — reaches none of the 22 sites, while its three positive controls (`PageSchema`, batch 11's `WebhookSchema`, batch 10's `StateMachineSchema`) all resolve `root-graph` in the same run; (3) no `.parse()` / `.safeParse()` on any of them exists in `objectstack`, `objectui` or the example apps outside their own unit tests — objectui re-exports the inferred TYPES only and says so (#2561). `.strict()` is a property of a PARSE and there is no parse, so closing them would enforce nothing and would spend a v17 breaking change to leave *"a precisely validated dead slot — the more convincing lie"* (the #4583 row below). The live question is ADR-0049 enforce-or-remove, filed as **#4988**; each file's header comment and its test file carry the same verdict (the batch 12 three-places standard). **Do not reschedule these as strictness work** — that is what the `(p)` was for, and it has been answered | @@ -673,15 +695,14 @@ it the same way: the decision is also written beside the schema and pinned in a test (`flow.test.ts`, `etl.test.ts`), because a row in a table is not where the next person to open that file will look. -#### `ui/` — 119 strip of 198 +#### `ui/` — 100 strip of 198 | File | Strip | Sites | Class | Batch | |---|---|---|---|---| | `component.zod.ts` | 29 | 29 | authorable (p) | Largest single block left. SDUI component props — **verify the React-prop open slots first**; `check:react-declaration-parity` compares two DECLARATIONS and cannot tell you which props a renderer reads | | `view.zod.ts` | 20 | 50 | mixed | Top level and the form/page shapes are closed (ADR-0089 + the final batch). Remaining are sub-blocks; `UserFiltersSchema` is the one the last batch **named as deliberately left open** — it strips page-only keys with a test pinning that, so closing it needs its own verification | -| `theme.zod.ts` | 14 | 14 | authorable (p) | Authored themes; `Typography` / `Animation` sub-blocks dominate | | `widget.zod.ts` | 9 | 9 | authorable (p) | Widget manifest + lifecycle/event/property/source | -| `chart.zod.ts` | 7 | 7 | authorable (p) | Axis / series / annotation / interaction / config / groupBy / aggregate | +| `chart.zod.ts` | 2 | 7 | **no gate** | `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two are NOT unfinished work — their carrier (``) is live but nothing parses them, so closing them would gate nothing (#4583). Blocked on wiring the react-page publish gate to parse the schema instead of re-deriving it — see the triage row | | `touch.zod.ts` | 7 | 7 | **no door** | ⛔ **not strictness work** — measured unreachable from every authoring root (#4001 批 13); ADR-0049 triage is #4988. See the triage row above | | `i18n.zod.ts` | 6 | 6 | authorable (p) | ⚠️ the triage row warns label shapes are wide-open records **by design** — verify before closing | | `animation.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch` — #4988 | @@ -708,15 +729,17 @@ line, which conflicts with nothing, merged clean and wrong on both sides. merged alongside #4876, which edits this same section, so the conflict was expected and both sides' row edits were kept before recomputing. -**Authorable strip in `ui/`: 97 of 119** (was 123 of 123). The subtotal moved by -26 while only 4 sites were CLOSED, and the 22-site gap is the batch's actual -finding rather than a rounding of it: `touch` (7), `animation` (4), `dnd` (4), +**Authorable strip in `ui/`: 76 of 100** — recomputed from the surviving +rows on every merge (29 + 20 + 9 + 2 + 7 + 6 + 4 + 4 + 4 + 3 + 3 + 2 + 2 + 2 + 1 + 1 + 1 = 100), never decremented by a batch's own count. 批 13 moved it +to 97 of 119 (from 123 of 123); 批 15 then closed all 14 `theme.zod.ts` sites +and 5 of `chart.zod.ts`'s 7. Both batches edited this same section and the +conflict was expected: every row from both sides was kept before recomputing, +and `check:strictness-ledger`'s header arithmetic is what settled the result. +The 22-site gap 批 13 opened is unchanged and is described below; 批 15 adds a +2-site gap of its own, in a DIFFERENT class. Of what remains, `app.zod.ts`'s +single site is still held pending the finding-16 `.extend()` check rather than +counted as ready. `keyboard` (4) and `offline` (3) were reclassified out of `authorable` because -their `(p)` resolved negative — **no metadata document is ever parsed against -them**, so there is no author for strictness to protect. The evidence is in their -triage row above; the live question is ADR-0049 enforce-or-remove (#4988), not -this ratchet. Of the 97 that remain, `app.zod.ts`'s single site is still held -pending the finding-16 `.extend()` check rather than counted as ready. The reclassification is worth reading as a method note, because batch 13 is the first time the `(p)` came back negative on a whole run of files rather than on @@ -729,6 +752,24 @@ output of a verification step is whatever it measures, including "this was never ratchet work". A batch that had skipped the check would have shipped 22 strict schemas, a breaking changeset, and ~58 curated alias entries that no parse would ever consult. +批 13's gap, restated so the recompute above does not bury it: `touch` (7), +`animation` (4), `dnd` (4), `keyboard` (4) and `offline` (3) were reclassified +out of `authorable` because their `(p)` resolved negative — **no metadata +document is ever parsed against them**, so there is no author for strictness to +protect. The evidence is in their triage row; the live question is ADR-0049 +enforce-or-remove (#4988), not this ratchet. + +批 15's 2 are `chart.zod.ts`'s remaining pair, and they are NOT the same verdict +wearing a different number. 批 13 established **`no door`** — no carrier key +exists, so no author can reach the shape. 批 15 needed a second one: **`no +gate`** — the carrier key exists and is LIVE (``, +published in the react-blocks contract and read by objectui's renderer to run +the query), but no `.parse()` stands between the author and the runtime. The +distinction is not pedantry, because the two imply OPPOSITE follow-ups: a +`no door` shape is a candidate for ADR-0049 REMOVAL, while a `no gate` shape is +a candidate for WIRING THE GATE — removing it would break a working feature, and +closing it would validate nothing. Collapsing the two would have pointed the next +batch at exactly the wrong action on both. The one `open` site this directory carried is **gone, and not by being closed**: `bulk-action.zod.ts`'s `BulkActionParamSchema.options` was the row that read diff --git a/packages/spec/src/ui/chart.test.ts b/packages/spec/src/ui/chart.test.ts index 9cd33677a0..9f8ff71f89 100644 --- a/packages/spec/src/ui/chart.test.ts +++ b/packages/spec/src/ui/chart.test.ts @@ -2,9 +2,121 @@ import { describe, it, expect } from 'vitest'; import { ChartTypeSchema, ChartConfigSchema, + ChartAxisSchema, + ChartSeriesSchema, + ChartAnnotationSchema, + ChartInteractionSchema, + ChartAggregateSchema, + ChartGroupBySchema, type ChartType, type ChartConfig, } from './chart.zod'; +import { ReportChartSchema } from './report.zod'; +import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '../kernel/metadata-type-schemas'; +import { ObjectStackSchema } from '../stack.zod'; + +/** + * Reachability of a schema from every metadata-type root plus `defineStack`'s + * `ObjectStackSchema`, by BFS over this build's in-memory Zod graph. + * + * Mirrors `computeSurfaceReachability` in `scripts/build-schemas.ts` (the + * #4650 closure). `derived-clone` counts as reachable: `.extend()` / `.strip()` + * produce a clone that shares no identity with the original but DOES share its + * per-property schema instances, which is exactly how `ChartConfigSchema` is + * reached through `ReportChartSchema`. + * + * ⚠️ Identity-keyed, so it must see the REAL schema instances. `lazySchema` + * returns a Proxy unless `OS_EAGER_SCHEMAS=1`, and comparing a Proxy against + * the instance stored in the graph reports every root as unreachable — which + * is precisely how the first run of this measurement produced three failing + * positive controls. Hence the resolve step. + */ +function reachableFromMetadataRoots(): (schema: unknown) => boolean { + // Identity is the schema's `_zod.def` OBJECT, never the schema binding. + // `lazySchema` hands out a Proxy unless `OS_EAGER_SCHEMAS=1`, and the graph + // holds the real instances — so comparing bindings reports every root as + // unreachable. That is not hypothetical: it is what the first run of this + // assertion did, and the positive controls above are the only reason it was + // caught instead of shipping as a green that proved nothing. `def` survives + // the Proxy (the `_zod` facade delegates to the real internals), so it is + // the one stable key for both identities. + const defOf = (s: unknown): unknown => (s as { _zod?: { def?: unknown } })?._zod?.def; + + const childrenOf = (node: unknown): unknown[] => { + const out: unknown[] = []; + const seen = new Set(); + const walk = (v: unknown): void => { + // `typeof v !== 'object'` alone is WRONG here and silently halves the + // graph: `lazySchema`'s Proxy target is `function lazyZod() {}`, so every + // lazy schema is `typeof 'function'`. Skipping those made the BFS stop at + // the first lazy node and report the whole chart family unreachable — + // caught only because the positive controls above went red. + // (`build-schemas.ts`'s equivalent walk never hit this: it runs under + // `OS_EAGER_SCHEMAS=1`, where there are no proxies at all.) + if (v === null || (typeof v !== 'object' && typeof v !== 'function') || seen.has(v)) return; + seen.add(v); + if (defOf(v)) { out.push(v); return; } + if (Array.isArray(v)) { for (const x of v) walk(x); return; } + if (v instanceof Map) { for (const x of v.values()) walk(x); return; } + for (const x of Object.values(v as Record)) walk(x); + }; + walk(defOf(node)); + return out; + }; + const shapeOf = (node: unknown): Record | null => { + const def = defOf(node) as { type?: string; shape?: Record } | undefined; + return def?.type === 'object' && def.shape ? def.shape : null; + }; + + const roots: unknown[] = []; + for (const type of listMetadataTypeSchemaTypes()) { + const s = getMetadataTypeSchema(type); + if (s) roots.push(s); + } + roots.push(ObjectStackSchema); + + const visitedDefs = new Set(); + const visitedNodes: unknown[] = []; + const queue = [...roots]; + while (queue.length > 0) { + const node = queue.pop(); + const def = defOf(node); + if (!def || visitedDefs.has(def)) continue; + visitedDefs.add(def); + visitedNodes.push(node); + for (const child of childrenOf(node)) queue.push(child); + } + + // (propName → prop def) pairs of every visited object node — the bridge that + // recognises a derived clone (`.extend()` / `.strip()` share no identity with + // the original but DO share its per-property schema instances, which is how + // `ChartConfigSchema` is reached through `ReportChartSchema`). + const bridged = new Map>(); + for (const node of visitedNodes) { + const shape = shapeOf(node); + if (!shape) continue; + for (const [name, prop] of Object.entries(shape)) { + const d = defOf(prop); + if (!d) continue; + let names = bridged.get(d); + if (!names) { names = new Set(); bridged.set(d, names); } + names.add(name); + } + } + + return (schema: unknown): boolean => { + const def = defOf(schema); + if (!def) return false; + if (visitedDefs.has(def)) return true; + const shape = shapeOf(schema); + if (!shape) return false; + for (const [name, prop] of Object.entries(shape)) { + const d = defOf(prop); + if (d && bridged.get(d)?.has(name)) return true; + } + return false; + }; +} describe('ChartTypeSchema', () => { it('should accept all comparison chart types', () => { @@ -253,3 +365,179 @@ describe('Chart ARIA Integration', () => { })).not.toThrow(); }); }); + +// ============================================================================ +// #4001 批 15 — the SPLIT verdict, pinned. +// +// Five sites are closed and two are deliberately open, on a door measurement. +// Both halves are pinned here, because both can regress and they regress in +// OPPOSITE directions: the closed half by someone reopening it, the open half +// by a later sweep "finishing the file" with a `strictObject` that gates +// nothing (#4583). The same verdict is recorded in `chart.zod.ts`'s header and +// in the ui/ row of `docs/audits/2026-07-unknown-key-strictness-ledger.md`. +// ============================================================================ +describe('#4001 批 15 — the five closed chart sites', () => { + const reject = (schema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: unknown } } }, value: unknown): string => { + const r = schema.safeParse(value); + expect(r.success, 'expected this to be REJECTED').toBe(false); + return JSON.stringify(r.error?.issues ?? []); + }; + + it('the controls parse — these tests fail closed, not by rejecting everything', () => { + expect(ChartConfigSchema.safeParse({ type: 'bar' }).success).toBe(true); + expect(ChartAxisSchema.safeParse({ field: 'status' }).success).toBe(true); + expect(ChartSeriesSchema.safeParse({ name: 'total' }).success).toBe(true); + expect(ChartAnnotationSchema.safeParse({ value: 10 }).success).toBe(true); + expect(ChartInteractionSchema.safeParse({}).success).toBe(true); + }); + + it.each([ + ['ChartConfigSchema', () => ChartConfigSchema, { type: 'bar', notAChartKey: 1 }], + ['ChartAxisSchema', () => ChartAxisSchema, { field: 'f', notAnAxisKey: 1 }], + ['ChartSeriesSchema', () => ChartSeriesSchema, { name: 'n', notASeriesKey: 1 }], + ['ChartAnnotationSchema', () => ChartAnnotationSchema, { value: 1, notAnAnnotationKey: 1 }], + ['ChartInteractionSchema', () => ChartInteractionSchema, { notAnInteractionKey: 1 }], + ])('%s rejects an undeclared key', (_name, get, value) => { + expect(reject(get() as never, value)).toContain(Object.keys(value).slice(-1)[0]); + }); + + // ---- the door: a strict schema nobody parses gates nothing ----------- + it('the door is the dashboard metadata root, not just the exported schema', () => { + const dash = getMetadataTypeSchema('dashboard'); + expect(dash, 'dashboard must resolve a schema — this is the parse door').toBeTruthy(); + const widget = (chartConfig: Record) => ({ + name: 'dash_one', label: 'D', + widgets: [{ id: 'w1', type: 'bar', title: 'W', dataset: 'ds', dimensions: ['a'], values: ['b'], chartConfig }], + }); + expect(dash!.safeParse(widget({ type: 'bar' })).success, 'control').toBe(true); + const r = dash!.safeParse(widget({ type: 'bar', chartType: 'bar' })); + expect(r.success).toBe(false); + expect(JSON.stringify(r.error?.issues)).toContain('widgets'); + }); + + it('strictness RIDES `.extend()` onto ReportChartSchema — the webhook/view trap, here on purpose', () => { + // `.extend()` inherits `.strict()` AND the error map. `ReportChartSchema` + // narrows xAxis/yAxis to dataset names and adds no key of its own, so the + // inherited key set is exactly right — but that has to be asserted, not + // assumed, because the same mechanic is what made finding 16 a finding. + expect(ReportChartSchema.safeParse({ type: 'bar', xAxis: 'dim', yAxis: 'measure' }).success, 'control').toBe(true); + const msg = reject(ReportChartSchema as never, { type: 'bar', xAxis: 'd', yAxis: 'm', chartType: 'bar' }); + expect(msg).toContain('chartType'); + expect(msg, 'the base error map rides too, so the report surface gets the rename').toContain('`chartType` → `type`'); + }); + + // ---- curation, each entry measured against a named sibling ---------- + it('crosses the axis/series vocabulary in BOTH directions', () => { + // Same file, sixty lines apart: the axis binds `field`/`title`, the series + // binds `name`/`label`. Neither is a typo for the other. + expect(reject(ChartAxisSchema as never, { field: 'f', name: 'status' })).toContain('`name` → `field`'); + expect(reject(ChartSeriesSchema as never, { name: 'n', field: 'total' })).toContain('`field` → `name`'); + expect(reject(ChartAxisSchema as never, { field: 'f', label: 'x' })).toContain('`label` → `title`'); + expect(reject(ChartSeriesSchema as never, { name: 'n', title: 'x' })).toContain('`title` → `label`'); + }); + + it('renames Recharts prop names onto the spec keys — the renderer an author debugs against', () => { + expect(reject(ChartAxisSchema as never, { field: 'f', dataKey: 'x' })).toContain('`dataKey` → `field`'); + expect(reject(ChartSeriesSchema as never, { name: 'n', stackId: 'g' })).toContain('`stackId` → `stack`'); + expect(reject(ChartSeriesSchema as never, { name: 'n', strokeDasharray: '4 4' })).toContain('`strokeDasharray` → `dashArray`'); + }); + + it('renames a region\'s range vocabulary onto value/endValue', () => { + // Getting `endValue` wrong collapses the region to a line at `value`. + const msg = reject(ChartAnnotationSchema as never, { value: 1, from: 1, to: 2 }); + expect(msg).toContain('`from` → `value`'); + expect(msg).toContain('`to` → `endValue`'); + }); + + it('carries the #3752 tombstones, one distinct sentence each (批 10)', () => { + const zoom = reject(ChartInteractionSchema as never, { zoom: true }); + expect(zoom).toContain('#3752'); + expect(zoom).toContain('brush: true'); + const click = reject(ChartInteractionSchema as never, { clickAction: 'x' }); + expect(click).toContain('#3752'); + expect(click).toContain('onSegmentClick'); + // Two keys at once ⇒ two DISTINCT bullets, not one string printed twice. + const both = reject(ChartInteractionSchema as never, { zoom: true, clickAction: 'x' }); + expect(both.split('• ').length - 1).toBe(2); + }); + + it('never prescribes `drillDown` — it is not a key this protocol declares', () => { + // The #3752 migration prose said "Migration: `drillDown`" until 批 15. + // There is no `drillDown` anywhere in the spec; it is an untyped + // `(schema as any).drillDown` read inside objectui's ObjectChart. Promoting + // that sentence into a rejection message would have handed an author the + // platform's authority for a key the same gate then rejects — the ledger's + // finding 7, a third time. Filed separately, corrected here. + const msg = reject(ChartInteractionSchema as never, { clickAction: 'x' }); + expect(msg).not.toContain('drillDown'); + expect(msg, 'it must name something that exists instead').toContain('drilldown'); + }); + + it('points wrong-layer keys at the layer that owns them, naming a real key', () => { + const width = reject(ChartConfigSchema as never, { type: 'bar', width: 400 }); + expect(width).toContain('layout.w'); + const stacked = reject(ChartConfigSchema as never, { type: 'bar', stacked: true }); + expect(stacked).toContain('series[].stack'); + const dataset = reject(ChartConfigSchema as never, { type: 'bar', dataset: 'ds' }); + expect(dataset).toContain('ADR-0021'); + }); + + it('every alias target it suggests is a key the schema accepts', () => { + // The `triggerPhrases` lesson (`shared/strict-object.ts`): never point an + // author at a key that rejects them a second time. + const shapeOf = (s: unknown) => Object.keys((s as { _zod: { def: { shape: Record } } })._zod.def.shape); + expect(shapeOf(ChartConfigSchema)).toEqual(expect.arrayContaining(['type', 'colors', 'showLegend', 'showDataLabels', 'annotations', 'interaction', 'subtitle', 'aria', 'height', 'xAxis', 'yAxis'])); + expect(shapeOf(ChartAxisSchema)).toEqual(expect.arrayContaining(['field', 'title', 'showGridLines', 'stepSize', 'logarithmic', 'min', 'max', 'format', 'position'])); + expect(shapeOf(ChartSeriesSchema)).toEqual(expect.arrayContaining(['name', 'label', 'type', 'stack', 'yAxis', 'variant', 'dashArray', 'opacity', 'color'])); + expect(shapeOf(ChartAnnotationSchema)).toEqual(expect.arrayContaining(['value', 'endValue', 'label', 'style', 'color', 'axis', 'type'])); + expect(shapeOf(ChartInteractionSchema)).toEqual(expect.arrayContaining(['tooltips', 'brush'])); + }); +}); + +describe('#4001 批 15 — the two chart sites deliberately LEFT OPEN (measured, not skipped)', () => { + // `ChartAggregateSchema` and `ChartGroupBySchema`'s object arm have a LIVE + // carrier — the react tier's `` prop, which + // objectui's ObjectChart reads to run the query — but no PARSE: they are + // unreachable from all 24 metadata-type roots and from `ObjectStackSchema`, + // and the react-page publish lint re-derives their rules by hand instead of + // parsing them. `.strict()` is a property of a parse, so closing them would + // gate nothing while making the real gap harder to see. + it('ChartAggregateSchema still STRIPS an undeclared key — deliberate', () => { + const parsed = ChartAggregateSchema.parse({ function: 'count', groupBy: 'status', groupby: 'status' }) as Record; + expect(parsed.groupby, 'if this is no longer stripped, re-read the header in chart.zod.ts').toBeUndefined(); + expect(parsed.groupBy).toBe('status'); + }); + + it('ChartGroupBySchema\'s object arm still STRIPS an undeclared key — deliberate', () => { + const parsed = ChartGroupBySchema.parse({ field: 'created_at', dateGranularty: 'month' }) as Record; + expect(parsed.dateGranularty).toBeUndefined(); + expect(parsed.field).toBe('created_at'); + }); + + it('neither is REACHABLE from the metadata-type roots — the measurement, re-run every CI', () => { + // The standing half of the door measurement, so the verdict cannot go + // stale in silence: the day someone gives `aggregate` a metadata carrier + // key this goes red and points them back at the header comment. + // + // It is a real BFS over this build's in-memory Zod graph from every + // metadata-type root plus `defineStack`'s `ObjectStackSchema` — the same + // closure `build-schemas.ts` uses for the #4650 deletion check — NOT a + // string search over a serialized schema, which cannot see a shape at all + // and would pass no matter what (the vacuous-green this campaign keeps + // paying for). The positive controls below are what prove that. + const reachable = reachableFromMetadataRoots(); + + // Positive controls, in the SAME run: the five closed sites of this file + // resolve. An instrument that says "unreachable" to everything is broken, + // not informative. + expect(reachable(ChartConfigSchema), 'positive control').toBe(true); + expect(reachable(ChartAxisSchema), 'positive control').toBe(true); + expect(reachable(ChartSeriesSchema), 'positive control').toBe(true); + expect(reachable(ChartAnnotationSchema), 'positive control').toBe(true); + expect(reachable(ChartInteractionSchema), 'positive control').toBe(true); + + // The measurement itself. + expect(reachable(ChartAggregateSchema), 'a carrier key would make this reachable — re-read chart.zod.ts').toBe(false); + expect(reachable(ChartGroupBySchema), 'a carrier key would make this reachable — re-read chart.zod.ts').toBe(false); + }); +}); diff --git a/packages/spec/src/ui/chart.zod.ts b/packages/spec/src/ui/chart.zod.ts index 41fb1f2fc3..dd6e85cb3b 100644 --- a/packages/spec/src/ui/chart.zod.ts +++ b/packages/spec/src/ui/chart.zod.ts @@ -2,6 +2,49 @@ import { z } from 'zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; +import { strictObject } from '../shared/strict-object'; + +// --------------------------------------------------------------------------- +// UNKNOWN-KEY POSTURE (#4001 批 15, ADR-0078) — this file is SPLIT, on a +// measurement, and the split is the point. Five of its seven object sites are +// closed; two are deliberately left open with the reason recorded, because +// closing them would gate nothing. +// +// ⚠️ Nothing above this block may be a JSDoc block: `build-docs.ts`'s +// `getFileDescription()` publishes the module's FIRST doc block as the +// reference page's description (#3746 trap 1). Hence `//`. Note you cannot +// safely spell that token out here either — see the longer note in +// `theme.zod.ts`, where quoting it inside a `//` line silently emptied the +// published page description. +// +// CLOSED (real door, three measurements, 2026-08-03): +// `ChartConfigSchema`, `ChartAxisSchema`, `ChartSeriesSchema`, +// `ChartAnnotationSchema`, `ChartInteractionSchema`. +// +// 1. CARRIER KEY — `dashboard.zod.ts` declares `DashboardWidget.chartConfig` +// and `report.zod.ts` declares `ReportChartSchema` (a +// `ChartConfigSchema.extend(...)`). `dashboard` and `report` are both +// registered metadata types. +// 2. GRAPH — a BFS from all 24 metadata-type roots plus `ObjectStackSchema` +// (the `build-schemas.ts` / #4650 closure) reaches all five as +// `root-graph`. Controls in the same run: `PageSchema` / +// `DashboardSchema` / `ReportSchema` / `WebhookSchema` / +// `StateMachineSchema` resolve; 批 13's measured no-door shapes +// (`TouchTargetConfigSchema`, `GestureConfigSchema`) do not. +// 3. PARSE — `getMetadataTypeSchema('dashboard' | 'report')` is what +// `MetadataManager.validate`, `GET /api/v1/meta` and the Studio form all +// go through, so a chart key is judged on the stored-metadata path. +// +// ⚠️ Strictness RIDES `.extend()` onto `ReportChartSchema` (the #4001 trap +// that bit `webhook` and `view`). That is intended and pinned in +// `chart.test.ts` — the report chart narrows `xAxis`/`yAxis` to dataset +// dimension/measure NAMES and adds no key of its own, so the inherited key +// set is exactly right and no `extraKeys` entry is needed. +// +// LEFT OPEN, DELIBERATELY (`ChartAggregateSchema`, `ChartGroupBySchema`) — +// see the block above those two schemas. Short version: their carrier is the +// REACT tier's `` prop, and nothing parses them. +// --------------------------------------------------------------------------- /** * Unified Chart Type Taxonomy @@ -92,37 +135,86 @@ export type ChartType = z.infer; * Chart Axis Schema * Definition for X and Y axes */ -export const ChartAxisSchema = lazySchema(() => z.object({ - /** Data field to map to this axis */ - field: z.string().describe('Data field key'), - - /** Axis title */ - title: I18nLabelSchema.optional().describe('Axis display title'), +export const ChartAxisSchema = lazySchema(() => strictObject( + { + surface: 'this chart axis', + history: + 'Until #4001 an undeclared axis key was dropped at parse and the axis rendered with the default scale and ticks — a chart that looked configured and was not.', + // MEASURED same-file inconsistency, both directions: this schema names its + // bound column `field` and its caption `title`, while `ChartSeriesSchema` + // twenty lines below names them `name` and `label`. An author who has just + // written a series writes the series spelling here. `dataKey` / `stackId` / + // `yAxisId` are Recharts' own prop names, and Recharts is the renderer + // behind these shapes — so they are what an author debugging in the browser + // reads off the component and writes back into the metadata. + aliases: { + name: 'field', key: 'field', dataKey: 'field', column: 'field', value: 'field', + label: 'title', text: 'title', caption: 'title', + grid: 'showGridLines', showGrid: 'showGridLines', gridLines: 'showGridLines', + step: 'stepSize', tickStep: 'stepSize', interval: 'stepSize', + log: 'logarithmic', logScale: 'logarithmic', scale: 'logarithmic', + minimum: 'min', maximum: 'max', + formatter: 'format', numberFormat: 'format', + side: 'position', align: 'position', + }, + }, + { + /** Data field to map to this axis */ + field: z.string().describe('Data field key'), - /** Value formatting (d3-format or similar) */ - format: z.string().optional().describe('Value format string (e.g., "$0,0.00")'), - - /** Axis scale settings */ - min: z.number().optional().describe('Minimum value'), - max: z.number().optional().describe('Maximum value'), - stepSize: z.number().optional().describe('Step size for ticks'), - - /** Appearance */ - showGridLines: z.boolean().default(true), - position: z.enum(['left', 'right', 'top', 'bottom']).optional().describe('Axis position'), - - /** Logarithmic scale */ - logarithmic: z.boolean().default(false), -})); + /** Axis title */ + title: I18nLabelSchema.optional().describe('Axis display title'), + + /** Value formatting (d3-format or similar) */ + format: z.string().optional().describe('Value format string (e.g., "$0,0.00")'), + + /** Axis scale settings */ + min: z.number().optional().describe('Minimum value'), + max: z.number().optional().describe('Maximum value'), + stepSize: z.number().optional().describe('Step size for ticks'), + + /** Appearance */ + showGridLines: z.boolean().default(true), + position: z.enum(['left', 'right', 'top', 'bottom']).optional().describe('Axis position'), + + /** Logarithmic scale */ + logarithmic: z.boolean().default(false), + }, +)); /** * Chart Series Schema * Defines a single data series in the chart */ -export const ChartSeriesSchema = lazySchema(() => z.object({ +export const ChartSeriesSchema = lazySchema(() => strictObject( + { + surface: 'this chart series', + history: + 'Until #4001 an undeclared series key was dropped at parse — the series still drew, in the palette colour, on the left axis, unstacked, which is precisely the configuration the author was overriding.', + // The mirror of `ChartAxisSchema`'s entries: `field`/`title` are the axis + // spellings of this schema's `name`/`label`. `stackId` / `yAxisId` / + // `strokeDasharray` are Recharts' prop names — and `dashArray`'s own + // `.describe()` says "SVG stroke-dasharray override", so the file itself + // teaches the spelling it then refuses. `chartType` is named in + // `react-blocks.ts` as the INTERNAL spelling that is deliberately not part + // of the author contract, which makes it a wrong-layer near-miss rather + // than a typo. + aliases: { + field: 'name', key: 'name', dataKey: 'name', column: 'name', + title: 'label', text: 'label', caption: 'label', + chartType: 'type', seriesType: 'type', kind: 'type', + stackId: 'stack', stackGroup: 'stack', group: 'stack', + axis: 'yAxis', yAxisId: 'yAxis', side: 'yAxis', + role: 'variant', + strokeDasharray: 'dashArray', strokeDashArray: 'dashArray', dashed: 'dashArray', + alpha: 'opacity', fillOpacity: 'opacity', strokeOpacity: 'opacity', + colour: 'color', fill: 'color', stroke: 'color', + }, + }, + { /** Field name for values */ name: z.string().describe('Field name or series identifier'), - + /** Display label */ label: I18nLabelSchema.optional().describe('Series display label'), @@ -155,21 +247,44 @@ export const ChartSeriesSchema = lazySchema(() => z.object({ /** Override series opacity (0–1). */ opacity: z.number().min(0).max(1).optional().describe('Series opacity override'), -})); + }, +)); /** * Chart Annotation Schema * Static lines or regions to highlight data */ -export const ChartAnnotationSchema = lazySchema(() => z.object({ - type: z.enum(['line', 'region']).default('line'), - axis: z.enum(['x', 'y']).default('y'), - value: z.union([z.number(), z.string()]).describe('Start value'), - endValue: z.union([z.number(), z.string()]).optional().describe('End value for regions'), - color: z.string().optional(), - label: I18nLabelSchema.optional(), - style: z.enum(['solid', 'dashed', 'dotted']).default('dashed'), -})); +export const ChartAnnotationSchema = lazySchema(() => strictObject( + { + surface: 'this chart annotation', + history: + 'Until #4001 an undeclared annotation key was dropped at parse and the reference line drew at the wrong place, in the default style, or not at all — while the annotation itself reported valid.', + // A region is authored as a RANGE, and every neighbouring range vocabulary + // in the protocol spells its ends `from`/`to` or `start`/`end` + // (`data/filter.zod.ts` operators, the dashboard date-range filter). This + // schema spells them `value`/`endValue`, so the mismatch is a different + // word for the same intent, not a slip. Getting `endValue` wrong is the + // expensive one: the region collapses to a line at `value`. + aliases: { + from: 'value', start: 'value', at: 'value', threshold: 'value', y: 'value', x: 'value', + to: 'endValue', end: 'endValue', until: 'endValue', valueEnd: 'endValue', + title: 'label', text: 'label', caption: 'label', + lineStyle: 'style', strokeStyle: 'style', dash: 'style', + colour: 'color', stroke: 'color', fill: 'color', + orientation: 'axis', direction: 'axis', + kind: 'type', shape: 'type', + }, + }, + { + type: z.enum(['line', 'region']).default('line'), + axis: z.enum(['x', 'y']).default('y'), + value: z.union([z.number(), z.string()]).describe('Start value'), + endValue: z.union([z.number(), z.string()]).optional().describe('End value for regions'), + color: z.string().optional(), + label: I18nLabelSchema.optional(), + style: z.enum(['solid', 'dashed', 'dotted']).default('dashed'), + }, +)); /** * Chart Interaction Schema @@ -181,25 +296,103 @@ export const ChartAnnotationSchema = lazySchema(() => z.object({ * * * `zoom` — no renderer had a zoom primitive behind it, and `brush` already * narrows a range. Migration: `brush: true`. - * * `clickAction` — a chart segment click already has two owners that DO - * work: `drillDown` (opens the filtered records, which is what a segment - * click is almost always for) and, in the react tier, the host's own - * `onSegmentClick`. A third, silent one only invited authors to wire a - * click that never fired. Migration: `drillDown`, or handle it in React. + * * `clickAction` — a chart segment click already has owners that DO work, + * so a third, silent one only invited authors to wire a click that never + * fired. Migration: in the react tier, the host's own `onSegmentClick`; + * on a report, `ReportSchema.drilldown` (ADR-0021 D2, on by default); + * on a dashboard widget, the renderer's segment drill under the widget's + * `options` bag, which is `passthrough` precisely so renderer-only + * capabilities have a declared home. */ -export const ChartInteractionSchema = lazySchema(() => z.object({ - tooltips: z.boolean().default(true).describe('Show the hover tooltip'), - brush: z.boolean().default(false).describe('Show the range selector under the plot'), -})); +// ⚠️ Kept OUT of the doc comment above on purpose — `build-docs.ts` publishes +// that block to the public reference page, and the following is a note to the +// next maintainer, not protocol documentation (the #3746 trap, in its subtler +// form: not the file's FIRST block, but internal prose inside a published one). +// +// That `clickAction` paragraph read "Migration: `drillDown`" from #3752 until +// #4001 批 15, and **`drillDown` is not a key this protocol declares +// anywhere** — it is an untyped `(schema as any).drillDown` read inside +// objectui's `ObjectChart`. Promoting that sentence into the strict rejection +// message below would have handed an author the platform's authority for a key +// the very same gate then rejects: the ledger's finding 7, third occurrence. +// The underlying gap — a live renderer capability with no spec declaration — +// is filed, not fixed here. +export const ChartInteractionSchema = lazySchema(() => strictObject( + { + surface: 'this chart interaction block', + history: + 'Until #4001 an undeclared interaction key was dropped at parse — including the two #3752 removed, so an author who kept writing `zoom` after it was retired got exactly the same silence as before the removal.', + aliases: { tooltip: 'tooltips', hover: 'tooltips', showTooltip: 'tooltips', rangeSelector: 'brush', slider: 'brush' }, + // The prescriptions #3752 wrote in this file's own doc comment, now + // delivered at the rejection instead of only to whoever reads the source. + // Two DISTINCT strings on purpose: `guidance` emits one bullet per key + // verbatim, so a shared sentence prints the same paragraph twice (批 10's + // `join`/`joinGateway` lesson). + guidance: { + zoom: + '`zoom` was removed in #3752 — no renderer ever had a zoom primitive behind it, and `brush` already narrows the visible range. Write `brush: true`.', + clickAction: + '`clickAction` was removed in #3752 — a segment click already has owners that work: the host\'s own `onSegmentClick` in the react tier, `drilldown` on a report (`ReportSchema.drilldown`, ADR-0021 D2, already on by default), and the renderer\'s segment drill under a dashboard widget\'s `options` bag. Use one of those.', + }, + }, + { + tooltips: z.boolean().default(true).describe('Show the hover tooltip'), + brush: z.boolean().default(false).describe('Show the range selector under the plot'), + }, +)); /** * Chart Configuration Base * Common configuration for all chart types */ -export const ChartConfigSchema = lazySchema(() => z.object({ +export const ChartConfigSchema = lazySchema(() => strictObject( + { + surface: 'this chart config', + history: + 'Until #4001 an undeclared chart key was dropped at parse and the chart rendered with the defaults it was written to override — the failure #4001 exists for, on a shape reachable from both the dashboard and report metadata roots.', + aliases: { + // `chartType` is named in `react-blocks.ts` as the INTERNAL spelling that + // is deliberately NOT part of the author contract, so an author who saw + // it in a flattened SDUI envelope writes it back here. + chartType: 'type', kind: 'type', visualization: 'type', + palette: 'colors', colorScheme: 'colors', colours: 'colors', + legend: 'showLegend', showLegends: 'showLegend', + dataLabels: 'showDataLabels', showLabels: 'showDataLabels', labels: 'showDataLabels', + // Same-file singular/plural split: `annotations` is plural and + // `interaction` is singular, three lines apart. + annotation: 'annotations', referenceLines: 'annotations', markers: 'annotations', + interactions: 'interaction', interactivity: 'interaction', + caption: 'subtitle', subTitle: 'subtitle', + accessibility: 'aria', ariaProps: 'aria', + plotHeight: 'height', + xAxes: 'xAxis', yAxes: 'yAxis', + }, + // Wrong-layer pointers. Each names the key the contract LANDS ON, not the + // one the author typed (#4410's lesson), and none of them promises a slot + // that does not exist — the check the `drillDown` correction above forced. + guidance: { + width: + '`width` is not a chart-level key: a chart fills its container, and the container\'s width is owned by the dashboard widget\'s `layout.w` (or the report block). Only `height` is chart-level.', + aggregate: + '`aggregate` is not part of the chart config. On a DASHBOARD widget the pre-ADR-0021 inline analytics shape was removed — bind a `dataset` and select `dimensions` + `values`. On a react `` it is a sibling PROP next to `objectName`, not a key inside the chart config.', + objectName: + '`objectName` is not part of the chart config — the data binding lives one level up: `dataset` on a dashboard widget (ADR-0021), or the `objectName` PROP on a react ``.', + dataset: + '`dataset` is not part of the chart config — it is the dashboard widget\'s own key (ADR-0021), a sibling of `chartConfig`, not a key inside it.', + data: + '`data` is not part of the chart config. Inline/precomputed rows are a react-tier `` prop; a metadata chart gets its rows from the widget\'s `dataset` binding.', + stacked: + '`stacked` is not a chart-level key, because stacking is not a chart family: it is a property of the SERIES. Give the series that should stack a shared `series[].stack` group id; series without one are grouped.', + axes: + '`axes` is not a key — the two axes are declared separately and asymmetrically: `xAxis` is a single axis, `yAxis` is an ARRAY (that is how dual-axis and combo charts are configured).', + options: + '`options` is not part of the chart config — renderer-only presentation extras belong in the dashboard widget\'s `options` bag, which is deliberately open for exactly that.', + }, + }, + { /** Chart Type */ type: ChartTypeSchema, - + /** Titles */ title: I18nLabelSchema.optional().describe('Chart title'), subtitle: I18nLabelSchema.optional().describe('Chart subtitle'), @@ -237,7 +430,8 @@ export const ChartConfigSchema = lazySchema(() => z.object({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), -})); + }, +)); /** * Object-bound chart aggregation @@ -252,6 +446,50 @@ export const ChartConfigSchema = lazySchema(() => z.object({ * (`chartAggregateResultKeys`) — read it before binding an axis. */ +// --------------------------------------------------------------------------- +// THE TWO SITES BELOW ARE DELIBERATELY NOT CLOSED (#4001 批 15) — and this is +// a measured verdict, not the batch running out of file. +// +// `ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are the only +// two of this file's seven object sites the 批 15 door measurement could not +// find a PARSE for: +// +// 1. CARRIER KEY — yes, and a LIVE one, which is what makes this different +// from 批 13's no-door files: `aggregate` is a real authorable prop on the +// react tier's `` (ADR-0081), it is +// published in the generated react-blocks contract, and objectui's +// `ObjectChart` reads `schema.aggregate` to run the query. +// 2. GRAPH — but the carrier is a REACT prop, not a metadata key, so neither +// schema is reachable from any of the 24 metadata-type roots or from +// `ObjectStackSchema`. Both come back UNREACHABLE in the same BFS run +// where the five closed schemas above come back `root-graph`. +// 3. PARSE — nothing in `objectstack`, `objectui` or the example apps calls +// `.parse()`/`.safeParse()` on either, outside this file's own unit +// tests. The gate that DOES judge an authored `aggregate` — the react-page +// publish lint (`packages/lint/src/validate-react-page-props.ts`) — +// re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field +// requirement, the result-column naming) and never checks unknown keys. +// In `react-blocks.ts` the prop is published as a hand-written TYPE +// STRING; the Zod schema beside it is not what the contract is generated +// from. +// +// `.strict()` is a property of a PARSE, and there is no parse. Closing these +// two would spend a v17 breaking change to make the file look finished and +// leave behind exactly what the ledger warns about — "a precisely validated +// dead slot is the more convincing lie" (#4583) — except worse than 批 13's +// case, because this vocabulary is not dead: authors write it and the renderer +// runs it. The gap is that no unknown-key gate stands between them, so +// `groupby` / `fn` / `dateGranularty` are silently dropped today and would go +// on being silently dropped after a `strictObject` here. +// +// The contract-first fix is to make the react-page publish gate PARSE this +// schema instead of re-deriving it — a change in `packages/lint`, not a +// strictness change in the spec. Filed rather than smuggled in here. +// +// DO NOT convert these two to `strictObject` before that is decided: it would +// read as load-bearing, and it would make the real gap harder to see. +// --------------------------------------------------------------------------- + /** * Aggregation functions an object-bound chart may ask for. * diff --git a/packages/spec/src/ui/theme.test.ts b/packages/spec/src/ui/theme.test.ts index 69910eee9d..080b534c3b 100644 --- a/packages/spec/src/ui/theme.test.ts +++ b/packages/spec/src/ui/theme.test.ts @@ -7,9 +7,11 @@ import { TypographySchema, BorderRadiusSchema, ShadowSchema, + defineTheme, type Theme, type ColorPalette, } from './theme.zod'; +import { ObjectStackSchema } from '../stack.zod'; describe('ThemeMode', () => { it('should accept valid theme modes', () => { @@ -460,3 +462,157 @@ describe('ThemeModeSchema (canonical name)', () => { expect(ThemeModeSchema).toBe(ThemeMode); }); }); + +// ============================================================================ +// #4001 批 15 — unknown keys are REJECTED, and the rejection is fixable. +// +// These assertions are the third of the three places this batch's verdict is +// recorded (the other two: the header comment in `theme.zod.ts`, and the ui/ +// row in `docs/audits/2026-07-unknown-key-strictness-ledger.md`). They pin +// three separate things, because each can regress on its own: +// +// 1. THE DOOR. Strictness is a property of a PARSE; a strict schema nobody +// parses gates nothing (#4583). So the first block asserts the parse +// exists — `defineTheme()` and `defineStack({ themes })` — rather than +// only asserting the schema is strict. +// 2. EVERY ONE of the 14 object sites is closed. Strictness does NOT recurse +// (the 批 13 finding: a strict shell around strip sub-blocks is the +// silhouette of a closed surface, not a closed surface), so each nested +// block is probed at its own path. +// 3. The CURATION — the aliases and tombstones that make the rejection +// fixable. Each entry here was measured against a named sibling contract, +// not guessed; the comments in `theme.zod.ts` say which. +// ============================================================================ +describe('#4001 批 15 — ThemeSchema unknown-key strictness', () => { + const base = { name: 'brand_theme', label: 'Brand', colors: { primary: '#000000' } }; + const reject = (theme: unknown): string => { + const r = ThemeSchema.safeParse(theme); + expect(r.success, 'expected this theme to be REJECTED').toBe(false); + return JSON.stringify(r.error?.issues ?? []); + }; + + it('the control parses — these tests fail closed, not by rejecting everything', () => { + expect(ThemeSchema.safeParse(base).success).toBe(true); + }); + + // ---- 1. the door ---------------------------------------------------- + it('defineTheme() is a real parse door — it throws on an undeclared key', () => { + expect(() => defineTheme({ ...base, spacing: {} } as never)).toThrow(/Unrecognized key/); + }); + + it('defineStack({ themes }) is the second door — ObjectStackSchema carries ThemeSchema', () => { + const shape = (ObjectStackSchema as unknown as { _zod: { def: { shape: Record } } })._zod.def.shape; + expect(Object.keys(shape), 'the carrier key this file is reachable through').toContain('themes'); + }); + + // ---- 2. all fourteen sites, each at its own path --------------------- + it('rejects an undeclared key at the TOP level', () => { + expect(reject({ ...base, extraKey: 1 })).toContain('extraKey'); + }); + + it('rejects an undeclared key in `colors` — strictness does not stop at the shell', () => { + expect(reject({ ...base, colors: { primary: '#000', notAColor: '#fff' } })).toContain('notAColor'); + }); + + 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]); + }); + + // ---- 3. curation ---------------------------------------------------- + it('renames the shadcn colour vocabulary onto the palette keys it maps to', () => { + // MEASURED against objectui's COLOR_TO_CSS_MAP: `surface` is emitted as + // `--card`, `text` as `--foreground`, `disabled` as `--muted`, `error` as + // `--destructive`. An author reading the rendered CSS writes the shadcn + // name back, which no edit distance can reach. + const msg = reject({ ...base, colors: { primary: '#000', card: '#fff', foreground: '#111', destructive: '#f00' } }); + expect(msg).toContain('`card` → `surface`'); + expect(msg).toContain('`foreground` → `text`'); + 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`'); + }); + + 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`'); + expect(reject({ ...base, radius: {} })).toContain('`radius` → `borderRadius`'); + expect(reject({ ...base, cssVars: {} })).toContain('`cssVars` → `customVars`'); + }); + + it('renames `inset` onto `inner` — CSS\'s word for what this scale calls inner', () => { + expect(reject({ ...base, shadows: { inset: '0 0 1px' } })).toContain('`inset` → `inner`'); + }); + + it('carries a TOMBSTONE, not a rename, for each of the eight props #3494 removed', () => { + for (const key of ['spacing', 'breakpoints', 'logo', 'density', 'wcagContrast', 'rtl', 'touchTarget', 'keyboardNavigation']) { + const msg = reject({ ...base, [key]: 'x' }); + expect(msg, `${key} must carry its own #3494 prescription`).toContain('#3494'); + expect(msg, `${key} must be named in its own prescription`).toContain('`' + key + '` was removed'); + } + }); + + it('gives each retired key its OWN sentence — a shared string prints N times (批 10)', () => { + const msg = reject({ ...base, rtl: true, density: 'compact' }); + expect(msg).toContain('text direction follows the document'); + expect(msg).toContain('compact/comfortable spacing'); + // Two keys, two DISTINCT bullets. + expect(msg.split('• ').length - 1).toBe(2); + }); + + it('never prescribes a vocabulary with no carrier key (the ledger\'s finding 7)', () => { + // `touchTarget` / `keyboardNavigation` look like they should point at + // `ui/touch.zod.ts` / `ui/keyboard.zod.ts`. 批 13 measured both as having + // NO carrier (#4988), so prescribing them would walk an author out of a + // loud rejection into a silent one. + const msg = reject({ ...base, touchTarget: 1, keyboardNavigation: true }); + expect(msg).not.toContain('touch.zod'); + expect(msg).not.toContain('keyboard.zod'); + }); + + it('every suggestion it makes is a key the schema actually accepts', () => { + // 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']; + 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); + }); +}); diff --git a/packages/spec/src/ui/theme.zod.ts b/packages/spec/src/ui/theme.zod.ts index 04a26d2d3b..b2c7a1bfcd 100644 --- a/packages/spec/src/ui/theme.zod.ts +++ b/packages/spec/src/ui/theme.zod.ts @@ -3,145 +3,384 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +// --------------------------------------------------------------------------- +// CLOSED AGAINST UNKNOWN KEYS (#4001 批 15, ADR-0078) — and the door was +// MEASURED before anything was tightened, not assumed. Read this before adding +// a key, and before "finishing" any sibling file by analogy. +// +// ⚠️ Nothing above this block may be a JSDoc block: `build-docs.ts`'s +// `getFileDescription()` publishes the module's FIRST doc block as the +// reference page's description, so a doc-comment header here would replace the +// public page's text with an internal note (#3746 trap 1). Hence `//`. +// +// And do not spell that hazard out with the literal two-star opener, either — +// `getFileDescription()` matches it with a bare regex over the raw source, so +// even INSIDE a `//` line it reads as the file's first doc block. The first +// draft of this very warning quoted the token, matched as an empty description, +// and deleted "Color Palette Schema / Defines brand colors and their variants" +// from the published page. The caution about the trap sprang the trap; caught +// by `check:docs`, which is exactly what it is for. +// +// THE DOOR (three measurements, 2026-08-03, each with controls in the run): +// +// 1. CARRIER KEY — `stack.zod.ts` declares `themes: z.array(ThemeSchema)`, +// and `defineTheme()` (exported from the package root) parses a theme +// directly. Both are doors an author writes by hand. +// 2. GRAPH — a BFS over this build's in-memory Zod graph from all 24 +// metadata-type roots (`listMetadataTypeSchemaTypes`) plus +// `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650 +// deletion check — reaches EVERY schema in this file (`ThemeSchema` and +// its sub-blocks `root-graph`, `ColorPaletteSchema` `derived-clone`). +// Controls in the same run: `PageSchema` / `DashboardSchema` / +// `ReportSchema` / `WebhookSchema` / `StateMachineSchema` all resolve, and +// 批 13's measured no-door shapes (`TouchTargetConfigSchema`, +// `GestureConfigSchema`) come back unreachable. So "reachable" here is a +// fact about the graph, not an instrument that says yes to everything. +// 3. PARSE — `defineStack()` parses `ObjectStackSchema` on every app boot and +// on every `objectstack build`, so a theme key is judged on the path an +// author actually runs. +// +// `theme` is deliberately NOT in `BUILTIN_METADATA_TYPE_SCHEMAS`, so the +// runtime metadata REST door does not validate a stored theme row. The gate +// this file provides is therefore the AUTHORING one (`defineStack` / +// `defineTheme`), and this comment does not claim more than that. +// +// WHY EVERY SUB-BLOCK IS `strict` AND NOT `passthrough` — the #4909 question, +// asked per block rather than per file, because the theme engine reads the two +// halves of this file DIFFERENTLY (`@object-ui/core`'s `ThemeEngine.ts`): +// +// * `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. +// * 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. +// --------------------------------------------------------------------------- + /** * Color Palette Schema * Defines brand colors and their variants. */ import { lazySchema } from '../shared/lazy-schema'; -export const ColorPaletteSchema = lazySchema(() => z.object({ - primary: z.string().describe('Primary brand color (hex, rgb, or hsl)'), - secondary: z.string().optional().describe('Secondary brand color'), - accent: z.string().optional().describe('Accent color for highlights'), - success: z.string().optional().describe('Success state color (default: green)'), - warning: z.string().optional().describe('Warning state color (default: yellow)'), - error: z.string().optional().describe('Error state color (default: red)'), - info: z.string().optional().describe('Info state color (default: blue)'), - - // Neutral colors - background: z.string().optional().describe('Background color'), - surface: z.string().optional().describe('Surface/card background color'), - text: z.string().optional().describe('Primary text color'), - textSecondary: z.string().optional().describe('Secondary text color'), - border: z.string().optional().describe('Border color'), - disabled: z.string().optional().describe('Disabled state color'), - - // Color variants (shades) - primaryLight: z.string().optional().describe('Lighter shade of primary'), - primaryDark: z.string().optional().describe('Darker shade of primary'), - secondaryLight: z.string().optional().describe('Lighter shade of secondary'), - secondaryDark: z.string().optional().describe('Darker shade of secondary'), -})); +import { strictObject } from '../shared/strict-object'; + +// Competing vocabulary, MEASURED not guessed: objectui's `COLOR_TO_CSS_MAP` +// renames every one of these on the way out (`surface` → `--card`, `text` → +// `--foreground`, `textSecondary` → `--muted-foreground`, `disabled` → +// `--muted`, `error` → `--destructive`). An author who read the rendered CSS — +// or who knows shadcn — writes the shadcn name back into the palette, which is +// a different WORD for the same intent rather than a typo, so edit distance +// cannot reach it. `aliasProbe` lowercases and strips `-`/`_`, so each entry +// also covers the `muted-foreground` spelling. +const COLOR_ALIASES: Readonly> = { + card: 'surface', + foreground: 'text', + mutedForeground: 'textSecondary', + muted: 'disabled', + destructive: 'error', + danger: 'error', + textPrimary: 'text', + secondaryText: 'textSecondary', +}; + +export const ColorPaletteSchema = lazySchema(() => strictObject( + { + surface: 'this theme color palette', + history: + 'Until #4001 an undeclared colour key was dropped at parse and every element kept its default colour — the brand change simply did not happen, and the theme still reported valid.', + aliases: COLOR_ALIASES, + }, + { + primary: z.string().describe('Primary brand color (hex, rgb, or hsl)'), + secondary: z.string().optional().describe('Secondary brand color'), + accent: z.string().optional().describe('Accent color for highlights'), + success: z.string().optional().describe('Success state color (default: green)'), + warning: z.string().optional().describe('Warning state color (default: yellow)'), + error: z.string().optional().describe('Error state color (default: red)'), + info: z.string().optional().describe('Info state color (default: blue)'), + + // Neutral colors + background: z.string().optional().describe('Background color'), + surface: z.string().optional().describe('Surface/card background color'), + text: z.string().optional().describe('Primary text color'), + textSecondary: z.string().optional().describe('Secondary text color'), + border: z.string().optional().describe('Border color'), + disabled: z.string().optional().describe('Disabled state color'), + + // Color variants (shades) + primaryLight: z.string().optional().describe('Lighter shade of primary'), + primaryDark: z.string().optional().describe('Darker shade of primary'), + secondaryLight: z.string().optional().describe('Lighter shade of secondary'), + secondaryDark: z.string().optional().describe('Darker shade of secondary'), + }, +)); /** * Typography Settings Schema * Font families, sizes, weights, and line heights. */ -export const TypographySchema = lazySchema(() => z.object({ - fontFamily: z.object({ - 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: z.object({ - 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: z.object({ - 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)'), - }).optional(), - - lineHeight: z.object({ - 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: z.object({ - 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(), -})); +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. + aliases: { + fonts: 'fontFamily', font: 'fontFamily', family: 'fontFamily', fontFamilies: 'fontFamily', + sizes: 'fontSize', size: 'fontSize', + weights: 'fontWeight', weight: 'fontWeight', + tracking: 'letterSpacing', spacing: 'letterSpacing', + leading: 'lineHeight', + }, + }, + { + fontFamily: strictObject( + { + surface: 'this theme font-family block', + 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' }, + }, + { + 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)'), + }, + ).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(), + }, +)); /** * Border Radius Schema * Rounded corners configuration. */ -export const BorderRadiusSchema = lazySchema(() => z.object({ - none: z.string().optional().describe('No border radius (0)'), - sm: z.string().optional().describe('Small border radius (e.g., 0.125rem)'), - base: z.string().optional().describe('Base border radius (e.g., 0.25rem)'), - md: z.string().optional().describe('Medium border radius (e.g., 0.375rem)'), - lg: z.string().optional().describe('Large border radius (e.g., 0.5rem)'), - xl: z.string().optional().describe('Extra large border radius (e.g., 0.75rem)'), - '2xl': z.string().optional().describe('2X large border radius (e.g., 1rem)'), - full: z.string().optional().describe('Full border radius (50%)'), -})); +export const BorderRadiusSchema = lazySchema(() => strictObject( + { + surface: 'this theme border-radius scale', + history: + 'Until #4001 an undeclared radius stop was dropped at parse and the corners rendered at the default radius.', + // MEASURED: `base` is emitted as the BARE `--radius`, which is the one + // radius variable objectui's stylesheets actually read — so `radius` is + // what an author copying from the rendered CSS writes. + aliases: { radius: 'base', default: 'base', normal: 'base', pill: 'full', round: 'full', rounded: 'full' }, + }, + { + none: z.string().optional().describe('No border radius (0)'), + sm: z.string().optional().describe('Small border radius (e.g., 0.125rem)'), + base: z.string().optional().describe('Base border radius (e.g., 0.25rem)'), + md: z.string().optional().describe('Medium border radius (e.g., 0.375rem)'), + lg: z.string().optional().describe('Large border radius (e.g., 0.5rem)'), + xl: z.string().optional().describe('Extra large border radius (e.g., 0.75rem)'), + '2xl': z.string().optional().describe('2X large border radius (e.g., 1rem)'), + full: z.string().optional().describe('Full border radius (50%)'), + }, +)); /** * Shadow Schema * Box shadow effects. */ -export const ShadowSchema = lazySchema(() => z.object({ - none: z.string().optional().describe('No shadow'), - sm: z.string().optional().describe('Small shadow'), - base: z.string().optional().describe('Base shadow'), - md: z.string().optional().describe('Medium shadow'), - lg: z.string().optional().describe('Large shadow'), - xl: z.string().optional().describe('Extra large shadow'), - '2xl': z.string().optional().describe('2X large shadow'), - inner: z.string().optional().describe('Inner shadow (inset)'), -})); +export const ShadowSchema = lazySchema(() => strictObject( + { + surface: 'this theme shadow scale', + history: + 'Until #4001 an undeclared shadow stop was dropped at parse and the surface rendered flat.', + // `inset` is CSS's own word for what this scale calls `inner` — and the + // key's own `.describe()` says so ("Inner shadow (inset)"). + aliases: { inset: 'inner', default: 'base', normal: 'base' }, + }, + { + none: z.string().optional().describe('No shadow'), + sm: z.string().optional().describe('Small shadow'), + base: z.string().optional().describe('Base shadow'), + md: z.string().optional().describe('Medium shadow'), + lg: z.string().optional().describe('Large shadow'), + xl: z.string().optional().describe('Extra large shadow'), + '2xl': z.string().optional().describe('2X large shadow'), + inner: z.string().optional().describe('Inner shadow (inset)'), + }, +)); /** * Animation Schema * Animation timing and duration settings. */ -export const AnimationSchema = lazySchema(() => z.object({ - duration: z.object({ - 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: z.object({ - 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(), -})); +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(() => z.object({ - 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)'), -})); +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 @@ -151,6 +390,36 @@ 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). +// +// Two of them are deliberately worded NOT to hand the author a replacement +// slot. `touchTarget` and `keyboardNavigation` read like they should point at +// `ui/touch.zod.ts` / `ui/keyboard.zod.ts` — but 批 13 measured both of those +// vocabularies as having no carrier key at all (#4988), so prescribing them +// 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. +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" }`).', + breakpoints: + '`breakpoints` was removed in #3494 — breakpoints are not theme-scoped. Author responsive behaviour per component (`page.components[].responsive`), where the protocol owns the breakpoint names.', + logo: + '`logo` was removed in #3494 — brand imagery is app-scoped, not theme-scoped: set `branding.logo` on the app (`ui/app.zod.ts`).', + density: + '`density` was removed in #3494 — no renderer read it. Express compact/comfortable spacing as your own tokens under `customVars`.', + wcagContrast: + '`wcagContrast` was removed in #3494 — it declared a check nothing ran. Contrast is measured against the palette you author (`contrastRatio` / `meetsContrastLevel` in the theme engine), never enabled by a flag.', + rtl: + '`rtl` was removed in #3494 — text direction follows the document and locale, not the theme.', + touchTarget: + '`touchTarget` was removed in #3494 — the theme engine never emitted a touch-target variable, so it changed nothing. If you need a token for it, declare one under `customVars`.', + keyboardNavigation: + '`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.', +}; + /** * Theme Configuration Schema * Complete theme definition for brand customization. @@ -158,40 +427,63 @@ export const ThemeMode = ThemeModeSchema; * #3494: the aspirational props `spacing`, `breakpoints`, `logo`, `density`, * `wcagContrast`, `rtl`, `touchTarget` and `keyboardNavigation` were removed — * the theme engine (objectui generateThemeVars) never consumed them, so - * authoring them was a silent no-op (liveness audit #1878/#1893). + * 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. */ -export const ThemeSchema = lazySchema(() => z.object({ - name: SnakeCaseIdentifierSchema.describe('Unique theme identifier (snake_case)'), - label: z.string().describe('Human-readable theme name'), - description: z.string().optional().describe('Theme description'), - - /** Theme mode */ - mode: ThemeModeSchema.default('light').describe('Theme mode (light, dark, or auto)'), - - /** Color system */ - colors: ColorPaletteSchema.describe('Color palette configuration'), - - /** Typography */ - typography: TypographySchema.optional().describe('Typography settings'), - - /** Border radius */ - borderRadius: BorderRadiusSchema.optional().describe('Border radius scale'), - - /** 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 */ - customVars: z.record(z.string(), z.string()).optional().describe('Custom CSS variables (key-value pairs)'), - - /** Extends another theme */ - extends: z.string().optional().describe('Base theme to extend from'), -})); +export const ThemeSchema = lazySchema(() => strictObject( + { + surface: 'this theme', + history: + 'Until #4001 an undeclared theme key was dropped at parse and the theme loaded looking complete — which is exactly how the eight props #3494 removed went on being authored after they stopped existing.', + aliases: { + palette: 'colors', colours: 'colors', colorPalette: 'colors', color: 'colors', + 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', + cssVars: 'customVars', variables: 'customVars', vars: 'customVars', customProperties: 'customVars', tokens: 'customVars', + extend: 'extends', parent: 'extends', inherits: 'extends', basedOn: 'extends', + title: 'label', displayName: 'label', + id: 'name', key: 'name', + darkMode: 'mode', colorScheme: 'mode', scheme: 'mode', + }, + guidance: THEME_RETIRED_KEY_GUIDANCE, + }, + { + name: SnakeCaseIdentifierSchema.describe('Unique theme identifier (snake_case)'), + label: z.string().describe('Human-readable theme name'), + description: z.string().optional().describe('Theme description'), + + /** Theme mode */ + mode: ThemeModeSchema.default('light').describe('Theme mode (light, dark, or auto)'), + + /** Color system */ + colors: ColorPaletteSchema.describe('Color palette configuration'), + + /** Typography */ + typography: TypographySchema.optional().describe('Typography settings'), + + /** Border radius */ + borderRadius: BorderRadiusSchema.optional().describe('Border radius scale'), + + /** 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 */ + customVars: z.record(z.string(), z.string()).optional().describe('Custom CSS variables (key-value pairs)'), + + /** Extends another theme */ + extends: z.string().optional().describe('Base theme to extend from'), + }, +)); export type Theme = z.infer; /** Authoring input for {@link Theme} — defaulted fields are optional. */