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

os-zhuang merged 1 commit into
mainfrom
claude/issue-3906-page-header-canonical-key

Conversation

@claude

@claude claude Bot 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:121 — ComponentRegistry.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:

Hit In fence? Action
content/docs/layout/page-header.mdx (the page itself; alias taught via the demo) yes banner added
content/docs/guide/layout.md:235 (json Usage) and :251 (typescript Schema API) yes note added above ### Usage
examples/schema-catalog/src/schemas/layout-page-header/pageheader-with-actions.json:2 no — Phase 2 untouched: 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 surface untouched
packages/layout/CHANGELOG.md:108 no untouched (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.

Gate Exit Its own verdict line
check-doc-links.mjs 0 Links are valid across 13 scan roots.
check-doc-component-types.mjs 0 ✅ Every documented component type is registered. (183 doc files, 1055 code blocks, 882 type literals)
check-changeset-presence.mjs 0 ✅ 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.mjs 0 ✅ No changeset declares a major bump.
check-control-bytes.mjs 0 ✅ check-control-bytes: OK (scanned 4921 tracked text file(s); skipped 85 binary).
check-skills-paths.mjs 0 ✅ 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 0 ts/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.md is 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
@os-zhuang
os-zhuang marked this pull request as ready for review August 24, 2026 01:57
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 24, 2026
Merged via the queue into main with commit 165c681 Aug 24, 2026
20 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-3906-page-header-canonical-key branch August 24, 2026 01:58
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Oct 7, 2026
…ode (objectui#3906) (objectstack-ai#11162)

Fixes objectstack-ai#3906
Clause-②: no

Phase 2 of the maintainer ruling on objectui#3906 (comment 5230005161,
direction (b)): the PageHeader docs page's author face becomes the
canonical `page:header` node. Phase 1 (the truth banner) landed as PR
objectstack-ai#5922; this is the last phase.

## What changed

- **`content/docs/layout/page-header.mdx`, rewritten.** Every example,
the property table, both layouts, the styling numbers, accessibility and
responsive behaviour are measured against `PageHeaderRenderer` in
`@object-ui/components` and the spec's `PageHeaderProps` (the
`ComponentPropsMap['page:header']` row). No number was carried over from
the old page or from `@object-ui/layout`'s `PageHeader`. The section
"Gating a header action on a relation field" was already canonical-true
and is kept as it was. The Phase-1 banner gives way to the rewritten
page, and the `page-header` / `layout:page-header` alias is named once,
as an alias, in its own short section.
- **`content/docs/guide/layout.md`, the PageHeader section only**, moved
to the same key. The hunk near the page node's button `label` that draft
PR objectstack-ai#11069 edits is untouched.
- **The catalog demo is re-keyed to `page:header`**, with its props in
`properties`, and renamed `layout-page-header/pageheader-with-actions`
to `layout-page-header/basic-page-header` (index regenerated with
`scripts/regenerate-catalog-index.py`, which moved only this entry).
- **The demo's test** re-derives its pins for the canonical node: shape,
a real `SchemaRenderer` render, the contract, and the registration's
declared inputs. The objectui#3787 hand-rolled-copy guard stays, now
checked against the canonical renderer's classes.
- An empty-frontmatter changeset,
`.changeset/3906-page-header-canonical-docs.md`, declares that nothing
is released.

## What the demo became, and why (the dispatch asked me to say which)

The old demo was `page-header` with `icon` and two `button` children. I
measured the canonical node, and neither shape carries over:

