diff --git a/.changeset/page-component-zero-reader-keys-removed.md b/.changeset/page-component-zero-reader-keys-removed.md new file mode 100644 index 0000000000..03a9a7870f --- /dev/null +++ b/.changeset/page-component-zero-reader-keys-removed.md @@ -0,0 +1,67 @@ +--- +"@objectstack/spec": major +--- + +refactor(spec)!: retire three SDUI page-component props no renderer honours — `page:header.icon`, `page:card.actions`, `record:details.layout` (#6946) + +Maintainer ruling 2026-08-09 (decision-inbox round, 「全部接受」): objectui#3829 +route (c) retires `PageHeaderProps.icon` and `PageCardProps.actions` upstream; +objectui#3818 retires `RecordDetailsProps.layout`. All three are the ADR-0049 +declared-but-unenforced shape on the platform contract — the spec advertised +configuration objectui drops on the floor, and the author got a success receipt +for it. + +FROM → TO, per key: + +- **`page:header.icon` → delete the key.** objectui resolves `icon` only per + header ACTION (`action.icon`); the header's own props bag is never asked for + one, and the component registration publishes no `icon` input. The header's + own identity is drawn by the record chrome (`recordChrome`, on by default), + and each action carries its own icon. +- **`page:card.actions` → author the buttons as components in `children` or + `footer`.** The card renderer builds its `` from `title`, `bordered`, + `children` and `footer`; there is no actions area in the markup and no + `actions` input in the registration. `element:button` and + `record:quick_actions` are what actually render. +- **`record:details.layout` → delete the key; `sections` already decides the + body.** This one WAS read — and only against `inline` | `compact`, two values + its `auto` | `custom` enum never permitted, so both legal values took the same + branch and the key selected nothing. Authoring `sections` gives the explicit + groups (the old `custom`); omitting it falls back to the object's + `highlightFields` (the old `auto`). + +⚠️ Two live keys share these leaf names and are UNTOUCHED: `page:header.actions` +(read by the header renderer) and `record:highlights.layout` (`horizontal` | +`vertical`, honoured). Every strip here is scoped by component `type`, never by +key name. + +The retirement kit: + +- **Tombstones** (`retiredKey()`) on all three, so the removal is audible in the + two channels an upgrading author actually hits: the input type becomes `never` + and the parse raises the prescription itself, not a generic "unrecognized key". +- **ADR-0087 D2 conversions + D3 chain steps** — + `page-structure-inert-keys-removed` (objectui#3829's two keys) and + `record-details-layout-removed` (objectui#3818's). Both are pure lossless + strips: none of the three ever had an effect to lose, and none has a lossless + rewrite target. `os migrate meta --from 16` rewrites existing sources. +- **`RETIRED_KEYS_BY_MAJOR[17]`** carries all three by exact key + (`ui/PageHeaderProps:icon`, `ui/PageCardProps:actions`, + `ui/RecordDetailsProps:layout`). +- Four in-repo pages stop authoring `page:header.icon` — the showcase's + project workspace and the published `mcp` / `cloud-connection` platform pages. + None of them ever drew an icon. +- Baselines regenerated: `authorable-surface/ui.json` gains three `[RETIRED]` + marks. `api-surface/`, `api-surface-signatures.json` and + `json-schema.manifest/` are byte-identical, which is the correct reading for + the tombstone route — those ratchets record export and def EXISTENCE, and a + tombstone narrows a def's keys without deleting the def. + +No runtime behaviour changes — that impossibility is the reason for the removal. + +Sequencing: objectui#3829 (drop the two parity-gate exemptions) and +objectui#3818 (delete the `layout` input and the dead `inline|compact` branch) +are Blocked-by #6946 and proceed on the next `.objectui-sha` pin bump. + + + diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 432eea9643..a63ca03a17 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -215,7 +215,7 @@ const result = AIChatWindowProps.parse(data); | :--- | :--- | :--- | :--- | | **title** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **bordered** | `boolean` | ✅ | | -| **actions** | `string[]` | optional | | +| **actions** | `never` | optional | [REMOVED] `page:card` property `actions` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no renderer ever read it: objectui's card renderer builds its `` from `title`, `bordered`, `children` and `footer` only, has no actions area, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key and author the buttons as components in the card's `children` or `footer` (`element:button`, `record:quick_actions`), which is what actually renders. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **children** | `any[]` | optional | Card content components, in order (the card body slot) | | **body** | `never` | optional | [REMOVED] `page:card` property `body` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a second spelling of the composition slot every other container calls `children`, and the renderer reads both. Rename the key to `children`; the value (an array of child components) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **footer** | `any[]` | optional | Card footer components (slot) | @@ -243,7 +243,7 @@ const result = AIChatWindowProps.parse(data); | :--- | :--- | :--- | :--- | | **title** | `string \| Record` | ✅ | Page title | | **subtitle** | `string \| Record` | optional | Page subtitle | -| **icon** | `string` | optional | Icon name | +| **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 | | **actions** | `string[]` | optional | Action IDs to show in header | | **recordChrome** | `boolean` | ✅ | Render the record chrome — the title as a record chip with its follow star and copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. | @@ -314,7 +314,7 @@ const result = AIChatWindowProps.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | ✅ | Number of columns for field layout (1-4) | -| **layout** | `Enum<'auto' \| 'custom'>` | ✅ | Layout mode: auto uses object highlightFields, custom uses explicit sections | +| **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **sections** | `{ name?: string; label?: string \| Record; columns?: integer; fields: string[] }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`. | | **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) | | **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 74e69c8409..f8e4b89476 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -222,6 +222,10 @@ Last, it reconciles the SDUI component-props surface with the renderers that ser That count turned out to be incomplete, and #6776 finishes it: five more keys the renderers read were still undeclared. Four are plain additions with no behaviour change (`page:header` `recordChrome`/`showStar`/`showCopyId`, which select between the record-chip header and the bare heading a dashboard wants, and `page:accordion.variant`, which decides whether the accordion draws its own dividers or leaves the border to each panel). The fifth is a rename, and the only one in the family whose defect is structural rather than an oversight: the tab strip's visual style was declared as `page:tabs.type`, which collides with the page component's OWN dispatch key. objectui's `SchemaRenderer` refuses to hoist `properties.type` for exactly that reason, `sdui-parser`'s `BASE_PROPS` contains `type` and skips it before any validation runs, and in a flat or JSX carrier the node reads `{ type: 'page:tabs', … }` so the name is already taken. The key was therefore unauthorable in every carrier but the nested `properties` object, and unvalidated even there. It becomes `tabStyle` — the spelling objectui publishes and the renderer already reads first in the flat carriers — which is `displayField` → `labelField` again: converge on the spelling that works, not the one that declares well, and keep one spelling rather than two (Prime Directive #12). +#6946 closes that reconciliation from the other side, on three keys the two earlier passes left standing (maintainer ruling 2026-08-09, decision-inbox round: objectui#3829 route (c) and objectui#3818). Two are the plain B class — declared here, read NOWHERE. `page:header.icon` is resolved by objectui only per header ACTION (`action.icon`); the header's own props bag is never asked for one, and `@object-ui/layout`'s `` takes an `icon` React prop from a host with no schema fallback beside the `schema?.actions ?? schema?.properties?.actions` fallback four lines away. `page:card.actions` has no actions area to render into at all: the card renderer builds its `` from `title`, `bordered`, `children` and `footer`, full stop. Both sat in objectui's own unpublished-exemption map as "spec declares it, NO renderer read point", which is what put the contract decision — wire it, publish it with a KNOWN GAP marker, or retire it — in front of the maintainer; the ruling retired it. Neither has a lossless rewrite target (a header has no second icon slot, and moving a card's action ids into `children` as components is a page rewrite, not a mechanical one), so both are pure strips. ⚠️ `page:header.actions` is LIVE and untouched — the strip is scoped by component type, never by key name. + +The third, `record:details.layout`, is a sharper shape and the one worth reading twice: it IS read. The renderer computes `schema.layout === 'inline' || schema.layout === 'compact' ? 'horizontal' : 'vertical'`, while the declared enum is `auto | custom` — so neither legal value can match, both take the same branch, and a key that was accepted and read still selected nothing, under a `.describe()` promising "auto uses object highlightFields, custom uses explicit sections". The behaviour that prose describes is real, but the renderer keys it off whether `sections` was authored, never off this flag. Every gate stayed green because `check:react-declaration-parity` compares two DECLARATIONS and objectui declared the same `auto | custom` enum — perfect agreement over a key nothing honoured — while a THIRD spelling (`stacked | inline | compact`) sat in `@object-ui/types`' mirror. A pure strip for the same reason: `auto`, `custom` and omission were behaviourally identical, so there is no value to carry. ⚠️ `record:highlights.layout` is a different, live, honoured key and is untouched. objectui#3829 and objectui#3818 drop the exemptions, the input and the dead branch on the next pin bump. + Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave `AggregationFunction` (#6188, ADR-0049). The enum declared eight functions and the SQL family compiles five — `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest to one refusal — so three were declared-but-unenforced against the backends this platform targets. What makes these two worse than an ordinary inert declaration is that another package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of the requested value, with no error and no log. The maintainer SPLIT the three rather than retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard staple, already lowered by `service-analytics` — with its SQL implementation following on its own card, so that declaration leads its implementation by decision rather than by drift. These two take the remove leg: display conveniences with no measured pull, and `string_agg` never had one shape to lower to (the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed on the received value so only the two spellings that used to be legal are told they "were removed". Of the two authoring surfaces only one is stored metadata: the conversion rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with neither `aggregate` nor `derived` fails the dataset's own refinement, so stripping just the key would emit an item that cannot parse) plus any derived measure the drop strands, with a notice each. Nothing is lost: `compileDataset` refused both by name already, so such a measure never produced a number. `QueryAST.aggregations[].function` is a request surface with no stored source — one semantic TODO below. The mongodb and in-memory backends that implemented these two are inside the #5499 freeze and are untouched; their code is simply no longer reachable through a spec-valid request. The same aggregation node loses one more member, and it is the sharper class of the two: `aggregations[].distinct` is removed (#6815, ADR-0049, maintainer ruling 2026-08-09). The functions above were declared and UNLOWERED — a caller on a SQL datasource got a refusal. This flag was declared and lowered by exactly ONE of the six faces that read an aggregation: the engine's in-memory fallback deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored it. So the same query answered a deduplicated `sum` on the fallback path and an ordinary `sum` on every SQL datasource, with the engine choosing between the two per query — by driver, by a non-UTC date bucket, by whether the driver aggregates natively at all. That is the divergence class #6203 and #5907 each closed on this axis, still open on this key, and it is worse to sit on because the wrong answer is a PLAUSIBLE NUMBER rather than a refusal: no error, no log, nothing for a dashboard author to notice. It survived the #4286 sweep of this very schema because that sweep asked which members no executor reads, and this one had a reader — the wrong question for a key whose defect is WHICH executor reads it. Remove rather than enforce, per the ruling: `count_distinct` (which just took the enforce leg above, and whose SQL lowering #6409 landed) already covers the only deduplicating spelling with measured demand, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a modelling mistake and would have to be lowered across five faces, two of them frozen under #5499, to buy it. The blast radius inside the fallback is narrower than the key suggests and was measured rather than assumed: only `sum` and `avg` ever changed answer — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed a Set, and dedupe does not move `min`/`max`. `AggregationNodeSchema` is non-strict, so the key is `retiredKey()`-tombstoned rather than bare-deleted: a plain deletion would have made zod silently STRIP what callers still send, trading a divergent flag for an ignored one (#3733, ADR-0104). One tombstone covers every aggregation door, because `QuerySchema.aggregations` and `EngineAggregateOptionsSchema.aggregations` reuse that one schema by reference. No conversion: a request surface with no stored source — one semantic TODO below, the disposition every other `data.query.*` retirement in this major already takes. @@ -286,6 +290,8 @@ The same descriptor loses a key in this step, and the pairing is the point (#674 | `page-card-body-to-children` | `page.component.page:card.body` | page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both) | retired — `migrate meta` only | | `inline-action-api-params-to-body-extra` | `page.component.element:button.action.params` | inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array) | live — protocol 17 loader accepts the old shape | | `page-tabs-type-to-tab-style` | `page.component.page:tabs.type` | page:tabs component prop 'type' → 'tabStyle' (#6776 — a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them) | retired — `migrate meta` only | +| `page-structure-inert-keys-removed` | `page.component.page:header.icon / page.component.page:card.actions` | page:header prop 'icon' and page:card prop 'actions' removed (#6946 — neither has a renderer read point in objectui; the header resolves icons per action and the card renders title/children/footer only) | retired — `migrate meta` only | +| `record-details-layout-removed` | `page.component.record:details.layout` | record:details component prop 'layout' removed (#6946 — the declared auto|custom modes were never implemented; the renderer branches only on inline|compact, values the schema never permitted, so both legal values selected nothing) | retired — `migrate meta` only | | `app-hidden-to-unpublished` | `app.hidden` | stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) diff --git a/examples/app-showcase/src/ui/pages/project-workspace.page.ts b/examples/app-showcase/src/ui/pages/project-workspace.page.ts index b6ac8698c1..7c5be42f61 100644 --- a/examples/app-showcase/src/ui/pages/project-workspace.page.ts +++ b/examples/app-showcase/src/ui/pages/project-workspace.page.ts @@ -28,7 +28,7 @@ export const ProjectWorkspacePage = definePage({ title: 'New Project + Tasks', subtitle: 'Master-detail entry — fill the project, add its tasks inline, and save them together.', - icon: 'folder-plus', + // `icon` removed here (#6946): `page:header` never drew one — retired from the spec. }, }, ], diff --git a/packages/cli/test/i18n-extract.test.ts b/packages/cli/test/i18n-extract.test.ts index 2d3802d76c..2ac69877db 100644 --- a/packages/cli/test/i18n-extract.test.ts +++ b/packages/cli/test/i18n-extract.test.ts @@ -187,7 +187,7 @@ describe('collectExpectedEntries', () => { type: 'page:header', // `title` duplicates `label` — resolved by the label // fallback, so it must NOT emit its own entry. - properties: { title: 'Connect an Agent', subtitle: 'Governed MCP access.', icon: 'bot' }, + properties: { title: 'Connect an Agent', subtitle: 'Governed MCP access.', actions: ['connect_agent'] }, }, ], }, diff --git a/packages/cloud-connection/src/cloud-connection-ui.ts b/packages/cloud-connection/src/cloud-connection-ui.ts index 5e2bae013f..832323594b 100644 --- a/packages/cloud-connection/src/cloud-connection-ui.ts +++ b/packages/cloud-connection/src/cloud-connection-ui.ts @@ -35,7 +35,7 @@ export const CloudConnectionSettingsPage: Page = { subtitle: 'Connect this runtime to an ObjectStack control plane to browse your ' + 'organization\'s private packages and install them here.', - icon: 'cloud', + // `icon` removed here (#6946): `page:header` never drew one — retired from the spec. }, }, ], diff --git a/packages/cloud-connection/src/marketplace-ui.ts b/packages/cloud-connection/src/marketplace-ui.ts index d832f38e92..36049b2804 100644 --- a/packages/cloud-connection/src/marketplace-ui.ts +++ b/packages/cloud-connection/src/marketplace-ui.ts @@ -58,7 +58,7 @@ export const MarketplaceInstalledPage = { properties: { title: 'Installed Apps', subtitle: 'Marketplace packages currently installed into this runtime\'s kernel.', - icon: 'package-check', + // `icon` removed here (#6946): `page:header` never drew one — retired from the spec. }, }, ], diff --git a/packages/mcp/src/connect-ui.ts b/packages/mcp/src/connect-ui.ts index b8ce85b0c6..64ede4633b 100644 --- a/packages/mcp/src/connect-ui.ts +++ b/packages/mcp/src/connect-ui.ts @@ -37,7 +37,7 @@ export const CONNECT_AGENT_PAGE = { subtitle: 'Give any MCP-capable AI client governed access to this environment — ' + 'every call runs under the caller\'s own permissions and row-level security.', - icon: 'bot', + // `icon` removed here (#6946): `page:header` never drew one — retired from the spec. }, }, ], diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 630c475aa5..00689e2f3e 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -2906,7 +2906,7 @@ describe('RestServer metadata translation — page documents', () => { components: [ { type: 'page:header', - properties: { title: 'Connect an Agent', subtitle: 'Give any MCP-capable client…', icon: 'bot' }, + properties: { title: 'Connect an Agent', subtitle: 'Give any MCP-capable client…', actions: ['connect_agent'] }, }, ], }, @@ -2923,7 +2923,7 @@ describe('RestServer metadata translation — page documents', () => { expect(out.item.label).toBe('连接智能体'); expect(out.item.regions[0].components[0].properties.title).toBe('连接智能体'); expect(out.item.regions[0].components[0].properties.subtitle).toBe('让任意支持 MCP 的 AI 客户端受控访问此环境。'); - expect(out.item.regions[0].components[0].properties.icon).toBe('bot'); + expect(out.item.regions[0].components[0].properties.actions).toEqual(['connect_agent']); }); it('translates page documents in a list response', async () => { diff --git a/packages/spec/authorable-defaults/ui.json b/packages/spec/authorable-defaults/ui.json index 4405695db3..2401ee260e 100644 --- a/packages/spec/authorable-defaults/ui.json +++ b/packages/spec/authorable-defaults/ui.json @@ -98,7 +98,6 @@ "ui/RecordChatterProps:defaultCollapsed = false", "ui/RecordChatterProps:position = \"sidebar\"", "ui/RecordDetailsProps:columns = \"2\"", - "ui/RecordDetailsProps:layout = \"auto\"", "ui/RecordHighlightsProps:layout = \"horizontal\"", "ui/RecordRelatedListProps:limit = 5", "ui/RecordRelatedListProps:relationshipValueField = \"id\"", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 258254345a..049b0e1efd 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -777,7 +777,7 @@ "ui/PageAccordionProps:aria", "ui/PageAccordionProps:items", "ui/PageAccordionProps:variant", - "ui/PageCardProps:actions", + "ui/PageCardProps:actions [RETIRED]", "ui/PageCardProps:aria", "ui/PageCardProps:body [RETIRED]", "ui/PageCardProps:bordered", @@ -801,7 +801,7 @@ "ui/PageHeaderProps:actions", "ui/PageHeaderProps:aria", "ui/PageHeaderProps:breadcrumb", - "ui/PageHeaderProps:icon", + "ui/PageHeaderProps:icon [RETIRED]", "ui/PageHeaderProps:recordChrome", "ui/PageHeaderProps:showCopyId", "ui/PageHeaderProps:showStar", @@ -856,7 +856,7 @@ "ui/RecordDetailsProps:columns", "ui/RecordDetailsProps:fields", "ui/RecordDetailsProps:hideFields", - "ui/RecordDetailsProps:layout", + "ui/RecordDetailsProps:layout [RETIRED]", "ui/RecordDetailsProps:sections", "ui/RecordHighlightsProps:aria", "ui/RecordHighlightsProps:fields", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 4c415ca978..303a126a19 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -380,6 +380,18 @@ "conversionId": "page-tabs-type-to-tab-style", "toMajor": 17 }, + { + "surface": "page.component.page:header.icon / page.component.page:card.actions", + "to": "page:header prop 'icon' and page:card prop 'actions' removed (#6946 — neither has a renderer read point in objectui; the header resolves icons per action and the card renders title/children/footer only)", + "conversionId": "page-structure-inert-keys-removed", + "toMajor": 17 + }, + { + "surface": "page.component.record:details.layout", + "to": "record:details component prop 'layout' removed (#6946 — the declared auto|custom modes were never implemented; the renderer branches only on inline|compact, values the schema never permitted, so both legal values selected nothing)", + "conversionId": "record-details-layout-removed", + "toMajor": 17 + }, { "surface": "app.hidden", "to": "stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", @@ -1274,6 +1286,18 @@ "conversionId": "page-tabs-type-to-tab-style", "toMajor": 17 }, + { + "surface": "page.component.page:header.icon / page.component.page:card.actions", + "to": "page:header prop 'icon' and page:card prop 'actions' removed (#6946 — neither has a renderer read point in objectui; the header resolves icons per action and the card renders title/children/footer only)", + "conversionId": "page-structure-inert-keys-removed", + "toMajor": 17 + }, + { + "surface": "page.component.record:details.layout", + "to": "record:details component prop 'layout' removed (#6946 — the declared auto|custom modes were never implemented; the renderer branches only on inline|compact, values the schema never permitted, so both legal values selected nothing)", + "conversionId": "record-details-layout-removed", + "toMajor": 17 + }, { "surface": "app.hidden", "to": "stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 97293f15c7..bd966485fd 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -5785,6 +5785,314 @@ const pageTabsTypeToTabStyle: MetadataConversion = { }, }; +/** + * `page:header.icon` and `page:card.actions` — two declared page-component props + * with NO renderer read point at all (protocol 17, #6946, ADR-0049). + * + * Maintainer ruling 2026-08-09 (decision-inbox round, 「全部接受」) on + * objectui#3829, **route (c)**: retire both upstream. That card had put three + * shapes on the table — wire the key, publish it with a KNOWN GAP marker (the + * `record:activity.showSubscriptionToggle` precedent), or retire it here — and + * the ruling took the third, so the platform contract loses two keys rather + * than growing two features nobody asked for. + * + * What "no read point" means, per key, measured against objectui at the + * `.objectui-sha` pin (`09987b68`) rather than inherited from the card: + * + * - **`page:header.icon`** — `PageHeaderRenderer` resolves `icon` only inside + * the ACTION pipeline (`action.icon`, per button); the header's own props + * bag is never asked for one. `@object-ui/layout`'s `` does + * draw an `icon`, but only from a REACT prop a host passes: unlike + * `actions`, which four lines away falls back to `schema?.actions ?? + * schema?.properties?.actions`, `icon` has no schema fallback, so an + * authored node cannot reach it. The registration publishes no `icon` + * input either. + * - **`page:card.actions`** — `PageCardRenderer` builds its `` from + * `title`, `bordered`, `body ?? children` and `footer`, full stop. There is + * no actions area in the markup and no `actions` input in the + * registration. Its sibling `page:header` DOES read `actions` off the bag, + * which is exactly why the divergence survived: the two declarations look + * identical. + * + * Both keys are in objectui's own `UNPUBLISHED_EXEMPTIONS` map as B-class + * ("spec declares it, NO renderer read point") entries pointing at objectui#3829 + * — an independent measurement of the same fact, taken in the other repo. + * + * **Pure lossless deletes.** Neither key ever had an effect to lose, and + * neither has a lossless rewrite target: the header has no second icon slot + * (its identity is the record chrome), and the card has no actions area to move + * a list into — buttons belong in `children`/`footer` as components, which is a + * page rewrite and not a mechanical one. So this strips, exactly as + * {@link recordPickerInertKeysRemoved} does for the picker's two inert keys. + * + * ⚠️ `page:header.actions` is NOT touched — it is read (`containers.tsx`, and + * `@object-ui/layout`'s header) and stays. One key name, two components, one + * of them live: the strip is scoped by component `type`, never by key name. + * + * objectui#3829 (drop the two exemptions) is Blocked-by #6946 and proceeds on + * the next pin bump. + */ +const pageStructureInertKeysRemoved: MetadataConversion = { + id: 'page-structure-inert-keys-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'page.component.page:header.icon / page.component.page:card.actions', + summary: + "page:header prop 'icon' and page:card prop 'actions' removed (#6946 — neither has a renderer " + + 'read point in objectui; the header resolves icons per action and the card renders ' + + 'title/children/footer only)', + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + const properties = component.properties; + if (!isDict(properties)) return component; + // Scoped by component type: `actions` is LIVE on `page:header` and `icon` + // is live on half a dozen other components, so a key-name-only strip + // would delete working metadata. + const key = + component.type === 'page:header' ? 'icon' + : component.type === 'page:card' ? 'actions' + : null; + if (!key) return component; + const stripped = stripKeys(properties, [key], emit, `${path}.properties`); + if (stripped === properties) return component; + return { ...component, properties: stripped }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'connect_agent', + regions: [ + { + name: 'header', + components: [ + // The published platform-page shape: title + subtitle + a + // decorative icon nothing drew. + { + type: 'page:header', + properties: { title: 'Connect an Agent', subtitle: 'Governed MCP access.', icon: 'bot' }, + }, + // `page:header.actions` SURVIVES — the live half of the same key + // name (see the ⚠️ above). + { type: 'page:header', properties: { title: 'Lead', actions: ['convert_lead'], icon: 'user' } }, + // `icon` on a component that is not a page header — not this + // entry's key, and live on that one. + { type: 'element:button', properties: { label: 'Open', icon: 'external-link' } }, + { + type: 'page:card', + properties: { title: 'Shortcuts', actions: ['new_task'], footer: [{ type: 'element:text' }] }, + }, + // A card nested inside a card's `children` (#6775 reach): the + // descent visits it, so the inner `actions` goes too. + { + type: 'page:card', + properties: { + title: 'Outer', + children: [ + { type: 'page:card', properties: { title: 'Inner', actions: ['edit'] } }, + ], + }, + }, + ], + }, + ], + }, + // The named-slot shape (#6776): a header authored into a slotted + // record page's `details` slot. + { + name: 'connect_agent_detail', + kind: 'slotted', + regions: [], + slots: { + details: { type: 'page:header', properties: { title: 'Agent', icon: 'bot' } }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'connect_agent', + regions: [ + { + name: 'header', + components: [ + { + type: 'page:header', + properties: { title: 'Connect an Agent', subtitle: 'Governed MCP access.' }, + }, + { type: 'page:header', properties: { title: 'Lead', actions: ['convert_lead'] } }, + { type: 'element:button', properties: { label: 'Open', icon: 'external-link' } }, + { + type: 'page:card', + properties: { title: 'Shortcuts', footer: [{ type: 'element:text' }] }, + }, + { + type: 'page:card', + properties: { + title: 'Outer', + children: [ + { type: 'page:card', properties: { title: 'Inner' } }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'connect_agent_detail', + kind: 'slotted', + regions: [], + slots: { + details: { type: 'page:header', properties: { title: 'Agent' } }, + }, + }, + ], + }, + expectedNotices: 5, + }, +}; + +/** + * `record:details.layout` — a mode selector whose two declared modes were never + * implemented (protocol 17, #6946, ADR-0049). + * + * Maintainer ruling 2026-08-09 (decision-inbox round, 「全部接受」) on + * objectui#3818: the removal direction. + * + * This one is NOT a zero-read-point key, and the distinction is the whole + * finding. `RecordDetailsRenderer` does read it — and reads it against values + * this schema never permitted: + * + * const layout = schema.layout === 'inline' || schema.layout === 'compact' + * ? 'horizontal' : 'vertical'; + * + * The declared enum is `auto | custom`. Neither matches, so both legal values + * take the same `vertical` branch: the key was accepted, read, and could not + * change anything. Its `.describe()` meanwhile promised "auto uses object + * highlightFields, custom uses explicit sections" — a behaviour the renderer + * implements, but keyed off whether `sections` is authored at all, never off + * this flag. + * + * **Why every gate stayed green over it.** `check:react-declaration-parity` + * compares two DECLARATIONS, not a declaration against an implementation + * (AGENTS.md) — and objectui's registry declared `layout` with the SAME + * `auto | custom` enum, so the gate saw perfect agreement over a key nothing + * honoured. A third spelling, `stacked | inline | compact`, sat in + * `@object-ui/types`' `RecordDetailsComponentProps` mirror. Three declarations + * of one key, none of them the branch the renderer takes. + * + * **A PURE STRIP, deliberately.** There is no value to carry: `auto` and + * `custom` were behaviourally identical to each other and to omission, so a + * rewrite would have nowhere lossless to go. What already decides the body is + * what the author wrote — `sections` for explicit groups, its absence for the + * object's `highlightFields`. + * + * ⚠️ `record:highlights.layout` is a DIFFERENT, live key (`horizontal | + * vertical`, honoured) and is untouched: this entry is scoped by component + * `type`. + * + * objectui#3818 (delete the `layout` input and the dead `inline|compact` + * branch) is Blocked-by #6946 and proceeds on the next pin bump. + */ +const recordDetailsLayoutRemoved: MetadataConversion = { + id: 'record-details-layout-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'page.component.record:details.layout', + summary: + "record:details component prop 'layout' removed (#6946 — the declared auto|custom modes were " + + 'never implemented; the renderer branches only on inline|compact, values the schema never ' + + 'permitted, so both legal values selected nothing)', + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'record:details') return component; + const properties = component.properties; + if (!isDict(properties)) return component; + const stripped = stripKeys(properties, ['layout'], emit, `${path}.properties`); + if (stripped === properties) return component; + return { ...component, properties: stripped }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'task_detail_layout', + regions: [ + { + name: 'main', + components: [ + // The `custom` spelling, alongside the `sections` that actually + // decides the body. + { + type: 'record:details', + properties: { + layout: 'custom', + columns: '2', + sections: [{ name: 'basics', fields: ['subject'] }], + }, + }, + // The `auto` spelling — the declared default, written out. + { type: 'record:details', properties: { layout: 'auto' } }, + // `layout` on a component that is not `record:details`: + // `record:highlights.layout` is LIVE and must survive. + { type: 'record:highlights', properties: { layout: 'horizontal', fields: ['status'] } }, + ], + }, + ], + }, + { + name: 'task_detail_layout_slotted', + kind: 'slotted', + regions: [], + slots: { + details: [ + { type: 'record:details', properties: { layout: 'custom', fields: ['name'] } }, + ], + }, + }, + ], + }, + after: { + pages: [ + { + name: 'task_detail_layout', + regions: [ + { + name: 'main', + components: [ + { + type: 'record:details', + properties: { + columns: '2', + sections: [{ name: 'basics', fields: ['subject'] }], + }, + }, + { type: 'record:details', properties: {} }, + { type: 'record:highlights', properties: { layout: 'horizontal', fields: ['status'] } }, + ], + }, + ], + }, + { + name: 'task_detail_layout_slotted', + kind: 'slotted', + regions: [], + slots: { + details: [ + { type: 'record:details', properties: { fields: ['name'] } }, + ], + }, + }, + ], + }, + expectedNotices: 3, + }, +}; + /** * `app.hidden: true` → `app._unpublished: true` on **stored** rows (protocol 17, * #4829, ADR-0045 amended 2026-08-09). @@ -5941,6 +6249,8 @@ export const CONVERSIONS_BY_MAJOR: Readonly` " + + 'takes an `icon` React prop from a host with no schema fallback beside the ' + + '`schema?.actions ?? schema?.properties?.actions` fallback four lines away. ' + + '`page:card.actions` has no actions area to render into at all: the card renderer builds ' + + 'its `` from `title`, `bordered`, `children` and `footer`, full stop. Both sat in ' + + "objectui's own unpublished-exemption map as \"spec declares it, NO renderer read point\", " + + 'which is what put the contract decision — wire it, publish it with a KNOWN GAP marker, or ' + + 'retire it — in front of the maintainer; the ruling retired it. Neither has a lossless ' + + 'rewrite target (a header has no second icon slot, and moving a card\'s action ids into ' + + '`children` as components is a page rewrite, not a mechanical one), so both are pure ' + + 'strips. ⚠️ `page:header.actions` is LIVE and untouched — the strip is scoped by component ' + + 'type, never by key name.\n\n' + + 'The third, `record:details.layout`, is a sharper shape and the one worth reading twice: ' + + 'it IS read. The renderer computes ' + + "`schema.layout === 'inline' || schema.layout === 'compact' ? 'horizontal' : 'vertical'`, " + + 'while the declared enum is `auto | custom` — so neither legal value can match, both take ' + + 'the same branch, and a key that was accepted and read still selected nothing, under a ' + + '`.describe()` promising "auto uses object highlightFields, custom uses explicit sections". ' + + 'The behaviour that prose describes is real, but the renderer keys it off whether ' + + '`sections` was authored, never off this flag. Every gate stayed green because ' + + '`check:react-declaration-parity` compares two DECLARATIONS and objectui declared the same ' + + '`auto | custom` enum — perfect agreement over a key nothing honoured — while a THIRD ' + + "spelling (`stacked | inline | compact`) sat in `@object-ui/types`' mirror. A pure strip " + + 'for the same reason: `auto`, `custom` and omission were behaviourally identical, so there ' + + 'is no value to carry. ⚠️ `record:highlights.layout` is a different, live, honoured key ' + + 'and is untouched. objectui#3829 and objectui#3818 drop the exemptions, the input and the ' + + 'dead branch on the next pin bump.\n\n' + 'Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave ' + '`AggregationFunction` (#6188, ADR-0049). The enum declared eight functions and the SQL ' + 'family compiles five — `SqlDriver.mapAggregateFunc` and the Turso ' @@ -1271,6 +1302,8 @@ const step17: MigrationStep = { 'dataset-measure-array-string-agg-removed', 'inline-action-api-params-to-body-extra', 'page-tabs-type-to-tab-style', + 'page-structure-inert-keys-removed', + 'record-details-layout-removed', 'app-hidden-to-unpublished', ], semantic: [ @@ -3374,6 +3407,25 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // takes (`query-joins-retired` / `query-cursor-retired` / // `query-distinct-retired` / `query-window-functions-retired`, #4286). 'data/AggregationNode:distinct', + // #6946 — three SDUI page-component props, retired by maintainer ruling + // 2026-08-09 (decision-inbox round, 「全部接受」): objectui#3829 route (c) + // for the first two, objectui#3818 for the third. Registered per key, as + // gate (b) reads them — nothing radiates from a neighbouring key, and this + // family needs that literally: `ui/PageHeaderProps:actions` and + // `ui/RecordHighlightsProps:layout` are LIVE keys sharing these leaf names. + // + // The first two are the B class — declared here, read NOWHERE in objectui + // (the header resolves icons per action; the card renders + // title/bordered/children/footer and has no actions area), and carried in + // that repo's own `UNPUBLISHED_EXEMPTIONS` map as exactly that. + 'ui/PageCardProps:actions', + 'ui/PageHeaderProps:icon', + // The third is a sharper shape: `layout` IS read, but only against + // `inline`/`compact` — values its `auto | custom` enum never permitted — so + // both legal values took the same branch. Declared on BOTH sides with the + // same enum, which is why the declaration-parity ratchet (two declarations, + // never a declaration vs an implementation) reported agreement over it. + 'ui/RecordDetailsProps:layout', ], }; diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 25d406b2b9..16bea6c505 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -908,7 +908,7 @@ describe('translatePage', () => { components: [ { type: 'page:header', - properties: { title: 'Connect an Agent', subtitle: 'Give any MCP-capable client…', icon: 'bot' }, + properties: { title: 'Connect an Agent', subtitle: 'Give any MCP-capable client…', actions: ['connect_agent'] }, }, ], }, @@ -926,9 +926,9 @@ describe('translatePage', () => { expect(out.regions[0].components[0].properties.subtitle).toBe('让任意支持 MCP 的 AI 客户端受控访问此环境。'); }); - it('preserves non-translatable header properties such as icon', () => { + it('preserves non-translatable header properties such as actions', () => { const out = translatePage(page, bundle, { locale: 'zh-CN' }); - expect(out.regions[0].components[0].properties.icon).toBe('bot'); + expect(out.regions[0].components[0].properties.actions).toEqual(['connect_agent']); }); it('leaves non-header components untouched', () => { diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index d9f3ed523a..19007d35cc 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -28,7 +28,6 @@ describe('PageHeaderProps', () => { expect(result.title).toBe('My Page'); expect(result.breadcrumb).toBe(true); expect(result.subtitle).toBeUndefined(); - expect(result.icon).toBeUndefined(); expect(result.actions).toBeUndefined(); }); @@ -36,7 +35,6 @@ describe('PageHeaderProps', () => { const header = { title: 'Dashboard', subtitle: 'Overview', - icon: 'home', breadcrumb: false, actions: ['action-1', 'action-2'], }; @@ -85,6 +83,29 @@ describe('PageHeaderProps recordChrome / showStar / showCopyId (#6776)', () => { }); }); +// #6946 — the header icon, retired by maintainer ruling 2026-08-09 +// (objectui#3829 route (c)). objectui resolves `icon` only per header ACTION; +// the header's own bag is never asked for one, and the registration publishes +// no `icon` input. Four in-repo pages authored it and none ever drew it. +describe('PageHeaderProps icon is retired (#6946)', () => { + it('rejects the retired `icon` with its prescription', () => { + expect(() => PageHeaderProps.parse({ title: 'Connect an Agent', icon: 'bot' })) + .toThrow(/`icon`.*removed.*`recordChrome`/s); + }); + + it('does not materialize the retired `icon` on a clean parse', () => { + expect(PageHeaderProps.parse({ title: 'Connect an Agent' })).not.toHaveProperty('icon'); + }); + + // The live half of the same key name, one component over: `page:header` + // DOES read `actions` off its props bag and keeps it. A strip scoped by key + // name rather than by component type would have taken this with it. + it('keeps `actions`, which the header renderer does read', () => { + expect(PageHeaderProps.parse({ title: 'Lead', actions: ['convert_lead'] }).actions) + .toEqual(['convert_lead']); + }); +}); + describe('PageTabsProps', () => { it('should accept valid tabs with defaults', () => { const tabs = { @@ -256,7 +277,6 @@ describe('PageCardProps', () => { const card = { title: 'Info Card', bordered: false, - actions: ['edit', 'delete'], children: ['component1'], footer: ['footer-component'], }; @@ -286,6 +306,20 @@ describe('PageCardProps', () => { it('does not materialize the retired `body` on a clean parse', () => { expect(PageCardProps.parse({ children: [] })).not.toHaveProperty('body'); }); + + // #6946 — the card's action list, retired by maintainer ruling 2026-08-09 + // (objectui#3829 route (c)). `PageCardRenderer` builds its `` from + // title/bordered/children/footer and has no actions area; the objectui + // registration publishes no `actions` input either. The prescription points + // at composition, which is what actually renders. + it('rejects the retired `actions` with the composition prescription', () => { + expect(() => PageCardProps.parse({ title: 'Shortcuts', actions: ['new_task'] })) + .toThrow(/`actions`.*removed.*`children`.*`footer`/s); + }); + + it('does not materialize the retired `actions` on a clean parse', () => { + expect(PageCardProps.parse({ title: 'Shortcuts', children: [] })).not.toHaveProperty('actions'); + }); }); describe('PageContainerProps — page:section / page:footer / page:sidebar (#5775)', () => { @@ -318,7 +352,6 @@ describe('RecordDetailsProps', () => { it('should accept empty with defaults', () => { const result = RecordDetailsProps.parse({}); expect(result.columns).toBe('2'); - expect(result.layout).toBe('auto'); expect(result.sections).toBeUndefined(); }); @@ -334,7 +367,6 @@ describe('RecordDetailsProps', () => { // `hideFields` key was silently stripped. it('accepts the showcase section shape verbatim (project-detail.page.ts:49)', () => { const details = { - layout: 'custom' as const, sections: [ { label: 'Overview', columns: 2, fields: ['name', 'account', 'owner', 'status'] }, { label: 'Financials', columns: 2, fields: ['budget', 'spent'] }, @@ -381,7 +413,7 @@ describe('RecordDetailsProps', () => { }); it('rejects the retired ID-list form rather than silently half-reading it', () => { - const r = RecordDetailsProps.safeParse({ layout: 'custom', sections: ['overview'] }); + const r = RecordDetailsProps.safeParse({ sections: ['overview'] }); expect(r.success).toBe(false); expect(r.success === false && r.error.issues[0].code).toBe('invalid_type'); expect(r.success === false && r.error.issues[0].path).toEqual(['sections', 0]); @@ -404,6 +436,31 @@ describe('RecordDetailsProps', () => { }); expect(result.hideFields).toEqual(hideFields); }); + + // #6946 — the mode selector whose two declared modes were never implemented, + // retired by maintainer ruling 2026-08-09 (objectui#3818). Unlike the other + // two keys in that ruling this one WAS read — against `inline`/`compact`, + // values this enum never permitted — so both legal values took the same + // branch and the key selected nothing. + it('rejects the retired `layout` with its prescription', () => { + expect(() => RecordDetailsProps.parse({ layout: 'custom' })) + .toThrow(/`layout`.*removed.*`sections`.*`highlightFields`/s); + // The declared default is refused too — `auto` was never distinguishable + // from `custom` or from omitting the key. + expect(() => RecordDetailsProps.parse({ layout: 'auto' })) + .toThrow(/`layout`.*removed/s); + }); + + it('does not materialize the retired `layout` on a clean parse', () => { + expect(RecordDetailsProps.parse({ sections: [{ label: 'Overview', fields: ['name'] }] })) + .not.toHaveProperty('layout'); + }); + + // The live half of the same key name, one component over. + it('leaves `record:highlights` layout alone — a different, honoured key', () => { + expect(RecordHighlightsProps.parse({ fields: ['status'], layout: 'horizontal' }).layout) + .toBe('horizontal'); + }); }); describe('RecordRelatedListProps', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 95daee61e3..3c411bd2f4 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -224,7 +224,33 @@ export type PageContainerProps = z.input; export const PageHeaderProps = z.object({ title: I18nLabelSchema.describe('Page title'), subtitle: I18nLabelSchema.optional().describe('Page subtitle'), - icon: z.string().optional().describe('Icon name'), + /** + * REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3829, + * route (c) — retire upstream). + * + * A header icon nothing has ever drawn. `PageHeaderRenderer` + * (`containers.tsx`) resolves `icon` only per header ACTION (`action.icon`, + * inside the action pipeline) and never off the header's own props bag; + * `@object-ui/layout`'s `` accepts an `icon` REACT prop from a + * host but — unlike `actions`, whose `schema?.actions ?? + * schema?.properties?.actions` fallback sits four lines away in the same + * function — gives it no schema fallback, so an authored node cannot reach + * it. objectui's registration publishes no `icon` input either, which is + * what put this key in that repo's `UNPUBLISHED_EXEMPTIONS` map as a B-class + * "spec declares it, NO renderer read point" entry. + * + * The live mechanism is the record chrome (`recordChrome`, on by default) + * for the header's own identity, and each action's own `icon` for the + * buttons beside it. + */ + icon: retiredKey( + '`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: z.boolean().default(true).describe('Show breadcrumb'), actions: z.array(z.string()).optional().describe('Action IDs to show in header'), /** @@ -347,7 +373,32 @@ export const PageTabsProps = z.object({ export const PageCardProps = z.object({ title: I18nLabelSchema.optional(), bordered: z.boolean().default(true), - actions: z.array(z.string()).optional(), + /** + * REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3829, + * route (c) — retire upstream). + * + * A card action list nothing has ever rendered. `PageCardRenderer` + * (`containers.tsx`) reads exactly four keys — `title`, `bordered`, + * `body ?? children`, `footer` — and returns a `` built from them; + * there is no actions area in the markup and no `actions` input in the + * registration, which is what put this key in objectui's + * `UNPUBLISHED_EXEMPTIONS` map as a B-class "spec declares it, NO renderer + * read point" entry. The card's sibling `page:header` DOES read `actions` + * off its bag, so the divergence was invisible to anyone reading the two + * declarations side by side. + * + * The live mechanism is composition: author the buttons as components in + * `children` or `footer` (`element:button`, `record:quick_actions`). + */ + actions: retiredKey( + '`page:card` property `actions` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — ' + + 'no renderer ever read it: objectui\'s card renderer builds its `` from `title`, ' + + '`bordered`, `children` and `footer` only, has no actions area, and the component registry ' + + 'never published it as an input, so an authored value was accepted and dropped. Delete the ' + + 'key and author the buttons as components in the card\'s `children` or `footer` ' + + '(`element:button`, `record:quick_actions`), which is what actually renders. ' + + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.', + ), /** * Card content, in order — the canonical composition slot, matching every * other container (`grid`, `flex`, `page:section`, `page:tabs` items). @@ -385,7 +436,35 @@ export const PageCardProps = z.object({ export const RecordDetailsProps = z.object({ columns: z.enum(['1', '2', '3', '4']).default('2').describe('Number of columns for field layout (1-4)'), - layout: z.enum(['auto', 'custom']).default('auto').describe('Layout mode: auto uses object highlightFields, custom uses explicit sections'), + /** + * REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3818 — + * the removal direction). + * + * The declared `auto` | `custom` semantics were never implemented. objectui's + * `RecordDetailsRenderer` does read `layout`, but only to test it against + * `inline` | `compact` — two values this enum never permitted — so BOTH legal + * values fell to the same `vertical` branch and the key selected nothing. + * That is why it survived `check:react-declaration-parity`: objectui's + * registry declared `layout` with the same `auto` | `custom` enum this schema + * did, and the gate compares two DECLARATIONS, never a declaration against a + * renderer (AGENTS.md). A third spelling, `stacked` | `inline` | `compact`, + * sat in `@object-ui/types`' mirror — three declarations of one key, none of + * them the branch the renderer takes. + * + * The live mechanism is what you author: `sections` renders the explicit + * groups (the old `custom`), and omitting it falls back to the object's + * `highlightFields` (the old `auto`). objectui#3818 deletes the input and the + * dead branch on the next pin bump. + */ + layout: retiredKey( + '`record:details` property `layout` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — ' + + 'its declared `auto` | `custom` semantics were never implemented: the renderer tests `layout` ' + + 'only against `inline` | `compact`, two values the schema never permitted, so both legal ' + + 'values took the same branch and the key selected nothing. Delete the key — the body is ' + + 'already chosen by what you author: `sections` renders the explicit groups (the old ' + + '`custom`), and omitting it falls back to the object\'s `highlightFields` (the old `auto`). ' + + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.', + ), /** * Field groups rendered as the detail body, IN ORDER. * diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index f3d430aa2b..0da23c9c9f 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -1027,7 +1027,6 @@ export const LeadDetailPage = definePage({ properties: { title: '{first_name} {last_name}', subtitle: '{company}', - icon: 'user-plus', breadcrumb: true, actions: [ConvertLeadAction], // inline action buttons in header },