Skip to content

Commit 32d5769

Browse files
feat(spec)!: a pie / donut / funnel / treemap / sankey widget takes one measure with a dimension too — refuse two or more at values, and rename the check to checkDashboardWidgetChartMeasureArity (#21293) (#21425)
Fixes #21293 Clause-②: yes (narrowing) Extends #20958's per-type measure-arity rule to the dimensioned arm, as triage ruled on objectstack-ai/objectui#11417 (comment `5943580519`, carried onto this card as `5944795015`): for `pie`, `donut`, `funnel`, `treemap` and `sankey`, two or more measures are refused **whatever the dimension**. It uses #20958's mechanism and message shape: the same check, one more arm, and one ADR-0087 semantic entry in #20958's shape. No second rule is added. `scatter` and `radar` with a dimension are outside the ruling and parse as before. ## What changed - **`packages/spec/src/ui/dashboard.zod.ts`** - New module-private `SINGLE_SERIES_CHART_TYPES` (`:784`): `pie` / `donut` / `funnel` / `treemap` / `sankey`, typed `as const satisfies readonly ChartType[]`. It is private, like the metric family's list. The check, its refusal text and the `values` doc string read it. - The check gains a second arm, the **single-series arm**. When `dimensions` is declared and non-empty, `values` has two or more members, and `type` is one of the five, it emits ONE `custom` issue at `values`. The issue names the widget `id`, the count and the type, and says the type "draws ONE series whatever its `dimensions`". It steers to `type: 'table'` (a column per measure), a bar-family type (one bar per measure in each category), or one widget per measure. - The dimensionless arm is unchanged, byte for byte, in both verdict and message. The arms are exclusive on `dimensions`, so a dimensionless `pie` still gets the dimensionless message, and only one issue. - The metric family still returns early (its sibling check owns it), and so do types outside `ChartTypeSchema`. - **Renamed export:** `checkDashboardWidgetDimensionlessMeasureArity` is now `checkDashboardWidgetChartMeasureArity` (`:878`, chained at `:1461`). The reason is in "The rename" below. - The `values` doc string now states the single-series rule. It reads the private constant. - **ADR-0087 entry** `packages/spec/src/migrations/entries/semantic/18.dashboard-widget-single-series-multi-measure-refused.ts`, plus the `gen:migration-registry` lap. Nothing between the markers was hand-edited. Its acceptance criteria name the doors as measured (below), state that the TODO cannot name per-document measures, and list what is and is not refused. It also records the export rename (FROM → TO). - **The #20958 entry, amended in two sentences** (`18.dashboard-widget-dimensionless-multi-measure-refused.ts`). It named the old export, and it said "the same seven types WITH a dimension keep accepting several measures exactly as before". This change makes both false, and `os migrate meta` prints both entries in the same hop. They now name the new export (noting the old name) and point the five at this entry. This file is outside the claim's listed file surface; see Deviations. - **Tests** - `dashboard.test.ts`: a new `[#21293]` block, plus the fixture triage below. - `object-refinement-check-exports.test.ts`: the parity catalogue row is renamed, and its fixtures are triaged. - **Changeset** `.changeset/21293-single-series-multi-measure-refused.md`: `minor`, a **BREAKING** banner, the `(narrowing)` arm, and exactly one ADR-0087 marker (`registered dashboard-widget-single-series-multi-measure-refused`). It carries a FROM → TO table that includes the export rename. - **Regenerated:** - `api-surface/ui.json` and `export-origins/ui.json`: one row renamed in each, `-1 / +1`. - The `values` row of `content/docs/references/ui/dashboard.mdx`. - `src/migrations/registry.ts`. ## The rename, and what objectui's mirror chains today The check now judges widgets WITH a dimension, so `...DimensionlessMeasureArity` names a boundary that no longer exists. A mirror deciding which export to chain reasons from that name first. This is the cheapest moment there will ever be to change it, because nothing chains it yet: - At the `.objectui-sha` pin `89cad75d55702cc4f267bead5bf267de575d5842`, `git grep "DimensionlessMeasureArity\|MULTI_MEASURE_TYPES"` returns **0 hits**. The control, `checkDashboardWidgetMetricMeasureArity` in `packages/types/src/zod/complex.zod.ts`, returns 2. objectui's `DashboardWidgetSchema` mirror chains `checkDashboardWidgetStageOrder` and `checkDashboardWidgetMetricMeasureArity` only (`complex.zod.ts:1304`, and the `attached` row in `spec-object-refinements-7715.test.ts:135`). - On objectui `origin/main` `5988b6b53` the readings are the same: 0 hits, control 2. - In this repository the old name had no consumer outside `packages/spec`'s own tests (`git grep`). - The spec object's check COUNT is unchanged at three, so objectui's census test sees no count change at the bump. A rename is a removal plus an addition, so the changeset carries the import FROM → TO, and `Clause-②` is `yes`. ## Measured before building (the dispatch's four hypotheses) The branch base is `4b20c84748`. It is newer than the dispatch's `1d0600bf66`, and `dashboard.zod.ts` did not change between the two. - **H1, holds.** The check returned early on any non-empty `dimensions` (old `:834`), and `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` listed the eight multi-measure types. At the base I built `dist` and ran `DashboardWidgetSchema.safeParse` on `{ id, type, dataset, dimensions: ['stage'], values: ['revenue', 'cost'] }`. It returned **ACCEPT** for `pie`, `donut`, `funnel`, `treemap`, `sankey`, `scatter`, `radar`, `bar` and `table`. One measure returned ACCEPT on all of them. The lit control, the same body plus `bogusProp`, returned REFUSE `unrecognized_keys` on every type. - **H2, holds.** objectui's renderer at the pin (`packages/plugin-charts/src/AdvancedChartImpl.tsx`) binds the first series on exactly these arms: - `pie` / `donut` at `:2095` (`const pieDataKey = series[0]?.dataKey || 'value'`); - `funnel` at `:2161`, `treemap` at `:2243` and `sankey` at `:2278` (`const dataKey = series[0]?.dataKey || 'value'`). - `radar` maps every series (`:2406` onward, `series.map(...)`). - objectui's `DatasetWidget.tsx` at the pin already names the same five as `SINGLE_SERIES_CHART_FAMILIES` (`:387`–`:413`), which is the #11417 diagnostic. It says `scatter` "refuses a second series out loud". - **H3, partly falsified.** There is no declared "order": `build-migration-registry.ts` derives the order by sorting entry ids ("Order is DERIVED, never declared"). The serial constraint did hold. PR #21399 (`analytics-query-window-non-negative-integer`) landed on `main` first, so this branch merged `origin/main` `db3fee3dc9` through `scripts/pm/os-regen-merge.sh` (merge commit `b3b3c516f9`). - The registry text-merged clean. - `gen:migration-registry` on the merged tree rewrote it byte-identical: 346 semantic entries, `git status` empty. - All three sibling entries are present at HEAD. Quoted-exact-name `git grep`, HEAD vs `origin/main`: `analytics-query-window-non-negative-integer` 1/1, `dashboard-widget-dimensionless-multi-measure-refused` 1/1, and `dashboard-widget-single-series-multi-measure-refused` 1 at HEAD, 0 on main. - **H4, census: no authored hit.** I scanned brace-local literals carrying `values: [...]` over every tracked `.ts/.tsx/.js/.mjs/.cjs/.json/.md/.mdx/.yml` file. - objectstack at `4b20c84748`: 496 literals, 33 on the seven types, 1 dimensioned multi-measure on the five. That one is `packages/spec/src/ui/object-refinement-check-exports.test.ts:365`, the fixture that pinned the old acceptance, triaged below. - Lit control: 9 dimensioned multi-measure literals on other types (`table` / `combo` widgets in `examples/app-showcase`, and others). - objectui at the pin: 480 literals, 13 on the seven, and 1 hit on the five. That hit is the prose example in `.changeset/11417-dashboard-single-series-dropped-measure.md:10`, not metadata. - `examples/**`, the showcase and CRM apps, and `packages/platform-objects` dashboards carry zero instances. - Dynamically built fixtures do not show up in a static scan. I inspected each downstream test that loops over chart types or builds widgets (`lint` `validate-widget-bindings`, `runtime-gate`, `validate-dashboard-widget-options`, and `metadata-protocol`'s two donut fixtures). All carry one measure. ## Doors after the change (built `dist`, one-shot probe, deleted afterwards, `git status` clean) | body (`dimensions: ['stage']`) | `DashboardSchema` | `getMetadataTypeSchema('dashboard')` | `ObjectStackDefinitionSchema` | `defineStack` | |---|---|---|---|---| | `pie`, 2 measures | REFUSE `widgets.0.values:custom` | REFUSE `widgets.0.values:custom` | REFUSE `dashboards.0.widgets.0.values:custom` | throws `defineStack validation failed (1 issue)`, naming the widget | | `sankey`, 2 measures | REFUSE | REFUSE | REFUSE | throws | | `pie`, 1 measure (control) | ACCEPT | ACCEPT | ACCEPT | returns | | `radar`, 2 measures (control) | ACCEPT | ACCEPT | ACCEPT | returns | | `bar`, 2 measures (control) | ACCEPT | ACCEPT | ACCEPT | returns | I did not re-measure `os validate` or the metadata save path. The entry does not claim them. They parse through the same schema at the same attachment point as #20958, which measured both. ## Fixture triage (pins whose premise this change makes false) - **`object-refinement-check-exports.test.ts`.** The row "two measures on a `pie` WITH a dimension — out of this rule" (`refusesAt: []`) is this arm's subject now. It becomes five refusing rows, one per single-series type. Accepting rows are added for `scatter` / `radar` with a dimension and for a one-measure dimensioned `pie`. - **`dashboard.test.ts`, the #20958 block.** "accepts `%s` with ONE dimension and two measures" iterated all seven types. It keeps `scatter` and `radar`, and the five move to the new block as refusals. - **`dashboard.test.ts`, the #17779 block.** "leaves `%s` — every NON-metric type — accepting three measures" ran on `WIDGET_BASE`, which carries a dimension. Its own claim is that the METRIC check leaves non-metric types alone, so that claim is now read off the metric export directly for all 15 types. The parse leg keeps the 10 types no check refuses. - **#20958's dimensionless pins are unchanged.** That covers the refusals, the explicit `dimensions: []` refusals, the message pin and the metric-family one-issue pin. ## New pins (`[#21293]` block in `dashboard.test.ts`) - Each of the five, with a dimension and two measures: REFUSE, `custom` at `values`, message naming the type. - The control: one measure on each of the five with a dimension: ACCEPT. - Each of the five with TWO dimensions: REFUSE. - `scatter` / `radar` with a dimension and two measures: ACCEPT. - Every multi-measure type with a dimension and three measures: ACCEPT. - **Taxonomy read off the door:** with a dimension, the set of `ChartTypeSchema` types refused at two measures is exactly the metric family plus the five. - A dimensionless widget of each of the five keeps the dimensionless message, word for word and as ONE issue. The parse leg equals the direct call. - The metric family with a dimension keeps its own refusal, and the chart export stays silent on it. - Direct-call legs: a dimensioned `pie` is refused; a wider-enum type (`custom`) and a dimensioned `scatter` are not. - `DashboardSchema.widgets[]` travel, and the `values` doc string. - The old export name is absent from `@objectstack/spec/ui`. The new export is the lit control. ## Verification Every reading below is at HEAD `b3b3c516f9` unless it names another commit. **Reverse verification**, at committed `221df98d72`, through `scripts/ablation-replace.mjs`: - The mutation turned the single-series arm's type guard into an unconditional `return`. The subject is imported from `src` by relative path, so no `dist` is involved. - The prediction was "turns red", and that is the direction observed. ``` anchor x1 -> x0 · marker x0 -> x1 · blob 9a8c228 -> cfdc26ae3cbf (mutation landed) RED vitest exit=1 19 failed | 342 passed (361) (dashboard.test.ts + object-refinement-check-exports.test.ts) restored blob 9a8c228 == HEAD blob, git diff HEAD empty; marker count after restore 0 ``` The 19 red tests: - 14 in the new block: 5 refusals, 5 two-dimension refusals, the taxonomy pin, the message pin, the direct-call pin and the travel pin; - 5 parity rows in the exports catalogue, one per dimensioned single-series fixture. **Tests.** Each is the package's own vitest run. Downstream packages were chosen by direction: the dependents of `@objectstack/spec` that import or parse the dashboard schemas (`git grep`). This is not the full `...@objectstack/spec` sweep. | package | result | |---|---| | `@objectstack/spec` (`--project local`) | 600 files, 17666 passed, 1 todo, exit 0 | | `@objectstack/spec` typecheck (tsc + scripts + test layer) | exit 0 (and at `221df98d72` before the merge). The test-layer ledger held at 52 files / 246 errors / 135 signatures; `tsc -p tsconfig.test.json --listFiles` lists both touched test files and the new entry. | | `@objectstack/lint` | 119 files, 5575 tests, exit 0 | | `@objectstack/sdui-parser` | 13 files, 218 tests, exit 0 | | `@objectstack/platform-objects` | 59 files, 949 tests, exit 0 | | `@objectstack/metadata-protocol` | 201 passed, 3 skipped (204 files); 2983 passed, 19 skipped; exit 0 | | `@objectstack/metadata` | 56 files, 836 tests, exit 0 | | `@objectstack/service-analytics` | 168 files; 3794 passed, 89 skipped; exit 0 | | `@objectstack/cli` (`--project unit`) | 247 files, 3538 tests, exit 0. The integration tier is declared to CI: no spawn entry or integration file is touched. | | `@objectstack/objectql` (`--project local`) | 364 files, 7360 tests, exit 0 | **Generated artefacts.** `pnpm --filter @objectstack/spec check:generated` reports "All 15 generated artifacts are up to date" on the merged tree, after a rebuild. `check:migration-registry` reports `registry.ts is current (346 semantic, 243 retired-key, 212 retired-def)`. **Gates.** - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `b3b3c516f9` derives 115 commands. The 10 changed paths are measured against merge base `db3fee3dc`; 671 changed lines. - All 115 ran. `--ran` reconciliation: "115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)". - Six first answered `PREREQUISITE NOT MET` (exit 3) and were re-run to a real exit 0 after a workspace build: - `check:doc-formula-expressions` and `check:doc-security-posture` (`lint` / `formula` not built); - `check:skill-examples` (`client-react` not built); - `check:docs-transcript-drift` (`lint` not built); - `check:dual-build-cjs-loads` (86 packages had no `dist`); - `check:lean-entry-closure` (`objectql` not built). - Compared with the seat's dispatch-time list (109), the re-derivation adds six: the changeset-driven `check-empty-changeset` pair, `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `check:objectui-changeset` and `check:pm-changeset-deadline-census`. All six ran. - Verdict lines: - `check-adr-0087-registration --base origin/main`: `[BREAKING+bang+clause-②-narrowing] registered dashboard-widget-single-series-multi-measure-refused (new here: dashboard-widget-single-series-multi-measure-refused)`. - `check:api-surface`: "public API surface + factory signatures unchanged ✓", against the regenerated snapshot. - `check:doc-authoring`: no internal issue-id in the 17250 customer-facing strings. The refusal text carries none. - `check:nul-bytes`: OK. **`Clause-②`, measured.** `node scripts/pm/check-widening-tells.mjs --declaration no --diff` on `git diff origin/main...HEAD` exits 4: - It reports five T2 tells, `dashboard.zod.ts:785`–`:789`. These are the members of the module-private `SINGLE_SERIES_CHART_TYPES` `as const` array. The array narrows the accept set, so these tells are false as widenings. - T3 does not fire, because the `api-surface/ui.json` hunk removes the old row beside the new one. - The export listing does gain a row: `checkDashboardWidgetChartMeasureArity (function)`, the renamed export. - `--declaration yes` exits 0. So the measured arm is `Clause-②: yes (narrowing)`. The `yes` holds because a renamed check function is exported for objectui's `.shape` mirror to chain, which is the dispatch's own example. The `(narrowing)` holds because the accept set shrinks. **Lint**, a measured narrowing rather than the repo-wide run (that run belongs to CI): - I ran `eslint --no-inline-config --format json` over the 6 touched `.ts` files. The JSON reports 6 files, 0 errors and 0 warnings. - Population: `eslint --print-config` returns a rule set for each file, so none is ignored. - Invariance: `parserOptions.project` and `projectService` are null for every file, and `eslint.config.mjs:327` states that it never enables type-aware linting. This diff therefore cannot move a verdict on any untouched file. ## Deviations - **One file outside the claim's listed surface:** the #20958 entry. Two of its sentences, one naming the export and one saying the dimensioned arm keeps accepting, became false in this change. `os migrate meta` prints both entries in one hop, so leaving them would print a contradiction. The standing dev definition requires fixing released text that a change makes false. The claim's "stop on breach" reads the other way, so the conflict is named here rather than settled silently. The edit is two sentences, and the registry region is regenerated, not hand-edited. ## Acceptance notes None of these is filed. None is a reproducible defect, a contract violation or an authoring trap. - **`check-widening-tells` T2 fires on a module-private `as const` set whose members narrow.** The declaration is `yes` for the export row anyway, so the tells decide nothing here. The matcher was not touched. The precedent (`SINGLE_MEASURE_WIDGET_TYPES`) predates the gate. Carrier: none. - **The single-series set is not exported.** objectui's dataset widget keeps its own renderer-derived copy (`SINGLE_SERIES_CHART_FAMILIES`, "Kept here, once, because the renderer has no declaration of it to read"). No import of a spec list is measured anywhere. If objectui's mirror or diagnostic later wants it, exporting it is a free widening. Carrier: none. - **No `STEP18_RATIONALE` fragment**, following #20958's precedent. ## Downstream: objectui's mirror (not in this PR; objectstack-ai/objectui#11417 carries it) `@object-ui/types` builds `DashboardWidgetSchema` from a `.shape` spread, so it runs only the checks it chains, and today it chains neither arm. At the pin bump that carries this change: - Import `checkDashboardWidgetChartMeasureArity` from `@objectstack/spec/ui`. - Chain it in `packages/types/src/zod/complex.zod.ts` after `.superRefine(checkDashboardWidgetMetricMeasureArity)`. - Add it to the `attached` list of the `DashboardWidgetSchema (complex.zod.ts)` row in `spec-object-refinements-7715.test.ts`. - Refusal shape: ONE `custom` issue at `path: ['values']`. With a dimension, the message starts "Widget `ID` declares N measures on `type: 'T'`, a single-series type (...) that draws ONE series whatever its `dimensions`". Without one, it keeps #20958's "declares N measures with no `dimensions`" text. objectstack-ai/objectui#11417 remains open: this PR carries only the spec half. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 520f66f commit 32d5769

10 files changed

Lines changed: 585 additions & 86 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a `pie` / `donut` / `funnel` / `treemap` / `sankey` dashboard widget takes ONE measure with a dimension too — two or more are refused at `values`, and the check export is renamed `checkDashboardWidgetChartMeasureArity` (#21293; extends #20958)
6+
7+
Clause-②: yes (narrowing) — the accept set NARROWS (that is the change), and the published surface swaps one export for another: `checkDashboardWidgetDimensionlessMeasureArity` is removed and `checkDashboardWidgetChartMeasureArity` is added in its place, the same check with a second arm.
8+
9+
<!-- adr-0087: registered dashboard-widget-single-series-multi-measure-refused -->
10+
11+
**BREAKING** accept-set narrowing at `dashboard.widgets[].values`, plus one renamed
12+
export, shipped as `minor` under this repo's launch-window convention for breaking
13+
changes (`check-changeset-no-major` refuses `major` while the window is open, so
14+
breaking-ness is carried by this banner and by the ADR-0087 disposition above,
15+
never by the bump level). The prescription is registered under protocol major 18
16+
as `dashboard-widget-single-series-multi-measure-refused`.
17+
18+
**What was wrong.** The previous release refused two or more measures on a
19+
dimensionless `pie` / `donut` / `funnel` / `scatter` / `radar` / `treemap` /
20+
`sankey`, and stepped aside for any widget that declared a dimension. Five of those
21+
types draw ONE series whatever the dimension: objectui's chart renderer binds the
22+
first series on its `pie` / `donut`, `funnel`, `treemap` and `sankey` arms and reads
23+
no other, so `{ type: 'pie', dimensions: ['stage'], values: ['revenue', 'cost'] }`
24+
drew one slice per stage for `revenue` and no trace of `cost`. Measured on this tree
25+
before the change: that body parsed through `DashboardWidgetSchema` on all five
26+
types (and on `scatter` / `radar` / `bar` / `table`), while `bogusProp` on the same
27+
widget was refused by name, the lit control. After it, the five are refused at
28+
`widgets[N].values`; `scatter` and `radar` with a dimension are outside the ruling
29+
and parse as before.
30+
31+
### Write instead
32+
33+
| wrote | write instead |
34+
|---|---|
35+
| `{ id: 'mix', type: 'pie', dataset: 'sales', dimensions: ['stage'], values: ['revenue', 'cost'] }` | `{ id: 'mix', type: 'table', dataset: 'sales', dimensions: ['stage'], values: ['revenue', 'cost'] }` — a column per measure |
36+
| the same, wanting a chart | `type: 'bar'` (or `column` / `horizontal-bar`) — one bar per measure in each stage |
37+
| the same, wanting the pie | `{ id: 'mix', type: 'pie', …, values: ['revenue'] }` **and** `{ id: 'mix_cost', type: 'pie', …, values: ['cost'] }` — one widget per measure, each with its own `id` (and `layout`, if you pin positions) |
38+
| `import { checkDashboardWidgetDimensionlessMeasureArity } from '@objectstack/spec/ui'` | `import { checkDashboardWidgetChartMeasureArity } from '@objectstack/spec/ui'` — same `(widget, ctx)` signature; chain it where the old name was chained |
39+
40+
No conversion does this for you: whether a two-measure pie by stage meant a table, a
41+
grouped bar chart or two pies is an authoring choice. The refusal is ONE `custom`
42+
issue at `widgets[N].values` naming the widget's `id`, the number of measures and
43+
the authored `type`, and saying that type draws one series whatever its
44+
`dimensions`.
45+
46+
**Why the export is renamed.** The dimensionless rule's check now has a second arm
47+
that judges widgets WITH a dimension, so its old name described a boundary that no
48+
longer exists. It refuses everything the old name refused, word for word on a
49+
dimensionless widget. No first-party consumer chained the old name: objectui's
50+
`DashboardWidgetSchema` mirror chains `checkDashboardWidgetStageOrder` and
51+
`checkDashboardWidgetMetricMeasureArity` only, measured at the pinned objectui
52+
commit and on objectui's `main`.
53+
54+
**Nothing else moves.** One measure parses on every type; `scatter` and `radar`
55+
keep accepting several measures with a dimension; every type in
56+
`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` keeps accepting any number of measures with
57+
or without a dimension; a dimensionless widget of the five keeps the dimensionless
58+
refusal, word for word and still ONE issue; the metric family's refusal is
59+
unchanged; an empty `values` keeps its `too_small`; a `type` outside
60+
`ChartTypeSchema` reports the type refusal alone. Census at the branch point
61+
(`4b20c8474`), every tracked `.ts` / `.tsx` / `.js` / `.mjs` / `.cjs` / `.json` /
62+
`.md` / `.mdx` / `.yml`: 496 literals carry `values: [...]`, 33 of them on one of
63+
the seven types, and the only dimensioned multi-measure one on the five is a spec
64+
test fixture that pinned the old acceptance (moved to the refusal in this change).
65+
The same scan over objectui at its pinned commit (`89cad75d5`) finds no authored
66+
widget of that shape — its one hit is the prose example in a changeset.

‎content/docs/references/ui/dashboard.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ const result = DashboardSchema.parse(data);
7777
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) |
7878
| **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) |
7979
| **dimensions** | `string[]` | optional | Dimension names — X/group/split |
80-
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
80+
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family and on the single-series pie/donut/funnel/treemap/sankey; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
8181
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
8282
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record<string, any>` | optional | Widget specific configuration |
8383
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
@@ -182,7 +182,7 @@ Dashboard header action
182182
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) |
183183
| **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) |
184184
| **dimensions** | `string[]` | optional | Dimension names — X/group/split |
185-
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
185+
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family and on the single-series pie/donut/funnel/treemap/sankey; with no dimensions, two or more only on table/pivot/bar/column/horizontal-bar/line/area/combo) |
186186
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
187187
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record<string, any>` | optional | Widget specific configuration |
188188
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |

