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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .changeset/dashboard-widget-action-aria-removed.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 13 additions & 5 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -111,7 +111,7 @@ Dashboard header action
| **filterBindings** | `Record<string, string \| 'false'>` | 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. |


---
Expand Down
Loading
Loading