diff --git a/.changeset/pageheader-title-optional.md b/.changeset/pageheader-title-optional.md new file mode 100644 index 0000000000..95d63afc10 --- /dev/null +++ b/.changeset/pageheader-title-optional.md @@ -0,0 +1,31 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec): `PageHeaderProps.title` is optional — matches the platform's own synthesized header (#7702) + +`PageHeaderProps.title` was declared **required**, but the platform's own +synthesizer (objectui `buildDefaultHeader`) emits every seeded `page:header` +with **no `title` at all** — `{ type: 'page:header', recordChrome, …actions }`. +`PageHeaderRenderer` (`containers.tsx`) reads +`schema?.title ?? schema?.properties?.title` and, finding neither, falls +through to the record chip's own record-derived heading: a static authored +title would be wrong on every record but one. `PageHeaderProps.safeParse` +therefore rejected the platform's own canonical output with `title: Invalid +input` — invisible on the write path today (`PageComponent.properties` is an +opaque `z.record`), but a standing contradiction that surfaces the moment any +props-level validation runs against a header node (`validateComponentProps`, +#5068, is exactly that consumer). + +Maintainer ruling 2026-08-11 (accepting the spec lane's A/B recommendation, +rejecting a sentinel-value option C): `title` becomes optional, and its +describe states the sanctioned spelling — **title omitted ⇒ the renderer +derives the heading from the record**. Authors still set it explicitly on +non-record pages (dashboards, landing pages) where there is no record to +derive a heading from. + +This is a widening change: every payload that validated before (with `title`) +still validates identically, and `title`, when present, still parses as +`I18nLabelSchema` exactly as before. The only newly-accepted shape is a +`page:header` with `title` omitted — the platform's own default. Minor, not +patch, because the accepted-input surface grows. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index a63ca03a17..db2a4dba5a 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -241,7 +241,7 @@ const result = AIChatWindowProps.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **title** | `string \| Record` | ✅ | Page title | +| **title** | `string \| Record` | optional | Page title. Omit to let the renderer derive the heading from the record (the default for record pages) — set explicitly on non-record pages (dashboard, landing) with no record to derive from. | | **subtitle** | `string \| Record` | optional | Page subtitle | | **icon** | `never` | optional | [REMOVED] `page:header` property `icon` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the header's own props bag, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key. The header's own identity is drawn by the record chrome (`recordChrome`, on by default) and each action carries its own `icon`. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **breadcrumb** | `boolean` | ✅ | Show breadcrumb | diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 06791cf1a9..808f0c284e 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -169,6 +169,26 @@ describe('validateComponentProps — value verdicts', () => { ); }); + /** + * #7702 — `PageHeaderProps.title` used to be required while the platform's + * own synthesizer (objectui `buildDefaultHeader`) emits every seeded + * `page:header` with NO `title` at all: `{ type: 'page:header', recordChrome + * }`. `validateComponentProps` (this rule) would therefore flag the + * platform's own default header as `component-props-invalid` on every + * synthesized record page. Maintainer ruling 2026-08-11: `title` is + * optional. This pins the exact synthesized shape as clean — no `invalid` + * finding for a missing `title`, ever. + */ + it('does not flag the synthesized page:header (no title) as invalid (#7702)', () => { + const findings = validateComponentProps( + stackWith([{ type: 'page:header', properties: { recordChrome: true } }]), + ); + expect( + invalid(findings).filter((f) => f.path.endsWith('.properties.title')), + ).toEqual([]); + expect(findings).toEqual([]); + }); + /** * `ElementDataSourceSchema` is the component-node binding that "overrides * page-level object context", and objectui's element renderers read it FIRST diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 19007d35cc..d0c70e4600 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -43,8 +43,28 @@ describe('PageHeaderProps', () => { expect(result.actions).toHaveLength(2); }); - it('should reject header without title', () => { - expect(() => PageHeaderProps.parse({})).toThrow(); + // #7702, maintainer ruling 2026-08-11: `title` is OPTIONAL. The platform's + // own synthesizer (objectui `buildDefaultHeader`) emits every seeded + // `page:header` with no `title` — the renderer falls through to the + // record-derived heading. `PageHeaderProps.safeParse` on that exact + // emission shape must succeed; it used to fail with `title: Invalid input`. + it('accepts a header without title — the synthesized shape (#7702)', () => { + // objectui `buildDefaultHeader`'s real emission: `{ type: 'page:header', + // recordChrome, ...(actions?) }` — no `title` key at all. + const result = PageHeaderProps.parse({ recordChrome: true }); + expect(result.title).toBeUndefined(); + expect(result.recordChrome).toBe(true); + }); + + it('accepts a completely empty header — every field optional or defaulted', () => { + const result = PageHeaderProps.parse({}); + expect(result.title).toBeUndefined(); + expect(result.breadcrumb).toBe(true); + }); + + it('still validates a present title as an I18nLabel', () => { + expect(() => PageHeaderProps.parse({ title: 42 })).toThrow(); + expect(PageHeaderProps.parse({ title: 'My Page' }).title).toBe('My Page'); }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 3c411bd2f4..e529fe9f16 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -222,7 +222,22 @@ export type PageContainerProps = z.input; */ export const PageHeaderProps = z.object({ - title: I18nLabelSchema.describe('Page title'), + /** + * Page title (#7702, maintainer ruling 2026-08-11 「接受你的建议,开始加速处理」 + * on the lane's A/B recommendation). OPTIONAL, not required: the platform's + * own synthesizer (objectui `buildDefaultHeader`) emits every seeded + * `page:header` with no `title` at all — `PageHeaderRenderer` + * (`containers.tsx:1013`) reads `schema?.title ?? schema?.properties?.title` + * and, finding neither, falls through to the record chip's own + * record-derived heading. A required `title` would reject the platform's + * own canonical output. Sanctioned spelling: title omitted ⇒ the renderer + * derives the heading from the record. Authors still set it explicitly for + * non-record pages (dashboards, landing pages) where there is no record to + * derive from. + */ + title: I18nLabelSchema.optional().describe( + 'Page title. Omit to let the renderer derive the heading from the record (the default for record pages) — set explicitly on non-record pages (dashboard, landing) with no record to derive from.', + ), subtitle: I18nLabelSchema.optional().describe('Page subtitle'), /** * REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3829,