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
5 changes: 5 additions & 0 deletions .changeset/record-details-headercolor-enum-12126.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@objectstack/spec": minor
---

Declare `headerColor` on the strict `record:details` section schema as a closed enum (#12126, maintainer ruling A 2026-08-26). The key was deliberately refused by #11661 because the renderer's only read was a template-literal Tailwind class that generated no CSS; objectui#6294 (merged 2026-08-25) replaced that read with a lookup of complete class literals, so the refusal outlived its recorded reason. The vocabulary is exactly the six tokens that lookup ships — `muted` | `muted/50` | `accent` | `primary/10` | `secondary/10` | `destructive/10` — tints only (`CardHeader` sets no foreground, so solids would need a paired `text-*-foreground`). Declared = enforced: `objectstack validate` refuses anything outside the enum at authoring time — including the renderer's `bg-*` pass-through spellings, which render only when the host app's Tailwind build happens to generate the class — instead of shipping a header that silently does not paint. Optional with NO schema default; the `title` withhold from #11661 stands unchanged.
3 changes: 2 additions & 1 deletion content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -753,7 +753,7 @@ Sort field and direction pair
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
| **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description? }`. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
Expand All@@ -774,6 +774,7 @@ Sort field and direction pair
| **defaultCollapsed** | `boolean` | optional | Start a `collapsible: true` section collapsed (renderer default: expanded). Consulted only when `collapsible` is on — a non-collapsible section never reads its collapse state. |
| **icon** | `string` | optional | Heading icon, as a lucide icon name (kebab-case, e.g. `building-2`). A value that is not an ASCII identifier (emoji, CJK text) renders as literal text beside the heading instead. Shown where the section heading renders: a titled section, or any collapsible section. |
| **description** | `string` | optional | Sub-heading text rendered under the section heading (plain string — the renderer applies no translation to it, unlike `label`). Renders on a titled or collapsible section; a collapsible section hides it while collapsed. |
| **headerColor** | `Enum<'muted' \| 'muted/50' \| 'accent' \| 'primary/10' \| 'secondary/10' \| 'destructive/10'>` | optional | Section-header background tint, from the closed six-token vocabulary rendered by objectui's `record:details` header (`muted` \| `muted/50` \| `accent` \| `primary/10` \| `secondary/10` \| `destructive/10`). A value outside the enum is refused at authoring time rather than silently not painting. Omit for an untinted header. |

### Nested Shape: `RecordDetailsProps.aria`

Expand Down
76 changes: 58 additions & 18 deletions packages/spec/src/ui/component.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -708,28 +708,68 @@ describe('RecordDetailsProps', () => {
expect(message).toContain('`defaultCollapse` → `defaultCollapsed`');
});

it('still refuses the two keys #11661 deliberately withholds (`title`, `headerColor`)', () => {
// Both are honoured by the renderer at the pin, and both stay OUT of the
// accept set on purpose: `title` is a second spelling of the heading slot
// `label` declares (the `page:card` `body`-vs-`children` shape, which
// #5775 converged rather than declared) and is held for a maintainer
// ruling; `headerColor` reaches the DOM only as `bg-${...}`, a
// template-literal Tailwind class that generates no CSS under the v4
// source scan — dead-in-practice, so declaring it would advertise a
// capability the renderer does not deliver. A later batch declaring
// either must flip this pin consciously.
for (const [key, value] of [
['title', 'Company'],
['headerColor', 'muted'],
it('still refuses the one key #11661 deliberately withholds (`title`)', () => {
// Honoured by the renderer at the pin, and OUT of the accept set on
// purpose: `title` is a second spelling of the heading slot `label`
// declares (the `page:card` `body`-vs-`children` shape, which #5775
// converged rather than declared) and is held for a maintainer ruling.
// A later batch declaring it must flip this pin consciously.
//
// `headerColor` was withheld alongside it until #12126 (maintainer
// ruling A, 2026-08-26): its refusal's recorded reason — a
// template-literal Tailwind read that generated no CSS — was repaired by
// objectui#6294's literal-class lookup, so the pin flipped CONSCIOUSLY,
// as this comment always anticipated. The key's new accept/reject
// boundary is pinned by the two tests below.
const r = RecordDetailsProps.safeParse({
sections: [{ label: 'A', fields: ['a'], title: 'Company' }],
});
expect(r.success).toBe(false);
const message = r.success === false
? r.error.issues.map((i) => i.message).join('\n')
: '';
expect(message).toContain('`title`');
});

it('accepts all six `headerColor` enum tokens verbatim, with no schema default (#12126)', () => {
// Ruling A: a closed z.enum over exactly the six tokens objectui#6294's
// `plugin-detail/src/headerColor.ts` lookup ships — complete class
// literals in a file every consuming app's Tailwind scan covers, so
// every enum value is guaranteed present in the compiled stylesheet.
// Declared = enforced.
for (const token of [
'muted', 'muted/50', 'accent', 'primary/10', 'secondary/10', 'destructive/10',
] as const) {
const section = RecordDetailsProps.parse({
sections: [{ label: 'A', fields: ['a'], headerColor: token }],
}).sections?.[0] as Record<string, unknown>;
expect(section.headerColor).toBe(token);
}
// No schema default: an omitted key means "no tint" — the renderer's own
// fallback, not an authored request (the `maxVisible` principle).
const bare = RecordDetailsProps.parse({
sections: [{ label: 'A', fields: ['a'] }],
}).sections?.[0] as Record<string, unknown>;
expect('headerColor' in bare).toBe(false);
});

it('refuses `headerColor` values outside the closed enum, by name (#12126)', () => {
// The boundary ruling A draws: everything outside the six tokens — an
// arbitrary palette guess (the objectui#6178 silent-no-paint failure
// mode), the renderer's `bg-*` pass-through spellings (which render only
// if the HOST app's Tailwind build happens to generate the class), and
// the solid tokens the tints-only vocabulary deliberately excludes — is
// refused at authoring time rather than shipping a header that silently
// does not paint.
for (const value of ['blue-100', 'bg-muted', 'primary', 'destructive']) {
const r = RecordDetailsProps.safeParse({
sections: [{ label: 'A', fields: ['a'], [key]: value }],
sections: [{ label: 'A', fields: ['a'], headerColor: value }],
});
expect(r.success).toBe(false);
const message = r.success === false
? r.error.issues.map((i) => i.message).join('\n')
: '';
expect(message).toContain(`\`${key}\``);
if (r.success) continue;
const issue = r.error.issues[0]!;
expect(issue.code).toBe('invalid_value');
expect(issue.path).toEqual(['sections', 0, 'headerColor']);
}
});

