Skip to content

docs(layout): name page:header as the canonical author key on the page-header docs (Phase 1) - #5922

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-3906-page-header-canonical-key
Aug 24, 2026
Merged

docs(layout): name page:header as the canonical author key on the page-header docs (Phase 1)#5922
os-zhuang merged 1 commit into
mainfrom
claude/issue-3906-page-header-canonical-key

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Part of #3906 — Phase 1 of the 2026-08-09 maintainer ruling (direction (b), phased). The reference is deliberately non-closing: the ruling keeps this card open for Phase 2 (the full-page rewrite to the canonical renderer, serialized behind #3789) and says the card is re-marked pm:blocked / Blocked-by: #3789 once Phase 1 lands. A closing keyword here would silently retire the card Phase 2 needs. My dispatch word asked for the closing spelling instead; the divergence is flagged to the PM seat in the report.

What was wrong

The layout page-header docs page taught the legacy alias as the author face. Its only live demo — examples/schema-catalog/src/schemas/layout-page-header/pageheader-with-actions.json — opens with "type": "page-header", and no line on the page said what the contract's key is. content/docs/guide/layout.md did the same in its PageHeader section (a json Usage snippet and a typescript Schema API block, both keyed page-header).

Premise re-verified on today's origin/main (0fce2ef81) before writing: still true, nothing had corrected it.

Measurement 1 — the canonical key, read from the SCHEMA

Read from the installed @objectstack/spec (not from another doc page), each line with a control:

[1] ComponentPropsMap membership (shape membership, not a passthrough parse)
has("page:header") = true
has("page-header") = false
has("layout:page-header") = false
CONTROL — a key known present: has("page:tabs") = true
total rows: 42
[2] PageComponentType enum membership
includes("page:header") = true
includes("page-header") = false
CONTROL — a value known present: includes("page:footer") = true
enum size: 34

Third, independent statement: PageHeaderProps is declared strictObject({ surface: 'this page:header', ... }) (src/ui/component.zod.ts:393), so the shape names itself — and its rejection messages quote that name back, as shown below.

ComponentPropsMap['page:header'] = PageHeaderProps is at src/ui/component.zod.ts:2428; 'page:header' sits in PageComponentType at src/ui/page.zod.ts:56.

Measurement 2 — what the alias actually does today

State: still accepted. Not tombstoned, not silently dropped.

  • packages/layout/src/index.ts:121ComponentRegistry.register('page-header', PageHeader, { namespace: 'layout', ... }), with no skipFallback, and Registry.ts:219 registers the bare key alongside layout:page-header in exactly that case. So a page-header node renders today, and it is what this page's demo renders.
  • It is a different renderer from the canonical node: page:header comes from ComponentRegistry.register('header', PageHeaderRenderer, { namespace: 'page', skipFallback: true }) in packages/components/src/renderers/layout/containers.tsx:1744 — the record-aware one.
  • The alias carries no contract. A node's props are validated by dispatching type through ComponentPropsMap; page-header has no row, and PageComponentSchema.properties is an open z.record(z.string(), z.unknown()) bag (src/ui/page.zod.ts). So a misspelled or retired key written under the alias is neither rejected nor reported.

Note on method: BaseSchema being .passthrough() makes "the alias survives safeParse" a phantom assertion — true of any key — so the readings above are shape membership plus the parser's own named verdicts:

[3] PageHeaderProps parse verdicts
canonical, declared keys only: OK -> output keys = ["title","subtitle","breadcrumb","recordChrome","showStar","showCopyId"]
retired key `icon`: REJECTED code=invalid_type path=["icon"]
message: `page:header` property `icon` was removed in @objectstack/spec ... (ADR-0087 D2) — no renderer ever read it ...
alias key `description`: REJECTED code=unrecognized_keys path=[]
message: Unrecognized key(s) on this `page:header`: `description`. Did you mean `description` → `subtitle`? ...
nonsense key (neg. control): REJECTED code=unrecognized_keys path=[]
message: Unrecognized key(s) on this `page:header`: `zzzNotAKey`. ...

The nonsense key is the negative control that makes the first three readings mean something.

Docs-surface sweep, with a control

Scope searched: the whole repo minus node_modules / .git / dist, for the alias written as an author key —

grep -rn -E '"type"[[:space:]]*:[[:space:]]*"page-header"|type:[[:space:]]*.page-header.'

Control (same command shape, a string I had read with my own eyes in pageheader-with-actions.json):"type": "button" → 181 hits repo-wide, including the two in that file. The search is not broken.

Hits, and the disposition of each:

HitIn fence?Action
content/docs/layout/page-header.mdx (the page itself; alias taught via the demo)yesbanner added
content/docs/guide/layout.md:235 (json Usage) and :251 (typescript Schema API)yesnote added above ### Usage
examples/schema-catalog/src/schemas/layout-page-header/pageheader-with-actions.json:2no — Phase 2untouched: re-keying the demo swaps in a different renderer and invalidates the page's measured Styling/Props
examples/schema-catalog/test/pageheader-with-actions.test.tsx (3 sites)no — out of surfaceuntouched
packages/layout/CHANGELOG.md:108nountouched (historical record)

content/docs/guide/slotted-pages.md already teaches page:header correctly and is left alone.

Before / after for every instance changed

