diff --git a/.changeset/6594-headercolor-mirror-enum.md b/.changeset/6594-headercolor-mirror-enum.md new file mode 100644 index 0000000000..fd7c3106a2 --- /dev/null +++ b/.changeset/6594-headercolor-mirror-enum.md @@ -0,0 +1,61 @@ +--- +'@object-ui/types': minor +--- + +`DetailViewSection.headerColor` is now the closed six-token vocabulary on both halves of +the contract — the TypeScript declaration and the `@object-ui/types/zod` mirror — instead of +`string` / `z.string()` (objectui#6594, maintainer ruling A of 2026-08-26 recorded at +objectstack#12126). The six are `muted`, `muted/50`, `accent`, `primary/10`, `secondary/10` +and `destructive/10`: exactly what `@object-ui/plugin-detail`'s `HEADER_COLOR_CLASSES` +resolves (objectui#6178) and exactly what `@objectstack/spec` declares on its strict +`record:details` section schema (objectstack PR #12616). + +## ⚠️ Accept-set narrowing — these spellings stop validating + +`DetailViewSectionSchema.headerColor` was `z.string().optional()`, so **any string parsed +green** while the renderer contributed no class for most of them. It is now +`z.enum([...]).optional()`: a value outside the six is refused at parse time with +`headerColor` named in the error path, and is a `tsc` error at every authoring site typed +against `DetailViewSection`. + +**Authored metadata in this repo needs no migration.** Measured before tightening, across +the whole tracked tree: `headerColor` occurs in **ten 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/` and `docs/` contain **zero** +occurrences (positive control: `sections` and `detail-view` both hit in those directories, +so the census reached them). Nothing in the repo authors a value outside the six. + +## The renderer's `bg-*` pass-through is deliberately NOT declared + +`headerColorClass` also hands a value that is already a complete `bg-*` 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 declaring it would promise +a capability the contract cannot keep. It stays a renderer affordance — still supported by +the renderer, never invited by the contract. The three renderer tests that exercise +off-contract values (`bg-accent`, `not-a-token`, `constructor`) now route them through a +documented `offContract()` seam in `DetailSection.headerColor.test.tsx`, which is the visible +consequence of the narrowing rather than a workaround for it: metadata still arrives as JSON +over the wire, where no compiler was involved, so the renderer must keep behaving sanely. + +## The three ends cannot drift + +`packages/plugin-detail/src/__tests__/headerColor.contractPin-6594.test.ts` pins the resolver, +the TypeScript declaration and the zod mirror against the ruled vocabulary — the resolver's +key set one-to-one at runtime, the declaration by invariant type equality, the mirror by +reading its own enum options. It fails in **both** directions: a seventh token on any one end, +or one of the six dropped from any one end, turns it red, and the comparator itself is pinned +against synthetic inputs so the guard has been shown to fail rather than only to pass. + +## Shape, and where it departs from the nearest precedent + +The nearest precedent is objectui#5853 (`.changeset/5853-tablecolumn-type-canonical-union.md`), +which narrowed `TableColumn.type` on the same three-ends pattern and **exported** a +`TABLE_COLUMN_TYPES` tuple for the zod mirror to build its enum from. That shape is not +available here and the difference is structural, not a preference: `packages/types/src/views.ts` +is a **type-only** module, so a tuple there would add a runtime export to the package barrel +(a value export cannot ride the barrel's `export type` block) and a runtime import edge from +the zod entry into `views.js`. #5853 had a second reason to export — producers needed its +`normalizeTableColumnType()` at their emit seam — and `headerColor` has no producer that needs +a runtime value. The literals are therefore written on each half and the anti-drift guarantee +is carried by the pin above, which also covers the third end a shared tuple could not reach: +the renderer, in a package `@object-ui/types` must not depend on. diff --git a/packages/plugin-detail/src/__tests__/DetailSection.headerColor.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.headerColor.test.tsx index e1d97f2f12..d13df56f5a 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.headerColor.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.headerColor.test.tsx @@ -31,6 +31,28 @@ const TITLE = 'Billing'; const baseSection = (extra: Partial): DetailViewSection => ({ title: TITLE, fields: [{ name: 'amount', label: 'Amount' }], ...extra }) as DetailViewSection; +/** + * A `headerColor` value the CONTRACT does not declare, handed to the renderer + * anyway. + * + * ⚠️ The cast is the POINT of the three tests that use it, not a workaround for + * them. objectui#6594 narrowed `DetailViewSection.headerColor` to the six ruled + * tokens, so an off-vocabulary value is a compile error at every authoring site + * — which is the guarantee that card bought, and `headerColor.contractPin-6594 + * .test.ts` is where it is pinned. The renderer still has to behave sanely when + * one reaches it anyway, because metadata arrives as JSON over the wire where + * no compiler was ever involved, and because the pass-through for a value that + * is already a complete `bg-*` class is a deliberate UNDECLARED affordance + * (objectstack#12126 ruling A rejected declaring it: whether the class renders + * depends on the host app's Tailwind build, so declaring it would promise a + * capability the contract cannot keep). + * + * ⛔ Do not widen the declaration to make these three compile without the cast. + * That deletes the distinction the ruling drew. + */ +const offContract = (value: string): DetailViewSection['headerColor'] => + value as DetailViewSection['headerColor']; + /** * The header element, located by the two padding classes `DetailSection` * passes to `CardHeader` on both render branches. `getBy`-style: it throws @@ -84,7 +106,7 @@ describe('DetailSection headerColor -> a class the stylesheet can carry (objectu it('a value that is already a `bg-*` class passes through, not doubled', () => { const { container } = render( , ); @@ -97,7 +119,7 @@ describe('DetailSection headerColor -> a class the stylesheet can carry (objectu it('an unmapped value contributes no class at all — never a fabricated one', () => { const { container, getByText } = render( , ); @@ -114,7 +136,7 @@ describe('DetailSection headerColor -> a class the stylesheet can carry (objectu it('an inherited Object.prototype key is not a vocabulary entry', () => { const { container } = render( , ); diff --git a/packages/plugin-detail/src/__tests__/headerColor.contractPin-6594.test.ts b/packages/plugin-detail/src/__tests__/headerColor.contractPin-6594.test.ts new file mode 100644 index 0000000000..cfebfb0bbd --- /dev/null +++ b/packages/plugin-detail/src/__tests__/headerColor.contractPin-6594.test.ts @@ -0,0 +1,254 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `headerColor` has ONE vocabulary across the three ends that declare it + * (objectui#6594, maintainer ruling A of 2026-08-26 recorded at + * objectstack#12126). + * + * ## The ends, and why a pin rather than a shared constant + * + * 1. `@object-ui/plugin-detail`'s `HEADER_COLOR_CLASSES` — the RESOLVER. It + * decides which class reaches the DOM (objectui#6178). + * 2. `@object-ui/types`' `DetailViewSection.headerColor` — the TypeScript + * declaration an author writes against. + * 3. `@object-ui/types/zod`'s `DetailViewSectionSchema.headerColor` — the + * published validator that judges authored metadata at parse time. + * + * The three cannot share one constant. `@object-ui/types` is the protocol layer + * and carries no dependency on any renderer (AGENTS.md §3: "Zero deps"), so the + * arrow can only run plugin-detail -> types, never back; and `../views.ts` is a + * TYPE-ONLY module, so a tuple lifted into it to feed both halves of the mirror + * would add a runtime export to the package barrel and a runtime import edge + * from the zod entry into `views.js`. This file buys the same "cannot drift" + * property from the direction that is legal: this package already devDepends on + * `@object-ui/types`, so it can see all three ends at once. + * + * ## The oracle is the ruling, not today's tree + * + * {@link RULED_VOCABULARY} is the maintainer's six tokens, written out here so + * that all three ends are compared against a FIXED point rather than against + * each other. Comparing ends pairwise would go green on a coordinated edit that + * moved every end off the ruling together; comparing each end to the ruling + * cannot. That is the opposite of the hand-maintained key list + * `zod-mirror-parity.test.ts` warns about — a ledger there tracks drift that + * exists, this is a decision that has been made. + * + * ## What is deliberately NOT declared + * + * `headerColorClass` also hands a value that is ALREADY a complete `bg-*` class + * straight through. The ruling rejected declaring that pass-through: it renders + * only where the host app's Tailwind build happens to emit that class, so a + * declaration would promise a capability the contract cannot keep. `bg-accent` + * is therefore pinned below as a value the RESOLVER accepts and both halves of + * the contract refuse — the asymmetry is the ruling, not an oversight. + */ + +import { describe, it, expect } from 'vitest'; +import type { DetailViewSection } from '@object-ui/types'; +import { DetailViewSectionSchema } from '@object-ui/types/zod'; + +import { headerColorClass, headerColorVocabulary } from '../headerColor'; + +/* ── Type-level helpers (the idiom of `zod-mirror-parity.test.ts`) ─────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +export type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +export type Expect = T; + +/* ── The oracle ───────────────────────────────────────────────────────────── */ + +/** + * The ruled vocabulary, verbatim from objectstack#12126 comment 5419726057: + * `z.enum` over "the six tokens objectui#6294 ships … and the `@object-ui/types` + * mirror narrows to match." + */ +const RULED_VOCABULARY = [ + 'muted', + 'muted/50', + 'accent', + 'primary/10', + 'secondary/10', + 'destructive/10', +] as const; + +type RuledToken = (typeof RULED_VOCABULARY)[number]; + +/* ── The comparator, shown to fail in both directions ─────────────────────── */ + +/** + * Reconcile one end's vocabulary against another: what each side has and the + * other does not. + * + * Factored out and driven by synthetic inputs below rather than asserted inline, + * for the reason `zod-mirror-parity.test.ts` gives for exporting its own + * reconciler: a run over TODAY's tree can only ever show that today's tree is + * green, and a comparison that has never been shown to FAIL is indistinguishable + * from no comparison. The recognition suite pins both directions. + */ +export function reconcileVocabularies( + actual: readonly string[], + expected: readonly string[], +): { missing: string[]; extra: string[]; duplicated: string[] } { + const actualSet = new Set(actual); + const expectedSet = new Set(expected); + return { + missing: expected.filter((token) => !actualSet.has(token)), + extra: actual.filter((token) => !expectedSet.has(token)), + duplicated: actual.filter((token, i) => actual.indexOf(token) !== i), + }; +} + +const AGREES = { missing: [], extra: [], duplicated: [] }; + +describe('headerColor pin — recognition: the comparator fails in both directions', () => { + it('is silent when the two vocabularies agree', () => { + expect(reconcileVocabularies(['a', 'b'], ['b', 'a'])).toEqual(AGREES); + }); + + it('names a token the end is MISSING (a token added to the oracle alone)', () => { + expect(reconcileVocabularies(['a'], ['a', 'b'])).toEqual({ + missing: ['b'], + extra: [], + duplicated: [], + }); + }); + + it('names a token the end has EXTRA (a token added to that end alone)', () => { + expect(reconcileVocabularies(['a', 'b'], ['a'])).toEqual({ + missing: [], + extra: ['b'], + duplicated: [], + }); + }); + + it('names a token declared twice, which a set comparison alone would hide', () => { + expect(reconcileVocabularies(['a', 'a'], ['a'])).toEqual({ + missing: [], + extra: [], + duplicated: ['a'], + }); + }); +}); + +/* ── End 1: the resolver ──────────────────────────────────────────────────── */ + +describe('headerColor pin — the resolver carries exactly the ruled vocabulary', () => { + it('matches HEADER_COLOR_CLASSES one-to-one', () => { + expect(reconcileVocabularies(Object.keys(headerColorVocabulary), RULED_VOCABULARY)).toEqual( + AGREES, + ); + }); + + it('resolves every ruled token to a class, and only complete literals', () => { + for (const token of RULED_VOCABULARY) { + const resolved = headerColorClass(token); + expect(resolved, `${token} should resolve to a class`).toBeDefined(); + expect(resolved).toBe(`bg-${token}`); + } + }); +}); + +/* ── End 2: the published TypeScript declaration ──────────────────────────── */ + +/** + * The declaration accepts the ruled vocabulary and NOTHING else. + * + * Invariant equality, so this fails in both directions: a seventh token added to + * `views.ts` alone, or one of the six dropped from it, both stop the two sides + * being mutually assignable. A widening back to `string` fails here first. + */ +export type assertionDeclarationIsTheRuledVocabulary = Expect< + Equal, RuledToken> +>; + +/** …and the key stays optional, which the equality above deliberately strips. */ +export type assertionDeclarationStaysOptional = Expect< + Equal +>; + +/* ── End 3: the published validator ───────────────────────────────────────── */ + +/** The mirror's declared options, read from its own shape — never restated. */ +function declaredEnumOptions(): string[] { + const member = DetailViewSectionSchema.shape.headerColor; + const unwrapped = (member as { unwrap?: () => unknown }).unwrap?.() ?? member; + const options = (unwrapped as { options?: unknown }).options; + expect( + Array.isArray(options), + 'DetailViewSectionSchema.headerColor should be an enum with declared options — a widening back to z.string() lands here', + ).toBe(true); + return [...(options as string[])]; +} + +/** A section that is otherwise valid, so only `headerColor` decides the verdict. */ +function sectionWith(headerColor: unknown): Record { + return { fields: [{ name: 'amount' }], headerColor }; +} + +describe('headerColor pin — the validator carries exactly the ruled vocabulary', () => { + it('declares the six as an enum, one-to-one with the ruling', () => { + expect(reconcileVocabularies(declaredEnumOptions(), RULED_VOCABULARY)).toEqual(AGREES); + }); + + it('parses every ruled token green', () => { + for (const token of RULED_VOCABULARY) { + const result = DetailViewSectionSchema.safeParse(sectionWith(token)); + expect(result.success, `${token} should parse: ${JSON.stringify(result.error?.issues)}`).toBe( + true, + ); + } + }); + + it('still accepts a section that omits the key', () => { + expect(DetailViewSectionSchema.safeParse({ fields: [{ name: 'amount' }] }).success).toBe(true); + }); + + it('refuses a string outside the vocabulary, naming `headerColor` in the path', () => { + const result = DetailViewSectionSchema.safeParse(sectionWith('blue-100')); + expect(result.success).toBe(false); + expect(result.error?.issues.map((issue) => issue.path.join('.'))).toContain('headerColor'); + }); +}); + +/* ── The undeclared pass-through, pinned as an asymmetry on purpose ───────── */ + +/** + * `bg-accent` is the shape of value the resolver hands through verbatim. Both + * halves of the contract refuse it, and that is the ruling: option B (declaring + * the pass-through) was rejected as a capability illusion, because whether the + * class renders depends on the host app's Tailwind build rather than on anything + * this workspace ships. + * + * If someone later declares it, the `@ts-expect-error` below becomes an unused + * directive and `tsc` fails — so the ruling cannot be reversed silently on the + * type side either. + */ +const passThroughSection: DetailViewSection = { + fields: [], + // @ts-expect-error — deliberately undeclared: the resolver's `bg-*` pass-through + // is a renderer affordance, not part of the contract (objectstack#12126 ruling A). + headerColor: 'bg-accent', +}; + +describe('headerColor pin — the `bg-*` pass-through stays UNDECLARED', () => { + it('is resolved by the renderer', () => { + expect(headerColorClass('bg-accent')).toBe('bg-accent'); + }); + + it('is refused by the validator', () => { + const result = DetailViewSectionSchema.safeParse(passThroughSection); + expect(result.success).toBe(false); + expect(result.error?.issues.map((issue) => issue.path.join('.'))).toContain('headerColor'); + }); + + it('is absent from the resolver vocabulary, so nothing offers it as a token', () => { + expect(Object.keys(headerColorVocabulary)).not.toContain('bg-accent'); + }); +}); diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index e419576326..0b93eeeeb4 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -198,10 +198,31 @@ export interface DetailViewSection { */ showBorder?: boolean; /** - * Header background color (Tailwind class) + * Header background tint, as one of six design-system tokens. + * + * Closed vocabulary — the same six `@object-ui/plugin-detail`'s + * `HEADER_COLOR_CLASSES` resolves, and the same six `@objectstack/spec` + * declares on its strict `record:details` section schema (maintainer ruling + * A, 2026-08-26, objectstack#12126). A value outside them is refused by the + * `DetailViewSectionSchema` mirror in `./zod/views.zod.ts`, whose `z.enum` + * is pinned one-to-one against that renderer module. + * + * The renderer additionally hands a value that is ALREADY a complete `bg-*` + * class straight through to the DOM. That pass-through is deliberately NOT + * declared here: it renders on the same terms as any `className` a schema + * carries — only if the host app's Tailwind build happens to emit that class + * — so declaring it would promise a capability the contract cannot keep + * (the ruling rejected declaring it for exactly that reason). + * * @example 'muted', 'primary/10' */ - headerColor?: string; + headerColor?: + | 'muted' + | 'muted/50' + | 'accent' + | 'primary/10' + | 'secondary/10' + | 'destructive/10'; /** * When true, hide fields whose value is null, undefined, or empty string. * If all fields are hidden, the entire section is hidden. diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 60940616ee..66066b2443 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -68,7 +68,20 @@ export const DetailViewSectionSchema = z.object({ columns: z.number().optional().describe('Grid columns for field layout'), visible: z.union([z.boolean(), z.string()]).optional().describe('Section visibility condition'), showBorder: z.boolean().optional().describe('Show border around section'), - headerColor: z.string().optional().describe('Header background color (Tailwind class)'), + // Closed vocabulary — the six design-system tint tokens + // `@object-ui/plugin-detail`'s `HEADER_COLOR_CLASSES` resolves, and the six + // `@objectstack/spec` declares on its strict `record:details` section schema + // (maintainer ruling A, 2026-08-26, objectstack#12126). Pinned one-to-one + // against that renderer module by + // `packages/plugin-detail/src/__tests__/headerColor.contractPin-6594.test.ts`, + // which fails in BOTH directions — a token added to either side alone is red. + // The renderer's verbatim `bg-*` pass-through stays an UNDECLARED affordance: + // the ruling rejected declaring it, since it only renders where the host + // app's Tailwind build happens to emit that class. + headerColor: z + .enum(['muted', 'muted/50', 'accent', 'primary/10', 'secondary/10', 'destructive/10']) + .optional() + .describe('Header background tint (one of six design-system tokens)'), }); /**