Skip to content

Commit 889ae47

Browse files
os-zhuangclaude
andauthored
feat(spec)!: RecordDetailsProps.sections 改为真实的对象形式,并补声明 hideFields (#5611) (#5734)
* feat(spec)!: declare RecordDetailsProps.sections in its real object form + declare hideFields (#5611) `record:details` declared a `sections` shape nothing produced and nothing consumed, and omitted a key a published platform page depends on. - `sections`: `z.array(z.string())` (section IDs) -> an object array `{ name?, label?, columns?, fields }`. Measured: zero ID-list producers and zero ID-list read paths. objectui's RecordDetailsRenderer maps every entry as an object with no string branch, `@object-ui/types` already mirrored the object form, the Studio block designer can only author it, and all four real pages (3 showcase + sys_user) author it. Per the maintainer ruling on #5611: no union with string, no ADR-0087 conversion layer. - `hideFields`: declared `z.array(z.string()).optional()`. Authored by sys-user.page.ts and read by the renderer; undeclared, so a non-strict z.object silently stripped it. - `name` is declared because `packages/lint`'s translation-section-name-missing rule tells authors to add it — a key one rule demands must not be a key the schema rejects. - Section `columns` uses an int range, not a literal union: same accepted set, but the docs generator renders numeric literals as quoted strings (filed as a separate finding), which would misdocument the key. Aligns the two now-stale `packages/lint` comments that described these keys as undeclared. Regenerated references/ui/component.mdx, authorable-surface.json and the strictness-ledger counts (component.zod.ts gains one nested object site). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D * chore(spec): 合并 main 后重生成 strictness-ledger counts 与 authorable-surface 合并 origin/main 后按 os-regen 纪律整体重生成(生成物不做文本合并): - counts.md: 456 sites = 本分支 component.zod.ts 的 30 + main 新增 action-params.zod.ts 的 1。文本合并给不出这个联合结果,故由 gen:strictness-ledger 重算。 - authorable-surface.json: 重出后同时含本分支的 ui/RecordDetailsProps:hideFields 与 main 的 ui/ActionSession:* 三键。 - authorable-surface.base.json 的重锚(baseRev 前移 + api/Discovery:scoping) 与本 PR 无关,已剔除。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fce4c73 commit 889ae47

8 files changed

Lines changed: 218 additions & 29 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `RecordDetailsProps.sections` declares the object form every page actually authors, and `hideFields` is declared (#5611)
6+
7+
`record:details` declared a `sections` shape that nothing produced and nothing
8+
consumed, and omitted a key a published platform page depends on. Both are now
9+
declared as delivered.
10+
11+
**BREAKING (authored metadata shape) — `sections` is an object array, not an ID list.**
12+
13+
```ts
14+
// FROM — declared, but written by zero pages and read by zero renderers
15+
sections: ['overview', 'financials']
16+
17+
// TO — what every real page already authors
18+
sections: [
19+
{ label: 'Overview', columns: 2, fields: ['name', 'account', 'owner'] },
20+
{ label: 'Financials', columns: 2, fields: ['budget', 'spent'] },
21+
]
22+
```
23+
24+
One-line fix: replace each section ID with `{ fields: [...] }`, naming the
25+
fields that section should render (add `label` for a heading, `columns` for its
26+
grid width, `name` to make the heading translatable).
27+
28+
**Why this is safe despite being a type change — it was measured, not assumed.**
29+
The ID-list form had **zero** read paths and **zero** producers:
30+
31+
- `objectui`'s `RecordDetailsRenderer` maps every `sections` entry as an object
32+
(`s.name` / `s.label` / `s.title` / `s.fields`) and has no string branch — a
33+
string entry would spread into a character map and render nothing;
34+
- `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared
35+
`Array<{ name?, label?, fields, ... }>`, and the Studio block designer can only
36+
author `{ label, columns, fields }`;
37+
- every page in this repo authors the object form — three showcase pages
38+
(`project-detail`, `task-detail`, `settings`) and the `sys_user` platform page;
39+
- `packages/lint` has modelled it as `nestedSections` (`sections[].fields[]`) all
40+
along.
41+
42+
So the "breakage" applies only to hypothetical stored metadata written against a
43+
declaration nothing ever honoured, and schema validation runs on the publish
44+
path — it does not rewrite data at rest.
45+
46+
**New: `hideFields`.** `z.array(z.string()).optional()` — field names omitted
47+
from the body, applied to `fields` and to every section's `fields`. The
48+
`sys_user` platform page has authored it since it shipped and the renderer reads
49+
it; it was undeclared, so it survived only because per-component `properties` is
50+
never parsed. Declaring it now means the parse gate (#5068) preserves it instead
51+
of silently stripping a live page's hidden-field list.
52+
53+
**Section keys**, each declared because it is delivered end to end:
54+
`fields` (required), `label`, `columns` (1-4), and `name` — the i18n anchor that
55+
resolves `objects.<object>._sections.<name>.label`, which `packages/lint`'s
56+
`translation-section-name-missing` rule tells authors to add.

content/docs/references/ui/component.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,9 @@ const result = AIChatWindowProps.parse(data);
291291
| :--- | :--- | :--- | :--- |
292292
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` || Number of columns for field layout (1-4) |
293293
| **layout** | `Enum<'auto' \| 'custom'>` || Layout mode: auto uses object highlightFields, custom uses explicit sections |
294-
| **sections** | `string[]` | optional | Section IDs to show (required when layout is "custom") |
294+
| **sections** | `{ name?: string; label?: string; columns?: integer; fields: string[] }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`. |
295295
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
296+
| **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) |
296297
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
297298

298299

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ regenerate.
2121
| Measure | Value |
2222
|---|---|
2323
| Triaged directories | 5 |
24-
| Object sites in them | 455 |
25-
| Still-open (strip) sites | 196 |
24+
| Object sites in them | 456 |
25+
| Still-open (strip) sites | 197 |
2626
| Files carrying at least one | 30 |
2727

2828
Remaining strip sites by class:
@@ -33,7 +33,7 @@ Remaining strip sites by class:
3333
| unresolved — needs a per-schema verdict | 33 |
3434
| wire / open — out of forced scope | 107 |
3535
| no door — no carrier, ADR-0049 territory | 14 |
36-
| no gate — carrier live, no parse | 29 |
36+
| no gate — carrier live, no parse | 30 |
3737

3838
## Posture, per triaged directory
3939

@@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th
4343

4444
| Dir | Sites | strict | passthrough | catchall | strip |
4545
|---|---|---|---|---|---|
46-
| `ui/` | 171 | 116 | 5 | 0 | 50 |
46+
| `ui/` | 172 | 116 | 5 | 0 | 51 |
4747
| `data/` | 162 | 54 | 1 | 0 | 107 |
4848
| `automation/` | 75 | 49 | 0 | 0 | 26 |
4949
| `security/` | 20 | 7 | 0 | 0 | 13 |
5050
| `studio/` | 27 | 27 | 0 | 0 | 0 |
51-
| **total** | **455** | **253** | **6** | **0** | **196** |
51+
| **total** | **456** | **253** | **6** | **0** | **197** |
5252

5353
## File-level triage — site counts
5454

@@ -65,7 +65,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
6565
| `app.zod.ts` | 18 |
6666
| `bulk-action.zod.ts` | 3 |
6767
| `chart.zod.ts` | 8 |
68-
| `component.zod.ts` | 29 |
68+
| `component.zod.ts` | 30 |
6969
| `dashboard.zod.ts` | 11 |
7070
| `dataset.zod.ts` | 4 |
7171
| `i18n.zod.ts` | 6 |
@@ -76,7 +76,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
7676
| `theme.zod.ts` | 6 |
7777
| `view.zod.ts` | 53 |
7878
| `widget.zod.ts` | 9 |
79-
| **total** | **171** |
79+
| **total** | **172** |
8080

8181
### `data/` — sites
8282

@@ -156,26 +156,26 @@ over it is here.
156156

157157
### `ui/` — open
158158

159-
**50 strip of 171**, in 7 file(s).
159+
**51 strip of 172**, in 7 file(s).
160160

161161
| File | Strip | Sites |
162162
|---|---|---|
163163
| `action-params.zod.ts` | 1 | 1 |
164164
| `app.zod.ts` | 1 | 18 |
165165
| `chart.zod.ts` | 2 | 8 |
166-
| `component.zod.ts` | 29 | 29 |
166+
| `component.zod.ts` | 30 | 30 |
167167
| `i18n.zod.ts` | 5 | 6 |
168168
| `view.zod.ts` | 3 | 53 |
169169
| `widget.zod.ts` | 9 | 9 |
170-
| **total** | **50** | **171** |
170+
| **total** | **51** | **172** |
171171

172172
| Bucket | Sites |
173173
|---|---|
174174
| authorable — the ruling's forced scope | 4 |
175175
| unresolved — needs a per-schema verdict | 0 |
176176
| wire / open — out of forced scope | 3 |
177177
| no door — no carrier, ADR-0049 territory | 14 |
178-
| no gate — carrier live, no parse | 29 |
178+
| no gate — carrier live, no parse | 30 |
179179

180180
### `data/` — open
181181

packages/lint/src/validate-page-field-bindings.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ describe('validatePageFieldBindings — highlights / KPI cards', () => {
8484
});
8585

8686
describe('validatePageFieldBindings — record:details real authored shape', () => {
87-
// Real pages author `sections: [{ label, fields }]`, which RecordDetailsProps
88-
// does not describe (it survives because `properties` is unvalidated).
87+
// Real pages author `sections: [{ label, fields }]` — the shape
88+
// `RecordDetailsProps` now declares too (#5611; it used to declare an ID
89+
// `string[]` that no page and no renderer ever used).
8990
it('walks sections[].fields[]', () => {
9091
const findings = validatePageFieldBindings({
9192
...baseStack(),

packages/lint/src/validate-page-field-bindings.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,11 @@ export interface ComponentFieldSpec {
164164

165165
export const COMPONENT_FIELD_SPECS: Readonly<Record<string, ComponentFieldSpec>> = {
166166
'record:highlights': { props: ['fields'] },
167-
// `sections`/`hideFields` are not in RecordDetailsProps, but every real page
168-
// authors them (they survive because `properties` is unvalidated).
167+
// `sections` (object form) and `hideFields` are what every real page authors,
168+
// and since #5611 they are what `RecordDetailsProps` declares — this model and
169+
// the spec agree. (Before that, `sections` was declared as an ID `string[]`
170+
// and `hideFields` not at all; both survived only because `properties` is
171+
// unvalidated.)
169172
'record:details': { props: ['fields', 'hideFields'], nestedSections: ['sections'] },
170173
'record:path': { props: ['statusField'] },
171174
'element:number': { props: ['field'] },
@@ -208,9 +211,11 @@ export function componentFieldRefs(
208211
const sections = Array.isArray(props[key]) ? (props[key] as unknown[]) : [];
209212
for (let si = 0; si < sections.length; si++) {
210213
const section = sections[si];
211-
// A `sections` that is a plain `string[]` (the shape `RecordDetailsProps`
212-
// actually declares — section IDs) yields nothing here, which is right:
213-
// those are not field names.
214+
// A non-object entry yields nothing here, which is right: lint runs on
215+
// unvalidated `properties`, so it must survive off-spec input rather than
216+
// throw on it. (Until #5611 the ID `string[]` this skips was the shape
217+
// `RecordDetailsProps` declared; it declares the object form now, so this
218+
// is a defensive guard rather than a divergence from the spec.)
214219
if (!isRec(section)) continue;
215220
refs.push(...fieldRefsFrom(section.fields, `${basePath}${sep}${key}[${si}].fields`));
216221
}

packages/spec/authorable-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7703,6 +7703,7 @@
77037703
"ui/RecordDetailsProps:aria",
77047704
"ui/RecordDetailsProps:columns",
77057705
"ui/RecordDetailsProps:fields",
7706+
"ui/RecordDetailsProps:hideFields",
77067707
"ui/RecordDetailsProps:layout",
77077708
"ui/RecordDetailsProps:sections",
77087709
"ui/RecordHighlightsProps:aria",

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

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,14 +149,88 @@ describe('RecordDetailsProps', () => {
149149
expect(result.sections).toBeUndefined();
150150
});
151151

152-
it('should accept custom layout with sections', () => {
153-
const details = { columns: '3' as const, layout: 'custom' as const, sections: ['sec-1', 'sec-2'] };
154-
expect(() => RecordDetailsProps.parse(details)).not.toThrow();
155-
});
156-
157152
it('should reject invalid column value', () => {
158153
expect(() => RecordDetailsProps.parse({ columns: '5' })).toThrow();
159154
});
155+
156+
// #5611: `sections` is the OBJECT form — the only form any page authors and
157+
// the only form any renderer reads. These fixtures are lifted verbatim from
158+
// the real pages so the schema is pinned to authored reality, not to a shape
159+
// invented here. Before this change every one of them was an `invalid_type`
160+
// rejection at `sections[0]` (the old `z.array(z.string())`), and the whole
161+
// `hideFields` key was silently stripped.
162+
it('accepts the showcase section shape verbatim (project-detail.page.ts:49)', () => {
163+
const details = {
164+
layout: 'custom' as const,
165+
sections: [
166+
{ label: 'Overview', columns: 2, fields: ['name', 'account', 'owner', 'status'] },
167+
{ label: 'Financials', columns: 2, fields: ['budget', 'spent'] },
168+
{ label: 'Timeline', columns: 2, fields: ['start_date', 'end_date'] },
169+
],
170+
};
171+
const result = RecordDetailsProps.parse(details);
172+
expect(result.sections).toHaveLength(3);
173+
expect(result.sections?.[0]).toEqual({
174+
label: 'Overview',
175+
columns: 2,
176+
fields: ['name', 'account', 'owner', 'status'],
177+
});
178+
// `columns: 1` is authored too (task-detail.page.ts:76).
179+
expect(() =>
180+
RecordDetailsProps.parse({ sections: [{ label: 'Details', columns: 1, fields: ['notes'] }] }),
181+
).not.toThrow();
182+
});
183+
184+
it('accepts a section with no columns (sys-user.page.ts:118)', () => {
185+
const result = RecordDetailsProps.parse({
186+
sections: [{ label: 'Identity', fields: ['name', 'image'] }],
187+
});
188+
expect(result.sections?.[0].columns).toBeUndefined();
189+
expect(result.sections?.[0].fields).toEqual(['name', 'image']);
190+
});
191+
192+
it('accepts an untitled section and a `name`-anchored one', () => {
193+
// No label: the renderer draws it borderless. No name: it is untranslatable
194+
// by construction, which is what `translation-section-name-missing` reports.
195+
expect(() => RecordDetailsProps.parse({ sections: [{ fields: ['notes'] }] })).not.toThrow();
196+
// `name` is the i18n anchor a lint rule tells authors to add, so the schema
197+
// must accept it — the rule and the schema cannot disagree.
198+
const named = RecordDetailsProps.parse({
199+
sections: [{ name: 'identity', label: 'Identity', fields: ['name'] }],
200+
});
201+
expect(named.sections?.[0].name).toBe('identity');
202+
});
203+
204+
it('requires `fields` on every section', () => {
205+
const r = RecordDetailsProps.safeParse({ sections: [{ label: 'Empty' }] });
206+
expect(r.success).toBe(false);
207+
expect(r.success === false && r.error.issues[0].path).toEqual(['sections', 0, 'fields']);
208+
});
209+
210+
it('rejects the retired ID-list form rather than silently half-reading it', () => {
211+
const r = RecordDetailsProps.safeParse({ layout: 'custom', sections: ['overview'] });
212+
expect(r.success).toBe(false);
213+
expect(r.success === false && r.error.issues[0].code).toBe('invalid_type');
214+
expect(r.success === false && r.error.issues[0].path).toEqual(['sections', 0]);
215+
});
216+
217+
it('rejects an out-of-range section column count', () => {
218+
expect(() =>
219+
RecordDetailsProps.parse({ sections: [{ label: 'Wide', columns: 5, fields: ['a'] }] }),
220+
).toThrow();
221+
});
222+
223+
it('preserves hideFields verbatim (sys-user.page.ts:106)', () => {
224+
// Undeclared until #5611, so a non-strict `z.object` dropped it on the
225+
// floor: the platform page's hidden-field list survived only because
226+
// nothing ever parsed these props.
227+
const hideFields = ['id', 'banned', 'ban_reason', 'ban_expires', 'email', 'role'];
228+
const result = RecordDetailsProps.parse({
229+
hideFields,
230+
sections: [{ label: 'Audit', fields: ['created_at', 'updated_at'] }],
231+
});
232+
expect(result.hideFields).toEqual(hideFields);
233+
});
160234
});
161235

162236
describe('RecordRelatedListProps', () => {

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

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,13 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod';
7676
// gate, not to close schemas nobody calls — filed as #5068, which also
7777
// records the two constraints that stop it being a drive-by: `type` is an open
7878
// union (unregistered types like `record:line_items` are authored in the wild),
79-
// and real pages already author shapes these schemas do not declare
80-
// (`record:details` `sections[].fields[]` / `hideFields[]`, the record picker's
81-
// `labelField` — see `packages/lint/src/validate-page-field-bindings.ts`, which
82-
// has documented the untyped bag all along).
79+
// and real pages already author shapes these schemas do not declare (the record
80+
// picker's `labelField` — see `packages/lint/src/validate-page-field-bindings.ts`,
81+
// which has documented the untyped bag all along). `record:details`
82+
// `sections[]` / `hideFields[]` WAS the largest such divergence and is now
83+
// closed: #5611 re-declared `sections` in the object form every page actually
84+
// authors and declared `hideFields`, so wiring the gate no longer turns three
85+
// showcase pages and the `sys_user` platform page into hard parse errors.
8386
//
8487
// When #5068 lands, this file becomes `authorable` and the ratchet applies. The
8588
// verdict is pinned in `component.test.ts` and in the `ui/` tables of
@@ -157,8 +160,56 @@ export const PageCardProps = z.object({
157160
export const RecordDetailsProps = z.object({
158161
columns: z.enum(['1', '2', '3', '4']).default('2').describe('Number of columns for field layout (1-4)'),
159162
layout: z.enum(['auto', 'custom']).default('auto').describe('Layout mode: auto uses object highlightFields, custom uses explicit sections'),
160-
sections: z.array(z.string()).optional().describe('Section IDs to show (required when layout is "custom")'),
163+
/**
164+
* Field groups rendered as the detail body, IN ORDER.
165+
*
166+
* Declared as the object form because that is the only form anything
167+
* delivers or authors (#5611). Until 17.x this key was `z.array(z.string())`
168+
* — "section IDs" — which no page in this repo, and no read path in
169+
* `objectui`, has ever used: `RecordDetailsRenderer` maps every entry as an
170+
* object (`s.name` / `s.label` / `s.fields`) with no string branch anywhere,
171+
* `@object-ui/types`' `RecordDetailsComponentProps` mirror declares the
172+
* object form, and the Studio block designer can only author
173+
* `{label, columns, fields}`. The ID-list spelling was a declaration with no
174+
* producer and no consumer, so it is gone rather than unioned in: one shape,
175+
* not two de-facto contracts (Prime Directive #12).
176+
*/
177+
sections: z.array(z.object({
178+
/**
179+
* Stable section identifier, snake_case. This is the i18n anchor: the
180+
* heading resolves through `objects.<object>._sections.<name>.label`, so a
181+
* section WITHOUT a name renders its authored `label` in every locale.
182+
* `packages/lint`'s `translation-section-name-missing` rule exists to tell
183+
* authors to add it, which is why it is declared here — a key one rule
184+
* demands must not be a key the schema rejects.
185+
*/
186+
name: z.string().optional().describe('Stable section identifier for i18n lookup (snake_case) — resolves `objects.<object>._sections.<name>.label`; a nameless section renders its authored label in every locale'),
187+
/** Heading text. Omit for an untitled section, which renders borderless. */
188+
label: I18nLabelSchema.optional().describe('Section heading (omit for an untitled, borderless section)'),
189+
/**
190+
* Field-grid width for THIS section; falls back to the renderer's own
191+
* derivation when omitted.
192+
*
193+
* An int range rather than `z.union([z.literal(1), …])` — same accepted set
194+
* (1-4), but the docs generator renders numeric literals as QUOTED strings
195+
* (`'1' | '2'`, see `FormSectionSchema.columns` in `references/ui/view.mdx`),
196+
* which would tell an author to write `columns: '2'` where this key requires
197+
* `2`. Shipping a reference that misdocuments the key is the exact harm
198+
* #5611 is fixing, so the shape that documents itself truthfully wins.
199+
*/
200+
columns: z.number().int().min(1).max(4).optional().describe('Field-grid columns for this section (1-4). Omitted → the renderer derives the width.'),
201+
/** Field names shown in this section, in order. */
202+
fields: z.array(z.string()).describe('Field names rendered in this section, in order'),
203+
})).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`.'),
161204
fields: z.array(z.string()).optional().describe('Explicit field list to display (optional, overrides highlightFields)'),
205+
/**
206+
* Field names to omit from the body, applied to both `fields` and every
207+
* section's `fields`. Authored by the published `sys_user` platform page and
208+
* read by `RecordDetailsRenderer`; it was simply never declared, so the
209+
* (unvalidated) props bag carried it. Declared now so the enforcement to come
210+
* does not silently strip a live platform page's hidden-field list.
211+
*/
212+
hideFields: z.array(z.string()).optional().describe('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)'),
162213
/** ARIA accessibility */
163214
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
164215
});

0 commit comments

Comments
 (0)