- **It draws no `children`.** A real render leaves the buttons out.
objectui's mirror refuses the key on this node (`children:
invalid_type`, objectui#9256), and the manifest reports
`not-a-container`.
- **`icon` is refused by name.** It is an ADR-0087 D2 tombstone on
`PageHeaderProps`.
- **`actions` can't stand in for the buttons.** It holds action IDS, and
those resolve against the object bound to the page. A docs demo has no
bound object, so ids render nothing and the renderer warns once per id.
Inline action objects are refused by the contract (`actions.0: expected
string`).

So the demo shows only title and subtitle, which is the shape the
canonical node accepts and can render here. That is why it no longer
carries "with actions" in its id. The page explains why the live demo
has no actions and links to Slotted pages for the header action row.

## Why `properties`, measured

- The spec's page component shape refuses `title`/`subtitle` written
beside `type` (`unrecognized_keys`).
- objectui's `safeValidateSchema` judges `page:header` props only inside
`properties`. Written beside `type`, even `subTitle` or `icon` goes
through unchecked.
- `SchemaRenderer` moves `properties.*` up onto the node before the
renderer reads it, so both spellings render. Only the `properties`
spelling is validated, so it is the one the page teaches.

## Beyond the claim's file surface (declared, not silent)

The claim lists four paths. Three more files had to move, because my
change turned two tests outside that list red:

- `packages/types/src/__tests__/page-actions-refusal-7926.test.ts`: its
LIT CONTROL scans `guide/layout.md` for a `page-header` JSON fence with
`actions`. Its own comment says it is really looking for the action-id
channel of `page:header`. It now looks for `page:header` with
`properties.actions`. This is the same defect class as this card (the
alias taught as the header's key), in a test that reads the guide.
- `examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx`:
`NODE_CENSUS.button` goes from 118 to 116, because the demo's two
buttons left the catalog. This is the catalog-authored case the table's
header permits, recorded in the table's own format.
- `.changeset/3906-page-header-canonical-docs.md`:
`check-changeset-presence` counts any file under a released package's
`src/`, and the types test is one.

## Tests

Final union at `c79ae7497`, run after the last commit:

- `pnpm exec vitest run` over `examples/schema-catalog/`, every test the
`markdown-test-inputs` ledger lists as reading
`content/docs/guide/layout.md` or `content/docs/**`, the catalog-corpus
walkers, and the three `packages/layout` page-header tests: `Test Files
66 passed (66)`, `Tests 3136 passed (3136)`.
- Type-check, after building the closure: `pnpm --filter
@object-ui/example-schema-catalog type-check` and `pnpm --filter
@object-ui/types type-check` both exit 0. `--listFilesOnly` confirms all
three changed tests are in those programs.
- `check-doc-snippet-types`: exit 0, "Every covered documentation
snippet compiles against the built types." `check-doc-example-types`:
exit 0.
- `check-doc-component-types`: exit 0, "Every documented component type
is registered."
- `check-doc-fence-languages`, `check-doc-example-ids` ("414 real
reference(s) all resolve"), `check-doc-example-shared-reader` and
`check-doc-links` ("Links are valid across 17 scan roots."): all exit 0.
- `check-control-bytes` and `check-new-cross-file-line-citations` ("0
new citation(s)"): exit 0.
- `check-changeset-presence` (empty frontmatter, "a complete answer to
this gate"), `check-changeset-no-major`, `check-changeset-claims`,
`check-pending-changeset-literals`, `check-changeset-fixed`: all exit 0.
- `regenerate-catalog-index.py --check`: exit 0.
- `eslint --no-inline-config` on the four changed code files: 0 errors,
0 warnings. The `.md`, `.mdx`, `.json` and changeset files match no
eslint configuration, so they are not linted. The config has no
type-aware linting (no `projectService` or `parserOptions`), so this
diff cannot change the verdict on any untouched file.
- **Reverse check** (one-off, nothing left in the tree), run with
`ablation-replace.mjs`:
- The mutation re-keyed the demo's `"type": "page:header"` back to
`"type": "page-header"`. The anchor went from 1 hit to 0 and the blob
changed.
- `pageheader-with-actions.test.tsx` turned red: 7 failed, 16 passed.
The failures were the shape, render, styling and mirror pins, plus three
controls built on the demo.
  - Restore: blob equals HEAD and `git diff HEAD` is empty.
- Two demo pins do not look at `type` and stayed green: the spec row's
full parse and the declared-inputs check.

## Acceptance notes (observations, not filed)

- **Stale comments on the alias's `icon` input.** Two comments give "the
docs page publishes the prop, and the docs page's only live demo writes
`icon: users`" as reasons to keep that input: one in
`packages/layout/src/index.ts`, one in
`packages/layout/src/__tests__/page-header-authorable-keys.test.tsx`.
After this PR both clauses are false. The objectui#3829 ruling itself
still holds, because the alias renderer really draws the icon. The same
test file also says the manifest pins run on "the real demo JSON"; they
now run on an inline fixture that is that former demo JSON word for
word. Nothing reads these comments. Carrier: whoever next touches the
alias registration.
- **Stale reason text in the version ledger.** The `doc-version-claims`
row for `guide/layout.md` quotes the icon tombstone as "(objectstack-ai#6946, ADR-0087
D2)". The guide now quotes the installed spec's message exactly,
"(ADR-0087 D2)". The ledger's claim key, `@objectstack/spec 17.0.0`,
still matches, so the test is green; only the row's reason text is out
of date.
- `sdui-parser`'s `validateTree` has no concept of the `properties` bag,
so a `page:header` node spelled the spec's way gets `unknown-prop
"properties"` there. The JSX tier writes attributes, so no author hits
this today. The demo test validates the node in its post-move shape and
says why.
- objectui's `safeValidateSchema` refuses `page-header` and
`layout:page-header` at `type` (`invalid_union`), exactly as it refuses
an unknown type, so the old demo never validated there. Retiring the
alias is not this card.

## Out-of-scope findings (for the seat to file; not filed here)

- **(c)** `content/docs/guide/slotted-pages.md`, "Example: customize
only the header", writes `eyebrow: 'ACCOUNT'` and `icon: 'building-2'`
under `page:header` `properties`. objectui's `safeValidateSchema`
rejects that exact node: `properties.icon: invalid_type` and
`properties: unrecognized_keys [eyebrow]`. The same node without those
two keys validates.
- **(b)** `PageHeaderProps.breadcrumb` is described as "Show breadcrumb"
and defaults to `true`. `PageHeaderRenderer` renders only an empty
element marked `data-page-breadcrumb-slot` and draws no trail. Measured
through a real `SchemaRenderer` render, in both the bare and record
layouts.

Session: `https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec`

---
_Generated by [Claude
Code](https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to 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