Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .changeset/8871-page-node-refuses-breadcrumbs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
'@object-ui/types': minor
---

Refuse `breadcrumbs` by name on the `page` node (objectui#8871, ADR-0049 enforce-or-remove).

**Accept-set change, deliberately.** A `page` document carrying `breadcrumbs` used to parse
GREEN and render nothing. `PageNodeSchema` never declared the key and no renderer ever read
it, so the array survived purely through `BaseSchema`'s `.passthrough()`. On the TypeScript
face, `tsc` **previously accepted** it too, through `BaseSchema`'s own `[key: string]: any`
index signature (`packages/types/src/base.ts:467`) — the same open door the zod mirror
walked through at runtime. It now fails at parse with the remedy in the message, and the
TypeScript twin is `breadcrumbs?: never`, so `tsc` refuses it at the authoring site before
anything runs — both faces narrow together.

**Why ADR-0049 and not a fresh ruling.** objectui#7926 refused `actions` on this same node
and, by its own comments, ruled on that key ONLY — its ruling is not borrowed here. What
reaches this key is the standing enforce-or-remove gate, which this repository applies to
this exact face: `packages/types/src/zod/tombstone.zod.ts`'s `retirementTombstone` is
documented as the "ADR-0049 RETIREMENT TOMBSTONE" helper and is internal to these zod
modules, 63 changesets cite the ADR, and `PageNodeSchema` already carried one of its refusal
arms one member up. objectui#7926 left this key parsing on purpose so that retiring it would
be a decision rather than an accident, and wrote a pin saying so; that pin is **flipped**,
not deleted.

**What was measured, on this branch's base `93127bd6f`.** Zero readers, with a **point-access**
probe rather than a bare word: on that base `\.breadcrumbs` scores 0 tree-wide (exit 1) against
`\.breadcrumb\b`'s **12** files tree-wide (**10** under `packages/`) as the lit control. At head
the same two probes read 16 and 13 and `\.breadcrumbs` is exit 0 over 4 files — every hit one of
this branch's own four files (this changeset, the refusal pin, `layout.ts`, `zod/layout.zod.ts`)
quoting the probe string, and the pin's own exclusions put head back at exit 1. The base reading
is the measurement; the head reading is this branch's echo of it. The bare word would have lied — it also names
Sentry's own unrelated concept (`app-shell/src/observability/sentry.ts`) and appears in two
comments listing UI surfaces (`core/src/utils/record-title.ts`,
`layout/src/NavigationRenderer.tsx`), so a bare probe reports five readers that do not exist.

Three author sites, all teaching passages in `content/docs/guide/layout.md`, and that count
**corrects objectui#7926's "1 site"**: its census reads every git-tracked JSON file, every
`json` fence in `.md`/`.mdx`, and every TS/TSX object literal via the TypeScript AST (PR
#8870), and it undercounted for **two different reasons**. The Schema API block declared
`breadcrumbs?: Array<{ label, href }>` outright and its literal does carry `type: 'page'`, but
that literal sits inside a markdown `typescript` fence — a fence **language** the census's
`json`-fence reader never visits, so it was never read at all. Best Practices §2 authored it
on a fragment inside a `json` fence the census does read, but that fragment never writes
`type`, so a `page`-tagged filter correctly excluded it. No example app, catalog fixture,
template or customer document writes the key, so the refusal strands no authored document in
this tree.

**Migration** — the trail is a NODE, and it already ships:

```json
{
"type": "page",
"title": "Acme Corporation",
"body": [
{
"type": "breadcrumb",
"items": [
{ "label": "Home", "href": "/" },
{ "label": "Customers", "href": "/customers" },
{ "label": "Acme Corporation" }
]
}
]
}
```

`breadcrumb` is a registered renderer taking the same `{ label, href }` item shape the
retired key carried, plus `separator`, `maxItems` and a per-item `icon`. ⛔ Not the
`page:header` block's `breadcrumb`, which is **singular** and a **boolean** display toggle
rather than a list of links — the guide's own "There is no `breadcrumbs` array" passage is
about that component, and is unchanged.

**Why a refusal and not a deletion.** There was nothing to delete: the key was never in the
shape, and under `.passthrough()` an undeclared key is not refused, it is KEPT. Declaring the
refusal is what makes it audible, and what converts a write from OUTSIDE this repository —
the half no in-tree census can read — into a named refusal carrying its own remedy.

**Scope.** One key, by name; the node is **not** strict. Only 2 of the 23 passthrough-
surviving undeclared keys land on a real SDUI `page` node (`actions` and this one); the rest
belong to different declarations that merely spell `type: 'page'`. Strictness would also have
reddened a living pin — `page-app-dashboard-spec-parity.test.ts`, "the component envelope
still passes unknown renderer props through" — which stays green and is re-asserted from this
card's side.

Marked `minor`. This card carries `Clause-②: yes`, declared on the dispatch claim, and this
changeset's own lead sentence is *"Accept-set change, deliberately"* — the reading AGENTS.md's
版本号策略 gives `minor` for objectui's own breaking changes. objectui#7926's `patch` does not
transfer here: its ruling was **specified** with `Clause-②: no`, a different premise, so
citing it for the level would import that ruling's conclusion without its premise. The
precedent that literally shares this card's `Clause-②: yes` reading is **objectui#5905**, where
the declaration is explicit on the card and both changesets took `minor`. Two further
retirements of the same shape also took `minor` but do **not** carry the declaration, so they
corroborate the level and ⛔ not the clause reading: objectui#4919 (a published TS type removed,
but the card pre-dates the `Clause-②:` spelling entirely) and objectui#5453 (no Clause-②
declaration, and its own ACCEPT record measured that narrowing as *"not consumer-visible"*).
48 changes: 35 additions & 13 deletions content/docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,15 @@ and `button.tsx`, which reads `schema.label`, renders a button with no text.
> the `page:header` block's own `actions` — which are **action ids**, not nodes
> (see the [PageHeader reference](/docs/layout/page-header)).

