From f5b009046b0eef8030bcdaf3ec17f5c3d75d1397 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 00:41:54 +0000 Subject: [PATCH] =?UTF-8?q?fix(spec):=20`aria`=20=E5=A2=93=E7=A2=91?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8C=87=E5=90=91=E5=90=8C=E4=B8=80=E5=A4=A7?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E9=87=8C=E5=B7=B2=E9=80=80=E4=BC=91=E7=9A=84?= =?UTF-8?q?=E8=90=BD=E7=82=B9=20(#6756)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dashboard.widgets[].aria` 的 retiredKey() 提示声称共享的 `AriaProps` "stays live on `app.aria`",而 `App.aria` 自己就是同一个 17.0.0 里被移除的 retiredKey() 墓碑;两行之后推荐的 `os migrate meta --from 16` 恰恰会把 `aria` 从 app 源里剥掉。反向的一半同样坏:`App.aria` 让作者写到 "component/widget",widget 那一半正是 #5010 退休的 `dashboard.widgets[].aria`。 两个墓碑互指对方已退休的键。 按 liveness ledger 实测重新校准落点:page.aria / page.components[].aria / list view 的 aria 为 live;action.aria 为 live 但 PARTIAL;chart.aria 无 ledger 行,故不列入 —— 多列一个没有渲染器读的面就是在重犯本 issue 修的错。 migrate 动词由 "rewrite it" 改为 "remove it":该转换调用 stripKeys,做的是剥离。 接受集合逐字节不变:改动全部是 retiredKey() 的 guidance 参数、注释与 ledger 的 note 散文。gen:schema 独立复核 1308 个 authorable default 未变。 两处既有断言此前把缺陷钉死(dashboard.test.ts 要求 /app\.aria/、 app.test.ts 期待 'component/widget'),已按其原意重新瞄准而非删除。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk --- .changeset/aria-tombstone-dead-carrier.md | 32 ++++ content/docs/references/ui/app.mdx | 2 +- content/docs/references/ui/dashboard.mdx | 2 +- docs/protocol-upgrade-guide.md | 2 +- packages/spec/liveness/app.json | 2 +- packages/spec/liveness/dashboard.json | 2 +- packages/spec/src/migrations/registry.ts | 4 +- packages/spec/src/ui/app.form.ts | 6 +- packages/spec/src/ui/app.test.ts | 6 +- packages/spec/src/ui/app.zod.ts | 10 +- .../src/ui/aria-carrier-tombstones.test.ts | 152 ++++++++++++++++++ packages/spec/src/ui/dashboard.test.ts | 10 +- packages/spec/src/ui/dashboard.zod.ts | 20 ++- 13 files changed, 231 insertions(+), 19 deletions(-) create mode 100644 .changeset/aria-tombstone-dead-carrier.md create mode 100644 packages/spec/src/ui/aria-carrier-tombstones.test.ts diff --git a/.changeset/aria-tombstone-dead-carrier.md b/.changeset/aria-tombstone-dead-carrier.md new file mode 100644 index 0000000000..47bc911a00 --- /dev/null +++ b/.changeset/aria-tombstone-dead-carrier.md @@ -0,0 +1,32 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): `aria` 墓碑不再把作者指向同一个大版本里已经退休的落点(#6756) + +`dashboard.widgets[].aria` 的 `retiredKey()` 提示告诉升级中的作者,共享的 +`AriaProps` 形状「stays live on `app.aria`」。但 `App.aria` 自己就是一个 +`retiredKey()` 墓碑,和它在**同一个 17.0.0** 里被 2026-06 app liveness 审计移除了。 +于是这条提示把作者送向一扇不存在的门,然后在两行之后递给他们 +`os migrate meta --from 16` —— 而 `app-dead-authoring-keys-removed` 这个转换恰好 +会把 `aria` 从源里**剥掉**。按提示操作的代价是两轮返工外加一次静默的数据丢失。 + +反向的一半同样是坏的:`App.aria` 的处方说「declare `aria` on the +**component/widget**」,而 widget 那一半指的正是 #5010 退休掉的 +`dashboard.widgets[].aria`。两个墓碑互相指向对方已经退休的键。 + +按实测(而非沿用原 issue 的猜测)重新校准了落点。`packages/spec` 里 +`aria: AriaPropsSchema` 共 25 处活声明,liveness ledger 的判定是:`page.aria`、 +`page.components[].aria`、list view 的 `aria` 为 `live`;`action.aria` 为 live 但 +标注 PARTIAL;`app.aria` / `dashboard.aria` / `dashboard.widgets[].aria` / +form view 的 `aria` 为 `dead`;`chart.aria` 没有 ledger 行。因此处方只列举了 +前三个无歧义的活载体 —— 多列一个没有渲染器读的面,就是在重犯本 issue 修的错。 + +同时把 `os migrate meta --from 16` 的动词从 "rewrite it" 改为 "remove it":该转换 +调用的是 `stripKeys(..., ['actionUrl','actionType','actionIcon','aria'])`,fixture 的 +`after` 里 `aria` 已被删除,它做的是剥离而不是搬迁。 + +**接受集合逐字节不变。** 改动的每一处都是 `retiredKey()` 的 guidance 参数、注释或 +ledger 的 `note` 散文;`retiredKey()` 无论字符串是什么都返回 +`z.never({ error: () => guidance }).optional()`。`gen:schema` 亦独立复核了 +1308 个 authorable default 未变。 diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index d19084e726..cc96b572a5 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -84,7 +84,7 @@ const result = ActionNavItemSchema.parse(data); | **embed** | `never` | optional | [REMOVED] `App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch. Delete the key. | | **mobileNavigation** | `never` | optional | [REMOVED] `App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — fully unimplemented; no renderer, including packages/mobile, ever read it). Delete the key; the block returns if/when a real mobile navigation ships. | | **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 | -| **aria** | `never` | optional | [REMOVED] `App.aria` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no renderer read app-level ARIA attributes). Declare `aria` on the component/widget that renders the DOM node instead. Delete the key. | +| **aria** | `never` | optional | [REMOVED] `App.aria` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no renderer read app-level ARIA attributes). Declare `aria` on the page component that renders the DOM node instead (`page.components[].aria`; `page.aria` and the list view `aria` are live too). Delete the key. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 0844861eb8..c10166fff1 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -111,7 +111,7 @@ Dashboard header action | **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | | **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget | | **responsive** | `never` | optional | [REMOVED] `dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (#4876, ADR-0049 D2) — no renderer ever read it, so per-widget breakpoint overrides were never applied: the value parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules (`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. The shared `ResponsiveConfig` shape is NOT gone — it stays live on `page.components[].responsive`, which objectui `useResponsiveConfig` really does read; move the layout there if you need breakpoint behaviour today. Run `os migrate meta --from 16` to rewrite it automatically. | -| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0 (#3896). Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `app.aria` and `page.components[].aria`. Run `os migrate meta --from 16` to rewrite it automatically. | +| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0 (#3896). Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 16` to remove it automatically. | ### Allowed Values: `DashboardWidget.type` diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 5c996e65b8..ffbf045feb 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -196,7 +196,7 @@ Last, it removes `dashboard.widgets[].responsive` (#4876) — the straggler of t Finally it CONVERGES `dashboard.widgets[].compareTo` (#5011) — the one entry in this step that is not a removal but a vocabulary merge, and the one whose defect was worst-shaped. The widget declared three arms with confident TSDoc; the analytics executor implements one contract, `DatasetSelection.compareTo` = `{ kind, dimension? }`, which has no `offset` in it. On the ADR-0021 dataset path the two string arms were DROPPED by the renderer (a comparison silently absent from a widget whose author asked for one) and `{ offset }` was forwarded into that contract with no dimension, so the executor threw `compareTo requires a timeDimension "undefined"` and errored the whole widget. All three arms worked on the legacy inline chart path. Same key, two fates — and the failing one was the path the spec itself calls canonical, which is why this ranks above an ordinary declared-but-unread key: the documentation was actively teaching a shape that crashes. The widget now declares the executor's own words, so `declared = enforced` holds by construction with no second vocabulary left to drift. `dimension` is optional and resolved by the EXECUTOR (one dated time dimension → that one; zero or several → a loud error naming the candidates), which is a producer-side resolution rule, not the consumer-side tolerance PD #12 forbids. The bare strings and `{ offset: '1y' }` replay mechanically; every other `{ offset }` duration is a semantic TODO below, because `previousPeriod` shifts by the resolved window's own length and rewriting `7d` into it would change which rows the comparison counts. The converged slot is also union-free, which is not cosmetic: zod collapses a failed union into one bare `Invalid input` and #5014 showed that curated guidance inside a union arm never reaches the author at all. -The same widget drill retires four more keys (#5010): the action trio `actionUrl`/`actionType`/`actionIcon`, and `aria`. The trio described a per-widget action BUTTON that no renderer in either repo has ever drawn — all 14 `actionUrl` reads in DashboardRenderer are scoped to `header.actions[]`, a different schema — and `actionIcon` had zero references anywhere outside its own declaration. `aria` is the dashboard-level `aria` removed by the #3896 sweep, one level down: declared ARIA attributes that never reached the DOM, i.e. an accessibility guarantee an author could state and nothing honoured. It survived that sweep for the same reason `responsive` did — `widgets` had no ledger drill until #4956 — not on evidence. This removal also settles a second-order cost the trio was carrying: `packages/lint`'s dashboard action-ref rule enforced ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key "the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so an author could FAIL A BUILD because a control that cannot render pointed at an action that also did not. That widget branch is deleted with the keys. Lossless deletes in every case — the keys contributed nothing to any rendered output — and the shared `AriaProps` shape is untouched, staying live on `app.aria` and `page.components[].aria`. Move a dashboard-wide affordance to `header.actions[]` (where `icon` is the header spelling of `actionIcon`); for per-row click-through use a dataset-bound `table`/`pivot`, whose rows drill through the semantic layer already. +The same widget drill retires four more keys (#5010): the action trio `actionUrl`/`actionType`/`actionIcon`, and `aria`. The trio described a per-widget action BUTTON that no renderer in either repo has ever drawn — all 14 `actionUrl` reads in DashboardRenderer are scoped to `header.actions[]`, a different schema — and `actionIcon` had zero references anywhere outside its own declaration. `aria` is the dashboard-level `aria` removed by the #3896 sweep, one level down: declared ARIA attributes that never reached the DOM, i.e. an accessibility guarantee an author could state and nothing honoured. It survived that sweep for the same reason `responsive` did — `widgets` had no ledger drill until #4956 — not on evidence. This removal also settles a second-order cost the trio was carrying: `packages/lint`'s dashboard action-ref rule enforced ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key "the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so an author could FAIL A BUILD because a control that cannot render pointed at an action that also did not. That widget branch is deleted with the keys. Lossless deletes in every case — the keys contributed nothing to any rendered output — and the shared `AriaProps` shape is untouched, staying live on `page.aria` / `page.components[].aria` and the list view `aria` — not on `app.aria`, which this same major retires (see `app-dead-authoring-keys-removed`, which strips it). Move a dashboard-wide affordance to `header.actions[]` (where `icon` is the header spelling of `actionIcon`); for per-row click-through use a dataset-bound `table`/`pivot`, whose rows drill through the semantic layer already. ⚠️ One protocol-17 change turns metadata ON rather than off, and it is the one to read first: declarative `apis:` endpoints EXECUTE from 17 (#5040). The surface used to be inert end to end — no route mounted, no matcher, every key including `authRequired` parsed and enforced nothing — which is why #4936 refused a non-empty `apis:` outright. 17 ships the executor and narrows that refusal to a per-endpoint publish gate, so an endpoint that passes the gate is MOUNTED and serves traffic the moment it is published. Any historical `apis:` block therefore changes meaning without changing a byte. Review every entry before upgrading, and pay particular attention to an explicit `authRequired: false`: the schema default is `true`, so an omission is safe, and only that explicit `false` opens anonymous access — which ADR-0121 D6 now pairs with a mandatory armed `rateLimit` (`enabled: true`; the key defaults to `false`, so a budget written without it meters nothing). Paths also move under the namespace carve-out `/api/v1/apps//` (ADR-0121 D1/D2). The full checklist is the `declarative-apis-endpoints-live` semantic entry below; it is a security review, not a rename, so nothing about it is applied for you. diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index 1c2e750aaf..22762abc68 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -250,7 +250,7 @@ "aria": { "status": "dead", "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142) — app-level ARIA was never read; declare aria on the rendering component/widget." + "note": "retiredKey tombstone (#4142) — app-level ARIA was never read; declare aria on the rendering page component (`page.components[].aria`, live in page.json). NOT on a dashboard widget: `dashboard.widgets[].aria` is `dead` in dashboard.json, retired by #5010 in this same major — corrected 2026-08-08 (#6756)." }, "objects": { "status": "dead", diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index 0a1c4c8e04..5cabfada16 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -147,7 +147,7 @@ "aria": { "status": "dead", "verifiedAt": "2026-08-04", - "note": "CALL GRAPH CLOSED BY HAND 2026-08-03, re-measured 2026-08-04 across both repos: no consumer of `widget.aria` anywhere. The `aria-*` attributes in DashboardRenderer / DatasetWidget are the renderer's own DOM attributes, and objectui's one `.aria` read (plugin-view/src/ObjectView.tsx:989) is the VIEW's. Same false-compliance shape as the dashboard-level `aria` removed in the #3896 sweep — an accessibility guarantee an author can declare and nothing honours. The shared AriaProps shape is untouched and stays live on `app.aria` / `page.components[].aria`. Retired 2026-08-04 via #5010 / ADR-0049 D2 (retiredKey tombstone + the protocol-17 `dashboard-widget-action-aria-removed` conversion). The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error carrying the prescription." + "note": "CALL GRAPH CLOSED BY HAND 2026-08-03, re-measured 2026-08-04 across both repos: no consumer of `widget.aria` anywhere. The `aria-*` attributes in DashboardRenderer / DatasetWidget are the renderer's own DOM attributes, and objectui's one `.aria` read (plugin-view/src/ObjectView.tsx:989) is the VIEW's. Same false-compliance shape as the dashboard-level `aria` removed in the #3896 sweep — an accessibility guarantee an author can declare and nothing honours. The shared AriaProps shape is untouched and stays live on `page.aria` / `page.components[].aria` and the list view `aria` (this ledger's own `page.props.aria` and `view.props.list.children.aria` rows) — corrected 2026-08-08 (#6756): it does NOT stay live on `app.aria`, whose `app.json` row is `dead` and which the protocol-17 `app-dead-authoring-keys-removed` conversion strips. Retired 2026-08-04 via #5010 / ADR-0049 D2 (retiredKey tombstone + the protocol-17 `dashboard-widget-action-aria-removed` conversion). The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error carrying the prescription." } } }, diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 106e3ff569..0c1418af9c 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -893,7 +893,9 @@ const step17: MigrationStep = { + 'an author could FAIL A BUILD because a control that cannot render pointed at an action ' + 'that also did not. That widget branch is deleted with the keys. Lossless deletes in every ' + 'case — the keys contributed nothing to any rendered output — and the shared `AriaProps` ' - + 'shape is untouched, staying live on `app.aria` and `page.components[].aria`. Move a ' + + 'shape is untouched, staying live on `page.aria` / `page.components[].aria` and the list ' + + 'view `aria` — not on `app.aria`, which this same major retires (see ' + + '`app-dead-authoring-keys-removed`, which strips it). Move a ' + 'dashboard-wide affordance to `header.actions[]` (where `icon` is the header spelling of ' + '`actionIcon`); for per-row click-through use a dataset-bound `table`/`pivot`, whose rows ' + 'drill through the semantic layer already.\n\n' diff --git a/packages/spec/src/ui/app.form.ts b/packages/spec/src/ui/app.form.ts index 77007beca5..ebcf3bd85e 100644 --- a/packages/spec/src/ui/app.form.ts +++ b/packages/spec/src/ui/app.form.ts @@ -87,8 +87,10 @@ export const appForm = defineForm({ // impression. The live surface is `FormView.sharing` (public data // collection), authored per form view. // `aria` removed: tombstoned in 17.0.0 — no renderer read app-level ARIA - // attributes; declare `aria` on the component/widget that renders the - // DOM node. + // attributes; declare `aria` on the page component that renders the + // DOM node (`page.components[].aria`). NOT on a dashboard widget — + // `dashboard.widgets[].aria` was retired in the same major (#5010, + // corrected in #6756). ], }, ], diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index 4df2e5242c..eaab54e698 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -1043,7 +1043,11 @@ describe('AppSchema with areas', () => { describe('retired dead keys carry prescriptions (#4001)', () => { it.each([ ['version', '1.0.0', 'manifest.version'], - ['aria', { label: 'x' }, 'component/widget'], + // ⚠️ The expected fragment used to be `component/widget`. The widget half + // of that prescription named `dashboard.widgets[].aria`, retired by #5010 + // in this same 17.0.0 — so the pin held `App.aria`'s prescription pointing + // at another tombstone (#6756). Re-aimed at the surviving carrier. + ['aria', { label: 'x' }, 'page.components[].aria'], ['objects', [], 'defineStack'], ['apis', [], 'defineStack'], ] as const)('rejects `%s` with its upgrade prescription', (key, value, fragment) => { diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 516034a9f5..cbff13a784 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1399,12 +1399,16 @@ export const AppSchema = lazySchema(() => strictObject( /** * REMOVED — never read at the APP level (2026-06 liveness audit). ARIA - * attributes are live on the component/widget surfaces that render DOM. + * attributes are live on the page / page-component / list-view surfaces that + * render DOM. NOT on a dashboard widget: `dashboard.widgets[].aria` was + * retired in this same 17.0.0 (#5010), so "component/widget" pointed half of + * its readers at another tombstone (#6756). */ aria: retiredKey( '`App.aria` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no ' + - 'renderer read app-level ARIA attributes). Declare `aria` on the component/widget ' + - 'that renders the DOM node instead. Delete the key.', + 'renderer read app-level ARIA attributes). Declare `aria` on the page component ' + + 'that renders the DOM node instead (`page.components[].aria`; `page.aria` and the ' + + 'list view `aria` are live too). Delete the key.', ), /** diff --git a/packages/spec/src/ui/aria-carrier-tombstones.test.ts b/packages/spec/src/ui/aria-carrier-tombstones.test.ts new file mode 100644 index 0000000000..ebeda329bb --- /dev/null +++ b/packages/spec/src/ui/aria-carrier-tombstones.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { DashboardWidgetSchema } from './dashboard.zod'; +import { AppSchema } from './app.zod'; +import { PageSchema, PageComponentSchema } from './page.zod'; +import { ListViewSchema } from './view.zod'; + +/** + * The two `aria` tombstones must not point at each other (#6756). + * + * `shared/retired-key.ts` states the contract these strings live under — "an + * agent bumping `@objectstack/spec` sees THIS string, not our docs site" — so a + * tombstone's enumeration of the surfaces that still carry the shape is + * CONTRACT, not prose. Both of these went stale in the same major, in a mutual + * loop no gate could see: + * + * - `dashboard.widgets[].aria` (#5010) told the author `AriaProps` "stays + * live on `app.aria`" — and `App.aria` is a `retiredKey()` tombstone + * removed in that same 17.0.0. The natural repair (lift the block up to the + * app) hit a second tombstone, and running the `os migrate meta --from 16` + * the same message recommends two lines later would then have STRIPPED it: + * the `app-dead-authoring-keys-removed` conversion lists `aria`. + * - `App.aria`, reciprocally, said "declare `aria` on the component/widget". + * The component half is right; the widget half named the key #5010 retired. + * + * This is #6630's class on a second surface, so the assertion shape is + * deliberately copied from `shared/retry-policy.test.ts`: pin the enumeration + * in BOTH directions, and refuse to buy either direction by weakening the + * prescription itself. + * + * There is no ADR-0112 `code`/`status` envelope to assert on this rejection + * class. `retiredKey()` is a Zod `never` whose issue carries the guidance as + * its `message`, so here the wording IS the whole contract (#5240). + * + * ⚠️ Two halves, two directions — stated up front because only one of them can + * go red on the defect this file was written for: + * + * - The TEXTUAL half (`the tombstone names ...`) is the live one. Restore + * either guidance string to its pre-#6756 wording and it goes RED. + * - The STRUCTURAL half (`really accepts` / `really rejects`) is GREEN before + * and after, by design: #6756 is a `domain:spec-surface` text fix whose + * admission test is that acceptance is byte-for-byte unchanged, so nothing + * here may flip. It is not decoration — it is the ground truth the textual + * half is checked against, and the anti-vacuity guard: it is what makes + * "names `page.components[].aria`" mean "names a surface that exists" + * rather than "contains a string we also typed into the test". + */ +describe('the `aria` tombstones name only live `AriaProps` carriers (#6756)', () => { + // One authored value of the shared shape, reused on every surface below so + // the comparison is like-for-like. + const ARIA = { ariaLabel: 'Total orders' } as const; + + const widgetWithAria = { + id: 'w1', type: 'metric', dataset: 'orders', values: ['total'], aria: ARIA, + }; + const appWithAria = { + name: 'sales_app', label: 'Sales', navigation: [], aria: ARIA, + }; + + const messageOf = (result: { success: boolean; error?: { issues: { message: string }[] } }) => { + expect(result.success, 'the tombstone must still REJECT — a passing parse means the key came back').toBe(false); + return result.error!.issues.map((i) => i.message).join('\n'); + }; + + it('the widget tombstone still fires, and points at surfaces that survived 17.0.0', () => { + const message = messageOf(DashboardWidgetSchema.safeParse(widgetWithAria)); + + // Anti-vacuity. Deleting the tombstone outright does not silence this file: + // `DashboardWidgetSchema` is a `strictObject`, so the key would still be + // refused — but as a generic unrecognized-key guidance that cannot contain + // the prescription's own opening clause. Wording drift fails the assertions + // below; DISAPPEARANCE fails this one. + expect( + message, + 'the widget `aria` prescription must still be reachable through the parse', + ).toContain('`dashboard.widgets[].aria` was removed'); + + // Every surface that still declares `aria: AriaPropsSchema` and is graded + // `live` in the liveness ledger, spelled the way an author writes it. A + // prescription NARROWER than the truth is the #4964 defect. + for (const live of ['`page.aria`', '`page.components[].aria`', 'list view `aria`']) { + expect(message, `the prescription must still name ${live}`).toContain(live); + } + + // ...and nothing retired. WIDER than the truth is the #6756 defect itself: + // `app.aria` is `dead` in `liveness/app.json` and stripped by the + // protocol-17 `app-dead-authoring-keys-removed` conversion, so naming it + // sent the author to a door that does not exist. Matched by idiom (any + // mention at all), not by the exact sentence that used to carry it. + expect( + message, + 'the prescription must not point at `app.aria`, retired in this same major', + ).not.toMatch(/app\.aria/i); + + // None of the above may be bought by weakening the prescription itself. + expect(message).toContain('author a `title`'); + // The conversion STRIPS this key (`stripKeys(..., [... 'aria'])`); it does + // not relocate it. The verb has to say so. + expect(message).toContain('os migrate meta --from 16'); + expect(message).toMatch(/to remove it/); + }); + + it('the App.aria tombstone points at a page component, not at the retired widget surface', () => { + const message = messageOf(AppSchema.safeParse(appWithAria)); + + // Anti-vacuity, same shape as above. + expect( + message, + 'the `App.aria` prescription must still be reachable through the parse', + ).toContain('`App.aria` was removed'); + + expect(message, 'the prescription must name the surviving carrier').toContain('`page.components[].aria`'); + + // The reciprocal half of the loop. "component/widget" read as an + // instruction sends half its readers at `dashboard.widgets[].aria`, which + // #5010 retired in this same 17.0.0. + expect( + message, + 'the prescription must not send the author to a dashboard widget', + ).not.toMatch(/widget/i); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Ground truth. Green before #6756 and green after — see the file docblock. + // These two assert the FACTS the prose above is checked against, so that the + // `toContain` list cannot drift into naming a surface that stopped existing + // (which is precisely how #6756 happened). + // ───────────────────────────────────────────────────────────────────────── + + it('every surface the tombstones name really accepts `AriaProps`', () => { + expect(PageSchema.safeParse({ + name: 'sales_home', label: 'Home', regions: [], aria: ARIA, + }).success, '`page.aria`').toBe(true); + + expect(PageComponentSchema.safeParse({ + type: 'steedos-labs.related-list', properties: {}, aria: ARIA, + }).success, '`page.components[].aria`').toBe(true); + + expect(ListViewSchema.safeParse({ + type: 'grid', columns: ['name'], aria: ARIA, + }).success, 'the list view `aria`').toBe(true); + }); + + it('and the two surfaces retired in 17.0.0 really reject it', () => { + expect(AppSchema.safeParse(appWithAria).success, '`app.aria` is a tombstone').toBe(false); + expect( + DashboardWidgetSchema.safeParse(widgetWithAria).success, + '`dashboard.widgets[].aria` is a tombstone', + ).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index b3d26423d3..af8e8f15c9 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -585,8 +585,16 @@ describe('[#5010] DashboardWidgetSchema — retired action trio + `aria`', () => expect(message).toMatch(/Delete the key/); // The shared shape survives elsewhere. Without this, the message reads as // "AriaProps is gone", which would send an author deleting live metadata. - expect(message).toMatch(/app\.aria/); + // + // ⚠️ This assertion used to require `/app\.aria/` — and `App.aria` is a + // `retiredKey()` tombstone removed in this same 17.0.0, so the pin was + // holding the prescription ON a dead surface (#6756). Re-aimed at the + // surfaces that really do still declare `aria: AriaPropsSchema` and are + // graded `live` in the liveness ledger. The full both-directions + // enumeration pin lives in `aria-carrier-tombstones.test.ts`. + expect(message).toMatch(/page\.aria/); expect(message).toMatch(/page\.components\[\]\.aria/); + expect(message).not.toMatch(/app\.aria/i); expect(message).not.toMatch(/Unrecognized key/); }); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index eb42da0fa9..8cd24371eb 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -12,8 +12,10 @@ import { ChartTypeSchema, ChartConfigSchema } from './chart.zod'; import { ActionType } from './action.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; // `AriaPropsSchema` is no longer imported here: `widgets[].aria` was retired -// (#5010). The shape itself is NOT removed — it stays live on `app.aria` and -// `page.components[].aria`, whose renderers really do apply it. See the +// (#5010). The shape itself is NOT removed — it stays live on `page.aria` / +// `page.components[].aria` and on the list view's `aria`, whose renderers +// really do apply it. (NOT on `app.aria`: that key is a `retiredKey()` +// tombstone of its own, removed in the same 17.0.0 — #6756.) See the // tombstone below. import { I18nLabelSchema } from './i18n.zod'; // `ResponsiveConfigSchema` is no longer imported here: `widgets[].responsive` @@ -608,7 +610,13 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ // in `DashboardRenderer` / `DatasetWidget` are the renderer's OWN DOM props, // and objectui's single `.aria` read (`plugin-view/ObjectView.tsx:989`) is a // `view`'s. The shared `AriaPropsSchema` is untouched — it stays live on - // `app.aria` and `page.components[].aria`, which really are applied. + // `page.aria` / `page.components[].aria` and on the list view's `aria`, + // which really are applied (`action.aria` carries the shape too, but the + // ledger grades that one PARTIAL). It is NOT live on `app.aria`: that key + // is a `retiredKey()` tombstone removed in the same 17.0.0 by the 2026-06 + // app liveness audit, and `os migrate meta --from 16` strips it — so the + // "lift it up to the app" repair this note used to imply was a dead end + // that also lost the block (#6756). aria: retiredKey( '`dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 D2) — ' + 'no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach ' + @@ -616,9 +624,9 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ 'removal the dashboard-level `aria` got in 17.0.0 (#3896). Delete the key. The dashboard ' + 'renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and ' + '`description`) on the widget instead — those ARE what the renderer labels the card with. ' + - 'The shared `AriaProps` shape is NOT gone: it stays live on `app.aria` and ' + - '`page.components[].aria`. ' + - 'Run `os migrate meta --from 16` to rewrite it automatically.', + 'The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, ' + + '`page.components[].aria` and the list view `aria`. ' + + 'Run `os migrate meta --from 16` to remove it automatically.', ), // ADR-0021 single-form: every widget binds a `dataset` and selects `values` // (both required above) — there is no inline-query shape to disambiguate.