Skip to content

Commit 527e050

Browse files
os-zhuangclaude
andauthored
feat(spec): declare record:details section headerColor as a closed six-token enum (#12616)
* feat(spec): declare record:details section headerColor as a closed six-token enum The #11661 refusal's recorded reason (template-literal Tailwind read generating no CSS) was repaired by objectui#6294's literal-class lookup; maintainer ruling A (2026-08-26) declares the key as z.enum over exactly the six tokens that lookup ships: muted, muted/50, accent, primary/10, secondary/10, destructive/10. Declared = enforced — values outside the enum (including bg-* pass-through spellings) are refused at authoring time instead of shipping a header that silently does not paint. Optional, no schema default; the title withhold stands and its pin flips consciously, as its own comment anticipated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4 * docs(spec): regenerate ui/component reference for the headerColor enum gen:docs wrote this page; gen:schema produced no tracked-file changes (authorable-surface.base.json lags by design — baseRev allowance). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a25e7d commit 527e050

4 files changed

Lines changed: 98 additions & 28 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
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.

content/docs/references/ui/component.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,7 @@ Sort field and direction pair
753753
| :--- | :--- | :--- | :--- |
754754
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
755755
| **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. |
756-
| **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? }`. |
756+
| **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? }`. |
757757
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
758758
| **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) |
759759
| **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
774774
| **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. |
775775
| **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. |
776776
| **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. |
777+
| **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. |
777778

778779
### Nested Shape: `RecordDetailsProps.aria`
779780

packages/spec/src/ui/component.test.ts

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -708,28 +708,68 @@ describe('RecordDetailsProps', () => {
708708
expect(message).toContain('`defaultCollapse` → `defaultCollapsed`');
709709
});
710710

