…ken enum
`headerColor` was `string` on the TypeScript declaration and `z.string()` on the
`@object-ui/types/zod` mirror, so any string parsed green while the renderer
contributed a class for only six of them. Both halves are now the closed
vocabulary `@object-ui/plugin-detail`'s `HEADER_COLOR_CLASSES` resolves and
`@objectstack/spec` declares on its strict `record:details` section schema:
muted, muted/50, accent, primary/10, secondary/10, destructive/10.
The renderer's verbatim `bg-*` pass-through stays UNDECLARED — maintainer
ruling A of 2026-08-26 rejected declaring it, since whether such a class
renders depends on the host app's Tailwind build. The three renderer tests that
exercise off-contract values now route them through a documented `offContract()`
seam, which is the narrowing's visible consequence rather than a workaround:
metadata still arrives as JSON over the wire, where no compiler was involved.
A new pin holds the three ends against the ruled vocabulary and fails in both
directions; its comparator is itself pinned against synthetic inputs, so the
guard has been shown to fail rather than only to pass.
Census before tightening: `headerColor` occurs in ten tracked files, none of
them authored metadata; `examples/`, `content/`, `apps/`, `e2e/` and `docs/`
carry zero occurrences (positive control: `sections`, `detail-view` both hit).
Part of #6594
Fixes#6594
DetailViewSection.headerColorwasstringon the TypeScript declaration andz.string()on the@object-ui/types/zodmirror, so any string parsed green while the renderer contributed a class for only six values. Both halves are now the closed vocabulary maintainer ruling A of 2026-08-26 named (recorded at objectstack#12126 comment 5419726057, provenance 「其他接受你的建议」):muted,muted/50,accent,primary/10,secondary/10,destructive/10— exactly what@object-ui/plugin-detail'sHEADER_COLOR_CLASSESresolves, and exactly what@objectstack/specdeclares on its strictrecord:detailssection schema.This PR stays DRAFT and carries
needs:contract-review. It changes a published contract face; the review chain owns any enqueue. Never ready, never auto-merge.What changed
packages/types/src/views.tsstringpackages/types/src/zod/views.zod.tsz.enum([...])replacesz.string()packages/plugin-detail/src/__tests__/headerColor.contractPin-6594.test.tspackages/plugin-detail/src/__tests__/DetailSection.headerColor.test.tsx.changeset/6594-headercolor-mirror-enum.md@object-ui/types: minorpackages/plugin-detail/src/headerColor.tsis untouched: the pin imports its exported vocabulary and never edits it.The
bg-*pass-through is deliberately NOT declaredheaderColorClassalso hands a value that is already a completebg-*class through untouched. Ruling A rejected declaring that (option B, the capability illusion): whether such a class renders depends on the host app's Tailwind build, so a declaration would promise a capability the contract cannot keep. It stays a renderer affordance the contract does not invite, and the pin holds that asymmetry explicitly —bg-accentis asserted to be resolved by the renderer and refused by both halves of the contract.The pin, and why it is not a shared constant
The three ends cannot share one tuple.
@object-ui/typesis the protocol layer and carries no dependency on any renderer (AGENTS.md section 3, "Zero deps"), so the arrow only runs plugin-detail to types. Andpackages/types/src/views.tsis a type-only module: a tuple lifted into it would add a runtime export to the package barrel — a value export cannot ride the barrel'sexport typeblock — and a runtime import edge from the zod entry intoviews.js. Measured, not assumed:grepfor runtime exports inviews.tsreturns nothing, andindex.tsre-exports it from inside anexport typeblock.So the pin lives in the package that may legally see all three ends. It compares each of them against the ruling itself rather than against each other, so a coordinated edit that moved every end off the ruling together cannot go green. Its comparator is itself pinned against synthetic inputs, so the guard has been shown to fail rather than only to pass.
Ablations — the pin fails in BOTH directions
Each leg: mutate, prove the mutation landed on disk by grepping the text it was meant to change, measure, restore, prove the restore by comparing the disk hash to the HEAD blob.
git diff HEADempty at the end of both scripts.tsc -p tsconfig.test.jsonEXIT=2 —headerColor.contractPin-6594.test.ts(168,3): error TS2344: Type 'false' does not satisfy the constraint 'true'and the same at (173,3)× declares the six as an enum, one-to-one with the ruling× matches HEADER_COLOR_CLASSES one-to-oneLeg A rebuilds
@object-ui/typeson both legs and proves what reacheddist. That is not ceremony:packages/plugin-detail/tsconfig.test.jsonsets"paths": {}, so@object-ui/typesresolves through the built.d.ts, not the source tree — an unrebuilt mutation would read GREEN and credit a pin that never fired. Restore leg proved by absence:ablation/7occurrences inviews.ts= 0 and indist/views.d.ts= 0, restored type-check EXIT=0.Fixture triage — the third file, and why it is in this PR
The narrowing turned three existing assertions in
DetailSection.headerColor.test.tsxinto compile errors, because they author off-vocabulary values on purpose to prove renderer behaviour:All three tests stay — they are the renderer's contract with metadata that arrives as JSON over the wire, where no compiler was involved. They now route their value through one documented
offContract()seam that says why the value is off-contract and forbids widening the declaration to make them compile without it. Re-judged individually rather than batch-respelled: none of the three is a misspelling of a live token, and none of them pins a branch this change removes.This is the only file outside the claimed surface, it is fallout of this change rather than a stray repair, and the claim comment on the card was amended to name it.
Census before tightening
headerColoroccurs in ten tracked files, none of them authored metadata: the renderer and its tests, the two declaration files changed here, and two markdown notes.examples/,content/,apps/,e2e/anddocs/carry zero occurrences. Positive control for that census:sectionsanddetail-viewboth hit in those directories, so the search reached them. Nothing in this repo authors a value outside the six, so no migration is owed.Changeset, graded against the nearest narrowing precedent
@object-ui/types: minor— the grade objectui#5853 took for the same class (.changeset/5853-tablecolumn-type-canonical-union.md,TableColumn.typefromz.string()toz.enum, maintainer ruling 2026-08-25).majoris barred by AGENTS.md's version-alignment rule. That precedent exported aTABLE_COLUMN_TYPEStuple; this one cannot, for the type-only-module reason above, and had no second motive to — #5853 also needed a runtimenormalizeTableColumnType()for producers, andheaderColorhas no producer that needs a runtime value. The changeset states that departure rather than leaving it to be rediscovered.Verification
Union run on the final commit,
2ed0542.pnpm exec vitest run packages/types/ packages/plugin-detail/—Test Files 192 passed (192),Tests 1946 passed (1946), EXIT=0. Includeszod-mirror-parity.test.ts, which is the guard that would have caught a one-sided narrowing.pnpm --filter @object-ui/types --filter @object-ui/plugin-detail type-check— bothDone, EXIT=0. Proved to MEASURE the new pins:tsc -p tsconfig.test.json --listFilesresolves both edited test files into the program, and readspackages/types/dist/*.d.ts(rebuilt).pnpm --filter @object-ui/types --filter @object-ui/plugin-detail lint— EXIT=0,0 errorsin both packages (259 and 880 pre-existing warnings, none on the files touched here).check:lint-coverage:lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).check:changeset-presence(3 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)),check:changeset-no-major,check:changeset-fixed,check:changeset-overwrite,check:control-bytes,check:spec-symbols,check:phantom-deps,check:self-import,check:vi-mock-specifiers,check:vi-mock-inherit,check:side-effects-array,check:node-esm-load,check:doc-snippets(271 of 271 block(s) judged, 0 failed— compiled against the built types, so a doc snippet authoring an off-vocabulary value would have failed here),check:doc-types,check:designer-field-key-parity.check:readme-exportsneeds every package built; all 302 failures readtype entry ./dist/index.d.ts is not on disk -- run pnpm build first, plus onepackagesRead: found 12, floor is 25. Zero of those lines namepackages/types— the half this diff could affect was judged and passed.check:eager-closureandcheck:sdui-registration-pinsboth needapps/consolebuilt and say so.Installability
The published
@objectstack/specthis repo installs is 17.2.0, and it contains zero occurrences ofheaderColor(positive controlsrecord:detailsandRecordDetailsPropsboth hit in the same package). The spec-side enum lives only on objectstack main. This card's acceptance is therefore internal to objectui — six literal tokens pinned against the renderer — and no spec symbol is imported. No parity gate in this repo binds this mirror to the installed spec's declarations: the two tests that nameDetailViewSectionarezod-mirror-parity.test.ts(TS-to-zod, green) andobject-view-spec-parity.test.ts(which namesrecord:detailsonly in prose). No fork to report.Generated by Claude Code