diff --git a/.changeset/dashboard-widget-action-aria-removed.md b/.changeset/dashboard-widget-action-aria-removed.md new file mode 100644 index 0000000000..c895bf37a5 --- /dev/null +++ b/.changeset/dashboard-widget-action-aria-removed.md @@ -0,0 +1,99 @@ +--- +"@objectstack/spec": major +"@objectstack/lint": minor +--- + +refactor(spec,lint)!: retire the dashboard widget action trio + `aria` — and the build gate that enforced a button nobody renders (#5010, ADR-0049) + +`DashboardWidgetSchema` let an author declare a per-widget action **button** +(`actionUrl` / `actionType` / `actionIcon`) and per-widget ARIA attributes +(`aria`). None of the four reached a renderer. Re-measured 2026-08-04 across both +repos on a closed call graph: + +- **the action trio** — all 14 `actionUrl` reads in objectui's + `DashboardRenderer.tsx` are scoped to `schema.header.actions[]`, which is + `DashboardHeaderAction`, a *different* schema. Nothing anywhere reads + `widget.actionUrl`. `actionIcon` is the starkest: zero references in either + repo outside its own declaration — not even the lint looked at it. +- **`aria`** — no consumer of `widget.aria` anywhere. The `aria-*` attributes in + `DashboardRenderer` / `DatasetWidget` are the renderer's own DOM attributes, + and objectui's single `.aria` read (`plugin-view/ObjectView.tsx:989`) is a + **view**'s. This is the dashboard-level `aria` that #3896 removed, one level + down — an accessibility guarantee an author could declare and nothing honoured. + +These four survived the #3896 sweep for the same reason `widgets[].responsive` +did, and it is not "we looked and they were live": the liveness ledger declared +no `children` on `dashboard.widgets`, so **no widget-level key had ever been +classified**. #4956 fixed that instrument and gave all 22 keys their first per-key +verdicts; this change acts on four of the six it found dead. + +## The second-order cost this settles + +`packages/lint`'s `validate-dashboard-action-refs` enforced **ERROR-severity** +reference integrity on `widgets[].actionUrl` — a dangling target failed the +build. Its docblock called the key *"the per-widget button"* and claimed to +mirror the objectui runtime dispatch. It did not, because that button does not +exist. So an author could be blocked from shipping because a control that cannot +render pointed at an action that also did not. + +A rule written to delete false affordances was sustaining one. That is why the +keys were retired rather than the check merely relaxed: the widget branch is +deleted, with a pin test asserting it stays silent and a second pin proving +header actions are still checked in the same stack. + +FROM → TO: + +| Removed | Replacement | +| :--- | :--- | +| `dashboard.widgets[].actionUrl` | `dashboard.header.actions[].actionUrl` | +| `dashboard.widgets[].actionType` | `dashboard.header.actions[].actionType` | +| `dashboard.widgets[].actionIcon` | `dashboard.header.actions[].icon` (the header spelling) | +| `dashboard.widgets[].aria` | **none** — delete it; author `title`/`description`, which the renderer really does label the card with | + +For a per-**row** affordance, reach for a dataset-bound `table`/`pivot` widget: +its rows are clickable and drill through the semantic layer already (no +per-widget drill config exists, by design — #5022). + +**The `AriaProps` shape is NOT removed — only this embed.** `AriaPropsSchema` / +`AriaProps` stay exported and stay live on `app.aria` and +`page.components[].aria`. Nothing importing the shape breaks. + +The retirement kit: + +- **Tombstones.** `retiredKey()` on all four, matching `responsive` in this same + schema. `DashboardWidgetSchema` *is* `.strict()`, so a plain delete would still + be loud — but only as a generic "unrecognized key". The tombstone keeps the key + declared so the rejection carries the **prescription**, and types it `never` so + authoring it fails `tsc` first. Pins assert the message *is* the prescription + and is *not* `Unrecognized key`. The action trio shares one prescription that + names all three, so an author who deletes the one key they were told about does + not hit the same error twice more. +- **ADR-0087 D2 conversion + D3 chain step** + (`dashboard-widget-action-aria-removed`, `retiredFromLoadPath`): + `os migrate meta --from 16` strips the four from author sources, and stored + dashboards replay clean instead of meeting a tombstone at load. Lossless + deletes — none of the keys had an effect to lose. Its own entry rather than + more keys on `dashboard-inert-keys-removed`, whose identity is the #3896 sweep. +- **Liveness rows stay** (`status: dead`, `verifiedAt`, a REMOVED note) because + a tombstone keeps the key in the walked shape — the `rls.priority` precedent. + `authorWarn`/`authorHint` are dropped from all four: the parse owns them now. +- Baselines moved at KEY level only, as the shape's survival implies: + `authorable-surface.json` gains four `… [RETIRED]` lines; + `json-schema.manifest.json`, `api-surface.json` and + `api-surface-signatures.json` are unchanged by construction — no def stopped + being emitted and no export was removed. + +No runtime behaviour changes — that impossibility is the reason for the removal. +The one behaviour that *does* change is a build that used to fail and now does +not. + +## Not in this change + +`widgets[].colorVariant`, the fifth dead key #5010 lists, is **deliberately +untouched**. The rewrite target its triage assumed — `options.colorVariant` — +measured dead as well: `options` only reaches a renderer through the inline +`componentSchema` path, and `dataset` is *required* on this schema, so every +spec-authorable widget is dataset-bound and renders through `DatasetWidget`, +which has no colour affordance at all. Moving the key would relocate 16 authored +sites (7 in `platform-objects`, 9 in `app-showcase`) from one dead slot to +another and mint a second inert key. Returned for adjudication. diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index ffd26e6294..51b16d980e 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -60,11 +60,19 @@ object's own fields. ### 3. Dead action/route references -A dashboard `header.actions[]` button (and a widget's `actionUrl`) names a target: -a `script`/`modal` action, or a `url` route. Nothing in the schema checks that the -target exists, so a button can ship pointing at an action defined nowhere — it -renders and then **silently does nothing** when clicked. This is ADR-0049's -"declared ≠ enforced" gate applied to *references*. +A dashboard `header.actions[]` button names a target: a `script`/`modal` action, +or a `url` route. Nothing in the schema checks that the target exists, so a button +can ship pointing at an action defined nowhere — it renders and then **silently +does nothing** when clicked. This is ADR-0049's "declared ≠ enforced" gate applied +to *references*. + +This check covers the dashboard **header** only. It used to check a +`widgets[].actionUrl` too — until #5010 measured that no renderer has ever drawn a +per-widget action button, which made the strictest arm of the rule fail builds +over a control that could not render. The three widget keys +(`actionUrl`/`actionType`/`actionIcon`) were retired in 17.0.0 rather than the +check merely relaxed; authoring one is now a `tsc` error and a parse error +carrying the fix. ```ts header: { diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 8ef07f9274..0a6a6df686 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -98,9 +98,9 @@ Dashboard header action | **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Widget color variant for theming | | **requiresObject** | `string` | optional | Hide the widget unless the named object is registered | | **requiresService** | `string` | optional | Hide the widget unless the named kernel service is registered | -| **actionUrl** | `string` | optional | URL or target for the widget action button | -| **actionType** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Type of action for the widget action button | -| **actionIcon** | `string` | optional | Icon identifier for the widget action button | +| **actionUrl** | `any` | optional | [REMOVED] `dashboard.widgets[].actionUrl` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to rewrite it automatically. | +| **actionType** | `any` | optional | [REMOVED] `dashboard.widgets[].actionType` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to rewrite it automatically. | +| **actionIcon** | `any` | optional | [REMOVED] `dashboard.widgets[].actionIcon` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to rewrite it automatically. | | **filter** | `any` | optional | Presentation-scope filter (runtimeFilter) | | **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) | | **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) | @@ -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** | `any` | 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** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +| **aria** | `any` | 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. | --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index ae45f34f89..8f0cb0903c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -624,7 +624,7 @@ sites left to be a verdict about. | `component.zod.ts` | ~~authorable (p)~~ **no gate** | **no parse anywhere (measured, #4001 批 17)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`. ⚠️ The #5056 bridge defect does not touch this row: it makes the derived-clone bridge report dead shapes as REACHABLE, the opposite direction, and nothing here rests on that bridge — all six positive controls resolve `root-graph` and all 52 targets miss BOTH `root-graph` and `derived-clone`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. Recorded in three places (file header, `component.test.ts` pin incl. a standing assertion that goes red the day `properties` gets a typed dispatch, this row) | | `theme.zod.ts` | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all (#4988) — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. ⚠️ **Separately filed, not answered here**: `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read). That is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live | | `app.zod.ts` | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first | -| `dashboard.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction | +| `dashboard.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction. ⚠️ **#5010 retired four more widget keys and moved this row's posture by nothing, which is the point.** The `#4956` drill gave `DashboardWidgetSchema`'s 22 widget-level keys their first per-key verdicts and found six dead; `actionUrl`/`actionType`/`actionIcon` (a per-widget action BUTTON no renderer in either repo has ever drawn — all 14 `actionUrl` reads in `DashboardRenderer` are scoped to `header.actions[]`) and `aria` (ARIA attributes that never reached the DOM — the dashboard-level `aria` the #3896 sweep removed, one level down) are now `retiredKey` tombstones beside `responsive`. **Strip sites remain 0 and the strictness verdict is untouched**, because a retirement is ADR-0049 work and this ratchet is not: closing a door makes a *dropped* key loud, it cannot make a *declared* one live — the same boundary `theme.zod.ts` records two rows up, met here from the other side. The removal also settled a second-order cost the strictness campaign could never have reached: `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 — an enforcement gate sustaining the very false affordance ADR-0049 wrote it to delete. That widget branch is gone, pinned. ⚠️ **`colorVariant`, the fifth dead key, is deliberately NOT retired here and this row must not be read as closing it**: the rewrite target the #4956 triage assumed (`options.colorVariant`) measured dead too — `options` only reaches a renderer through `componentSchema` on the INLINE path, and `dataset` is required on this schema, so every spec-authorable widget is dataset-bound and renders through `DatasetWidget`, which has no colour affordance at all. Moving the key there would relocate 16 authored sites from one dead slot to another and mint a second inert key. Returned for adjudication; `chartConfig`'s dashboard-face inertness (11 of 12 keys, #5175) is the same shape on the neighbouring slot | | `widget.zod.ts` | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 16)** — the `(p)` resolved NEGATIVE for the whole file, the second such run after 批 13's five. Three independent measurements on 2026-08-04: (1) nothing under `packages/spec/src` imports this module except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for a widget shape — `field.widget` is a `z.string()` naming a registered *component* and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack` (4 766 nodes) reaches none of the six shapes, while `PageSchema` / `ObjectListViewSchema` resolve in the same run, a fresh `z.object` and a deliberate look-alike both resolve unreachable, and a synthetic carrier flips all six to reachable; (3) zero `.parse()` / `.safeParse()` in `objectstack`, `objectui` or `cloud` outside this file's own tests — objectui re-exports the inferred TYPES only and under different names (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161), and a `cloud` code search returns 0 for every symbol against a working index (`"@objectstack/spec"` → 345). ADR-0049 enforce-or-remove is **#5055**. ⚠️ **The campaign's own BFS said REACHABLE on the first run** — a false positive in the derived-clone bridge, filed as **#5056**: zod's `.describe()` returns a clone that SHARES the original `_zod.def`, so `WidgetManifestSchema.name` / `.label` (a described `SnakeCaseIdentifierSchema` / `I18nLabelSchema`) are def-identical to the same leaves on live schemas, and a bridge firing on ANY one shared property links two unrelated shapes. 2 shared keys of 20. The error is one-directional — it can only manufacture a door, i.e. it can only make a batch tighten something dead. Corrected to whole-shape overlap in `ui/door-reachability.testkit.ts` and pinned in `widget.test.ts` | | `page.zod.ts` | authorable | partially strict (ADR-0089) | | `chart.zod.ts` | **mixed — 6 authorable, 2 no gate** | **5 strict as of #4001 批 15**, a sixth added at **#5022**; 2 deliberately left open. `ChartConfigSchema` / `ChartAxis` / `ChartSeries` / `ChartAnnotation` / `ChartInteraction` are `root-graph`-reachable from the `dashboard` and `report` metadata roots (`DashboardWidget.chartConfig`, `ReportChartSchema`), so they are judged on the stored-metadata path and are now closed. **`ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are NOT**, and this is the batch's real finding. They are not 批 13's no-door case — their carrier is LIVE: `aggregate` is a real authorable prop on the react tier's `` (ADR-0081), published in the generated react-blocks contract, and objectui's `ObjectChart` reads `schema.aggregate` to run the query. What is missing is the PARSE: neither schema is reachable from any metadata-type root or from `ObjectStackSchema` (both `UNREACHABLE` in the run where the five above come back `root-graph`), nothing in the three repos calls `.parse()` on them outside this file's unit tests, and the gate that DOES judge an authored `aggregate` — the react-page publish lint — re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field requirement, the result-column naming) and never checks unknown keys. `react-blocks.ts` publishes the prop as a hand-written TYPE STRING; the Zod schema beside it is not what the contract is generated from. So `groupby` / `dateGranularty` are silently dropped today and would go on being silently dropped after a `strictObject` here — `.strict()` is a property of a parse. A fourth class, **`no gate`**: carrier live, parse absent. Distinct from `no door` (批 13), where the carrier itself does not exist. The contract-first fix is to make the publish gate PARSE the schema instead of re-deriving it — a `packages/lint` change, filed rather than smuggled into a spec strictness batch. Recorded in three places (schema-adjacent comment, test pin incl. a standing BFS assertion that goes red the day a carrier key appears, this row). ⚠️ One correction shipped with the tightening: the `clickAction` migration text #3752 wrote into this file prescribed **`drillDown`, which at the time was not a key this protocol declared anywhere** — it was an untyped `(schema as any).drillDown` read inside objectui's `ObjectChart`. Promoting that sentence into a strict rejection would have handed an author the platform's authority for a key the same gate then rejects: finding 7, third occurrence, this time caught before shipping. The prose and the tombstone now name `onSegmentClick` / `ReportSchema.drilldown` / the widget's `options` bag, all of which exist. Filed separately — and **closed at #5022**, which is the entry worth reading twice, because the fix is not the one the file's own prose implied. The gap was real (a live renderer capability with no declaration), but the two carriers that prose pointed at both measured DEAD on the dashboard metadata path: `widget.chartConfig.drillDown` is read by nothing (`DashboardRenderer` never looks at `chartConfig`; `DatasetWidget` forwards exactly one key out of it, `showLegend`), and `widget.options.drillDown` is read only inside `DashboardRenderer`'s legacy `isObjectProvider` branch, which a spec-legal v17 widget cannot reach — `dataset` is required, so `datasetBound` is always true and that component schema is discarded unrendered. An ADR-0021 dataset-bound widget drills through the semantic layer and reads no drill config at all, which the platform's own docs had already said (`content/docs/ui/dashboards.mdx`: *there is no per-widget drill configuration in the dataset form*) while this ledger row pointed authors at the `options` bag. So `drillDown` was declared as `ChartDrillDownSchema` at the ONE surface measured to read it — the react tier's `` prop, published through `react-blocks.ts`'s interaction overlay rather than through `ChartConfigSchema`, precisely so the dashboard surface does not inherit an inert key. The shape is the honest six (`enabled`/`filter`/`title`/`target`/`columns`/`maxRows`); objectui's wider renderer-side `DrillDownConfig` (`mode`/`report`/`view`/`sort`, and a `navigate` target) was NOT copied — a chart reads none of them and two are read by no widget at all (objectui#3354) — and each absent key is a `guidance` entry saying so rather than a rename. Two second-order findings came out of the same measurement and are filed, not fixed here: **#5175** (`chartConfig` delivers 1 of its 12 keys on the dashboard path, and `liveness/dashboard.json` records evidence that overstates it) and **objectui#3354**. **`chart` 6 → 7 at the re-measurement** — no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 634151110e..096328cd59 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -196,6 +196,8 @@ 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. + ⚠️ 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. ### Mechanical (applied for you) @@ -223,6 +225,7 @@ Finally it CONVERGES `dashboard.widgets[].compareTo` (#5011) — the one entry i | `view-inert-keys-removed` | `view.list.responsive / view.list.performance / view.form.defaultSort / view.form.aria` | view keys removed (#3896 close-out): list 'responsive'/'performance', form 'defaultSort'/'aria' — no renderer read them (list aria/data and form data stay live) | retired — `migrate meta` only | | `dashboard-inert-keys-removed` | `dashboard.aria / dashboard.performance / dashboard.widgets[].performance` | dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them) | retired — `migrate meta` only | | `dashboard-widget-responsive-removed` | `dashboard.widgets[].responsive` | dashboard widget key 'responsive' removed (#4876 — no renderer ever applied per-widget breakpoint overrides; page.components[].responsive is unaffected) | retired — `migrate meta` only | +| `dashboard-widget-action-aria-removed` | `dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / dashboard.widgets[].actionIcon / dashboard.widgets[].aria` | dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria' removed (#5010 — no renderer ever drew a per-widget action button, and widget ARIA attributes never reached the DOM; use header.actions[] and the widget title/description) | retired — `migrate meta` only | | `dashboard-widget-compareto-converged` | `dashboard.widgets[].compareTo` | dashboard widget 'compareTo' converged on the executor's { kind, dimension? } contract (#5011 — the bare strings and { offset: '1y' } rewrite mechanically; other { offset } durations have no faithful target and are reported, not guessed) | retired — `migrate meta` only | | `agent-knowledge-removed` | `agent.knowledge` | agent key 'knowledge' removed (#3896 close-out — declaring sources/indexes never scoped retrieval; restrict at the knowledge-service level) | retired — `migrate meta` only | | `skill-trigger-phrases-removed` | `skill.triggerPhrases` | skill key 'triggerPhrases' removed (#3896 close-out — activation is triggerConditions + the agent's skills[] allowlist; phrases were a dead-end projection) | retired — `migrate meta` only | diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index ece6e724fc..53a5bd1e96 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -421,14 +421,6 @@ describe('lintLivenessProperties', () => { }], }); - it('warns on a widget action button that no renderer draws (`actionUrl`)', () => { - const findings = lintLivenessProperties(dash({ actionUrl: '/apps/sales/orders' })); - const hit = findings.find((f) => f.message.includes('widgets.actionUrl')); - expect(hit).toBeDefined(); - expect(hit!.where).toContain('sales_overview'); - expect(hit!.hint).toMatch(/header\.actions/); - }); - it('warns on `colorVariant`, the key this repo\'s own system dashboard authors 7 times', () => { const findings = lintLivenessProperties(dash({ colorVariant: 'teal' })); const hit = findings.find((f) => f.message.includes('widgets.colorVariant')); @@ -438,24 +430,57 @@ describe('lintLivenessProperties', () => { expect(hit!.hint).toMatch(/options/); }); - it('warns on a widget `aria` block that never reaches the DOM', () => { - const findings = lintLivenessProperties(dash({ aria: { ariaLabel: 'Total pipeline' } })); - expect(findings.map((f) => f.message).some((m) => m.includes('widgets.aria'))).toBe(true); - }); - it('fans out over EVERY widget, not just the first', () => { const findings = lintLivenessProperties({ dashboards: [{ name: 'ops', widgets: [ { id: 'a', type: 'metric', dataset: 'd', values: ['v'] }, - { id: 'b', type: 'metric', dataset: 'd', values: ['v'], actionIcon: 'plus' }, + { id: 'b', type: 'metric', dataset: 'd', values: ['v'], colorVariant: 'teal' }, ], }], }); // The dead key is on the SECOND widget — a walk that only looked at // `widgets[0]` would be silently half-blind on every real dashboard. - expect(findings.map((f) => f.message).some((m) => m.includes('widgets.actionIcon'))).toBe(true); + expect(findings.map((f) => f.message).some((m) => m.includes('widgets.colorVariant'))).toBe(true); + }); + + // ── #5010: four of these keys are RETIRED, so this lint must go quiet ───── + // + // This lint is ledger-driven by design: it warns on rows carrying + // `authorWarn`. When a key is retired the row keeps its `dead` verdict (the + // tombstone keeps the key in the walked shape) but drops `authorWarn`, + // because the advisory has been replaced by something strictly louder — a + // `tsc` error and a parse error carrying the prescription. + // + // Asserting the SILENCE is the point. A retired key that still warned here + // would tell an author to "move the affordance" for a key they cannot + // author at all, and would double-report every real occurrence. + it.each(['actionUrl', 'actionType', 'actionIcon', 'aria'])( + 'no longer warns on the retired `%s` — the strict parse owns it now (#5010)', + (key) => { + const value = key === 'aria' ? { ariaLabel: 'Total pipeline' } : 'x'; + const findings = lintLivenessProperties(dash({ [key]: value })); + expect(findings.map((f) => f.message).some((m) => m.includes(`widgets.${key}`))).toBe(false); + }, + ); + + it('the retirement silenced only those four — `colorVariant` still warns beside them', () => { + // The negative control for the block above. Without it, a change that + // broke the dashboard walk entirely (or dropped `dashboard` from + // TYPE_COLLECTIONS again) would read as "the retirement worked". + const findings = lintLivenessProperties(dash({ + actionUrl: '/apps/sales/orders', + actionType: 'url', + actionIcon: 'plus', + aria: { ariaLabel: 'Total pipeline' }, + colorVariant: 'teal', + })); + const messages = findings.map((f) => f.message); + expect(messages.some((m) => m.includes('widgets.colorVariant'))).toBe(true); + for (const retired of ['actionUrl', 'actionType', 'actionIcon', 'aria']) { + expect(messages.some((m) => m.includes(`widgets.${retired}`))).toBe(false); + } }); it('stays silent on a widget built entirely from live keys', () => { diff --git a/packages/lint/src/validate-dashboard-action-refs.test.ts b/packages/lint/src/validate-dashboard-action-refs.test.ts index 9beb3efe20..a26ff88c85 100644 --- a/packages/lint/src/validate-dashboard-action-refs.test.ts +++ b/packages/lint/src/validate-dashboard-action-refs.test.ts @@ -159,25 +159,53 @@ describe('validateDashboardActionRefs (ADR-0049 references / #3367)', () => { expect(findings).toEqual([]); }); - it('checks per-widget actionUrl buttons (script)', () => { + // #5010 — the widget branch is GONE, and this is the pin that keeps it gone. + // + // Until 17.0.0 this rule raised an ERROR (a failed build) for a dangling + // `widgets[].actionUrl` target, on the docblock's claim that it mirrored the + // objectui runtime dispatch. It did not: no renderer draws a per-widget action + // button, so the strictest arm of the rule guarded a control that cannot + // render. The keys are now tombstoned in the spec, which owns the rejection — + // this rule must stay silent rather than fail a build a second time over. + it('does NOT check per-widget actionUrl: no per-widget button exists (#5010)', () => { const findings = validateDashboardActionRefs({ dashboards: [ { name: 'ops', label: 'Ops', widgets: [ + // A target that would have been an ERROR before #5010: `ghost_action` + // is defined nowhere in this stack. { id: 'kpi', dataset: 'd', values: ['x'], actionType: 'script', actionUrl: 'ghost_action' }, { id: 'noaction', dataset: 'd', values: ['x'] }, ], }, ], }); + expect(findings).toEqual([]); + }); + + it('still checks header actions when a widget carries a legacy action key (#5010)', () => { + // Mixed stack: the header target is dead AND a stale widget key survives in + // a stored document. Exactly one finding, and it belongs to the header — + // proving the widget key is ignored rather than merely out-prioritised. + const findings = validateDashboardActionRefs({ + dashboards: [ + { + name: 'ops', + label: 'Ops', + header: { actions: [{ label: 'Export', actionType: 'script', actionUrl: 'ghost_header' }] }, + widgets: [ + { id: 'kpi', dataset: 'd', values: ['x'], actionType: 'script', actionUrl: 'ghost_widget' }, + ], + }, + ], + }); expect(findings).toHaveLength(1); expect(findings[0]).toMatchObject({ severity: 'error', rule: DASHBOARD_ACTION_TARGET_UNDEFINED, - where: 'dashboard "ops" · widget "kpi" action', - path: 'dashboards[0].widgets[0].actionUrl', + path: 'dashboards[0].header.actions[0].actionUrl', }); }); diff --git a/packages/lint/src/validate-dashboard-action-refs.ts b/packages/lint/src/validate-dashboard-action-refs.ts index c2d9692eca..d28f6c6174 100644 --- a/packages/lint/src/validate-dashboard-action-refs.ts +++ b/packages/lint/src/validate-dashboard-action-refs.ts @@ -1,17 +1,17 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0049 — references] Reference-integrity for dashboard header & widget - * action targets (issue #3367). + * [ADR-0049 — references] Reference-integrity for dashboard header action + * targets (issue #3367). * * ADR-0049 established the "enforce-or-remove" gate for spec *properties*: a * declared property the runtime does not honour is a false promise and must be * enforced, marked experimental, or removed. This rule applies the SAME honesty - * principle to *references*. A dashboard header action (or a widget's header - * action button) names a target — a `script`/`modal` action, or a `url` route — - * that must actually resolve. A dangling target ships a button that renders and, - * on click, silently does nothing: a false affordance, exactly the failure - * ADR-0049 exists to prevent, just for a reference rather than a property. + * principle to *references*. A dashboard header action names a target — a + * `script`/`modal` action, or a `url` route — that must actually resolve. A + * dangling target ships a button that renders and, on click, silently does + * nothing: a false affordance, exactly the failure ADR-0049 exists to prevent, + * just for a reference rather than a property. * * Nothing in the protocol schema can express this: `actionUrl` is a free string, * so `{ actionType: 'script', actionUrl: 'export_dashboard_pdf' }` parses and @@ -19,7 +19,24 @@ * * Surfaces checked: * - dashboard `header.actions[]` — each `{ actionType, actionUrl }` - * - dashboard `widgets[].actionUrl` (+ `actionType`) — the per-widget button + * + * ## The widget branch, and why it is gone (#5010) + * + * This rule used to check `widgets[].actionUrl` too, describing it as "the + * per-widget button" and claiming in this docblock that it "mirrors the objectui + * runtime dispatch". It did not: no renderer in either repo has ever drawn a + * per-widget action button — all 14 `actionUrl` reads in `DashboardRenderer` are + * scoped to `header.actions[]`. So the strictest arm of this rule (a dangling + * `script`/`modal` target is an ERROR, i.e. a failed build) was enforcing + * referential integrity for a button that could not render. An author could be + * blocked from shipping because a control that does not exist pointed at an + * action that also did not. + * + * That inversion — a rule written to delete false affordances, itself sustaining + * one — is why the widget keys were retired rather than the check merely + * relaxed: `widgets[].actionUrl` / `actionType` / `actionIcon` are now tombstoned + * in `@objectstack/spec` 17.0.0, so authoring one is a `tsc` error and a parse + * error carrying the prescription. There is no widget target left to resolve. * * Resolution mirrors the objectui runtime dispatch (`DashboardRenderer` + * `DashboardView`) so the lint flags exactly what would fail to resolve at @@ -230,7 +247,7 @@ interface HeaderAction { } /** - * Validate every dashboard header / widget action reference in a stack. Returns + * Validate every dashboard header action reference in a stack. Returns * findings (empty = clean). `script`/`modal` dead targets are errors; `url` * unresolved routes are warnings. */ @@ -249,7 +266,7 @@ export function validateDashboardActionRefs(stack: AnyRec): DashboardActionRefFi path: string, ) => { const target = strName(action.actionUrl); - if (!target) return; // nothing referenced (widget with no action button) + if (!target) return; // nothing referenced if (target.includes('${')) return; // dynamic target — not statically resolvable // Renderer default: a missing actionType is treated as a 'url' navigation @@ -320,19 +337,12 @@ export function validateDashboardActionRefs(stack: AnyRec): DashboardActionRefFi ); } - // Per-widget action buttons. - const widgets = asArray(dash.widgets); - for (let wi = 0; wi < widgets.length; wi++) { - const widget = widgets[wi]; - if (!widget || typeof widget !== 'object') continue; - if (!strName(widget.actionUrl)) continue; - const widgetId = strName(widget.id) ?? `#${wi}`; - checkOne( - { actionType: widget.actionType as string | undefined, actionUrl: widget.actionUrl as string | undefined }, - `dashboard "${dashName}" · widget "${widgetId}" action`, - `${dashPath}.widgets[${wi}].actionUrl`, - ); - } + // Per-widget action buttons: NOT checked — they do not exist. See the + // docblock (#5010). `widgets[].actionUrl` / `actionType` / `actionIcon` are + // tombstoned in the spec as of 17.0.0, so a stack reaching this rule cannot + // carry a widget target: the parse rejects it upstream with the + // prescription. Re-adding a branch here would resurrect an ERROR-severity + // gate over an affordance no renderer draws. } return findings; diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 0576f943dc..5703c2b88a 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7149,10 +7149,10 @@ "ui/DashboardNavItem:requiresService", "ui/DashboardNavItem:type", "ui/DashboardNavItem:visible", - "ui/DashboardWidget:actionIcon", - "ui/DashboardWidget:actionType", - "ui/DashboardWidget:actionUrl", - "ui/DashboardWidget:aria", + "ui/DashboardWidget:actionIcon [RETIRED]", + "ui/DashboardWidget:actionType [RETIRED]", + "ui/DashboardWidget:actionUrl [RETIRED]", + "ui/DashboardWidget:aria [RETIRED]", "ui/DashboardWidget:chartConfig", "ui/DashboardWidget:colorVariant", "ui/DashboardWidget:compareTo", diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index 493d8c1949..0a1c4c8e04 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -72,24 +72,18 @@ }, "actionUrl": { "status": "dead", - "authorWarn": true, - "verifiedAt": "2026-08-03", - "authorHint": "No renderer draws a per-widget action button — only `dashboard.header.actions[]` is dispatched. Move the affordance to a header action, or drop the key.", - "note": "CALL GRAPH CLOSED BY HAND 2026-08-03 (objectui @91757a7). All 14 `actionUrl` occurrences in DashboardRenderer.tsx are scoped to `schema.header.actions[]` (:242-245 ActionDef build, :282-284 label i18n, :767-792 dispatch) — that is the DashboardHeaderAction schema, a different shape. Nothing anywhere reads `widget.actionUrl`. Note the second-order cost: packages/lint/src/validate-dashboard-action-refs.ts:328-333 enforces reference integrity on this key and its docblock calls it 'the per-widget button', mirroring a runtime dispatch that does not exist — so a dangling target fails the build for an affordance that never renders. ADR-0049 enforce-or-remove tracked in #5010." + "verifiedAt": "2026-08-04", + "note": "CALL GRAPH CLOSED BY HAND 2026-08-03, re-measured 2026-08-04 (objectui @91757a7). All 14 `actionUrl` occurrences in DashboardRenderer.tsx are scoped to `schema.header.actions[]` (:242-245 ActionDef build, :282-284 label i18n, :767-792 dispatch) — that is the DashboardHeaderAction schema, a different shape. Nothing anywhere read `widget.actionUrl`. The second-order cost is settled in the same change: packages/lint/src/validate-dashboard-action-refs.ts enforced ERROR-severity reference integrity on this key while its docblock called it 'the per-widget button', so a dangling target failed the build for an affordance that never rendered — that widget branch is deleted, with a pin test asserting it stays silent. 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." }, "actionType": { "status": "dead", - "authorWarn": true, - "verifiedAt": "2026-08-03", - "authorHint": "Pairs with the dead `actionUrl` — no per-widget action button exists. Use `dashboard.header.actions[]`.", - "note": "Same absence as `actionUrl`, same measurement (objectui @91757a7): every `actionType` read in the dashboard renderer belongs to `header.actions[]`. Read only by packages/lint/src/validate-dashboard-action-refs.ts:331 to pick which resolution rule to apply to the (unrendered) `actionUrl`. ADR-0049 enforce-or-remove tracked in #5010." + "verifiedAt": "2026-08-04", + "note": "Same absence as `actionUrl`, same measurement (objectui @91757a7): every `actionType` read in the dashboard renderer belongs to `header.actions[]`. Its only consumer was the action-ref lint, picking which resolution rule to apply to the (unrendered) `actionUrl`; that branch is gone too. 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." }, "actionIcon": { "status": "dead", - "authorWarn": true, - "verifiedAt": "2026-08-03", - "authorHint": "No per-widget action button renders, so its icon reaches nothing. Use `dashboard.header.actions[].icon`.", - "note": "The starkest of the three: zero references in either repo outside this schema declaration and one objectui type comment listing spec-derived keys (packages/types/src/complex.ts:676). Not even the action-ref lint looks at it. ADR-0049 enforce-or-remove tracked in #5010." + "verifiedAt": "2026-08-04", + "note": "The starkest of the three: zero references in either repo outside this schema declaration and one objectui type comment listing spec-derived keys (packages/types/src/complex.ts:676). Not even the action-ref lint looked at it. 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." }, "filter": { "status": "live", @@ -99,7 +93,6 @@ }, "compareTo": { "status": "live", - "verifiedAt": "2026-08-03", "verifiedAt": "2026-08-04", "evidence": "packages/services/service-analytics/src/dataset-executor.ts — runCompare() reads `compareTo.kind` (shiftRange) and resolves `compareTo.dimension` via resolveCompareDimension(); packages/spec/src/contracts/analytics-service.ts DatasetCompareTo is the same `{ kind, dimension? }` shape the widget now declares; objectui @91757a7: packages/plugin-dashboard/src/DatasetWidget.tsx:163-168 (forwards the structured object into DatasetSelection.compareTo)", "note": "CONVERGED 2026-08-04 (#5011) — this entry SUPERSEDES the path-split record it carried, which is now history rather than the shape. What it recorded was real: the widget declared three arms ('previousPeriod' / 'previousYear' / { offset }) that only the LEGACY inline object-provider chart path could run (objectui packages/core/src/utils/compare-to.ts shiftFilterByCompareTo), while on the ADR-0021 dataset path — the one the spec calls the single author-facing analytics shape — DatasetWidget deliberately DROPPED the two string arms and forwarded `{ offset }` into a contract with no `offset` in it, so dataset-executor.ts threw 'compareTo requires a timeDimension \"undefined\"'. Same key, two fates, the failing one blessed. The fix converged the widget's vocabulary onto the executor's: `compareTo` is now `{ kind, dimension? }`, a thin projection of DatasetSelection.compareTo, so `declared = enforced` holds by construction with no second vocabulary left to drift. `dimension` is optional and resolved BY THE EXECUTOR (exactly one dated time dimension → that one; zero or several → a loud error listing candidates) — a producer-side resolution rule, not the consumer-side tolerance PD #12 forbids. `{ offset }` retired via the ADR-0087 `dashboard-widget-compareto-converged` conversion (+ the `dashboard-widget-compareto-offset` semantic migration for durations with no faithful target). Still LIVE, and now on the canonical path: the reason the verdict is unchanged is that the CONSUMER was never missing, only the agreement about what it consumes. ⚠️ One half is out of this repo: objectui's legacy inline chart path (DashboardRenderer.tsx:495 → ObjectChart, CompareToConfig in packages/core/src/utils/compare-to.ts) still expects the retired three-arm shape and adapts in objectui#3337, which also deletes the now-unnecessary DatasetWidget.tsx:163-168 string-drop workaround. Until that lands, read this `live` as 'the dataset path honours it'; the inline path is mid-handoff, not unread." @@ -153,10 +146,8 @@ }, "aria": { "status": "dead", - "authorWarn": true, - "verifiedAt": "2026-08-03", - "authorHint": "Declared ARIA attributes never reach the DOM on a dashboard widget — no renderer applies them. Delete the key; the renderer emits its own aria-* attributes.", - "note": "CALL GRAPH CLOSED BY HAND 2026-08-03 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. ADR-0049 enforce-or-remove tracked in #5010." + "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." } } }, diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index aa95b41496..f1ccc960b2 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -194,6 +194,12 @@ "conversionId": "dashboard-widget-responsive-removed", "toMajor": 17 }, + { + "surface": "dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / dashboard.widgets[].actionIcon / dashboard.widgets[].aria", + "to": "dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria' removed (#5010 — no renderer ever drew a per-widget action button, and widget ARIA attributes never reached the DOM; use header.actions[] and the widget title/description)", + "conversionId": "dashboard-widget-action-aria-removed", + "toMajor": 17 + }, { "surface": "dashboard.widgets[].compareTo", "to": "dashboard widget 'compareTo' converged on the executor's { kind, dimension? } contract (#5011 — the bare strings and { offset: '1y' } rewrite mechanically; other { offset } durations have no faithful target and are reported, not guessed)", @@ -863,6 +869,12 @@ "conversionId": "dashboard-widget-responsive-removed", "toMajor": 17 }, + { + "surface": "dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / dashboard.widgets[].actionIcon / dashboard.widgets[].aria", + "to": "dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria' removed (#5010 — no renderer ever drew a per-widget action button, and widget ARIA attributes never reached the DOM; use header.actions[] and the widget title/description)", + "conversionId": "dashboard-widget-action-aria-removed", + "toMajor": 17 + }, { "surface": "dashboard.widgets[].compareTo", "to": "dashboard widget 'compareTo' converged on the executor's { kind, dimension? } contract (#5011 — the bare strings and { offset: '1y' } rewrite mechanically; other { offset } durations have no faithful target and are reported, not guessed)", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 0d91d45977..498444d36d 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -2284,6 +2284,91 @@ const dashboardWidgetResponsiveRemoved: MetadataConversion = { }, }; +/** + * dashboard.widgets[].actionUrl / actionType / actionIcon / aria (#5010) — the + * four widget-level keys the #4956 drill judged dead on a closed call graph. + * + * Two affordances, one removal, because both fail the same way and an upgrading + * source carries them together: + * + * - the action TRIO described a per-widget button. No renderer in either repo + * draws one; every action a dashboard dispatches comes from + * `header.actions[]`. Its only reader was `packages/lint`'s reference- + * integrity rule, which failed builds when a button that cannot render + * pointed at an action that did not exist — that widget branch is deleted in + * the same change. + * - `aria` promised ARIA attributes that never reached the DOM: the same false + * compliance the DASHBOARD-level `aria` was retired for above, one level + * down. `dashboard-inert-keys-removed` took the parent key in the #3896 + * sweep and left this one, because `widgets` had no ledger drill then. + * + * A SEPARATE entry rather than more keys on `dashboard-inert-keys-removed`, for + * the reason `dashboard-widget-responsive-removed` gives just above: that entry's + * identity is the #3896 sweep, and folding a differently-evidenced removal into + * it would misattribute this one in `spec-changes.json` and the upgrade guide — + * the two places an upgrading author actually reads. All are `toMajor: 17`, so a + * stored dashboard carrying keys from several of them is cleaned in one replay. + * + * `colorVariant`, the fifth key #5010 lists, is deliberately NOT here: its + * disposition is unresolved (the rewrite target `options.colorVariant` measured + * dead on the ADR-0021 dataset path too), and 16 authored sites depend on the + * answer. Retiring it later is a new entry, not an edit to this one. + */ +const dashboardWidgetActionAriaRemoved: MetadataConversion = { + id: 'dashboard-widget-action-aria-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: + 'dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / ' + + 'dashboard.widgets[].actionIcon / dashboard.widgets[].aria', + summary: + "dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria' removed " + + '(#5010 — no renderer ever drew a per-widget action button, and widget ARIA attributes never ' + + 'reached the DOM; use header.actions[] and the widget title/description)', + apply(stack, emit) { + return mapCollection(stack, 'dashboards', (d, path) => { + const widgets = d.widgets; + if (!Array.isArray(widgets)) return d; + let touched = false; + const rebuilt = widgets.map((w, i) => { + if (!w || typeof w !== 'object' || Array.isArray(w)) return w; + const cleaned = stripKeys( + w as Record, + ['actionUrl', 'actionType', 'actionIcon', 'aria'], + emit, + `${path}.widgets[${i}]`, + ); + if (cleaned !== w) touched = true; + return cleaned; + }); + if (!touched) return d; + return { ...d, widgets: rebuilt }; + }); + }, + fixture: { + before: { + dashboards: [{ + name: 'ops_overview', + widgets: [{ + id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], + actionUrl: 'export_dashboard_pdf', + actionType: 'script', + actionIcon: 'download', + aria: { ariaLabel: 'Total orders' }, + }], + }], + }, + after: { + dashboards: [{ + name: 'ops_overview', + widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'] }], + }], + }, + // One notice per KEY, not per widget — four keys on one widget. + expectedNotices: 4, + }, +}; + /** * dashboard.widgets[].compareTo (#5011) — a VOCABULARY convergence, not a * removal: the widget's three declared arms are replaced by the one shape the @@ -3901,6 +3986,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { expect(c.responsive).toEqual({ columns: { xs: 12, lg: 4 }, order: { xs: 2, lg: 1 }, hiddenOn: ['xs'] }); }); }); + +// ============================================================================ +// [#5010] the widget action trio + `aria` are RETIRED +// ============================================================================ +// +// RUNTIME assertions for the same reason the #4876 block above gives: a +// compile-time pin in `packages/spec` is dead text (#4642). The tombstone's +// `tsc` channel is proved by the build of the packages that author dashboards. +// +// Two affordances, one block, because they were retired as one change: +// - `actionUrl`/`actionType`/`actionIcon` — a per-widget action BUTTON that no +// renderer has ever drawn (every dispatched action comes from +// `header.actions[]`); +// - `aria` — ARIA attributes that never reached the DOM, the dashboard-level +// `aria` retired by #3896 one level down. +describe('[#5010] DashboardWidgetSchema — retired action trio + `aria`', () => { + const widget = { id: 'orders_kpi', type: 'metric', dataset: 'orders', values: ['total'] }; + + const parseWith = (extra: Record): string => { + try { + DashboardWidgetSchema.parse({ ...widget, ...extra }); + } catch (e) { return String((e as Error).message); } + return ''; + }; + + it.each([ + ['actionUrl', 'export_dashboard_pdf'], + ['actionType', 'script'], + ['actionIcon', 'download'], + ] as const)('REJECTS an authored `%s` with the prescription', (key, value) => { + const message = parseWith({ [key]: value }); + + // The prescription, in the parts an upgrading author needs: the + // fully-qualified key, the version, the issue, and the fix. + expect(message).toMatch(new RegExp(`dashboard\\.widgets\\[\\]\\.${key}`)); + expect(message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); + expect(message).toMatch(/#5010/); + // The three went together — an author who deletes only the one key they + // were told about would hit this same error twice more. + expect(message).toMatch(/delete all three/i); + // It must name the surviving home, or this reads as "dashboards cannot have + // buttons" rather than "the button belongs on the header". + expect(message).toMatch(/header\.actions\[\]/); + // The tombstone is what makes it a prescription; a plain `.strict()` + // rejection of a DELETED key would be a generic unrecognized-key error. + expect(message).not.toMatch(/Unrecognized key/); + }); + + it('REJECTS an authored `aria` with the prescription, naming its surviving homes', () => { + const message = parseWith({ aria: { ariaLabel: 'Total orders' } }); + + expect(message).toMatch(/dashboard\.widgets\[\]\.aria/); + expect(message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); + expect(message).toMatch(/#5010/); + 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/); + expect(message).toMatch(/page\.components\[\]\.aria/); + expect(message).not.toMatch(/Unrecognized key/); + }); + + it('still accepts a widget carrying none of the four (nothing else was stripped)', () => { + const w = DashboardWidgetSchema.parse(widget); + for (const k of ['actionUrl', 'actionType', 'actionIcon', 'aria']) { + expect(w).not.toHaveProperty(k); + } + expect(w.dataset).toBe('orders'); + expect(w.values).toEqual(['total']); + }); + + // ── CONTROLS: only the WIDGET embeds go ──────────────────────────────────── + it('CONTROL: `header.actions[]` still takes the whole action vocabulary', () => { + const d = DashboardSchema.parse({ + name: 'ops', label: 'Ops', + header: { + actions: [{ label: 'Export', actionUrl: 'export_dashboard_pdf', actionType: 'script', icon: 'download' }], + }, + widgets: [widget], + }); + // The header action is the live dispatch path (DashboardRenderer builds an + // ActionDef from exactly these keys) — it must round-trip untouched. + expect(d.header?.actions?.[0]).toMatchObject({ + label: 'Export', actionUrl: 'export_dashboard_pdf', actionType: 'script', icon: 'download', + }); + }); + + it('CONTROL: `AriaPropsSchema` is still exported and still parses', async () => { + const ui = await import('./index'); + expect(ui.AriaPropsSchema).toBeTruthy(); + expect(ui.AriaPropsSchema.parse({ ariaLabel: 'Total orders' }).ariaLabel).toBe('Total orders'); + }); + + it('CONTROL: `page.components[].aria` parses exactly as before', async () => { + const { PageComponentSchema } = await import('./page.zod'); + const c = PageComponentSchema.parse({ + type: 'page:sidebar', properties: {}, aria: { ariaLabel: 'Sidebar' }, + }); + expect(c.aria).toEqual({ ariaLabel: 'Sidebar' }); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index de40a9e3c5..f8bf8f0f3a 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -9,7 +9,11 @@ import { DateGranularity } from '../data/query.zod'; import { ChartTypeSchema, ChartConfigSchema } from './chart.zod'; import { ActionType } from './action.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; -import { I18nLabelSchema, AriaPropsSchema } from './i18n.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 +// tombstone below. +import { I18nLabelSchema } from './i18n.zod'; // `ResponsiveConfigSchema` is no longer imported here: `widgets[].responsive` // was retired (#4876). The shape itself is NOT removed — it stays live on // `page.components[].responsive` (`page.zod.ts`), whose renderer really does @@ -32,19 +36,23 @@ export const WidgetColorVariantSchema = lazySchema(() => z.enum([ ]).describe('Widget color variant')); /** - * Action type for widget action buttons. + * Action type for DASHBOARD HEADER action buttons. + * + * Named `Widget…` for history only — since #5010 its single consumer is + * `DashboardHeaderActionSchema`. The per-widget `actionType` this was also + * shared with was retired in 17.0.0: no renderer ever drew a per-widget button. * * `ActionType` itself, not a hand-kept subset of it. The two lists had drifted * apart by one member — `form` — and the disagreement was backwards: a - * dashboard header or widget action button dispatches through the same - * `ActionRunner` that implements `form` (objectui's `DashboardRenderer` routes - * everything except a raw `url` into it, deliberately, so a `flow` header - * action works — objectstack#3528). So the narrower enum rejected at validation - * exactly what the shared dispatcher then executes at runtime. + * dashboard header action button dispatches through the same `ActionRunner` + * that implements `form` (objectui's `DashboardRenderer` routes everything + * except a raw `url` into it, deliberately, so a `flow` header action works — + * objectstack#3528). So the narrower enum rejected at validation exactly what + * the shared dispatcher then executes at runtime. * * Derived rather than restated: a type added to `ActionType` is dispatchable - * from a widget the moment the runner implements it, with no second list to - * remember. + * from a header action the moment the runner implements it, with no second list + * to remember. */ export const WidgetActionTypeSchema = lazySchema(() => ActionType.describe('Widget action type')); @@ -278,6 +286,29 @@ const COMPARE_TO_STRING_RETIRED = (kind: 'previousPeriod' | 'previousYear') => + 'when the selection has more than one dated time dimension; with one, the executor resolves ' + 'it. Run `os migrate meta --from 16` to rewrite it automatically.'; +// ── Per-widget action button prescriptions (#5010) ─────────────────────────── +// +// `//` rather than `/** */` deliberately, per the `COMPARE_TO_*` block above: +// build-docs lifts JSDoc onto the reference page, and an upgrade note is not a +// doc for a shape that still exists. +// +// The three keys read as one affordance ("give this widget its own button"), so +// they share one prescription and name each other — an author who removes only +// `actionUrl` should learn in the same breath that its two companions are gone +// too, rather than hitting three parse errors in three edit rounds. +const WIDGET_ACTION_RETIRED = (key: 'actionUrl' | 'actionType' | 'actionIcon') => + `\`dashboard.widgets[].${key}\` was removed in @objectstack/spec 17.0.0 (#5010, ` + + 'ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. ' + + 'No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from ' + + '`header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; ' + + 'delete all three. ' + + 'Put the affordance on the dashboard header instead — ' + + "`header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched " + + '(`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of ' + + '`actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` ' + + 'bound to a dataset: its rows are clickable and drill through the semantic layer. ' + + 'Run `os migrate meta --from 16` to rewrite it automatically.'; + /** * Dashboard Widget Schema * A single component on the dashboard grid. @@ -318,15 +349,19 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({ */ requiresService: z.string().optional().describe('Hide the widget unless the named kernel service is registered'), - /** Action URL for the widget header action button */ - actionUrl: z.string().optional().describe('URL or target for the widget action button'), - - /** Action type for the widget header action button */ - actionType: WidgetActionTypeSchema.optional().describe('Type of action for the widget action button'), + // `actionUrl` / `actionType` / `actionIcon` REMOVED (#5010, ADR-0049 D2): + // the three keys described a per-widget header action BUTTON that no renderer + // has ever drawn. Re-measured 2026-08-04 against objectui@91757a7: all 14 + // `actionUrl` reads in `DashboardRenderer.tsx` are scoped to + // `schema.header.actions[]` (a `DashboardHeaderAction`, a different schema); + // `actionIcon` has zero references in either repo outside this declaration. + // The one consumer of the pair was `packages/lint`'s reference-integrity rule, + // which failed builds when a NEVER-RENDERED button pointed at a missing + // action — its widget branch goes with the keys, in this same change. + actionUrl: retiredKey(WIDGET_ACTION_RETIRED('actionUrl')), + actionType: retiredKey(WIDGET_ACTION_RETIRED('actionType')), + actionIcon: retiredKey(WIDGET_ACTION_RETIRED('actionIcon')), - /** Icon for the widget header action button */ - actionIcon: z.string().optional().describe('Icon identifier for the widget action button'), - /** Presentation-scope filter (MongoDB-style), ANDed into the dataset query as `runtimeFilter`. */ filter: FilterConditionSchema.optional().describe('Presentation-scope filter (runtimeFilter)'), @@ -548,8 +583,27 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({ 'Run `os migrate meta --from 16` to rewrite it automatically.', ), - /** ARIA accessibility attributes */ - aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), + // `aria` REMOVED (#5010, ADR-0049 D2): the same "false compliance" the + // dashboard-level `aria` was retired for in the #3896 close-out (see the + // tombstone on `DashboardSchema` below) — declared ARIA attributes never + // reached the DOM, so a dashboard could claim accessibility work that had + // measurably not happened. Call graph closed by hand across both repos + // 2026-08-04: no consumer of `widget.aria` anywhere. The `aria-*` attributes + // 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. + 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 ' + + '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.', + ), // ADR-0021 single-form: every widget binds a `dataset` and selects `values` // (both required above) — there is no inline-query shape to disambiguate. }, { error: strictWidgetAnalyticsError }) diff --git a/packages/spec/src/ui/i18n.zod.ts b/packages/spec/src/ui/i18n.zod.ts index 1d256d9810..f126bd5a7a 100644 --- a/packages/spec/src/ui/i18n.zod.ts +++ b/packages/spec/src/ui/i18n.zod.ts @@ -12,9 +12,12 @@ import { z } from 'zod'; // // - `AriaPropsSchema` is carried as `aria:` on ~30 live shapes across six // metadata-type roots — `ListViewSchema`, `PageSchema`, `PageComponentSchema`, -// `DashboardWidgetSchema`, `ChartConfigSchema`, `ActionSchema`, and 20 SDUI -// component defs — and a BFS from all 24 roots plus `defineStack` reaches it -// directly. It was silently stripping: through the `view` root, +// `ChartConfigSchema`, `ActionSchema`, and 20 SDUI component defs — and a BFS +// from all 24 roots plus `defineStack` reaches it directly. (`DashboardWidgetSchema` +// was a seventh carrier when this was measured; its `aria` embed was retired +// later the same day — #5010, ADR-0049 — because no dashboard renderer applied +// it. The shape and every carrier above are unaffected.) +// It was silently stripping: through the `view` root, // `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN and returned // `aria: {}`, so the accessible name the author wrote simply did not exist. // - `I18nObjectSchema` / `PluralRuleSchema` / `NumberFormatSchema` /