Expand Down
42 changes: 33 additions & 9 deletions packages/spec/src/ui/component.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -902,25 +902,49 @@ export const RecordDetailsProps = strictObject({
* the `.objectui-sha` pin (`190fbd01`, objectui `plugin-detail/src/
* renderers/record-details.tsx` + `plugin-detail/src/DetailSection.tsx`).
*
* Two keys the same measurement found are deliberately NOT declared here
* (#11661 holds their forks):
* One key the same measurement found is deliberately NOT declared here
* (#11661 holds its fork):
* - `title` — the renderer's `s.title ?? s.label` limb is a second
* spelling of the heading slot `label` already declares (identical
* localization handling, zero producers). Same shape as the `page:card`
* `body`-vs-`children` pair, which #5775 CONVERGED rather than declared
* — one heading slot, not two de-facto contracts (Prime Directive #12).
* Held for the maintainer's declare-vs-converge ruling.
* - `headerColor` — the renderer's only read is `bg-${headerColor}`, a
* template-literal Tailwind class: Tailwind v4 scans source text with
* no safelist, so this call site generates NO CSS and an authored value
* works only when some other source file happens to use the same class
* literally. Dead-in-practice at the pin (zero producers); declaring it
* would advertise a capability the renderer does not deliver.
*
* `headerColor` used to be withheld alongside it (the renderer's only
* read was `bg-${headerColor}`, a template-literal Tailwind class that
* generates no CSS under the v4 source scan — declaring it would have
* advertised a capability the renderer did not deliver). objectui#6294
* (merged 2026-08-25) replaced the interpolation with a lookup of
* complete class literals in `plugin-detail/src/headerColor.ts`, so the
* renderer now delivers the key because the module declares it; the
* refusal outlived its recorded reason and #12126 (maintainer ruling A,
* 2026-08-26) declares the key below as a closed enum.
*/
defaultCollapsed: z.boolean().optional().describe('Start a `collapsible: true` section collapsed (renderer default: expanded). Consulted only when `collapsible` is on — a non-collapsible section never reads its collapse state.'),
icon: z.string().optional().describe('Heading icon, as a lucide icon name (kebab-case, e.g. `building-2`). A value that is not an ASCII identifier (emoji, CJK text) renders as literal text beside the heading instead. Shown where the section heading renders: a titled section, or any collapsible section.'),
description: z.string().optional().describe('Sub-heading text rendered under the section heading (plain string — the renderer applies no translation to it, unlike `label`). Renders on a titled or collapsible section; a collapsible section hides it while collapsed.'),
})).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description? }`.'),
/**
* Section-header background tint (#12126, maintainer ruling A 2026-08-26:
* declare as a CLOSED enum — declared = enforced). The vocabulary is
* exactly the six complete class literals objectui's
* `plugin-detail/src/headerColor.ts` lookup ships (objectui#6294): those
* literals live in a file every consuming app's Tailwind scan covers, so
* each enum value is guaranteed to be in the compiled stylesheet. Tints
* only, by the renderer module's own reasoning: `CardHeader` sets no
* foreground colour, so a solid `bg-primary` would leave the title
* unreadable without a paired `text-*-foreground`.
*
* Anything outside the enum — including the renderer's `bg-*`
* pass-through spellings, which render only if the HOST app's Tailwind
* build happens to generate the class — is refused at authoring time
* rather than shipping a header that silently does not paint (the
* objectui#6178 failure mode this key's old refusal existed to prevent).
* Optional with NO schema default: an omitted key means "no tint", the
* renderer's own fallback.
*/
headerColor: z.enum(['muted', 'muted/50', 'accent', 'primary/10', 'secondary/10', 'destructive/10']).optional().describe('Section-header background tint, from the closed six-token vocabulary rendered by objectui\'s `record:details` header (`muted` | `muted/50` | `accent` | `primary/10` | `secondary/10` | `destructive/10`). A value outside the enum is refused at authoring time rather than silently not painting. Omit for an untinted header.'),
})).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`.'),
fields: z.array(z.string()).optional().describe('Explicit field list to display (optional, overrides highlightFields)'),
/**
* Field names to omit from the body, applied to both `fields` and every
Expand Down
Loading