Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/6594-headercolor-mirror-enum.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,28 @@ const TITLE = 'Billing';
const baseSection = (extra: Partial<DetailViewSection>): 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
Expand DownExpand Up@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'bg-accent' })}
section={baseSection({ ...extra, headerColor: offContract('bg-accent') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'not-a-token' })}
section={baseSection({ ...extra, headerColor: offContract('not-a-token') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'constructor' })}
section={baseSection({ ...extra, headerColor: offContract('constructor') })}
data={{ amount: 1 }}
/>,
);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
export type Expect<T extends true> = 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<NonNullable<DetailViewSection['headerColor']>, RuledToken>
>;

/** …and the key stays optional, which the equality above deliberately strips. */
export type assertionDeclarationStaysOptional = Expect<
Equal<DetailViewSection['headerColor'], RuledToken | undefined>
>;

/* ── 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<string, unknown> {
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');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/6594-headercolor-mirror-enum.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,28 @@ const TITLE = 'Billing';
const baseSection = (extra: Partial<DetailViewSection>): 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
Expand DownExpand Up@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'bg-accent' })}
section={baseSection({ ...extra, headerColor: offContract('bg-accent') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'not-a-token' })}
section={baseSection({ ...extra, headerColor: offContract('not-a-token') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'constructor' })}
section={baseSection({ ...extra, headerColor: offContract('constructor') })}
data={{ amount: 1 }}
/>,
);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
export type Expect<T extends true> = 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<NonNullable<DetailViewSection['headerColor']>, RuledToken>
>;

/** …and the key stays optional, which the equality above deliberately strips. */
export type assertionDeclarationStaysOptional = Expect<
Equal<DetailViewSection['headerColor'], RuledToken | undefined>
>;

/* ── 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<string, unknown> {
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');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/6594-headercolor-mirror-enum.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,28 @@ const TITLE = 'Billing';
const baseSection = (extra: Partial<DetailViewSection>): 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
Expand DownExpand Up@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'bg-accent' })}
section={baseSection({ ...extra, headerColor: offContract('bg-accent') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'not-a-token' })}
section={baseSection({ ...extra, headerColor: offContract('not-a-token') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'constructor' })}
section={baseSection({ ...extra, headerColor: offContract('constructor') })}
data={{ amount: 1 }}
/>,
);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
export type Expect<T extends true> = 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<NonNullable<DetailViewSection['headerColor']>, RuledToken>
>;

/** …and the key stays optional, which the equality above deliberately strips. */
export type assertionDeclarationStaysOptional = Expect<
Equal<DetailViewSection['headerColor'], RuledToken | undefined>
>;

/* ── 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<string, unknown> {
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');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/6594-headercolor-mirror-enum.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,28 @@ const TITLE = 'Billing';
const baseSection = (extra: Partial<DetailViewSection>): 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
Expand DownExpand Up@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'bg-accent' })}
section={baseSection({ ...extra, headerColor: offContract('bg-accent') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'not-a-token' })}
section={baseSection({ ...extra, headerColor: offContract('not-a-token') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'constructor' })}
section={baseSection({ ...extra, headerColor: offContract('constructor') })}
data={{ amount: 1 }}
/>,
);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
export type Expect<T extends true> = 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<NonNullable<DetailViewSection['headerColor']>, RuledToken>
>;

/** …and the key stays optional, which the equality above deliberately strips. */
export type assertionDeclarationStaysOptional = Expect<
Equal<DetailViewSection['headerColor'], RuledToken | undefined>
>;

/* ── 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<string, unknown> {
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');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/6594-headercolor-mirror-enum.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,28 @@ const TITLE = 'Billing';
const baseSection = (extra: Partial<DetailViewSection>): 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
Expand DownExpand Up@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'bg-accent' })}
section={baseSection({ ...extra, headerColor: offContract('bg-accent') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'not-a-token' })}
section={baseSection({ ...extra, headerColor: offContract('not-a-token') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'constructor' })}
section={baseSection({ ...extra, headerColor: offContract('constructor') })}
data={{ amount: 1 }}
/>,
);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
export type Expect<T extends true> = 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<NonNullable<DetailViewSection['headerColor']>, RuledToken>
>;

/** …and the key stays optional, which the equality above deliberately strips. */
export type assertionDeclarationStaysOptional = Expect<
Equal<DetailViewSection['headerColor'], RuledToken | undefined>
>;

/* ── 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<string, unknown> {
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');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/6594-headercolor-mirror-enum.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,28 @@ const TITLE = 'Billing';
const baseSection = (extra: Partial<DetailViewSection>): 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
Expand DownExpand Up@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'bg-accent' })}
section={baseSection({ ...extra, headerColor: offContract('bg-accent') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'not-a-token' })}
section={baseSection({ ...extra, headerColor: offContract('not-a-token') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'constructor' })}
section={baseSection({ ...extra, headerColor: offContract('constructor') })}
data={{ amount: 1 }}
/>,
);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
export type Expect<T extends true> = 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<NonNullable<DetailViewSection['headerColor']>, RuledToken>
>;

/** …and the key stays optional, which the equality above deliberately strips. */
export type assertionDeclarationStaysOptional = Expect<
Equal<DetailViewSection['headerColor'], RuledToken | undefined>
>;

/* ── 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<string, unknown> {
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');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/6594-headercolor-mirror-enum.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,28 @@ const TITLE = 'Billing';
const baseSection = (extra: Partial<DetailViewSection>): 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
Expand DownExpand Up@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'bg-accent' })}
section={baseSection({ ...extra, headerColor: offContract('bg-accent') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'not-a-token' })}
section={baseSection({ ...extra, headerColor: offContract('not-a-token') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'constructor' })}
section={baseSection({ ...extra, headerColor: offContract('constructor') })}
data={{ amount: 1 }}
/>,
);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
export type Expect<T extends true> = 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<NonNullable<DetailViewSection['headerColor']>, RuledToken>
>;

/** …and the key stays optional, which the equality above deliberately strips. */
export type assertionDeclarationStaysOptional = Expect<
Equal<DetailViewSection['headerColor'], RuledToken | undefined>
>;

/* ── 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<string, unknown> {
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');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/6594-headercolor-mirror-enum.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,28 @@ const TITLE = 'Billing';
const baseSection = (extra: Partial<DetailViewSection>): 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
Expand DownExpand Up@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'bg-accent' })}
section={baseSection({ ...extra, headerColor: offContract('bg-accent') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'not-a-token' })}
section={baseSection({ ...extra, headerColor: offContract('not-a-token') })}
data={{ amount: 1 }}
/>,
);
Expand All@@ -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(
<DetailSection
section={baseSection({ ...extra, headerColor: 'constructor' })}
section={baseSection({ ...extra, headerColor: offContract('constructor') })}
data={{ amount: 1 }}
/>,
);
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
export type Expect<T extends true> = 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<NonNullable<DetailViewSection['headerColor']>, RuledToken>
>;

/** …and the key stays optional, which the equality above deliberately strips. */
export type assertionDeclarationStaysOptional = Expect<
Equal<DetailViewSection['headerColor'], RuledToken | undefined>
>;

/* ── 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<string, unknown> {
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');
});
});
Loading
Loading