diff --git a/.changeset/record-details-headercolor-enum-12126.md b/.changeset/record-details-headercolor-enum-12126.md new file mode 100644 index 0000000000..a3be628a8d --- /dev/null +++ b/.changeset/record-details-headercolor-enum-12126.md @@ -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. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index f4237e5258..d28a35670a 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -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; 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; 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). | @@ -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` diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index d392b8b361..82e721ce03 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -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; + 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; + 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']); } }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 8af034614f..f176f06178 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -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