> **⛔ `breadcrumbs` on a `page` node is refused by name** (objectui#8871). This page used
> to declare it in the Schema API block below and author it in two passages, and it drew
> **nothing**: no renderer has ever read the key, and `BaseSchema`'s `.passthrough()` kept
> the array rather than refusing it — the same silent-accept shape as `actions`, retired
> under the same ADR-0049 enforce-or-remove gate. The trail is a **node**, not a key: put
> a `breadcrumb` node in `body`, as [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation)
> shows. ⛔ Not the `page:header` block's `breadcrumb` either — that one is singular and a
> **boolean** display toggle, not a list of links.

### Schema API

<!-- doc-snippet: fragment — a SHAPE excerpt, not an expression — the keys carry `?` optional markers and trailing prose comments, so the object literal cannot parse as TypeScript (measured: TS1109 / TS1005 / TS1011) -->
Expand All @@ -204,11 +213,8 @@ and `button.tsx`, which reads `schema.label`, renders a button with no text.
title?: string, // Page title
description?: string, // Page description/subtitle
icon?: string, // Optional icon
breadcrumbs?: Array<{ // Breadcrumb navigation
label: string,
href?: string
}>,
// NO `actions` — refused by name (objectui#7926); put the buttons in `body`
// NO `breadcrumbs` — refused by name (objectui#8871); put a `breadcrumb` node in `body`

// Content
body: SchemaNode, // Main page content
Expand Down Expand Up @@ -528,18 +534,22 @@ Omit `sidebar` and the content fills the width under the top bar.

### Detail Page with Actions

Same rule as above: the buttons are nodes in `body`, not an `actions` key on the page.
Same rule as above, and it governs the trail too: the breadcrumb and the buttons are both
**nodes in `body`** — never a `breadcrumbs` or an `actions` key on the page.

```json
{
"type": "page",
"title": "Acme Corporation",
"breadcrumbs": [
{ "label": "Home", "href": "/" },
{ "label": "Customers", "href": "/customers" },
{ "label": "Acme Corporation" }
],
"body": [
{
"type": "breadcrumb",
"items": [
{ "label": "Home", "href": "/" },
{ "label": "Customers", "href": "/customers" },
{ "label": "Acme Corporation" }
]
},
{
"type": "flex",
"justify": "end",
Expand Down Expand Up @@ -675,19 +685,31 @@ Compose the shell once and let the page JSON change per route:

### 2. Breadcrumbs for Deep Navigation

Add breadcrumbs to help users navigate:
Add a breadcrumb trail to help users navigate. It is a **node in `body`**, not a key on the
page — `breadcrumb`, singular, is the registered renderer:

```json
{
"breadcrumbs": [
"type": "breadcrumb",
"items": [
{ "label": "Home", "href": "/" },
{ "label": "Products", "href": "/products" },
{ "label": "Electronics", "href": "/products/electronics" },
{ "label": "Laptops" }
]
],
"separator": "/",
"maxItems": 3
}
```

`separator` defaults to `/`, and `maxItems` collapses the middle of a long trail behind an
ellipsis while keeping the first crumb and the current page. See the
[Breadcrumb reference](/docs/components/data-display/breadcrumb) for the per-key face.

⛔ Not `"breadcrumbs"` on the `page` node — that key has no reader and is refused by name
(objectui#8871), the same way `actions` is. ⛔ Nor the `page:header` block's `breadcrumb`,
which is a **boolean** display toggle rather than a list of links.

### 3. Action Buttons at the Top of the Body

Place primary actions in the first `body` node, so they sit above the content:
Expand Down
24 changes: 18 additions & 6 deletions packages/types/src/__tests__/page-actions-refusal-7926.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@
* a measured zero. On a real `page` NODE only two undeclared keys survive
* passthrough: `actions` (3 sites, all of them the guide passages this card
* rewrites) and `breadcrumbs` (1 site, no reader either — its own question, NOT
* ruled on here). Every other undeclared key the same grep found belongs to a
* ruled on here; ruled and refused since by objectui#8871 under ADR-0049, whose
* own census also corrected the "1 site" reading recorded here to THREE — this
* one read `page`-TAGGED objects and missed two of the guide's `breadcrumbs`
* passages for two DIFFERENT reasons: one passage's literal does carry
* `type: 'page'` but sits inside a markdown `typescript` fence the census's
* `json`-fence reader never visits, and the other is a `json`-fenced fragment
* that never writes `type` at all). Every other undeclared key the same grep found belongs to a
* DIFFERENT declaration that merely spells `type: 'page'`: nav items, spec `page`
* list views, `registerMetadataResource` rows. None of them is parsed by this
* schema — and `page-app-dashboard-spec-parity.test.ts` PINS the node staying open
Expand Down Expand Up @@ -128,16 +134,22 @@ describe('objectui#7926 — the `page` node refuses `actions` (contract half)',
// break it: the cheap way to refuse `actions` is `.strict()`, and the census
// is the reason that is the wrong shape.
expect(PageNodeSchema.safeParse({ type: 'page', someRendererProp: 42 }).success).toBe(true);
// `breadcrumbs` is the OTHER undeclared key the census found on a real page
// node. It has no reader either, and objectui#7926 does NOT rule on it — so
// it must still parse. If a later card retires it, this line is the one that
// says so out loud instead of the change happening by accident here.
// `breadcrumbs` was the OTHER undeclared key the census found on a real page
// node, and this line used to assert it STILL PARSED — objectui#7926 did not
// rule on it, and the assertion existed so that a later retirement would have
// to say so out loud here instead of happening by accident.
//
// objectui#8871 is that retirement (ADR-0049 enforce-or-remove), so the leg is
// FLIPPED rather than deleted: the closure stays asserted instead of becoming
// a silent absence. Its own pins live in `page-breadcrumbs-refusal-8871.test.ts`;
// what this line still owns is the fact that the node did NOT go strict to get
// there — `someRendererProp` above is the same census leg, unmoved.
expect(
PageNodeSchema.safeParse({
type: 'page',
breadcrumbs: [{ label: 'Home', href: '/' }],
}).success,
).toBe(true);
).toBe(false);
});

it('the TypeScript twin refuses it too', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,20 @@ const CASES: Record<string, Case> = {
// this shape, and this ledger is the right place for the decision to be
// visible. `../__tests__/page-actions-refusal-7926.test.ts` owns the
// behaviour; this row owns the fact that the key exists at all.
local: ['title', 'pageType', 'body', 'children', 'actions'],
//
// `breadcrumbs` is the SECOND of that kind and joins for the same reason,
// under the same gate (objectui#8871, ADR-0049 enforce-or-remove). Its
// authority is not objectui#7926's ruling — that one covers `actions` only —
// but the standing enforce-or-remove discipline this package applies to this
// face. Same shape, same helper, same reason to be visible here rather than
// exempted: a local REFUSAL, not a local capability.
// `../__tests__/page-breadcrumbs-refusal-8871.test.ts` owns the behaviour.
//
// ⚠️ These two are the ONLY members of this row that are refusals. Adding a
// third means a third undeclared key was found surviving `.passthrough()` on
// this node — which is the census that decides between one more named refusal
// and finally making the node strict. ⛔ Do not grow this list reflexively.
local: ['title', 'pageType', 'body', 'children', 'actions', 'breadcrumbs'],
},
App: {
spec: SpecAppSchema,
Expand Down
Loading
Loading