1. content/docs/layout/page-header.mdx — before: the page opened straight into The PageHeader component provides a consistent header…, with the first mention of page:header 39 lines down and framed as a prop discussion. After: a banner immediately under the frontmatter that names the canonical key, states the alias's measured state in the three-way vocabulary (accepted / tombstoned / dropped → accepted), says what the alias costs an author (no props dispatch), and points at Slotted pages for the canonical node. No existing alias mention was deleted — the alias still renders, and authors with stored documents need to know where they stand.

2. content/docs/guide/layout.md — before: ## PageHeader Component → intro → ### Usage with "type": "page-header", no statement of the canonical key anywhere in the section. After: the same truth in one blockquote between the intro and ### Usage, so it sits above the snippet a reader copies. The snippets themselves are unchanged — they document the layout component, whose props (icon, showBack, children) are not the canonical node's.

Neither edit touches the schema, the registry or a renderer: the page was what was wrong.

Verification

Gate union re-run after the final commit, at 1086a1215. Exit codes captured by redirect-then-capture (never across a pipe); verdict lines quoted as each gate printed them.

GateExitIts own verdict line
check-doc-links.mjs0Links are valid across 13 scan roots.
check-doc-component-types.mjs0✅ Every documented component type is registered. (183 doc files, 1055 code blocks, 882 type literals)
check-changeset-presence.mjs0✅ No source of a released package changed in this range, so no changeset is owed. — followed, so no changeset is added
check-changeset-no-major.mjs0✅ No changeset declares a major bump.
check-control-bytes.mjs0✅ check-control-bytes: OK (scanned 4921 tracked text file(s); skipped 85 binary).
check-skills-paths.mjs0✅ check-skills-paths: OK (93/94 stated path(s) resolve across 18 guide file(s); 1 baselined).

No ablation. This is a docs-only diff — there is no guard to delete and re-measure. Saying so plainly rather than staging a theatrical one. The substitute evidence is Measurements 1 and 2 above, each with its control.

check-doc-snippet-types.mjs — NOT RUN, and that is a precondition, not a verdict. It exited 1 with:

The snippet program was NOT run: the packages it resolves against are not built, or are typed from source.

naming 15 unbuilt packages (packages/types, packages/core, packages/components, …). That state is identical with and without this diff — a markdown edit cannot build or unbuild a package. Reported as not-run rather than claimed green. What makes it a safe not-run for this diff specifically:

  • content/docs/layout/page-header.mdx is covered by the gate (not in UNGATED_DOCS), but it contains 0ts/tsx fences — control: content/docs/guide/slotted-pages.md has 4 and content/docs/guide/architecture.md has 9, counted by the same command. The gate compiles nothing from it either way.
  • content/docs/guide/layout.mdis in the gate's UNGATED_DOCS debt list, so its green would have said nothing about that page regardless.
  • This diff adds 0 code fences of any language, so there are no samples of mine to verify against built types.

Lint narrowing, declared with its three pieces of evidence. The repo-wide pnpm lint (turbo run lint) was not run; the narrowing is a measurement, not a skip:

  1. Population read from eslint's own configuration, not from a guess about which files count: npx eslint --no-inline-config --format json on both changed files reports, for each, File ignored because no matching configuration was supplied.
  2. File count read from --format json: 2 results, errorCount: 0, and the only warning on each is that ignore notice — i.e. 0 linted files in this diff.
  3. Invariance for untouched files: the diff is two .md/.mdx files (git diff --name-only), no source, no config, no tsconfig. eslint applies no configuration to those extensions, so no rule's verdict on any untouched file can move.

Control bytes, self-scanned beyond the gate: a control-character grep over both changed files returns no match (exit 1). Positive control: the same command over a file holding one \x01 byte matches (exit 0).

Not touched, deliberately

⛔ No schema, registry or renderer change. ⛔ Nothing under packages/. ⛔ The demo JSON stays on the alias — re-keying it is Phase 2 work and would invalidate the page's measured Styling section, which was taken against this renderer. ⛔ Not marked ready, no auto-merge.


Generated by Claude Code

…e-header docs
The layout `page-header` docs page taught the legacy alias as the author face:
its only live demo writes `"type": "page-header"`, and nothing on the page said
that the contract's key is `page:header`. The layout guide's PageHeader section
did the same in two snippets.
Phase 1 of the 2026-08-09 maintainer ruling (direction (b), phased): a banner
that tells the truth about the key's status now, so an AI author copying from
the page copies the canonical spelling, ahead of the full-page rewrite that is
serialized behind objectui#3789.
Measured against the installed @objectstack/spec, with controls:
- ComponentPropsMap has `page:header` (row -> PageHeaderProps), and neither
`page-header` nor `layout:page-header`; control `page:tabs` = present.
- PageComponentType includes `page:header`, not `page-header`; control
`page:footer` = present.
- PageHeaderProps.safeParse rejects `icon` by name (retiredKey) and
`description` as an unrecognized key of "this `page:header`", with a
nonsense key as the negative control.
- The alias is STILL ACCEPTED, not tombstoned and not dropped:
registerLayout() registers `page-header` (namespace `layout`, no
skipFallback, so the bare key too), and it is what the page's demo renders.
No schema, registry or renderer change; the demo and the page's measured
Styling/Props stay on the alias's renderer, which is Phase 2's job.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude