Skip to content

Commit d2363e7

Browse files
fix(layout): stop the page-header alias advertising description as an input (#3265)
`registerLayout()` declared `inputs: [title, description]` for the legacy kebab alias, while `@objectstack/spec/ui`'s `PageHeaderProps` — and the canonical `page:header` renderer in `@object-ui/components` — declare `subtitle`. `inputs` is a DECLARATION surface (the designer palette, the framework's `check:react-declaration-parity`), so this did not tolerate a legacy spelling, it published a second dialect and taught authors a non-spec key. Metadata that took the offer renders a subtitle under `page-header` and loses it under `page:header`. The runtime `subtitle ?? description` read in `PageHeader.tsx` stays on purpose: the alias exists for out-of-repo consumer schemas, so zero in-repo hits is not evidence nobody writes it, and dropping the read would silently delete an external page's second line. It is retired with the ADR-0087 D2 conversion entry `page-header-subtitle-alias`, which lives in the framework repo. Narrowing the declaration is unconditional and changes no runtime behaviour. New tests pin both halves: the registration may not declare `description`, must declare `subtitle`, and may declare nothing the spec's own shape does not; the runtime fallback is pinned as a sequencing guard, to be deleted together with the conversion entry. Refs #3226 Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa Co-authored-by: Claude <noreply@anthropic.com>
1 parent 825bbe3 commit d2363e7

3 files changed

