Skip to content

Commit dd33bf9

Browse files
os-warrenclaude
andauthored
feat(spec): declare defaultCollapsed / icon / description on record:details sections; withhold title (alias fork) and headerColor (dead-in-practice) (#11902)
* feat(spec): declare defaultCollapsed / icon / description on record:details sections Three more section keys the renderer honours and the strict shape refused (#11661, inheriting the #11289 ruling: declare what the renderer honours; renderer unchanged). Optional, no schema defaults — the describe() texts name the renderer-derived defaults, measured at the .objectui-sha pin (190fbd01: DetailSection.tsx:139/516/546/520/557). The same measurement's title and headerColor stay deliberately refused, pinned by test: title is a second spelling of the declared heading slot (alias fork held for a ruling); headerColor only reaches the DOM as a template-literal Tailwind class that generates no CSS (dead-in-practice, filed as objectui#6178). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy * docs(spec): regenerate ui/component reference for the #11661 section keys check:generated proved exactly two artifacts stale; gen:docs wrote this page, gen:api-surface regenerated byte-identical (the staleness was the reverse- verification leg's restored src mtime vs dist, not a surface change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f6d047 commit dd33bf9

4 files changed

Lines changed: 122 additions & 2 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 three more `record:details` section keys the renderer has honoured all along (#11661, inheriting the #11289 ruling): `defaultCollapsed` (start a `collapsible: true` section collapsed; renderer default expanded), `icon` (heading icon, lucide name; non-identifier values render as literal text) and `description` (plain-string sub-heading under the section heading). All three were refused by the strict section schema, so `objectstack validate` warned an authored key "did nothing" while the renderer read it. All three are optional with NO schema defaults — the fallbacks stay the renderer's. The same measurement's `title` and `headerColor` deliberately remain refused: `title` is a second spelling of the heading slot `label` declares (held for a declare-vs-converge ruling), and `headerColor` only reaches the DOM as a template-literal Tailwind class that generates no CSS (dead-in-practice; reported as an objectui finding).

content/docs/references/ui/component.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ const result = AIChatWindowProps.parse(data);
539539
| :--- | :--- | :--- | :--- |
540540
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
541541
| **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. |
542-
| **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? }`. |
542+
| **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? }`. |
543543
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
544544
| **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) |
545545
| **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). |

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,94 @@ describe('RecordDetailsProps', () => {
645645
expect(message).toContain('`showBorders` → `showBorder`');
646646
});
647647