711-
it('still refuses the two keys #11661 deliberately withholds (`title`, `headerColor`)', () => {
712-
// Both are honoured by the renderer at the pin, and both stay OUT of the
713-
// accept set on purpose: `title` is a second spelling of the heading slot
714-
// `label` declares (the `page:card` `body`-vs-`children` shape, which
715-
// #5775 converged rather than declared) and is held for a maintainer
716-
// ruling; `headerColor` reaches the DOM only as `bg-${...}`, a
717-
// template-literal Tailwind class that generates no CSS under the v4
718-
// source scan — dead-in-practice, so declaring it would advertise a
719-
// capability the renderer does not deliver. A later batch declaring
720-
// either must flip this pin consciously.
721-
for (const [key, value] of [
722-
['title', 'Company'],
723-
['headerColor', 'muted'],
711+
it('still refuses the one key #11661 deliberately withholds (`title`)', () => {
712+
// Honoured by the renderer at the pin, and OUT of the accept set on
713+
// purpose: `title` is a second spelling of the heading slot `label`
714+
// declares (the `page:card` `body`-vs-`children` shape, which #5775
715+
// converged rather than declared) and is held for a maintainer ruling.
716+
// A later batch declaring it must flip this pin consciously.
717+
//
718+
// `headerColor` was withheld alongside it until #12126 (maintainer
719+
// ruling A, 2026-08-26): its refusal's recorded reason — a
720+
// template-literal Tailwind read that generated no CSS — was repaired by
721+
// objectui#6294's literal-class lookup, so the pin flipped CONSCIOUSLY,
722+
// as this comment always anticipated. The key's new accept/reject
723+
// boundary is pinned by the two tests below.
724+
const r = RecordDetailsProps.safeParse({
725+
sections: [{ label: 'A', fields: ['a'], title: 'Company' }],
726+
});
727+
expect(r.success).toBe(false);
728+
const message = r.success === false
729+
? r.error.issues.map((i) => i.message).join('\n')
730+
: '';
731+
expect(message).toContain('`title`');
732+
});
733+
734+
it('accepts all six `headerColor` enum tokens verbatim, with no schema default (#12126)', () => {
735+
// Ruling A: a closed z.enum over exactly the six tokens objectui#6294's
736+
// `plugin-detail/src/headerColor.ts` lookup ships — complete class
737+
// literals in a file every consuming app's Tailwind scan covers, so
738+
// every enum value is guaranteed present in the compiled stylesheet.
739+
// Declared = enforced.
740+
for (const token of [
741+
'muted', 'muted/50', 'accent', 'primary/10', 'secondary/10', 'destructive/10',
724742
] as const) {
743+
const section = RecordDetailsProps.parse({
744+
sections: [{ label: 'A', fields: ['a'], headerColor: token }],
745+
}).sections?.[0] as Record<string, unknown>;
746+
expect(section.headerColor).toBe(token);
747+
}
748+
// No schema default: an omitted key means "no tint" — the renderer's own
749+
// fallback, not an authored request (the `maxVisible` principle).
750+
const bare = RecordDetailsProps.parse({
751+
sections: [{ label: 'A', fields: ['a'] }],
752+
}).sections?.[0] as Record<string, unknown>;
753+
expect('headerColor' in bare).toBe(false);
754+
});
755+
756+
it('refuses `headerColor` values outside the closed enum, by name (#12126)', () => {
757+
// The boundary ruling A draws: everything outside the six tokens — an
758+
// arbitrary palette guess (the objectui#6178 silent-no-paint failure
759+
// mode), the renderer's `bg-*` pass-through spellings (which render only
760+
// if the HOST app's Tailwind build happens to generate the class), and
761+
// the solid tokens the tints-only vocabulary deliberately excludes — is
762+
// refused at authoring time rather than shipping a header that silently
763+
// does not paint.
764+
for (const value of ['blue-100', 'bg-muted', 'primary', 'destructive']) {
725765
const r = RecordDetailsProps.safeParse({
726-
sections: [{ label: 'A', fields: ['a'], [key]: value }],
766+
sections: [{ label: 'A', fields: ['a'], headerColor: value }],
727767
});
728768
expect(r.success).toBe(false);
729-
const message = r.success === false
730-
? r.error.issues.map((i) => i.message).join('\n')
731-
: '';
732-
expect(message).toContain(`\`${key}\``);
769+
if (r.success) continue;
770+
const issue = r.error.issues[0]!;
771+
expect(issue.code).toBe('invalid_value');
772+
expect(issue.path).toEqual(['sections', 0, 'headerColor']);
733773
}
734774
});
735775

packages/spec/src/ui/component.zod.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -902,25 +902,49 @@ export const RecordDetailsProps = strictObject({
902902
* the `.objectui-sha` pin (`190fbd01`, objectui `plugin-detail/src/
903903
* renderers/record-details.tsx` + `plugin-detail/src/DetailSection.tsx`).
904904
*
905-
* Two keys the same measurement found are deliberately NOT declared here
906-
* (#11661 holds their forks):
905+
* One key the same measurement found is deliberately NOT declared here
906+
* (#11661 holds its fork):
907907
* - `title` — the renderer's `s.title ?? s.label` limb is a second
908908
* spelling of the heading slot `label` already declares (identical
909909
* localization handling, zero producers). Same shape as the `page:card`
910910
* `body`-vs-`children` pair, which #5775 CONVERGED rather than declared
911911
* — one heading slot, not two de-facto contracts (Prime Directive #12).
912912
* Held for the maintainer's declare-vs-converge ruling.
913-
* - `headerColor` — the renderer's only read is `bg-${headerColor}`, a
914-
* template-literal Tailwind class: Tailwind v4 scans source text with
915-
* no safelist, so this call site generates NO CSS and an authored value
916-
* works only when some other source file happens to use the same class
917-
* literally. Dead-in-practice at the pin (zero producers); declaring it
918-
* would advertise a capability the renderer does not deliver.
913+
*
914+
* `headerColor` used to be withheld alongside it (the renderer's only
915+
* read was `bg-${headerColor}`, a template-literal Tailwind class that
916+
* generates no CSS under the v4 source scan — declaring it would have
917+
* advertised a capability the renderer did not deliver). objectui#6294
918+
* (merged 2026-08-25) replaced the interpolation with a lookup of
919+
* complete class literals in `plugin-detail/src/headerColor.ts`, so the
920+
* renderer now delivers the key because the module declares it; the
921+
* refusal outlived its recorded reason and #12126 (maintainer ruling A,
922+
* 2026-08-26) declares the key below as a closed enum.
919923
*/
920924
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.'),
921925
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.'),
922926
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.'),
923-
})).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description? }`.'),
927+
/**
928+
* Section-header background tint (#12126, maintainer ruling A 2026-08-26:
929+
* declare as a CLOSED enum — declared = enforced). The vocabulary is
930+
* exactly the six complete class literals objectui's
931+
* `plugin-detail/src/headerColor.ts` lookup ships (objectui#6294): those
932+
* literals live in a file every consuming app's Tailwind scan covers, so
933+
* each enum value is guaranteed to be in the compiled stylesheet. Tints
934+
* only, by the renderer module's own reasoning: `CardHeader` sets no
935+
* foreground colour, so a solid `bg-primary` would leave the title
936+
* unreadable without a paired `text-*-foreground`.
937+
*
938+
* Anything outside the enum — including the renderer's `bg-*`
939+
* pass-through spellings, which render only if the HOST app's Tailwind
940+
* build happens to generate the class — is refused at authoring time
941+
* rather than shipping a header that silently does not paint (the
942+
* objectui#6178 failure mode this key's old refusal existed to prevent).
943+
* Optional with NO schema default: an omitted key means "no tint", the
944+
* renderer's own fallback.
945+
*/
946+
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.'),
947+
})).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`.'),
924948
fields: z.array(z.string()).optional().describe('Explicit field list to display (optional, overrides highlightFields)'),
925949
/**
926950
* Field names to omit from the body, applied to both `fields` and every

0 commit comments

Comments
 (0)