‎packages/spec/api-surface/ui.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,7 @@
478478
"chartAggregateCategoryKey (function)",
479479
"chartAggregateResultKeys (function)",
480480
"chartAggregateValueKey (function)",
481-
"checkDashboardWidgetDimensionlessMeasureArity (function)",
481+
"checkDashboardWidgetChartMeasureArity (function)",
482482
"checkDashboardWidgetMetricMeasureArity (function)",
483483
"checkDashboardWidgetStageOrder (function)",
484484
"checkGlobalFilterDateDefaultValue (function)",

‎packages/spec/export-origins/ui.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@
463463
"chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)",
464464
"chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)",
465465
"chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)",
466-
"checkDashboardWidgetDimensionlessMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetDimensionlessMeasureArity (function)",
466+
"checkDashboardWidgetChartMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetChartMeasureArity (function)",
467467
"checkDashboardWidgetMetricMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetMetricMeasureArity (function)",
468468
"checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)",
469469
"checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)",

‎packages/spec/src/migrations/entries/semantic/18.dashboard-widget-dimensionless-multi-measure-refused.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,16 @@ export const entry: SemanticMigration = {
6060
+ '`id`, the number of measures and the authored `type`, when `dimensions` is absent or an empty '
6161
+ 'array, `values` carries two or more measures, and `type` is `pie`, `donut`, `funnel`, '
6262
+ '`scatter`, `radar`, `treemap` or `sankey`. The check is the exported '
63-
+ '`checkDashboardWidgetDimensionlessMeasureArity`, and the set it reads is the exported '
63+
+ '`checkDashboardWidgetChartMeasureArity` (exported as '
64+
+ '`checkDashboardWidgetDimensionlessMeasureArity` until '
65+
+ '`dashboard-widget-single-series-multi-measure-refused` gave it a second arm and renamed it), and '
66+
+ 'the set it reads is the exported '
6467
+ '`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` — one list, which the check, the refusal text and the '
6568
+ '`values` doc string all read. '
66-
+ 'WHAT IS NOT, so this is not read as complete: the same seven types WITH a dimension keep '
67-
+ 'accepting several measures exactly as before (whether that shape renders them all is a '
68-
+ 'separate question this entry does not answer); one measure parses on every type; every type in '
69+
+ 'WHAT IS NOT, so this is not read as complete: the same seven types WITH a dimension are '
70+
+ 'outside THIS entry — `scatter` and `radar` keep accepting several measures with a dimension, and '
71+
+ '`pie` / `donut` / `funnel` / `treemap` / `sankey` are refused with a dimension too, by '
72+
+ '`dashboard-widget-single-series-multi-measure-refused`; one measure parses on every type; every type in '
6973
+ '`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` keeps accepting any number of measures with no '
7074
+ 'dimension; the metric family (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`, and a '
7175
+ 'widget that declares no `type`, which resolves to `metric`) keeps its OWN refusal, unchanged '
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'dashboard-widget-single-series-multi-measure-refused',
7+
surface: 'dashboard widget measure arity WITH a dimension on a single-series chart type — '
8+
+ '`dashboard.widgets[].values` (`DashboardWidgetSchema.values`) on a widget whose `dimensions` '
9+
+ 'declares one or more dimensions and whose `type` is `pie`, `donut`, `funnel`, `treemap` or '
10+
+ '`sankey`; and the check export `checkDashboardWidgetDimensionlessMeasureArity` (from '
11+
+ '`@objectstack/spec/ui`), renamed `checkDashboardWidgetChartMeasureArity`',
12+
replacement: 'Keep ONE measure on the widget, or pick a visual that renders several. With a '
13+
+ 'dimension, `type: \'table\'` renders a column per measure and a bar-family type (`bar` / '
14+
+ '`column` / `horizontal-bar`) renders one bar per measure in each category; both keep the '
15+
+ 'unbounded `values` they have always had. Or keep the type and give each measure its OWN '
16+
+ 'widget: a new `id`, the same `dataset` and `dimensions`, that one measure in `values`, and its '
17+
+ 'own `layout` if the dashboard pins grid positions. ⛔ The migration does not do this for you '
18+
+ 'and no conversion could: whether a two-measure pie by stage meant a table, a grouped bar chart '
19+
+ 'or two pies is an authoring choice, and N widgets need N ids and N boxes on a 12-column grid, '
20+
+ 'which is a LAYOUT decision about a dashboard the registry has never seen. A mirror that '
21+
+ 'chained the old check export by name imports `checkDashboardWidgetChartMeasureArity` instead: '
22+
+ 'same signature, same attachment point, and it refuses everything the old name refused.',
23+
reason:
24+
'Triage\'s ruling on objectui\'s finding that a dimensioned pie draws only its first measure: '
25+
+ 'the spec refuses, the renderer does not invent. It extends '
26+
+ '`dashboard-widget-dimensionless-multi-measure-refused`, which applied maintainer ruling D '
27+
+ '(「协议不正确的应该先修改协议」) to a widget with NO dimension, to the dimensioned arm for '
28+
+ 'the five types that draw one series whatever the dimension. Measured in objectui\'s shared '
29+
+ 'chart renderer: the `pie` / `donut`, `funnel`, `treemap` and `sankey` arms each bind the '
30+
+ 'first series and read no other, so `{ type: \'pie\', dimensions: [\'stage\'], values: '
31+
+ '[\'revenue\', \'cost\'] }` drew one slice per stage for `revenue` and no trace of `cost` — the '
32+
+ 'dataset query selects and computes every measure, and all but the first are thrown away. '
33+
+ 'Every door accepted the document, because the dimensionless rule stepped aside for any '
34+
+ 'widget that declared a dimension. That is the declared≠delivered shape ADR-0049 exists to '
35+
+ 'end. A pie of several measures has zero measured pull, so no rendering is invented for it. '
36+
+ 'The census before the change found zero authored dimensioned multi-measure widgets of the five '
37+
+ 'types in the platform\'s examples, its first-party dashboards or objectui\'s example apps, so '
38+
+ 'this ships at once with no deprecation window. Relaxing later is free and needs no second '
39+
+ 'migration — a type whose renderer gains a declared rendering for several measures with a '
40+
+ 'dimension leaves the single-series set — while leaving the shape accepted costs an author a '
41+
+ 'widget that silently drops what they declared. The check export is renamed in the same change '
42+
+ 'because its old name said a widget with a dimension was outside it, which stopped being true; '
43+
+ 'no first-party consumer chained it under that name.',
44+
acceptanceCriteria:
45+
'⚠️ WHICH DOOR: the refusal is the spec\'s, attached at the same point as the dimensionless rule, '
46+
+ 'so it reaches every door that parses the spec schema — measured on `defineStack`, which throws '
47+
+ 'naming the widget, and on the stack schema, the dashboard schema and the `dashboard` '
48+
+ 'metadata-type schema, each refusing at `widgets[N].values`. It is NOT refused by objectui\'s '
49+
+ 'client-side authoring door until that door chains the export: `@object-ui/types` builds its '
50+
+ '`DashboardWidgetSchema` from a `.shape` spread of the spec\'s, which carries the FIELDS and '
51+
+ 'drops every object-level check, so its editor keeps accepting a dimensioned two-measure `pie` '
52+
+ 'and the author meets the refusal at publish. ⇒ Do not read a green editor as a clean '
53+
+ 'dashboard; re-parse through the spec. '
54+
+ '⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose emitted once '
55+
+ 'per hop, with no per-document interpolation and no filtering by whether the stack carries the '
56+
+ 'shape, so `os migrate meta` prints THIS paragraph, not a list of your widgets. The refusal is '
57+
+ 'what names them, per widget, on the re-parse — drive the fix off `os validate`, not off the '
58+
+ 'migrate output. '
59+
+ 'WHAT IS REFUSED, exactly: ONE `custom` issue at `widgets[N].values`, naming the widget\'s '
60+
+ '`id`, the number of measures and the authored `type`, and saying the type draws one series '
61+
+ 'whatever its `dimensions`, when `dimensions` declares at least one dimension, `values` carries '
62+
+ 'two or more measures, and `type` is `pie`, `donut`, `funnel`, `treemap` or `sankey`. The check '
63+
+ 'is the exported `checkDashboardWidgetChartMeasureArity` — the dimensionless rule\'s own check, '
64+
+ 'with a second arm; the single-series set it reads is not exported, and the refusal prints it. '
65+
+ 'WHAT IS NOT, so this is not read as complete: `scatter` and `radar` WITH a dimension keep '
66+
+ 'accepting several measures exactly as before (the ruling named the five: `radar` draws every '
67+
+ 'series, and `scatter` says on the chart that it draws one); every type in '
68+
+ '`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` keeps accepting any number of measures with a dimension; '
69+
+ 'one measure parses on every type; a DIMENSIONLESS widget of the five keeps the dimensionless '
70+
+ 'refusal, word for word and still ONE issue; the metric family (`metric` / `kpi` / `gauge` / '
71+
+ '`solid-gauge` / `bullet`, and a widget that declares no `type`, which resolves to `metric`) '
72+
+ 'keeps its OWN refusal, unchanged; an EMPTY `values` keeps the field\'s own `too_small`; a '
73+
+ '`type` outside `ChartTypeSchema` reports the TYPE refusal alone, and called directly on a '
74+
+ 'wider type enum the export judges only the types the spec declares; and whether each measure '
75+
+ 'EXISTS in the bound dataset is still unreachable from this schema. '
76+
+ 'VERIFY by re-parsing each dashboard: a dashboard that had one two-measure `pie` by stage should '
77+
+ 'end with a `table` or bar-family widget carrying both measures, or with two widgets of one '
78+
+ 'measure each — check the rendered grid afterwards, because the second measure is a number the '
79+
+ 'dashboard was ALREADY paying to compute and had never shown.',
80+
};

0 commit comments

Comments
 (0)