648+
// #11661 — three more section keys in exactly the pre-#11289 position
649+
// (honoured by the renderer, refused by this shape), declared under the
650+
// inherited ruling. Measured at the `.objectui-sha` pin (`190fbd01`):
651+
// `defaultCollapsed` at `DetailSection.tsx:139`
652+
// (`useState(section.defaultCollapsed ?? false)`), `icon` at
653+
// `DetailSection.tsx:516/546`, `description` at `DetailSection.tsx:520/557`.
654+
it('preserves the #11661 section keys verbatim', () => {
655+
const result = RecordDetailsProps.parse({
656+
sections: [{
657+
label: 'Company',
658+
fields: ['industry', 'website'],
659+
collapsible: true,
660+
defaultCollapsed: true,
661+
icon: 'building-2',
662+
description: 'Firmographics and reach',
663+
}],
664+
});
665+
expect(result.sections?.[0].defaultCollapsed).toBe(true);
666+
expect(result.sections?.[0].icon).toBe('building-2');
667+
expect(result.sections?.[0].description).toBe('Firmographics and reach');
668+
});
669+
670+
it('does not materialize the #11661 keys on a clean parse', () => {
671+
// Same `maxVisible` principle as the #11289 trio: expanded / no icon / no
672+
// sub-heading are the RENDERER'S fallbacks; a schema default would turn
673+
// "the author said nothing" into "the author asked for the default".
674+
const section = RecordDetailsProps.parse({
675+
sections: [{ label: 'Overview', fields: ['name'] }],
676+
}).sections?.[0] as Record<string, unknown>;
677+
expect('defaultCollapsed' in section).toBe(false);
678+
expect('icon' in section).toBe(false);
679+
expect('description' in section).toBe(false);
680+
});
681+
682+
it('rejects wrongly-typed values for the #11661 keys', () => {
683+
for (const [key, value] of [
684+
['defaultCollapsed', 'yes'],
685+
['icon', 7],
686+
['description', ['two', 'lines']],
687+
] as const) {
688+
const r = RecordDetailsProps.safeParse({
689+
sections: [{ label: 'A', fields: ['a'], [key]: value }],
690+
});
691+
expect(r.success).toBe(false);
692+
expect(r.success === false && r.error.issues[0].code).toBe('invalid_type');
693+
expect(r.success === false && r.error.issues[0].path).toEqual(['sections', 0, key]);
694+
}
695+
});
696+
697+
it('still refuses unknown section keys after the #11661 widening', () => {
698+
// The strict face survives, and the new keys entered the "did you mean"
699+
// candidate list — the declaration reached the same error map the strict
700+
// shape reads.
701+
const r = RecordDetailsProps.safeParse({
702+
sections: [{ label: 'A', fields: ['a'], defaultCollapse: true }],
703+
});
704+
expect(r.success).toBe(false);
705+
const message = r.success === false
706+
? r.error.issues.map((i) => i.message).join('\n')
707+
: '';
708+
expect(message).toContain('`defaultCollapse` → `defaultCollapsed`');
709+
});
710+
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'],
724+
] as const) {
725+
const r = RecordDetailsProps.safeParse({
726+
sections: [{ label: 'A', fields: ['a'], [key]: value }],
727+
});
728+
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}\``);
733+
}
734+
});
735+
648736
it('preserves hideFields verbatim (sys-user.page.ts:106)', () => {
649737
// Undeclared until #5611, so a non-strict `z.object` dropped it on the
650738
// floor: the platform page's hidden-field list survived only because

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -892,7 +892,34 @@ export const RecordDetailsProps = strictObject({
892892
collapsible: z.boolean().optional().describe('Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off).'),
893893
/** Card chrome; the renderer derives it from the presence of a title. */
894894
showBorder: z.boolean().optional().describe('Draw this section\'s card chrome (renderer default: derived — on for a titled section, off for an untitled one). Set `false` for a borderless titled section, or `true` for a bordered untitled one.'),
895-
})).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder? }`.'),
895+
/**
896+
* Three more section keys the renderer has honoured all along (#11661 —
897+
* same defect class as #11289, inheriting its 2026-08-23 ruling WITH its
898+
* reason: declare what the renderer honours; the renderer is unchanged).
899+
* Optional with NO schema default, for the same `maxVisible` reason as the
900+
* #11289 trio above; the defaults in the describe() texts are MEASURED at
901+
* the `.objectui-sha` pin (`190fbd01`, objectui `plugin-detail/src/
902+
* renderers/record-details.tsx` + `plugin-detail/src/DetailSection.tsx`).
903+
*
904+
* Two keys the same measurement found are deliberately NOT declared here
905+
* (#11661 holds their forks):
906+
* - `title` — the renderer's `s.title ?? s.label` limb is a second
907+
* spelling of the heading slot `label` already declares (identical
908+
* localization handling, zero producers). Same shape as the `page:card`
909+
* `body`-vs-`children` pair, which #5775 CONVERGED rather than declared
910+
* — one heading slot, not two de-facto contracts (Prime Directive #12).
911+
* Held for the maintainer's declare-vs-converge ruling.
912+
* - `headerColor` — the renderer's only read is `bg-${headerColor}`, a
913+
* template-literal Tailwind class: Tailwind v4 scans source text with
914+
* no safelist, so this call site generates NO CSS and an authored value
915+
* works only when some other source file happens to use the same class
916+
* literally. Dead-in-practice at the pin (zero producers); declaring it
917+
* would advertise a capability the renderer does not deliver.
918+
*/
919+
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.'),
920+
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.'),
921+
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.'),
922+
})).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description? }`.'),
896923
fields: z.array(z.string()).optional().describe('Explicit field list to display (optional, overrides highlightFields)'),
897924
/**
898925
* Field names to omit from the body, applied to both `fields` and every

0 commit comments

Comments
 (0)