Skip to content

Commit eaaf03c

Browse files
refactor(spec,lint)!: 退役 dashboard widget 的 action 三键与 aria —— 连同那道「为不存在的按钮做引用完整性」的门 (#5010) (#5255)
* refactor(spec,lint)!: 退役 dashboard widget 的 action 三键与 aria —— 连同那道「为不存在的按钮做引用完整性」的门 (#5010) #4956 给 `DashboardWidgetSchema` 的 22 个 widget 级键补上首次逐键裁决, 其中 6 个 dead。本单处置其中 4 个(零 authored、零 renderer): - `actionUrl` / `actionType` / `actionIcon` —— 描述的是一个「每个 widget 自己的 操作按钮」,而两个仓库里**没有任何渲染器画过它**。DashboardRenderer 里 14 处 `actionUrl` 全部限定在 `schema.header.actions[]`(DashboardHeaderAction,另一个 schema);`actionIcon` 更彻底,除自身声明外零引用。 - `aria` —— 声明的 ARIA 属性从未到达 DOM,与 #3896 删掉的看板级 `aria` 是同一种 「假合规」,只是低一层。 四个键躲过 #3896 sweep 的原因与 `widgets[].responsive` 相同:台账当时对 `dashboard.widgets` 没有 `children`,widget 级键**一个都没被分类过** —— 是仪器有 洞,不是键有豁免。 ## 一并处置的二阶成本 `packages/lint` 的 `validate-dashboard-action-refs` 对 `widgets[].actionUrl` 做的是 **ERROR 级**引用完整性校验(目标解析不到就构建失败),docblock 自称 「the per-widget button」并声称镜像 objectui 运行时派发 —— 而那个按钮不存在。 于是作者可能因为一个**永远不会渲染**的控件指向一个不存在的 action 而被卡住构建。 一条为消灭「假affordance」而写的规则,自己维持着一个。widget 分支随键一起删除, 并留下 pin 测试保证它不会回来。 ## 退役套件 - 四个 `retiredKey()` 墓碑(与同 schema 内 `responsive` 一致):tsc 报 `never`, parse 抛出**处方**而非泛化的 unrecognized key;action 三键共用一条处方并互相点名。 - ADR-0087 D2 转换 + D3 链步 `dashboard-widget-action-aria-removed` (`retiredFromLoadPath`),`os migrate meta --from 16` 自动改写。 - 台账四行**保留**(墓碑令键仍在被遍历的 shape 内 —— rls.priority 先例), 去掉 `authorWarn`/`authorHint`:严格 parse 接管,ledger-driven 的 CLI advisory 改为断言**静默**,并留 `colorVariant` 作阳性对照。 - `authorable-surface.json` 四行转 `[RETIRED]`;四张 ratchet 按构造不动 (无 def 停止 emit、无导出移除)。 `AriaPropsSchema` 本身**未删除** —— 仍活在 `app.aria` / `page.components[].aria`。 ## 不在本单内 `widgets[].colorVariant`(第 5 个 dead 键)**刻意未动**:其改写目标 `options.colorVariant` 实测同样是死的 —— `options` 只在 inline 路径经 `componentSchema` 到达渲染器,而本 schema 的 `dataset` 是必填,故所有 spec 可授权 widget 均为 dataset-bound、走 DatasetWidget,而后者完全没有配色能力。 改写只会把 16 处 authored 从一个死槽搬到另一个死槽,并多造一个惰性键。交回裁决。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 * build(spec): 合并 origin/main 后按 os-regen 纪律重生成 authorable-surface 合并驱动把 `authorable-surface.json` 记为 pending(它不做文本合并), 从合并后的树整体重生成,补回 #5237 的 `ui/UserFilters:allowAddTab`。 9 张生成物全部 up to date。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 * docs(spec): 订正 WidgetActionTypeSchema 的一处陈述 —— 派发源是 header action #5010 退役 widget 级 actionType 后,「a type added to ActionType is dispatchable from a widget」不再成立;唯一消费者是 DashboardHeaderActionSchema。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 108ba8d commit eaaf03c

16 files changed

Lines changed: 534 additions & 94 deletions
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/lint": minor
4+
---
5+
6+
refactor(spec,lint)!: retire the dashboard widget action trio + `aria` — and the build gate that enforced a button nobody renders (#5010, ADR-0049)
7+
8+
`DashboardWidgetSchema` let an author declare a per-widget action **button**
9+
(`actionUrl` / `actionType` / `actionIcon`) and per-widget ARIA attributes
10+
(`aria`). None of the four reached a renderer. Re-measured 2026-08-04 across both
11+
repos on a closed call graph:
12+
13+
- **the action trio** — all 14 `actionUrl` reads in objectui's
14+
`DashboardRenderer.tsx` are scoped to `schema.header.actions[]`, which is
15+
`DashboardHeaderAction`, a *different* schema. Nothing anywhere reads
16+
`widget.actionUrl`. `actionIcon` is the starkest: zero references in either
17+
repo outside its own declaration — not even the lint looked at it.
18+
- **`aria`** — no consumer of `widget.aria` anywhere. The `aria-*` attributes in
19+
`DashboardRenderer` / `DatasetWidget` are the renderer's own DOM attributes,
20+
and objectui's single `.aria` read (`plugin-view/ObjectView.tsx:989`) is a
21+
**view**'s. This is the dashboard-level `aria` that #3896 removed, one level
22+
down — an accessibility guarantee an author could declare and nothing honoured.
23+
24+
These four survived the #3896 sweep for the same reason `widgets[].responsive`
25+
did, and it is not "we looked and they were live": the liveness ledger declared
26+
no `children` on `dashboard.widgets`, so **no widget-level key had ever been
27+
classified**. #4956 fixed that instrument and gave all 22 keys their first per-key
28+
verdicts; this change acts on four of the six it found dead.
29+
30+
## The second-order cost this settles
31+
32+
`packages/lint`'s `validate-dashboard-action-refs` enforced **ERROR-severity**
33+
reference integrity on `widgets[].actionUrl` — a dangling target failed the
34+
build. Its docblock called the key *"the per-widget button"* and claimed to
35+
mirror the objectui runtime dispatch. It did not, because that button does not
36+
exist. So an author could be blocked from shipping because a control that cannot
37+
render pointed at an action that also did not.
38+
39+
A rule written to delete false affordances was sustaining one. That is why the
40+
keys were retired rather than the check merely relaxed: the widget branch is
41+
deleted, with a pin test asserting it stays silent and a second pin proving
42+
header actions are still checked in the same stack.
43+
44+
FROM → TO:
45+
46+
| Removed | Replacement |
47+
| :--- | :--- |
48+
| `dashboard.widgets[].actionUrl` | `dashboard.header.actions[].actionUrl` |
49+
| `dashboard.widgets[].actionType` | `dashboard.header.actions[].actionType` |
50+
| `dashboard.widgets[].actionIcon` | `dashboard.header.actions[].icon` (the header spelling) |
51+
| `dashboard.widgets[].aria` | **none** — delete it; author `title`/`description`, which the renderer really does label the card with |
52+
53+
For a per-**row** affordance, reach for a dataset-bound `table`/`pivot` widget:
54+
its rows are clickable and drill through the semantic layer already (no
55+
per-widget drill config exists, by design — #5022).
56+
57+
**The `AriaProps` shape is NOT removed — only this embed.** `AriaPropsSchema` /
58+
`AriaProps` stay exported and stay live on `app.aria` and
59+
`page.components[].aria`. Nothing importing the shape breaks.
60+
61+
The retirement kit:
62+
63+
- **Tombstones.** `retiredKey()` on all four, matching `responsive` in this same
64+
schema. `DashboardWidgetSchema` *is* `.strict()`, so a plain delete would still
65+
be loud — but only as a generic "unrecognized key". The tombstone keeps the key
66+
declared so the rejection carries the **prescription**, and types it `never` so
67+
authoring it fails `tsc` first. Pins assert the message *is* the prescription
68+
and is *not* `Unrecognized key`. The action trio shares one prescription that
69+
names all three, so an author who deletes the one key they were told about does
70+
not hit the same error twice more.
71+
- **ADR-0087 D2 conversion + D3 chain step**
72+
(`dashboard-widget-action-aria-removed`, `retiredFromLoadPath`):
73+
`os migrate meta --from 16` strips the four from author sources, and stored
74+
dashboards replay clean instead of meeting a tombstone at load. Lossless
75+
deletes — none of the keys had an effect to lose. Its own entry rather than
76+
more keys on `dashboard-inert-keys-removed`, whose identity is the #3896 sweep.
77+
- **Liveness rows stay** (`status: dead`, `verifiedAt`, a REMOVED note) because
78+
a tombstone keeps the key in the walked shape — the `rls.priority` precedent.
79+
`authorWarn`/`authorHint` are dropped from all four: the parse owns them now.
80+
- Baselines moved at KEY level only, as the shape's survival implies:
81+
`authorable-surface.json` gains four `… [RETIRED]` lines;
82+
`json-schema.manifest.json`, `api-surface.json` and
83+
`api-surface-signatures.json` are unchanged by construction — no def stopped
84+
being emitted and no export was removed.
85+
86+
No runtime behaviour changes — that impossibility is the reason for the removal.
87+
The one behaviour that *does* change is a build that used to fail and now does
88+
not.
89+
90+
## Not in this change
91+
92+
`widgets[].colorVariant`, the fifth dead key #5010 lists, is **deliberately
93+
untouched**. The rewrite target its triage assumed — `options.colorVariant`
94+
measured dead as well: `options` only reaches a renderer through the inline
95+
`componentSchema` path, and `dataset` is *required* on this schema, so every
96+
spec-authorable widget is dataset-bound and renders through `DatasetWidget`,
97+
which has no colour affordance at all. Moving the key would relocate 16 authored
98+
sites (7 in `platform-objects`, 9 in `app-showcase`) from one dead slot to
99+
another and mint a second inert key. Returned for adjudication.

content/docs/deployment/validating-metadata.mdx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,19 @@ object's own fields.
6060

6161
### 3. Dead action/route references
6262

63-
A dashboard `header.actions[]` button (and a widget's `actionUrl`) names a target:
64-
a `script`/`modal` action, or a `url` route. Nothing in the schema checks that the
65-
target exists, so a button can ship pointing at an action defined nowhere — it
66-
renders and then **silently does nothing** when clicked. This is ADR-0049's
67-
"declared ≠ enforced" gate applied to *references*.
63+
A dashboard `header.actions[]` button names a target: a `script`/`modal` action,
64+
or a `url` route. Nothing in the schema checks that the target exists, so a button
65+
can ship pointing at an action defined nowhere — it renders and then **silently
66+
does nothing** when clicked. This is ADR-0049's "declared ≠ enforced" gate applied
67+
to *references*.
68+
69+
This check covers the dashboard **header** only. It used to check a
70+
`widgets[].actionUrl` too — until #5010 measured that no renderer has ever drawn a
71+
per-widget action button, which made the strictest arm of the rule fail builds
72+
over a control that could not render. The three widget keys
73+
(`actionUrl`/`actionType`/`actionIcon`) were retired in 17.0.0 rather than the
74+
check merely relaxed; authoring one is now a `tsc` error and a parse error
75+
carrying the fix.
6876

6977
```ts
7078
header: {

content/docs/references/ui/dashboard.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,9 @@ Dashboard header action
9898
| **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Widget color variant for theming |
9999
| **requiresObject** | `string` | optional | Hide the widget unless the named object is registered |
100100
| **requiresService** | `string` | optional | Hide the widget unless the named kernel service is registered |
101-
| **actionUrl** | `string` | optional | URL or target for the widget action button |
102-
| **actionType** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Type of action for the widget action button |
103-
| **actionIcon** | `string` | optional | Icon identifier for the widget action button |
101+
| **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. |
102+
| **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. |
103+
| **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. |
104104
| **filter** | `any` | optional | Presentation-scope filter (runtimeFilter) |
105105
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) |
106106
| **dataset** | `string` || Dataset name to bind (ADR-0021) |
@@ -111,7 +111,7 @@ Dashboard header action
111111
| **filterBindings** | `Record<string, string \| 'false'>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
112112
| **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget |
113113
| **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. |
114-
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
114+
| **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. |
115115

116116

117117
---

0 commit comments

Comments
 (0)