Lines changed: 171 additions & 2 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@object-ui/layout": patch
3+
---
4+
5+
The legacy `page-header` alias stops advertising `description` as an authorable
6+
key (objectui#3226).
7+
8+
FROM: `registerLayout()` declared `inputs: [title, description]`. TO:
9+
`inputs: [title, subtitle]` — the key `@objectstack/spec/ui`'s `PageHeaderProps`
10+
declares, and the one the canonical `page:header` renderer in
11+
`@object-ui/components` already declares.
12+
13+
`inputs` is a DECLARATION surface, not documentation: the designer builds its
14+
property palette from it, and the framework's `check:react-declaration-parity`
15+
diffs it against the spec schemas. Declaring `description` therefore did not
16+
merely tolerate a legacy spelling — it published a second dialect for the one
17+
concept the protocol calls `subtitle`, and told authors (an AI author most
18+
readily, since the registry is what it reads to learn the shape) that the
19+
non-spec key was legal. Metadata that took the offer renders a subtitle under
20+
`page-header` and silently loses it under `page:header`: same JSON, two results,
21+
which is the outcome a single contract exists to prevent.
22+
23+
No runtime behaviour changes. `PageHeader` still reads `subtitle ?? description`,
24+
deliberately: this alias exists for out-of-repo consumer schemas, so "no in-repo
25+
author writes `description`" (verified — zero hits) is not evidence that nobody
26+
does, and dropping the read today would delete an external page's second line
27+
while its title kept rendering, the least reportable failure mode there is. That
28+
read is retired together with an ADR-0087 D2 conversion entry
29+
(`page-header-subtitle-alias`, `description``subtitle` rewritten at load
30+
time), which lives in the framework repo and is tracked separately. Narrowing the
31+
declaration did not need to wait on it and breaks no consumer; leaving the
32+
declaration wrong in the meantime keeps minting the metadata the conversion would
33+
then have to absorb.
34+
35+
New tests pin both halves so neither can drift back: the registration may not
36+
declare `description`, must declare `subtitle`, and — checked against the spec's
37+
own shape rather than a hand-written allowlist — may declare nothing
38+
`@objectstack/spec` does not; while the runtime fallback is pinned as a sequencing
39+
guard, to be deleted in the same change that lands the conversion entry.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The `page-header` alias declares only keys `@objectstack/spec` declares
11+
* (objectui#3226).
12+
*
13+
* The legacy kebab key `page-header` and the canonical protocol key
14+
* `page:header` (in `@object-ui/components`) render the same concept, but this
15+
* one used to DECLARE a different authorable key for the secondary line:
16+
* `description`, where the spec's `PageHeaderProps` — and therefore
17+
* `page:header` — declares `subtitle`. That is not a tolerated legacy spelling,
18+
* it is a second dialect published on the declaration surface: `inputs` is what
19+
* the designer offers as fields and what the framework's
20+
* `check:react-declaration-parity` diffs against the spec schemas, so an author
21+
* (especially an AI one) was being TOLD `description` was legal. Metadata that
22+
* took the offer renders a subtitle under `page-header` and silently loses it
23+
* under `page:header` — the same JSON, two results, which is the failure mode
24+
* one contract exists to prevent.
25+
*
26+
* The cross-check below is deliberately derived from the spec's own shape
27+
* rather than a hand-written allowlist: a future input added here that the spec
28+
* does not declare fails for the same reason `description` did, without anyone
29+
* having to remember this issue.
30+
*
31+
* SEQUENCING — read before "finishing the job". `PageHeader.tsx` still READS
32+
* `subtitle ?? description` at runtime, and the last test in this file pins
33+
* that on purpose. The alias exists precisely for out-of-repo consumer schemas,
34+
* so "no in-repo author writes `description`" (true, verified) says nothing
35+
* about whether anyone does; dropping the read today would delete an external
36+
* page's second line while its title kept rendering — the least reportable
37+
* failure there is. The read goes away together with the ADR-0087 D2 conversion
38+
* entry `page-header-subtitle-alias` (`description` → `subtitle` rewritten at
39+
* load time), which lives in the framework repo. Narrowing the DECLARATION did
40+
* not need to wait on it and changes no runtime behaviour; deleting the READ
41+
* does. When that conversion lands, delete the fallback AND the last test here
42+
* in one change.
43+
*/
44+
45+
import { describe, it, expect, beforeAll } from 'vitest';
46+
import { render, screen } from '@testing-library/react';
47+
import { ComponentRegistry } from '@object-ui/core';
48+
import { PageHeaderProps as SpecPageHeaderProps } from '@objectstack/spec/ui';
49+
50+
import { registerLayout, PageHeader } from '../index';
51+
52+
/** Authorable keys of the spec node this renderer serves. */
53+
const specKeys = new Set(Object.keys(SpecPageHeaderProps.shape));
54+
55+
const declaredInputNames = (type: string, namespace?: string): string[] => {
56+
const config = ComponentRegistry.getConfig(type, namespace);
57+
if (!config) throw new Error(`"${namespace ? `${namespace}:${type}` : type}" is not registered`);
58+
return (config.inputs ?? []).map((input) => input.name);
59+
};
60+
61+
beforeAll(() => {
62+
registerLayout();
63+
});
64+
65+
describe('the `page-header` registration declares the spec key, not a dialect', () => {
66+
it('is registered under the bare key and its namespace', () => {
67+
expect(ComponentRegistry.getConfig('page-header')).toBeTruthy();
68+
expect(ComponentRegistry.getConfig('page-header', 'layout')).toBeTruthy();
69+
});
70+
71+
// The one assertion this issue is about: whatever else changes, the
72+
// declaration surface must never advertise `description` again.
73+
it.each([
74+
['page-header', undefined],
75+
['page-header', 'layout'],
76+
])('does not advertise `description` on %s (namespace: %s)', (type, namespace) => {
77+
expect(declaredInputNames(type, namespace)).not.toContain('description');
78+
});
79+
80+
it('declares `subtitle` — the spec key for the secondary line', () => {
81+
expect(declaredInputNames('page-header')).toContain('subtitle');
82+
expect(specKeys.has('subtitle')).toBe(true);
83+
// …and the spec has no `description` at all, which is the whole reason the
84+
// old declaration was wrong rather than merely redundant.
85+
expect(specKeys.has('description')).toBe(false);
86+
});
87+
88+
it('declares nothing `@objectstack/spec` does not', () => {
89+
const offSpec = declaredInputNames('page-header').filter((name) => !specKeys.has(name));
90+
expect(offSpec).toEqual([]);
91+
});
92+
});
93+
94+
describe('the runtime `description` fallback stays until the conversion entry lands', () => {
95+
// NOT an endorsement of the alias — a guard on the ORDER. Removing this read
96+
// before `page-header-subtitle-alias` exists is the deletion route that was
97+
// considered and rejected: external schemas authored with `description` would
98+
// lose their subtitle silently. Delete this test in the same change that
99+
// deletes the fallback, once the conversion rewrites the key upstream.
100+
it('still renders a legacy `description` as the secondary line', () => {
101+
render(<PageHeader title="Customer Details" description="View and edit customer information" />);
102+
expect(screen.getByText('View and edit customer information')).toBeTruthy();
103+
});
104+
105+
it('lets the spec key win when both are present', () => {
106+
render(<PageHeader title="Customer Details" subtitle="From the spec" description="From the alias" />);
107+
expect(screen.getByText('From the spec')).toBeTruthy();
108+
expect(screen.queryByText('From the alias')).toBeNull();
109+
});
110+
});

packages/layout/src/index.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,33 @@ export function registerLayout() {
2727
// namespace. We intentionally do NOT re-register `page:header` here —
2828
// doing so would (depending on package load order) clobber the
2929
// record-aware renderer in components with this thinner one.
30+
//
31+
// `inputs` declares the AUTHORABLE surface, and it must name the same keys
32+
// the spec does — the designer and the framework's
33+
// `check:react-declaration-parity` read this list and treat everything in it
34+
// as a legal input (objectui#3226). `@objectstack/spec/ui`'s
35+
// `PageHeaderProps` declares `subtitle`; it has no `description`. This list
36+
// used to declare `description`, so the alias did not merely tolerate a
37+
// legacy spelling — it ADVERTISED a second dialect for the one concept
38+
// `page:header` calls `subtitle`, and metadata authored against it renders
39+
// a subtitle here and nothing at all under the canonical key.
40+
//
41+
// The runtime `subtitle ?? description` fallback in `PageHeader.tsx` stays
42+
// for now ON PURPOSE: this alias exists for out-of-repo consumer schemas, so
43+
// "no in-repo author writes `description`" is not evidence that nobody does,
44+
// and dropping the read would silently delete their second line. That read
45+
// is retired together with an ADR-0087 D2 conversion entry
46+
// (`page-header-subtitle-alias`, `description` → `subtitle` at load time),
47+
// which lives in the framework repo. Narrowing the DECLARATION is
48+
// unconditional and independent of that: it breaks no consumer, and it stops
49+
// the registry from teaching the wrong key in the meantime.
3050
ComponentRegistry.register('page-header', PageHeader, {
3151
namespace: 'layout',
3252
label: 'Page Header',
3353
category: 'Layout',
3454
inputs: [
35-
{ name: 'title', type: 'string' },
36-
{ name: 'description', type: 'string' }
55+
{ name: 'title', type: 'string', label: 'Title' },
56+
{ name: 'subtitle', type: 'string', label: 'Subtitle' }
3757
]
3858
});
3959

0 commit comments

Comments
 (0)