diff --git a/.changeset/17385-dashboard-chart-config-structure-refused.md b/.changeset/17385-dashboard-chart-config-structure-refused.md new file mode 100644 index 00000000000..01be6f5d454 --- /dev/null +++ b/.changeset/17385-dashboard-chart-config-structure-refused.md @@ -0,0 +1,48 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +**BREAKING** — a dataset-bound dashboard widget's `chartConfig` carries appearance only: `type`, `xAxis`, `yAxis` and `series` are refused by name, each refusal naming the dataset selection the intent belongs in. + +Clause-②: yes (narrowing) + +`DashboardWidgetSchema.dataset` is REQUIRED, so **every** dashboard widget is dataset-bound, and ADR-0021 already made the dataset the owner of the chart's structure: it decides which series exist and which column each one reads. `chartConfig` nonetheless declared `type` / `xAxis` / `yAxis` / `series`, and the two answers met with no rule between them. That was not merely inert. An authored `yAxis[].field` was a live MEMBERSHIP channel — the renderer synthesised a series from the authored axes when the chart declared none — so one authored axis could silently re-point a dataset-bound series at a different column while the chart still drew, which reads as a true statement about the data. Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」, on options C+D together: state the ownership split in the protocol AND refuse the four keys by name. + +## FROM → TO + +| you wrote inside `chartConfig` (17.4 and earlier) | write instead | +| --- | --- | +| `type: 'line'` | `type: 'line'` on the WIDGET, beside `dataset` — the widget's own `type` is the chart family and it always won; nothing on this face ever read the chart config's | +| `xAxis: { field: 'stage' }` | `dimensions: ['stage']` on the widget — the dataset dimension the category axis plots | +| `yAxis: [{ field: 'amount' }]` | `values: ['amount']` on the widget — the dataset measures, one entry per mark. A second axis is a second measure, not a second axis declaration | +| `series: [{ name: 'amount' }]` | `values` (plus a second `dimensions` entry to split) — series membership follows the selection; an entry naming a measure outside it was already being ignored | + +**The one-line fix:** delete the four keys; the widget's `type` and its `dimensions` / `values` are the chart's structure. + +`os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + +## What is NOT retired + +The keys stay authorable on `ChartConfigSchema` itself, and that is the half a blanket refusal would have broken. A react-tier `` binds inline rows with no dataset behind them, so its axes are the author's and are unchanged — `react-blocks.ts` still publishes all four in that block's `dataProps`. `ReportChartSchema` keeps its own `xAxis` / `yAxis`, narrowed to its bound dataset's dimension and measure names. The refusal lives on a new per-carrier `DashboardWidgetChartConfigSchema` (`ChartConfigSchema.extend(…)`, the `ReportChartSchema` spelling) precisely so it cannot reach those two. + +## What this costs, stated rather than discovered + +`xAxis` / `yAxis` / `series` carried presentation alongside the binding — axis titles, number formats, bounds, grid lines, log scale, and per-series labels, colours, stacking and mark types. Refusing the keys takes the presentation with the binding: a dataset-bound chart takes those from the dataset's own dimension and measure declarations, and `colors` on the chart config remains the palette channel. **The combo chart a dataset-bound widget could author through `series[].type` has no authoring channel on this face any more.** That capability loss is ruled, not incidental — the option that kept it was on the table and was not taken. + +## Accept-set movement, both directions + +Narrowing, on a dataset-bound widget: the four keys move from accepted to refused. **And one widening, which is forced by the ruling rather than chosen:** `ChartConfigSchema.type` is REQUIRED, so before this change a `chartConfig` without a `type` was refused as incomplete. Refusing `type` while keeping the bag authorable for appearance — which ruling item 1 requires in as many words — means absence must now be legal. So `chartConfig: { title: 'Revenue' }` on a dashboard widget moves from refused to accepted. That is why the declaration reads `yes (narrowing)` rather than `no`. + +## The retirement kit + +- **Four `retiredKey()` tombstones on the widget carrier**, registered as `ui/DashboardWidgetChartConfig:type` / `:xAxis` / `:yAxis` / `:series` under protocol 18. `tsc` types each key `never`, so every authoring site in a consumer's tree fails to compile before anything runs, and a value that reaches a parse raises the prescription rather than a bare unrecognized-key report. +- **The ADR-0087 pair.** The D2 conversion `dashboard-widget-chart-config-structure-removed` strips the four keys from stored dashboard widgets (dashboards only — reports and the react tier keep theirs); the D3 semantic entry `dashboard-widget-chart-config-structure-refused` carries the judgement, because moving what the keys MEANT into the dataset selection needs facts the widget does not hold — an authored axis field can name a dataset dimension the widget never selected. +- **The liveness rows stay and are regraded `dead`**, the `retiredKey` route's discipline: the tombstone keeps the key in the walked shape, so the row remains and records why. Three of them were graded `live` on their presentation half on 2026-09-12 and that measurement is recorded as overridden, not withdrawn. +- **`chart-config-missing` is withdrawn from `@objectstack/lint`.** It advised a `combo` widget with no `chartConfig` to declare `chartConfig: { series: [{ name, type }] }` — metadata the schema now refuses — and after the ruling there is nothing a `combo` author can do about the finding. The rule ID stays exported, so an existing `suppressWarnings: ['chart-config-missing']` entry keeps parsing. `chart-field-unknown` still fires on a legacy document and its hints now say delete-and-migrate instead of describing what the keys used to carry. + +## What an operator with a STORED dashboard sees + +A `sys_metadata` `dashboard` row written before this release can carry any of the four. Nothing breaks at read: the conversion replays on rehydration and strips them, so the row is served canonical, and `os migrate meta --stored --apply` rewrites the rows. ⚠️ The strip is the mechanical half only. A widget whose authored axes AGREED with its selection renders identically afterwards — that is the expected case. A widget that renders differently was relying on the membership channel this removes, which is the case the ruling was made about. + + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 3913e991867..a7a6966df63 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1532 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1533 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 273 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 157 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **195** | **1532** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 158 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **195** | **1533** | 14 protocol modules | --- @@ -361,7 +361,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 157 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 158 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -373,7 +373,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`bulk-action.zod.ts`](/docs/references/ui/bulk-action) | `BulkActionDef`, `BulkActionExecution`, `BulkActionOperation`, `BulkActionParam` | | [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` | | [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTimelineProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` | -| [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` | +| [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetChartConfig`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` | | [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` | | [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` | | [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel`, `InlineLocaleMap` | diff --git a/content/docs/references/ui/chart.mdx b/content/docs/references/ui/chart.mdx index 5b64f81aec6..47bc7c9a707 100644 --- a/content/docs/references/ui/chart.mdx +++ b/content/docs/references/ui/chart.mdx @@ -108,9 +108,9 @@ Inline aggregation for an object-bound chart | **title** | `string \| Record` | optional | Chart title | | **subtitle** | `string \| Record` | optional | Chart subtitle | | **description** | `string \| Record` | optional | Accessibility description — announced to screen readers as the chart’s label | -| **xAxis** | `{ field: string; title?: string \| Record; format?: string; min?: number; … }` | optional | X-Axis configuration | -| **yAxis** | `{ field: string; title?: string \| Record; format?: string; min?: number; … }[]` | optional | Y-Axis configuration (support dual axis) | -| **series** | `{ name: string; label?: string \| Record; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]` | optional | Defined series configuration | +| **xAxis** | `{ field: string; title?: string \| Record; format?: string; min?: number; … }` | optional | X-Axis configuration. Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the dataset decides it (ADR-0021). | +| **yAxis** | `{ field: string; title?: string \| Record; format?: string; min?: number; … }[]` | optional | Y-Axis configuration (support dual axis). Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the dataset decides it (ADR-0021). | +| **series** | `{ name: string; label?: string \| Record; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]` | optional | Defined series configuration. Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the dataset decides it (ADR-0021). | | **colors** | `string[] \| Record` | optional | Color palette (string[]) or value→color map (`{ value: color }`) | | **height** | `number` | optional | Fixed plot height in pixels (overrides the container default) | | **showLegend** | `boolean` | optional (default: `true`) | Display legend | diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 46a0757c6c7..a6e37627a4c 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -12,8 +12,8 @@ description: Dashboard protocol schemas ## TypeScript Usage ```typescript -import { DashboardSchema, DashboardHeaderSchema, DashboardHeaderActionSchema, DashboardWidgetSchema, DashboardWidgetOptionsSchema, GlobalFilterSchema, GlobalFilterOptionsFromSchema, WidgetActionTypeSchema, WidgetColorVariantSchema } from '@objectstack/spec/ui'; -import type { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, DashboardWidgetOptions, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui'; +import { DashboardSchema, DashboardHeaderSchema, DashboardHeaderActionSchema, DashboardWidgetSchema, DashboardWidgetChartConfigSchema, DashboardWidgetOptionsSchema, GlobalFilterSchema, GlobalFilterOptionsFromSchema, WidgetActionTypeSchema, WidgetColorVariantSchema } from '@objectstack/spec/ui'; +import type { Dashboard, DashboardHeader, DashboardHeaderAction, DashboardWidget, DashboardWidgetChartConfig, DashboardWidgetOptions, GlobalFilter, GlobalFilterOptionsFrom, WidgetActionType, WidgetColorVariant } from '@objectstack/spec/ui'; // Validate data const result = DashboardSchema.parse(data); @@ -65,7 +65,7 @@ const result = DashboardSchema.parse(data); | **title** | `string \| Record` | optional | Widget title | | **description** | `string \| Record` | optional | Widget description text below the header | | **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>` | optional (default: `"metric"`) | Visualization type | -| **chartConfig** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record; subtitle?: string \| Record; description?: string \| Record; … }` | optional | Chart visualization configuration | +| **chartConfig** | `{ title?: string \| Record; subtitle?: string \| Record; description?: string \| Record; colors?: string[] \| Record; … }` | optional | Chart appearance: title, subtitle, description, colors, height, legend, data labels, annotations, interaction. The bound dataset decides which series exist and which column each one reads (ADR-0021), so `type` (the widget's own `type` wins), `xAxis`, `yAxis` and `series` are refused here — select `dimensions` / `values` instead. | | **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 | @@ -170,7 +170,7 @@ Dashboard header action | **title** | `string \| Record` | optional | Widget title | | **description** | `string \| Record` | optional | Widget description text below the header | | **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'combo' \| 'gauge' \| 'solid-gauge' \| … +6 more>` | optional (default: `"metric"`) | Visualization type | -| **chartConfig** | `{ type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; title?: string \| Record; subtitle?: string \| Record; description?: string \| Record; … }` | optional | Chart visualization configuration | +| **chartConfig** | `{ title?: string \| Record; subtitle?: string \| Record; description?: string \| Record; colors?: string[] \| Record; … }` | optional | Chart appearance: title, subtitle, description, colors, height, legend, data labels, annotations, interaction. The bound dataset decides which series exist and which column each one reads (ADR-0021), so `type` (the widget's own `type` wins), `xAxis`, `yAxis` and `series` are refused here — select `dimensions` / `values` instead. | | **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 | @@ -216,13 +216,13 @@ Dashboard header action | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>` | ✅ | | +| **type** | `never` | optional | [REMOVED] `dashboard.widgets[].chartConfig.type` was removed in @objectstack/spec 17.5.0 (ADR-0021 · ADR-0049 D2) — on a dataset-bound widget the dataset already decides nothing about the chart family — the WIDGET's own `type` does, and it always won: the dashboard renderer maps the widget type to the chart family and never reads this key, so the authored value could only agree with the dataset selection or silently disagree with it. Delete the key. Write the family on the widget instead: `type: 'line'` beside `dataset`, not inside `chartConfig`. The key is NOT gone from the chart config itself: it stays authorable on the react `` tier, where the chart is bound to inline `data` and there is no dataset to derive it from. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **title** | `string \| Record` | optional | Chart title | | **subtitle** | `string \| Record` | optional | Chart subtitle | | **description** | `string \| Record` | optional | Accessibility description — announced to screen readers as the chart’s label | -| **xAxis** | `{ field: string; title?: string \| Record; format?: string; min?: number; … }` | optional | X-Axis configuration | -| **yAxis** | `{ field: string; title?: string \| Record; format?: string; min?: number; … }[]` | optional | Y-Axis configuration (support dual axis) | -| **series** | `{ name: string; label?: string \| Record; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]` | optional | Defined series configuration | +| **xAxis** | `never` | optional | [REMOVED] `dashboard.widgets[].chartConfig.xAxis` was removed in @objectstack/spec 17.5.0 (ADR-0021 · ADR-0049 D2) — on a dataset-bound widget the dataset already decides which column the category axis plots — it is the dataset DIMENSION the widget selects, so the authored value could only agree with the dataset selection or silently disagree with it. Delete the key. Select the dimension instead: `dimensions: ['stage']` on the widget. Axis APPEARANCE (title, number format, min/max, grid lines, log scale) has no home on a dataset-bound widget — the dataset's dimension declaration is what labels and formats the axis. The key is NOT gone from the chart config itself: it stays authorable on the react `` tier, where the chart is bound to inline `data` and there is no dataset to derive it from. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **yAxis** | `never` | optional | [REMOVED] `dashboard.widgets[].chartConfig.yAxis` was removed in @objectstack/spec 17.5.0 (ADR-0021 · ADR-0049 D2) — on a dataset-bound widget the dataset already decides which columns the value axis plots — they are the dataset MEASURES the widget selects, so the authored value could only agree with the dataset selection or silently disagree with it. Delete the key. Select the measures instead: `values: ['amount']` on the widget, one entry per mark. A second axis is a second measure, not a second axis declaration. The key is NOT gone from the chart config itself: it stays authorable on the react `` tier, where the chart is bound to inline `data` and there is no dataset to derive it from. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **series** | `never` | optional | [REMOVED] `dashboard.widgets[].chartConfig.series` was removed in @objectstack/spec 17.5.0 (ADR-0021 · ADR-0049 D2) — on a dataset-bound widget the dataset already decides which series exist and which column each one reads — series membership follows from the measures in `values` and the split in `dimensions`, so the authored value could only agree with the dataset selection or silently disagree with it. Delete the key. Select the measures and the split instead: `values` declares one series per measure and a second `dimensions` entry splits them. Per-series appearance (label, colour, mark type, stacking) is not authorable on a dataset-bound widget; `colors` on this same chart config is the palette channel that remains. The key is NOT gone from the chart config itself: it stays authorable on the react `` tier, where the chart is bound to inline `data` and there is no dataset to derive it from. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **colors** | `string[] \| Record` | optional | Color palette (string[]) or value→color map (`{ value: color }`) | | **height** | `number` | optional | Fixed plot height in pixels (overrides the container default) | | **showLegend** | `boolean` | optional (default: `true`) | Display legend | @@ -249,6 +249,51 @@ Dashboard header action | **stageOrder** | `(string \| number \| boolean)[]` | optional | Explicit stage order for a funnel widget, as the dimension's stored values. `funnel` is the only widget type that reads it, and the schema refuses it on any other type rather than accepting an order nothing consults — order those with sortBy/sortOrder instead. There is no `pyramid` widget type — write `funnel`. | +--- + +## DashboardWidgetChartConfig + +Chart APPEARANCE for a dataset-bound widget (ADR-0021): title, subtitle, description, colors, height, legend, data labels, annotations and interaction. The dataset decides which series exist and which column each one reads, so `type`, `xAxis`, `yAxis` and `series` are refused here — write them as the widget's own `type` and its `dimensions` / `values` selection. + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `never` | optional | [REMOVED] `dashboard.widgets[].chartConfig.type` was removed in @objectstack/spec 17.5.0 (ADR-0021 · ADR-0049 D2) — on a dataset-bound widget the dataset already decides nothing about the chart family — the WIDGET's own `type` does, and it always won: the dashboard renderer maps the widget type to the chart family and never reads this key, so the authored value could only agree with the dataset selection or silently disagree with it. Delete the key. Write the family on the widget instead: `type: 'line'` beside `dataset`, not inside `chartConfig`. The key is NOT gone from the chart config itself: it stays authorable on the react `` tier, where the chart is bound to inline `data` and there is no dataset to derive it from. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **title** | `string \| Record` | optional | Chart title | +| **subtitle** | `string \| Record` | optional | Chart subtitle | +| **description** | `string \| Record` | optional | Accessibility description — announced to screen readers as the chart’s label | +| **xAxis** | `never` | optional | [REMOVED] `dashboard.widgets[].chartConfig.xAxis` was removed in @objectstack/spec 17.5.0 (ADR-0021 · ADR-0049 D2) — on a dataset-bound widget the dataset already decides which column the category axis plots — it is the dataset DIMENSION the widget selects, so the authored value could only agree with the dataset selection or silently disagree with it. Delete the key. Select the dimension instead: `dimensions: ['stage']` on the widget. Axis APPEARANCE (title, number format, min/max, grid lines, log scale) has no home on a dataset-bound widget — the dataset's dimension declaration is what labels and formats the axis. The key is NOT gone from the chart config itself: it stays authorable on the react `` tier, where the chart is bound to inline `data` and there is no dataset to derive it from. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **yAxis** | `never` | optional | [REMOVED] `dashboard.widgets[].chartConfig.yAxis` was removed in @objectstack/spec 17.5.0 (ADR-0021 · ADR-0049 D2) — on a dataset-bound widget the dataset already decides which columns the value axis plots — they are the dataset MEASURES the widget selects, so the authored value could only agree with the dataset selection or silently disagree with it. Delete the key. Select the measures instead: `values: ['amount']` on the widget, one entry per mark. A second axis is a second measure, not a second axis declaration. The key is NOT gone from the chart config itself: it stays authorable on the react `` tier, where the chart is bound to inline `data` and there is no dataset to derive it from. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **series** | `never` | optional | [REMOVED] `dashboard.widgets[].chartConfig.series` was removed in @objectstack/spec 17.5.0 (ADR-0021 · ADR-0049 D2) — on a dataset-bound widget the dataset already decides which series exist and which column each one reads — series membership follows from the measures in `values` and the split in `dimensions`, so the authored value could only agree with the dataset selection or silently disagree with it. Delete the key. Select the measures and the split instead: `values` declares one series per measure and a second `dimensions` entry splits them. Per-series appearance (label, colour, mark type, stacking) is not authorable on a dataset-bound widget; `colors` on this same chart config is the palette channel that remains. The key is NOT gone from the chart config itself: it stays authorable on the react `` tier, where the chart is bound to inline `data` and there is no dataset to derive it from. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **colors** | `string[] \| Record` | optional | Color palette (string[]) or value→color map (`{ value: color }`) | +| **height** | `number` | optional | Fixed plot height in pixels (overrides the container default) | +| **showLegend** | `boolean` | optional (default: `true`) | Display legend | +| **showDataLabels** | `boolean` | optional (default: `false`) | Display data labels | +| **annotations** | `{ type: Enum<'line' \| 'region'>; axis: Enum<'x' \| 'y'>; value: number \| string; endValue?: number \| string; … }[]` | optional | Reference lines/bands drawn over the plot: `{ type: "line" \| "region", axis: "x" \| "y", value, endValue?, color?, label?, style? }` | +| **interaction** | `{ tooltips: boolean; brush: boolean }` | optional | Interaction toggles: `{ tooltips?, brush? }` | +| **aria** | `never` | optional | [REMOVED] `ChartConfig.aria` — authored as `dashboard.widgets[].chartConfig.aria`, `report.chart.aria` and `report.blocks[].chart.aria` — was removed in @objectstack/spec 17 (ADR-0049 D2). No chart renderer ever applied it: the chart implementation declares no `aria` prop, the presentation lowering names it nowhere, and the react `` block never published it, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied on this same chart config is its sibling `description`, which the chart renderer lowers onto the chart graphic as `role="img"` plus `aria-label`. The shared `AriaProps` shape is NOT gone — `ariaLabel` / `ariaDescribedBy` / `role` stay live in the `aria` block on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | + +### Nested Shape: `DashboardWidgetChartConfig.annotations[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `Enum<'line' \| 'region'>` | optional (default: `"line"`) | | +| **axis** | `Enum<'x' \| 'y'>` | optional (default: `"y"`) | | +| **value** | `number \| string` | ✅ | Start value | +| **endValue** | `number \| string` | optional | End value for regions | +| **color** | `string` | optional | | +| **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | +| **style** | `Enum<'solid' \| 'dashed' \| 'dotted'>` | optional (default: `"dashed"`) | | + +### Nested Shape: `DashboardWidgetChartConfig.interaction` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **tooltips** | `boolean` | optional (default: `true`) | Show the hover tooltip | +| **brush** | `boolean` | optional (default: `false`) | Show the range selector under the plot | + + --- ## DashboardWidgetOptions diff --git a/content/docs/references/ui/report.mdx b/content/docs/references/ui/report.mdx index dc640ab3f7b..64f50f9363b 100644 --- a/content/docs/references/ui/report.mdx +++ b/content/docs/references/ui/report.mdx @@ -49,7 +49,7 @@ const result = JoinedReportBlockSchema.parse(data); | **description** | `string \| Record` | optional | Accessibility description — announced to screen readers as the chart’s label | | **xAxis** | `string` | ✅ | Dataset dimension name for the X-axis (bound-dataset dimension, not a raw field) | | **yAxis** | `string` | ✅ | Dataset measure name for the Y-axis (bound-dataset measure, not a raw field) | -| **series** | `{ name: string; label?: string \| Record; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]` | optional | Defined series configuration | +| **series** | `{ name: string; label?: string \| Record; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]` | optional | Defined series configuration. Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the dataset decides it (ADR-0021). | | **colors** | `string[] \| Record` | optional | Color palette (string[]) or value→color map (`{ value: color }`) | | **height** | `number` | optional | Fixed plot height in pixels (overrides the container default) | | **showLegend** | `boolean` | optional (default: `true`) | Display legend | @@ -113,7 +113,7 @@ const result = JoinedReportBlockSchema.parse(data); | **description** | `string \| Record` | optional | Accessibility description — announced to screen readers as the chart’s label | | **xAxis** | `string` | ✅ | Dataset dimension name for the X-axis (bound-dataset dimension, not a raw field) | | **yAxis** | `string` | ✅ | Dataset measure name for the Y-axis (bound-dataset measure, not a raw field) | -| **series** | `{ name: string; label?: string \| Record; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]` | optional | Defined series configuration | +| **series** | `{ name: string; label?: string \| Record; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]` | optional | Defined series configuration. Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the dataset decides it (ADR-0021). | | **colors** | `string[] \| Record` | optional | Color palette (string[]) or value→color map (`{ value: color }`) | | **height** | `number` | optional | Fixed plot height in pixels (overrides the container default) | | **showLegend** | `boolean` | optional (default: `true`) | Display legend | @@ -161,7 +161,7 @@ const result = JoinedReportBlockSchema.parse(data); | **description** | `string \| Record` | optional | Accessibility description — announced to screen readers as the chart’s label | | **xAxis** | `string` | ✅ | Dataset dimension name for the X-axis (bound-dataset dimension, not a raw field) | | **yAxis** | `string` | ✅ | Dataset measure name for the Y-axis (bound-dataset measure, not a raw field) | -| **series** | `{ name: string; label?: string \| Record; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]` | optional | Defined series configuration | +| **series** | `{ name: string; label?: string \| Record; type?: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; color?: string; … }[]` | optional | Defined series configuration. Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the dataset decides it (ADR-0021). | | **colors** | `string[] \| Record` | optional | Color palette (string[]) or value→color map (`{ value: color }`) | | **height** | `number` | optional | Fixed plot height in pixels (overrides the container default) | | **showLegend** | `boolean` | optional (default: `true`) | Display legend | diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index df2cd1845b1..b821d4a1568 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -100,11 +100,38 @@ selects `dimensions` (X / group / split) and `values` (the measures to plot): | `description` | `string` | optional | Text shown below the title | | `filter` | `FilterCondition` | optional | Presentation-scope filter (`runtimeFilter`) | | `layout` | `object` | optional | Grid position and size (auto-flowed into the grid when omitted) | -| `chartConfig` | `object` | optional | Advanced chart configuration | +| `chartConfig` | `object` | optional | Chart **appearance**: `title`, `subtitle`, `description`, `colors`, `height`, `showLegend`, `showDataLabels`, `annotations`, `interaction`. The bound dataset decides which series exist and which column each one reads, so `type`, `xAxis`, `yAxis` and `series` are refused here — see below | | `colorVariant` | `enum` | optional | KPI/card accent color | | `compareTo` | `object` | optional | Period-over-period comparison: `{ kind: 'previousPeriod' \| 'previousYear', dimension? }`. Omit `dimension` when the selection dates exactly one time dimension — the runtime resolves it, and errors naming the candidates rather than guessing when it cannot. | | `options` | `object` | optional | Renderer extras **plus** the query keys below | +### What `chartConfig` does **not** carry + +A dashboard widget is always bound to a dataset (`dataset` is required), and the +dataset owns the chart's **structure**: which series exist, and which column +each one reads. `chartConfig` carries **appearance**. So four keys that the +shared chart config declares are refused on a dashboard widget by name, each +refusal naming where the intent belongs: + +| you wrote inside `chartConfig` | write this on the widget instead | +| :--- | :--- | +| `type: 'line'` | `type: 'line'` — the widget's own `type` is the chart family, and it always won | +| `xAxis: { field: 'stage' }` | `dimensions: ['stage']` — the dataset dimension the category axis plots | +| `yAxis: [{ field: 'amount' }]` | `values: ['amount']` — the dataset measures, one entry per mark | +| `series: [{ name: 'amount' }]` | `values` (+ a second `dimensions` entry to split) — series membership follows the selection | + +Axis titles, number formats, axis bounds and per-series labels, colours and mark +types went with those keys: a dataset-bound chart takes them from the dataset's +own dimension and measure declarations. `colors` on the chart config is the +palette channel that remains. + +The keys are **not** gone from the chart config everywhere. A react-tier +`` binds inline rows with no dataset behind them, so its +axes are the author's and are unchanged; a report chart names its bound +dataset's dimension and measure directly in `xAxis` / `yAxis`. + +Existing sources: `os migrate meta --from 17` lists the mechanical edits. + ### Widget `options` `options` is an open bag: keys the renderer understands (`icon`, `columns`, diff --git a/examples/app-crm/src/dashboards/pipeline.dashboard.ts b/examples/app-crm/src/dashboards/pipeline.dashboard.ts index 251de4c60ac..8dcc8f25084 100644 --- a/examples/app-crm/src/dashboards/pipeline.dashboard.ts +++ b/examples/app-crm/src/dashboards/pipeline.dashboard.ts @@ -98,13 +98,6 @@ export const PipelineDashboard: Dashboard = { values: ['opp_count'], // Axis fields name the dataset's dimension/measure (NOT base columns) — // post-cutover query rows are keyed by measure name (issue #1721). - chartConfig: { - type: 'line', - xAxis: { field: 'close_date', showGridLines: true, logarithmic: false }, - yAxis: [{ field: 'opp_count', showGridLines: true, logarithmic: false }], - showLegend: true, - showDataLabels: false, - }, layout: { x: 0, y: 2, w: 8, h: 4 }, }, { @@ -122,13 +115,6 @@ export const PipelineDashboard: Dashboard = { dataset: 'opportunity_metrics', dimensions: ['stage'], values: ['opp_count'], - chartConfig: { - type: 'bar', - xAxis: { field: 'stage', showGridLines: true, logarithmic: false }, - yAxis: [{ field: 'opp_count', showGridLines: true, logarithmic: false }], - showLegend: true, - showDataLabels: false, - }, layout: { x: 8, y: 2, w: 4, h: 4 }, }, @@ -142,13 +128,6 @@ export const PipelineDashboard: Dashboard = { dataset: 'opportunity_metrics', dimensions: ['stage'], values: ['total_amount'], - chartConfig: { - type: 'pie', - xAxis: { field: 'stage', showGridLines: true, logarithmic: false }, - yAxis: [{ field: 'total_amount', showGridLines: true, logarithmic: false }], - showLegend: true, - showDataLabels: false, - }, layout: { x: 0, y: 6, w: 6, h: 4 }, }, ], diff --git a/examples/app-showcase/src/ui/dashboards/chart-gallery.dashboard.ts b/examples/app-showcase/src/ui/dashboards/chart-gallery.dashboard.ts index 4ccef2d326c..a3b7083f7c4 100644 --- a/examples/app-showcase/src/ui/dashboards/chart-gallery.dashboard.ts +++ b/examples/app-showcase/src/ui/dashboards/chart-gallery.dashboard.ts @@ -1,19 +1,21 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { ChartConfig, ChartType, Dashboard } from '@objectstack/spec/ui'; +import type { Dashboard } from '@objectstack/spec/ui'; const taskDs = 'showcase_task_metrics'; const projectDs = 'showcase_project_metrics'; -/** Axis fields name the dataset's dimension/measure — query rows are keyed by - * measure NAME post-cutover, never the base column (issue #1721). */ -const cfg = (type: ChartType, dimension: string, measure: string): ChartConfig => ({ - type, - xAxis: { field: dimension, showGridLines: true, logarithmic: false }, - yAxis: [{ field: measure, showGridLines: true, logarithmic: false }], - showLegend: true, - showDataLabels: false, -}); +/** + * The `cfg()` helper this file used to carry is gone with the keys it wrote. + * A dataset-bound widget's `chartConfig` is APPEARANCE only (ADR-0021; + * maintainer ruling 2026-09-12): the dataset decides which series exist and + * which column each one reads, so the axis and series bindings the helper + * emitted are now the widget's own `dimensions` / `values` — which every + * widget below already declares, which is exactly why the helper was writing + * the same two names twice. `showLegend: true` / `showDataLabels: false` were + * the schema defaults, so nothing is lost by dropping the bag entirely; the + * widgets that demonstrate real appearance keep one. + */ /** * Chart Gallery — one widget per chart family the dashboard renderer can draw @@ -44,13 +46,13 @@ export const ChartGalleryDashboard: Dashboard = { { id: 'kpi_total_spent', type: 'metric', title: 'Total Spent', dataset: projectDs, values: ['spent_sum'], layout: { x: 8, y: 0, w: 4, h: 2 } }, // ── Comparison ───────────────────────────────────────────────────────── - { id: 'bar_by_status', type: 'bar', title: 'Tasks by Status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], chartConfig: cfg('bar', 'status', 'task_count'), layout: { x: 0, y: 2, w: 4, h: 4 } }, - { id: 'column_by_priority', type: 'column', title: 'Tasks by Priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], chartConfig: cfg('column', 'priority', 'task_count'), layout: { x: 4, y: 2, w: 4, h: 4 } }, - { id: 'hbar_hours', type: 'horizontal-bar', title: 'Hours by Status', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], chartConfig: cfg('horizontal-bar', 'status', 'est_hours'), layout: { x: 8, y: 2, w: 4, h: 4 } }, + { id: 'bar_by_status', type: 'bar', title: 'Tasks by Status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 2, w: 4, h: 4 } }, + { id: 'column_by_priority', type: 'column', title: 'Tasks by Priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 4, y: 2, w: 4, h: 4 } }, + { id: 'hbar_hours', type: 'horizontal-bar', title: 'Hours by Status', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], layout: { x: 8, y: 2, w: 4, h: 4 } }, // ── Trend (month-bucketed via the dataset's created_at granularity) ────── - { id: 'line_created', type: 'line', title: 'Tasks Created (monthly)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], chartConfig: cfg('line', 'created_at', 'task_count'), layout: { x: 0, y: 6, w: 6, h: 4 } }, - { id: 'area_created', type: 'area', title: 'Tasks Created (area)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], chartConfig: cfg('area', 'created_at', 'task_count'), layout: { x: 6, y: 6, w: 6, h: 4 } }, + { id: 'line_created', type: 'line', title: 'Tasks Created (monthly)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 0, y: 6, w: 6, h: 4 } }, + { id: 'area_created', type: 'area', title: 'Tasks Created (area)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 6, y: 6, w: 6, h: 4 } }, // ── Mixed — the family `series[].type` and `series[].yAxis` exist for: // a count as bars on the left axis, a rate as a line on the right. ── @@ -61,35 +63,30 @@ export const ChartGalleryDashboard: Dashboard = { dataset: taskDs, dimensions: ['created_at'], values: ['task_count', 'avg_progress'], - chartConfig: { - type: 'combo', - xAxis: { field: 'created_at', showGridLines: true, logarithmic: false }, - yAxis: [ - { field: 'task_count', showGridLines: true, logarithmic: false }, - { field: 'avg_progress', showGridLines: false, logarithmic: false }, - ], - series: [ - { name: 'task_count', label: 'Tasks', type: 'bar', yAxis: 'left' }, - { name: 'avg_progress', label: 'Avg Progress', type: 'line', yAxis: 'right' }, - ], - showLegend: true, - showDataLabels: false, - }, + // ⚠️ The per-series mark type this widget used to author + // (`series: [{ type: 'bar' }, { type: 'line' }]`) is gone with the + // structure keys: on a dataset-bound widget the dataset decides which + // series exist, and there is no authoring channel left for "this measure + // as bars, that one as a line". A ruled cost, not an oversight — see the + // D3 entry `dashboard-widget-chart-config-structure-refused`. The two + // measures still plot as a combo family; which mark each gets is the + // renderer's. + chartConfig: { title: 'Tasks and average progress, by month' }, layout: { x: 0, y: 10, w: 12, h: 4 }, }, // ── Distribution ───────────────────────────────────────────────────────── - { id: 'pie_status', type: 'pie', title: 'Status Split', dataset: taskDs, dimensions: ['status'], values: ['task_count'], chartConfig: cfg('pie', 'status', 'task_count'), layout: { x: 0, y: 14, w: 4, h: 4 } }, - { id: 'donut_priority', type: 'donut', title: 'Priority Split', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], chartConfig: cfg('donut', 'priority', 'task_count'), layout: { x: 4, y: 14, w: 4, h: 4 } }, - { id: 'funnel_status', type: 'funnel', title: 'Status Funnel', dataset: taskDs, dimensions: ['status'], values: ['task_count'], chartConfig: cfg('funnel', 'status', 'task_count'), layout: { x: 8, y: 14, w: 4, h: 4 } }, + { id: 'pie_status', type: 'pie', title: 'Status Split', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 14, w: 4, h: 4 } }, + { id: 'donut_priority', type: 'donut', title: 'Priority Split', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 4, y: 14, w: 4, h: 4 } }, + { id: 'funnel_status', type: 'funnel', title: 'Status Funnel', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 8, y: 14, w: 4, h: 4 } }, // ── Relationship + Advanced ────────────────────────────────────────────── - { id: 'scatter_estimate', type: 'scatter', title: 'Estimate vs Progress', dataset: taskDs, dimensions: ['progress'], values: ['avg_estimate'], chartConfig: cfg('scatter', 'progress', 'avg_estimate'), layout: { x: 0, y: 18, w: 6, h: 4 } }, - { id: 'radar_priority', type: 'radar', title: 'Priority Radar', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], chartConfig: cfg('radar', 'priority', 'task_count'), layout: { x: 6, y: 18, w: 6, h: 4 } }, + { id: 'scatter_estimate', type: 'scatter', title: 'Estimate vs Progress', dataset: taskDs, dimensions: ['progress'], values: ['avg_estimate'], layout: { x: 0, y: 18, w: 6, h: 4 } }, + { id: 'radar_priority', type: 'radar', title: 'Priority Radar', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 6, y: 18, w: 6, h: 4 } }, // ── Composition ────────────────────────────────────────────────────────── - { id: 'treemap_hours', type: 'treemap', title: 'Hours Treemap', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], chartConfig: cfg('treemap', 'status', 'est_hours'), layout: { x: 0, y: 22, w: 6, h: 4 } }, - { id: 'sankey_flow', type: 'sankey', title: 'Status Flow (Sankey)', dataset: taskDs, dimensions: ['status'], values: ['task_count'], chartConfig: cfg('sankey', 'status', 'task_count'), layout: { x: 6, y: 22, w: 6, h: 4 } }, + { id: 'treemap_hours', type: 'treemap', title: 'Hours Treemap', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], layout: { x: 0, y: 22, w: 6, h: 4 } }, + { id: 'sankey_flow', type: 'sankey', title: 'Status Flow (Sankey)', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 6, y: 22, w: 6, h: 4 } }, // ── Tabular (real grouped tables, multiple measures) ───────────────────── { id: 'table_projects', type: 'table', title: 'Projects by Account', dataset: projectDs, dimensions: ['account'], values: ['project_count', 'budget_sum', 'spent_sum'], layout: { x: 0, y: 26, w: 6, h: 4 } }, diff --git a/examples/app-showcase/src/ui/dashboards/ops-dashboard.dashboard.ts b/examples/app-showcase/src/ui/dashboards/ops-dashboard.dashboard.ts index ce9a1c14e0c..351eb55f2d4 100644 --- a/examples/app-showcase/src/ui/dashboards/ops-dashboard.dashboard.ts +++ b/examples/app-showcase/src/ui/dashboards/ops-dashboard.dashboard.ts @@ -1,17 +1,18 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { ChartConfig, ChartType, Dashboard } from '@objectstack/spec/ui'; +import type { Dashboard } from '@objectstack/spec/ui'; const taskDs = 'showcase_task_metrics'; const projectDs = 'showcase_project_metrics'; -const cfg = (type: ChartType, dimension: string, measure: string): ChartConfig => ({ - type, - xAxis: { field: dimension, showGridLines: true, logarithmic: false }, - yAxis: [{ field: measure, showGridLines: true, logarithmic: false }], - showLegend: true, - showDataLabels: false, -}); +/** + * A dataset-bound widget's `chartConfig` is APPEARANCE only (ADR-0021; + * maintainer ruling 2026-09-12): the dataset decides which series exist and + * which column each one reads. The `cfg()` helper this file carried wrote the + * axis bindings a second time — the widget's own `dimensions` / `values` + * already name them — and its other two keys were the schema defaults, so it + * goes rather than being trimmed to nothing. + */ /** * Delivery Operations — a *believable business* dashboard (vs the Chart Gallery, @@ -88,12 +89,12 @@ export const OpsDashboard: Dashboard = { { id: 'kpi_total_budget', type: 'metric', title: 'Total Budget', dataset: projectDs, values: ['budget_sum'], filterBindings: { task_status: false }, colorVariant: 'success', layout: { x: 9, y: 0, w: 3, h: 2 } }, // ── Health + throughput ────────────────────────────────────────────── - { id: 'col_health', type: 'column', title: 'Projects by Health', dataset: projectDs, dimensions: ['health'], values: ['project_count'], chartConfig: cfg('column', 'health', 'project_count'), filterBindings: { task_status: false }, layout: { x: 0, y: 2, w: 4, h: 4 } }, - { id: 'bar_status', type: 'bar', title: 'Tasks by Status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], chartConfig: cfg('bar', 'status', 'task_count'), layout: { x: 4, y: 2, w: 4, h: 4 } }, - { id: 'donut_priority', type: 'donut', title: 'Priority Mix', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], chartConfig: cfg('donut', 'priority', 'task_count'), layout: { x: 8, y: 2, w: 4, h: 4 } }, + { id: 'col_health', type: 'column', title: 'Projects by Health', dataset: projectDs, dimensions: ['health'], values: ['project_count'], filterBindings: { task_status: false }, layout: { x: 0, y: 2, w: 4, h: 4 } }, + { id: 'bar_status', type: 'bar', title: 'Tasks by Status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 4, y: 2, w: 4, h: 4 } }, + { id: 'donut_priority', type: 'donut', title: 'Priority Mix', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 8, y: 2, w: 4, h: 4 } }, // ── Trend + account spend ──────────────────────────────────────────── - { id: 'line_created', type: 'line', title: 'Task Throughput (monthly)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], chartConfig: cfg('line', 'created_at', 'task_count'), layout: { x: 0, y: 6, w: 6, h: 4 } }, + { id: 'line_created', type: 'line', title: 'Task Throughput (monthly)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 0, y: 6, w: 6, h: 4 } }, // The fifth project-bound widget — same opt-out. #7568's body names four // (the tiles a reader watches drop to 0); this table zeroed with them, // silently, because an empty table reads as "no data" rather than as a diff --git a/examples/app-showcase/src/ui/dashboards/revenue-pulse.dashboard.ts b/examples/app-showcase/src/ui/dashboards/revenue-pulse.dashboard.ts index c55b499365a..4bde1ff5018 100644 --- a/examples/app-showcase/src/ui/dashboards/revenue-pulse.dashboard.ts +++ b/examples/app-showcase/src/ui/dashboards/revenue-pulse.dashboard.ts @@ -1,17 +1,18 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import type { ChartConfig, ChartType, Dashboard } from '@objectstack/spec/ui'; +import type { Dashboard } from '@objectstack/spec/ui'; const invoiceDs = 'showcase_invoice_metrics'; const accountDs = 'showcase_account_metrics'; -const cfg = (type: ChartType, dimension: string, measure: string): ChartConfig => ({ - type, - xAxis: { field: dimension, showGridLines: true, logarithmic: false }, - yAxis: [{ field: measure, showGridLines: true, logarithmic: false }], - showLegend: true, - showDataLabels: false, -}); +/** + * A dataset-bound widget's `chartConfig` is APPEARANCE only (ADR-0021; + * maintainer ruling 2026-09-12): the dataset decides which series exist and + * which column each one reads. The `cfg()` helper this file carried wrote the + * axis bindings a second time — the widget's own `dimensions` / `values` + * already name them — and its other two keys were the schema defaults, so it + * goes rather than being trimmed to nothing. + */ /** * Revenue Pulse — the dashboard-level filters demo (framework#2501, @@ -60,10 +61,10 @@ export const RevenuePulseDashboard: Dashboard = { { id: 'kpi_accounts_alltime', type: 'metric', title: 'Accounts (all time)', dataset: accountDs, values: ['account_count'], colorVariant: 'purple', filterBindings: { dateRange: false, region: false }, layout: { x: 9, y: 0, w: 3, h: 2 } }, // ── Trends + distribution ──────────────────────────────────────────── - { id: 'line_invoices_by_month', type: 'line', title: 'Invoices by Month', dataset: invoiceDs, dimensions: ['issued_on'], values: ['invoice_count'], chartConfig: cfg('line', 'issued_on', 'invoice_count'), layout: { x: 0, y: 2, w: 6, h: 4 } }, - { id: 'col_accounts_by_month', type: 'column', title: 'Accounts Signed by Month', dataset: accountDs, dimensions: ['signed_on'], values: ['account_count'], chartConfig: cfg('column', 'signed_on', 'account_count'), filterBindings: { dateRange: 'signed_on', region: 'sales_region' }, layout: { x: 6, y: 2, w: 6, h: 4 } }, - { id: 'donut_invoices_by_status', type: 'donut', title: 'Invoices by Status', dataset: invoiceDs, dimensions: ['status'], values: ['invoice_count'], chartConfig: cfg('donut', 'status', 'invoice_count'), layout: { x: 0, y: 6, w: 6, h: 4 } }, - { id: 'bar_accounts_by_industry', type: 'bar', title: 'Accounts by Industry', dataset: accountDs, dimensions: ['industry'], values: ['account_count'], chartConfig: cfg('bar', 'industry', 'account_count'), filterBindings: { dateRange: 'signed_on', region: 'sales_region' }, layout: { x: 6, y: 6, w: 6, h: 4 } }, + { id: 'line_invoices_by_month', type: 'line', title: 'Invoices by Month', dataset: invoiceDs, dimensions: ['issued_on'], values: ['invoice_count'], layout: { x: 0, y: 2, w: 6, h: 4 } }, + { id: 'col_accounts_by_month', type: 'column', title: 'Accounts Signed by Month', dataset: accountDs, dimensions: ['signed_on'], values: ['account_count'], filterBindings: { dateRange: 'signed_on', region: 'sales_region' }, layout: { x: 6, y: 2, w: 6, h: 4 } }, + { id: 'donut_invoices_by_status', type: 'donut', title: 'Invoices by Status', dataset: invoiceDs, dimensions: ['status'], values: ['invoice_count'], layout: { x: 0, y: 6, w: 6, h: 4 } }, + { id: 'bar_accounts_by_industry', type: 'bar', title: 'Accounts by Industry', dataset: accountDs, dimensions: ['industry'], values: ['account_count'], filterBindings: { dateRange: 'signed_on', region: 'sales_region' }, layout: { x: 6, y: 6, w: 6, h: 4 } }, // ── Percent scale (objectui#3136) ──────────────────────────────────── // A ratio measure rendered on the two surfaces that disagreed: a KPI card diff --git a/examples/app-todo/src/dashboards/task.dashboard.ts b/examples/app-todo/src/dashboards/task.dashboard.ts index 78beba22e05..e631e8f2c4a 100644 --- a/examples/app-todo/src/dashboards/task.dashboard.ts +++ b/examples/app-todo/src/dashboards/task.dashboard.ts @@ -60,8 +60,12 @@ export const TaskDashboard: Dashboard = { }, // Row 2: Task Distribution - // chartConfig axis fields name the dataset's dimension/measure — query - // rows are keyed by measure NAME post-cutover (issue #1721). + // The widget's `dimensions` / `values` ARE the axis binding: on a + // dataset-bound widget the dataset decides which series exist and which + // column each one reads (ADR-0021), so `chartConfig.xAxis` / `.yAxis` / + // `.series` / `.type` are refused there and the `chartConfig` these + // widgets carried — which restated those two names and then the schema + // defaults — is gone. { id: 'tasks_by_status', title: 'Tasks by Status', @@ -70,7 +74,6 @@ export const TaskDashboard: Dashboard = { dataset: 'task_metrics', dimensions: ['status'], values: ['task_count'], - chartConfig: { type: 'pie', xAxis: { field: 'status', showGridLines: true, logarithmic: false }, yAxis: [{ field: 'task_count', showGridLines: true, logarithmic: false }], showLegend: true, showDataLabels: false }, layout: { x: 0, y: 2, w: 6, h: 4 }, options: { showLegend: true } }, @@ -82,7 +85,6 @@ export const TaskDashboard: Dashboard = { dataset: 'task_metrics', dimensions: ['priority'], values: ['task_count'], - chartConfig: { type: 'bar', xAxis: { field: 'priority', showGridLines: true, logarithmic: false }, yAxis: [{ field: 'task_count', showGridLines: true, logarithmic: false }], showLegend: true, showDataLabels: false }, layout: { x: 6, y: 2, w: 6, h: 4 }, options: { horizontal: true } }, @@ -96,7 +98,6 @@ export const TaskDashboard: Dashboard = { dataset: 'task_metrics', dimensions: ['completed_date'], values: ['task_count'], - chartConfig: { type: 'line', xAxis: { field: 'completed_date', showGridLines: true, logarithmic: false }, yAxis: [{ field: 'task_count', showGridLines: true, logarithmic: false }], showLegend: true, showDataLabels: false }, layout: { x: 0, y: 6, w: 8, h: 4 }, options: { showDataLabels: true } }, @@ -108,7 +109,6 @@ export const TaskDashboard: Dashboard = { dataset: 'task_metrics', dimensions: ['category'], values: ['task_count'], - chartConfig: { type: 'donut', xAxis: { field: 'category', showGridLines: true, logarithmic: false }, yAxis: [{ field: 'task_count', showGridLines: true, logarithmic: false }], showLegend: true, showDataLabels: false }, layout: { x: 8, y: 6, w: 4, h: 4 }, options: { showLegend: true } }, diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index 2672bf93004..736c8536c9b 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -212,7 +212,12 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { expect(findings[0].message).toContain('matches an authored entry BY NAME'); expect(findings[0].message).toContain('lands on nothing'); expect(findings[0].message).not.toContain('will not contain'); - expect(findings[0].hint).toContain('`series[].name` selects WHICH derived series'); + // The shape sentence now says DELETE rather than describing what the key + // carried: `chartConfig.series` is refused on a dataset-bound widget + // (ADR-0021; ruling 2026-09-12), so advice about how to use it correctly + // would be advice about a document the parse refuses. + expect(findings[0].hint).toContain('`chartConfig.series` is refused on a dataset-bound widget'); + expect(findings[0].hint).toContain('Delete the key.'); }); it('(d) a declared-but-unselected measure gets the targeted message', () => { @@ -253,28 +258,26 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { expect(quiet).toEqual([]); }); - it('(d) warns when a `combo` widget has no chartConfig at all', () => { - const findings = validateWidgetBindings(chartStack({ type: 'combo', chartConfig: undefined })); - expect(findings).toHaveLength(1); - expect(findings[0].severity).toBe('warning'); - expect(findings[0].rule).toBe(CHART_CONFIG_MISSING); - expect(findings[0].message).toContain("'combo'"); - // The message names the REAL consequence — the mark, not the binding. The - // old wording ("cannot determine which measure to plot, so the series - // renders empty") was false against the pinned renderer (#14436), so it is - // pinned here as absent rather than merely replaced. - expect(findings[0].message).toContain('per-series mark'); - expect(findings[0].message).not.toContain('renders empty'); - expect(findings[0].hint).toContain('chartConfig: { series: [{ name:'); - expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_CONFIG_MISSING}']`); - }); - - it('(d) missing chartConfig is suppressible per widget', () => { + it('(d) `chart-config-missing` is WITHDRAWN — a `combo` widget with no chartConfig is silent', () => { + // It warned, and its hint said "Give each measure its mark — chartConfig: + // { series: [{ name, type }] }". `chartConfig.series` is now refused on a + // dataset-bound widget (ADR-0021; maintainer ruling 2026-09-12), so the + // hint taught metadata the schema rejects. There is no authoring channel + // for a per-series mark on this face at all, so the advice is withdrawn + // rather than reworded: a warning nobody can act on is noise. + expect(validateWidgetBindings(chartStack({ type: 'combo', chartConfig: undefined }))).toEqual([]); + }); + + it('(d) the withdrawn rule ID still exists, so an author\'s suppressWarnings keeps parsing', () => { + // Compatibility, deliberately pinned: the id is exported and may be named + // in a stack that predates the withdrawal. Naming it must stay a no-op + // rather than becoming an unknown-rule error. + expect(CHART_CONFIG_MISSING).toBe('chart-config-missing'); expect(validateWidgetBindings(chartStack({ type: 'combo', chartConfig: undefined, suppressWarnings: [CHART_CONFIG_MISSING], - }))).toHaveLength(0); + }))).toEqual([]); }); it('(d) non-chart types do not warn on missing chartConfig', () => { diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index 5ef6b84a45f..2733671627d 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -1186,9 +1186,10 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { `first dimension (${list(dims)}). The binding is a silent no-op, not a query ` + `that fails.`, hint: - `Point xAxis.field at a dataset dimension name, or drop the key — \`xAxis\` ` + - `carries presentation only (title, format, gridlines) and the axis binding ` + - `comes from this widget's \`dimensions\`.` + + `Delete the key: \`chartConfig.xAxis\` is refused on a dataset-bound widget ` + + `(ADR-0021) and the x-axis binding comes from this widget's \`dimensions\`. ` + + `Run \`os migrate meta --from 17\` to list the mechanical edits for existing ` + + `sources; apply them by hand.` + `${suggestName(xAxis.field, dimensionNames)} ${suppressHint}`, }); } @@ -1216,11 +1217,14 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { ? `Add "${field}" to the widget's values, or bind the chart to a selected measure.` : `Post-cutover data is keyed by the dataset's measure NAME, not the ` + `base column.${suggestName(field, selectedValues.size > 0 ? selectedValues : measures.keys())}`; + // Both positions are now REFUSED outright on a dataset-bound widget + // (ADR-0021; ruling 2026-09-12), so the shape sentence says delete + // rather than describing what the key used to carry. const shapeHint = kind === 'series' - ? `\`series[].name\` selects WHICH derived series the presentation lands on; it ` + - `cannot add, remove or re-point one.` - : `\`yAxis[]\` carries presentation only (title, min/max, position); the bindings ` + - `come from \`values\`.`; + ? `\`chartConfig.series\` is refused on a dataset-bound widget: series membership ` + + `follows \`values\` (and a second \`dimensions\` entry to split). Delete the key.` + : `\`chartConfig.yAxis\` is refused on a dataset-bound widget: the bindings come ` + + `from \`values\`, one entry per mark. Delete the key.`; push({ severity: 'warning', rule: CHART_FIELD_UNKNOWN, @@ -1239,25 +1243,26 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { const name = series[k]?.name; if (typeof name === 'string') measureField(`series[${k}].name`, name, 'series'); } - } else if (isMarkMixing) { - push({ - severity: 'warning', - rule: CHART_CONFIG_MISSING, - message: - `'${w.type}' widget has no chartConfig — a combination chart takes its ` + - `per-series mark from \`chartConfig.series[].type\`, and with none declared ` + - `every measure (${list(values)}) draws with the same default mark, so the ` + - `chart is not a combination at all. The data and the axis are unaffected: ` + - `the renderer derives those from this widget's dimensions and values.`, - hint: - `Give each measure its mark — chartConfig: { series: [{ name: '', ` + - `type: 'bar' | 'line' | 'area' }] } — naming measures this widget selects ` + - `(${list(values)}). \`series[].name\` selects WHICH derived series the mark ` + - `lands on; it cannot add, remove or re-point one. If one uniform mark is ` + - `intentional, prefer that family's own widget type, or suppress with: ` + - `suppressWarnings: ['${CHART_CONFIG_MISSING}']`, - }); } + // ── `chart-config-missing` NO LONGER FIRES — and the id is kept ────── + // + // The rule advised a `combo` widget with no `chartConfig` to declare one: + // "Give each measure its mark — chartConfig: { series: [{ name, type }] }". + // `chartConfig.series` is now REFUSED on a dataset-bound widget (ADR-0021; + // maintainer ruling 2026-09-12, decision batch #121 item 1), so that hint + // told an author to write metadata the schema rejects — a lint rule and a + // parse door contradicting each other, which is worse than either alone. + // + // The advice cannot be repaired, only withdrawn: after the ruling there is + // NO authoring channel for a per-series mark on a dataset-bound widget, so + // there is nothing a `combo` author can do about the finding. A warning + // nobody can act on is noise that teaches readers to skim. + // + // ⛔ The id stays exported and `isMarkMixing` stays computed above. An + // author's `suppressWarnings: ['chart-config-missing']` must keep parsing + // (the same compatibility the #14436 narrowing kept), and the next card on + // this surface needs the set it names. What is withdrawn is the EMISSION. + void isMarkMixing; // ── (d1) a dataset widget with an empty selection (#15462, #15508) ── // Neither shape is about `chartConfig` — the renderer degrades before it diff --git a/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts b/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts index accd5ad132e..47c4f4ed5ef 100644 --- a/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts @@ -83,7 +83,15 @@ const legitBoard = () => ({ dataset: 'orders_ds', dimensions: ['status'], values: ['order_count'], - chartConfig: { type: 'bar', xAxis: { field: 'status' }, yAxis: [{ field: 'order_count' }] }, + // ⚠️ The `chartConfig` on these two widgets carried `type` / `xAxis` / + // `yAxis` / `series`, which are refused on a dataset-bound widget since the + // chart-structure ownership ruling (ADR-0021; 2026-09-12): the dataset + // decides which series exist and which column each one reads. The board is + // still a legitimate three-widget board — the bindings it used to restate + // are the `dimensions` / `values` on the very same widgets — so the fixture + // keeps an APPEARANCE `chartConfig` rather than dropping the key, which is + // what keeps this fixture exercising the key at all. + chartConfig: { title: 'By Status', showLegend: false }, }, { id: 'by_region', @@ -92,7 +100,7 @@ const legitBoard = () => ({ dataset: 'orders_ds', dimensions: ['region'], values: ['order_count'], - chartConfig: { type: 'donut', series: [{ name: 'order_count' }] }, + chartConfig: { title: 'By Region' }, }, ], }); diff --git a/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts b/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts index 82896e1c883..fc0823751ea 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts @@ -280,18 +280,29 @@ describe('#17502 — the served repeater row offers no column the parse door ref expect(overDropped).toEqual([]); // Non-vacuity: without this the two assertions above pass over a ledger - // that read nothing at all. `dashboard` is the type that exercises both - // depths — the five repeater-row columns pinned above, and three - // top-level tombstones — so the ledger is proven to reach a row shape - // and not only the surface. Sorted, so key ORDER is not what is pinned. + // that read nothing at all. `dashboard` is the type that exercises every + // depth — the five repeater-row columns pinned above, three top-level + // tombstones, and FIVE inside the widget's own `chartConfig` — so the + // ledger is proven to reach a row shape and not only the surface. + // Sorted, so key ORDER is not what is pinned. expect([...(removedByType.get('dashboard') ?? [])].sort()).toEqual([ ...RETIRED_WIDGET_COLUMNS.map((k) => `dashboard.properties.widgets.items.properties.${k}`), 'dashboard.properties.refreshInterval', 'dashboard.properties.aria', 'dashboard.properties.performance', - // [#17751, arrived with main] `ChartConfigSchema.aria` retired one - // level deeper than the widget row, inside `chartConfig`. + // [#17751] `ChartConfigSchema.aria` retired one level deeper than the + // widget row, inside `chartConfig`. 'dashboard.properties.widgets.items.properties.chartConfig.properties.aria', + // [#17385] The four STRUCTURE keys, tombstoned on the widget's own + // `chartConfig` carrier (`DashboardWidgetChartConfigSchema`) when the + // ownership ruling landed: on a dataset-bound widget the dataset + // decides which series exist and which column each one reads + // (ADR-0021; 2026-09-12). Same depth as `aria` above, and the same + // reason for being here — a `retiredKey()` keeps the key in the + // walked shape, so the served payload must drop the column. + ...['type', 'xAxis', 'yAxis', 'series'].map( + (k) => `dashboard.properties.widgets.items.properties.chartConfig.properties.${k}`, + ), ].sort()); }); diff --git a/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts b/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts index 5bfca17bc6c..24aa207fbc7 100644 --- a/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts @@ -121,12 +121,20 @@ const threeWidgetBoard = (dataset: string) => ({ { id: 'by_status', type: 'bar', title: 'By status', dataset, dimensions: ['status'], values: ['order_count'], - chartConfig: { type: 'bar', xAxis: { field: 'status' }, yAxis: [{ field: 'order_count' }] }, + // ⚠️ The `chartConfig` on these two widgets carried `type` / `xAxis` / + // `yAxis` / `series`, which are refused on a dataset-bound widget since the + // chart-structure ownership ruling (ADR-0021; 2026-09-12): the dataset + // decides which series exist and which column each one reads. The board is + // still a legitimate three-widget board — the bindings it used to restate + // are the `dimensions` / `values` on the very same widgets — so the fixture + // keeps an APPEARANCE `chartConfig` rather than dropping the key, which is + // what keeps this fixture exercising the key at all. + chartConfig: { title: 'By status', showLegend: false }, }, { id: 'by_region', type: 'donut', title: 'By region', dataset, dimensions: ['region'], values: ['order_count'], - chartConfig: { type: 'donut', series: [{ name: 'order_count' }] }, + chartConfig: { title: 'By region' }, }, ], }); diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 720e2c2f464..b48baedfa1a 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -121,6 +121,9 @@ "DashboardParsed (type)", "DashboardSchema (const)", "DashboardWidget (type)", + "DashboardWidgetChartConfig (type)", + "DashboardWidgetChartConfigParsed (type)", + "DashboardWidgetChartConfigSchema (const)", "DashboardWidgetOptions (type)", "DashboardWidgetOptionsSchema (const)", "DashboardWidgetParsed (type)", diff --git a/packages/spec/authorable-defaults/ui.json b/packages/spec/authorable-defaults/ui.json index 8a89d4c6b88..006c5cd51a1 100644 --- a/packages/spec/authorable-defaults/ui.json +++ b/packages/spec/authorable-defaults/ui.json @@ -30,6 +30,8 @@ "ui/DashboardHeader:showDescription = true", "ui/DashboardHeader:showTitle = true", "ui/DashboardWidget:type = \"metric\"", + "ui/DashboardWidgetChartConfig:showDataLabels = false", + "ui/DashboardWidgetChartConfig:showLegend = true", "ui/ElementButtonProps:disabled = false", "ui/ElementButtonProps:iconPosition = \"left\"", "ui/ElementButtonProps:size = \"medium\"", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 32cf71ec886..7110599aa0d 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -302,6 +302,20 @@ "ui/DashboardWidget:title", "ui/DashboardWidget:type", "ui/DashboardWidget:values", + "ui/DashboardWidgetChartConfig:annotations", + "ui/DashboardWidgetChartConfig:aria [RETIRED]", + "ui/DashboardWidgetChartConfig:colors", + "ui/DashboardWidgetChartConfig:description", + "ui/DashboardWidgetChartConfig:height", + "ui/DashboardWidgetChartConfig:interaction", + "ui/DashboardWidgetChartConfig:series [RETIRED]", + "ui/DashboardWidgetChartConfig:showDataLabels", + "ui/DashboardWidgetChartConfig:showLegend", + "ui/DashboardWidgetChartConfig:subtitle", + "ui/DashboardWidgetChartConfig:title", + "ui/DashboardWidgetChartConfig:type [RETIRED]", + "ui/DashboardWidgetChartConfig:xAxis [RETIRED]", + "ui/DashboardWidgetChartConfig:yAxis [RETIRED]", "ui/DashboardWidgetOptions:dateGranularity", "ui/DashboardWidgetOptions:limit", "ui/DashboardWidgetOptions:sortBy", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index ce2dd912aca..e52ae665847 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -75,6 +75,8 @@ "DashboardNavItemSchema": "ui/DashboardNavItem", "DashboardSchema": "ui/Dashboard", "DashboardWidget": "ui/DashboardWidget", + "DashboardWidgetChartConfig": "ui/DashboardWidgetChartConfig", + "DashboardWidgetChartConfigSchema": "ui/DashboardWidgetChartConfig", "DashboardWidgetOptions": "ui/DashboardWidgetOptions", "DashboardWidgetOptionsSchema": "ui/DashboardWidgetOptions", "DashboardWidgetSchema": "ui/DashboardWidget", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index d19dff2033f..59cc4a3b62a 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -117,6 +117,9 @@ "DashboardParsed": "src/ui/dashboard.zod.ts#DashboardParsed (type)", "DashboardSchema": "src/ui/dashboard.zod.ts#DashboardSchema (const)", "DashboardWidget": "src/ui/dashboard.zod.ts#DashboardWidget (type)", + "DashboardWidgetChartConfig": "src/ui/dashboard.zod.ts#DashboardWidgetChartConfig (type)", + "DashboardWidgetChartConfigParsed": "src/ui/dashboard.zod.ts#DashboardWidgetChartConfigParsed (type)", + "DashboardWidgetChartConfigSchema": "src/ui/dashboard.zod.ts#DashboardWidgetChartConfigSchema (const)", "DashboardWidgetOptions": "src/ui/dashboard.zod.ts#DashboardWidgetOptions (type)", "DashboardWidgetOptionsSchema": "src/ui/dashboard.zod.ts#DashboardWidgetOptionsSchema (const)", "DashboardWidgetParsed": "src/ui/dashboard.zod.ts#DashboardWidgetParsed (type)", diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index d8445ee8760..aafef937f44 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -40,6 +40,7 @@ "ui/DashboardHeaderAction", "ui/DashboardNavItem", "ui/DashboardWidget", + "ui/DashboardWidgetChartConfig", "ui/DashboardWidgetOptions", "ui/Dataset", "ui/DatasetDimension", diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index c21f8d22f26..43bd4e467f5 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -47,16 +47,16 @@ }, "chartConfig": { "status": "live", - "verifiedAt": "2026-09-12", + "verifiedAt": "2026-09-20", "evidenceScope": "cross-repo", - "evidence": "objectui @53ded82bf: packages/core/src/utils/chart-presentation.ts#chartConfigPresentation lowers the NINE chrome keys onto the chart schema, one guard per key — showLegend, showDataLabels, title, subtitle, description, height, annotations, interaction, and colors split two ways into the positional palette and categoryColors; objectui @53ded82bf: packages/core/src/utils/chart-presentation.ts#mergeAuthoredPresentation merges the authored xAxis / yAxis / series PRESENTATION onto the bindings the dataset selection derived, through #mergeAuthoredSeries and #axisPresentation, dropping only the two binding keys ChartSeries.name and ChartAxis.field so that membership stays with the dataset; objectui @53ded82bf: packages/plugin-dashboard/src/DatasetWidget.tsx:1415 calls mergeAuthoredPresentation, :1483 calls chartConfigPresentation and :1529 spreads both results onto the chart schema handed to the renderer; objectui @53ded82bf: packages/plugin-dashboard/src/__tests__/DatasetWidget.chartConfig.dom.test.tsx pins the DOM effect of each forwarded chrome key; objectui @53ded82bf: packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx pins the series and axis merge in the DOM", - "note": "chart presentation config on the dataset-bound chart path — read PER KEY, not forwarded as a bag. RE-MEASURED 2026-09-10 against this checkout's own `.objectui-sha` pin 53ded82bf7a4, and the previous reading is superseded in two independent ways. FIRST, THE CITATION MOVED REPOS-INTERNALLY: `chartConfigPresentation` was lifted out of `plugin-dashboard` into `@object-ui/core`'s `chart-presentation` module by objectui#4877, so the old pointer `objectui @230ffd875: packages/plugin-dashboard/src/DatasetWidget.tsx:380-429` — still byte-exact at the commit it names, every one of its ten sub-line citations included — lands on the RE-EXPORT block at that same range in the pinned tree, and the nine `if`s it describes are in another package. Note what could not have caught this: a foreign path is counted and never resolved by this gate, on purpose, so an objectui citation cannot rot the way a repo-local one does — only a hand re-measurement finds it, and this row is the second in this file to be re-anchored that way. SECOND, THE COUNT CHANGED: 12 of the 14 keys `ChartConfigSchema` declares now reach the renderer on this face, not 9. `xAxis` / `yAxis` / `series` were recorded here as unforwarded on the stated grounds that they are derived from the dataset selection and an authored value would shadow the derived binding. objectui#4229 ruled that belief half right and shipped the split it implies: the dataset owns series MEMBERSHIP and the column each binding reads — concretely `ChartSeries.name` and `ChartAxis.field`, which are dropped on the way through — while every other key on those same objects is the author's and merges onto the derived binding by name/key match with the explicit binding winning. So those three keys are LIVE on this face today. The two that still do not reach it are `type` and `aria`. `type` is deliberate: the widget's own `type` picks the chart family through CHART_TYPE_MAP and objectui pins that nothing outranks it, so a dashboard author's `chartConfig.type` parses and does nothing while the same key is live on the react tier, published as a flat `dataProps` entry by `ui/react-blocks.ts`. `aria` has no reader on either face: the chart implementation declares no `aria` prop, the renderer's ARIA injection reads the flat `ariaLabel` / `ariaDescribedBy` / `role`, and the react block's `dataProps` omits it. DRILLED 2026-09-12: this row now carries `children`, one verdict per `ChartConfigSchema` key, and THAT is the per-key classification — read the children, not this blanket `live`, which says only that the bag as a whole reaches a consumer. The drill was not expressible until today: the walk used to stop after one level, in silence, so a `children` map written at this coordinate sat at depth two where the gate never looked (#17424 taught it to recurse, and made its boundary announce itself). The sentence this replaces said exactly that, and it stopped being true the morning #17424 landed — which is the same rot this row has now been re-anchored for twice, one layer up from a citation. Drilling the row surfaced six containers one level further down (`xAxis`, `yAxis`, `series`, `annotations`, `interaction`, `aria`, 39 child keys between them); they are RECORDED in scripts/liveness/undrilled-containers.baseline.json rather than drilled, because the evidence work has not been done and fanning this row's verdicts down over them would manufacture verdicts — the one thing the drill rule forbids by name. ⚠️ `xAxis` / `yAxis` / `series` are the sharpest of those six: each is `live` as a whole while one of its OWN sub-keys is inert by design (`ChartAxis.field`, `ChartSeries.name` — dropped so membership stays with the dataset), so a blanket reading of those three children is wrong in a direction this ledger exists to catch. What is still open on #17385 is the PROTOCOL half only: the 2026-09-12 ruling on decision batch #121 (C+D) has the protocol state the ownership split — the dataset decides which series exist and which column each reads, `chartConfig` carries appearance — and REFUSE `xAxis` / `yAxis` / `series` and `type` by name on a dataset-bound widget, with a major changeset and an ADR-0087 semantic migration. That moves a published accept set and is the spec seat's to land; `aria`'s ADR-0049 disposition is its own card (#17751). HISTORY, kept because it is why this row is read carefully: the entry once read 'chart-config bag forwarded to the chart renderer', which was never measured — at that time exactly one key, `showLegend` via #3135, was lowered, and the overstatement mis-steered the #5022 measurement for half its length. #5175 measured the real 1-of-14 state and #7016 forwarded the keys the chart block measurably delivers.", + "evidence": "objectui @53ded82bf: packages/core/src/utils/chart-presentation.ts#chartConfigPresentation lowers the NINE appearance keys onto the chart schema, one guard per key — showLegend, showDataLabels, title, subtitle, description, height, annotations, interaction, and colors split two ways into the positional palette and categoryColors; objectui @53ded82bf: packages/plugin-dashboard/src/DatasetWidget.tsx#DatasetWidget (calls chartConfigPresentation and spreads the result onto the chart schema handed to SchemaRenderer); objectui @53ded82bf: packages/plugin-dashboard/src/__tests__/DatasetWidget.chartConfig.dom.test.tsx; objectui @53ded82bf: packages/plugin-dashboard/src/__tests__/DatasetWidget.chartConfig.test.tsx", + "note": "APPEARANCE ONLY on the dataset-bound chart path, as of 2026-09-20 — read PER KEY, never forwarded as a bag. This carrier is no longer `ChartConfigSchema`: the widget's `chartConfig` now parses as `DashboardWidgetChartConfigSchema`, a per-carrier `.extend()` that tombstones the four STRUCTURE keys (`type`, `xAxis`, `yAxis`, `series`) — ADR-0021 · ADR-0049, maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the dataset decides which series exist and which column each one reads, so those four are refused by name here and their rows below are graded `dead` with the removal recorded; the nine appearance keys stay live and are unchanged. ⚠️ THE EARLIER READING IS NOT WITHDRAWN, IT IS OVERRIDDEN: the 2026-09-12 per-key pass measured `xAxis`/`yAxis`/`series` LIVE on their presentation half (`mergeAuthoredPresentation` merges authored presentation onto the derived bindings, dropping `ChartAxis.field` and `ChartSeries.name`), and that measurement was correct. The ruling took the keys anyway, because `field` was a live MEMBERSHIP channel — the renderer synthesised a series from an authored axis when the chart declared none, so one authored field could silently re-point a dataset-bound series at another column while the chart still drew. The presentation those objects also carried (axis titles, formats, bounds, grid lines, per-series labels, colours and mark types) goes with them; a dataset-bound chart takes that from the dataset's own dimension and measure declarations. ⚠️ The objectui merge code at the pin `53ded82bf7a4` is therefore now unreachable metadata-side on this face and is the sibling's follow-up (objectui#4044), not evidence of a live key. `type` was already graded `dead` here and the removal executes that verdict; `aria` was removed in the same protocol-18 wave by `chart-config-aria-removed`. ⚠️ Coordinate, not key name: `ChartConfigSchema` itself keeps all four keys for the react `` tier (no dataset ⇒ the author's axes are the only ones), and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis` — this row is `dashboard/widgets.chartConfig` and only that.", "children": { "type": { "status": "dead", - "verifiedAt": "2026-09-12", + "verifiedAt": "2026-09-20", "evidenceScope": "cross-repo", - "note": "RE-MEASURED 2026-09-12 at this checkout's own `.objectui-sha` pin 53ded82bf7a4, the whole ChartConfigSchema key set at once. No consumer on this face: `chartConfigPresentation` has no `type` branch at all — the lowering is a whitelist, and its own docblock states the exclusion criterion (\"It does not fight the dataset derivation. `type` stays out: the calling surface's own type already picks the chart family\"). `DatasetWidget` resolves the family from the WIDGET's own `type` through `CHART_TYPE_MAP` and writes that as `chartType` on the chart schema; the `...chartPresentation` spread that follows it never carries a `type` or `chartType` key, so an authored `chartConfig.type` cannot outrank or even reach it. Pinned as a NEGATIVE by name: \"ignores chartConfig.type — the widget type owns the chart family\" (DatasetWidget.chartConfig.test.tsx). ⚠️ Read the coordinate, not the key name: the SAME `ChartConfigSchema.type` is live one metadata root over, on the react tier, where `packages/spec/src/ui/react-blocks.ts` publishes it in ``'s `dataProps` and its own comment explains why it cannot ride the props bag (it is the SDUI envelope's component discriminator). This row is about `dashboard.widgets[].chartConfig.type` and that is where it is dead — no per-face verdict vocabulary is invented for it. ⚠️ `type` is also the one REQUIRED key of `ChartConfigSchema`, so every dashboard author who writes a `chartConfig` at all is made to write a key this face ignores. DISPOSITION IS ALREADY RULED AND IS NOT THIS ROW'S TO EXECUTE: the 2026-09-12 ruling on decision batch #121 refuses `type` on a dataset-bound widget by name, with a major changeset and an ADR-0087 semantic migration — that is the protocol half of #17385 and it belongs to the spec seat, not to this drill." + "note": "REMOVED 2026-09-20 (ADR-0021 · ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」) — tombstoned at the schema on THIS carrier (`DashboardWidgetChartConfigSchema`; retiredKey carries the prescription, so authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 `dashboard-widget-chart-config-structure-removed` conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). The previous verdict on this row was already `dead` and the removal executes it: nothing on this face ever read the key — `chartConfigPresentation` has no `type` branch, and `DatasetWidget` resolves the chart family from the WIDGET's own `type` through `CHART_TYPE_MAP`. Write the family on the widget: `type: 'line'` beside `dataset`. ⚠️ The key is NOT retired from `ChartConfigSchema` itself: the react `` tier still publishes it (an inline-data chart has no dataset to derive structure from) and `ReportChartSchema` keeps its own narrowed axes — this row is the DASHBOARD widget's coordinate and only that. " }, "title": { "status": "live", @@ -80,25 +80,22 @@ "note": "Lowered as the chart container's accessible name — `role=\"img\"` plus `aria-label`. Pinned in the DOM by name: \"announces chartConfig.description as the chart graphic accessible name\", against the negative \"leaves the graphic unlabelled when no description is declared\". This is the accessibility channel that actually works on this face, and it is why `aria` beside it is left unforwarded rather than flattened into the same slot." }, "xAxis": { - "status": "live", - "verifiedAt": "2026-09-12", + "status": "dead", + "verifiedAt": "2026-09-20", "evidenceScope": "cross-repo", - "evidence": "objectui @53ded82bf: packages/core/src/utils/chart-presentation.ts#mergeAuthoredPresentation; objectui @53ded82bf: packages/core/src/utils/chart-presentation.ts#axisPresentation; objectui @53ded82bf: packages/plugin-dashboard/src/DatasetWidget.tsx#DatasetWidget (calls mergeAuthoredPresentation, then chartConfigPresentation, and spreads both results onto the chart schema handed to SchemaRenderer); objectui @53ded82bf: packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx", - "note": "PRESENTATION forwards, BINDING does not, and the split is structural rather than a guard. `axisPresentation` copies `title` / `format` / `min` / `max` / `stepSize` / `showGridLines` / `position` / `logarithmic` off the authored axis and drops `field`, because `normalizeChartSchema` synthesises series out of `yAxis[].field` when a chart declares none — so a forwarded `field` would be a live membership channel. The plotted x column stays `buildChartSeries`' `xAxisKey`, i.e. the widget's `dimensions[0]`. Pinned by name: \"does not let an authored axis `field` reach the renderer\". ⚠️ The key is `live` and one of its OWN sub-keys is inert; that asymmetry is why the sub-keys are not classified here — see the container disposition for this coordinate." + "note": "REMOVED 2026-09-20 (ADR-0021 · ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」) — tombstoned at the schema on THIS carrier (`DashboardWidgetChartConfigSchema`; retiredKey carries the prescription, so authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 `dashboard-widget-chart-config-structure-removed` conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). The previous verdict was `live` on the presentation half (`mergeAuthoredPresentation` → `axisPresentation` forwarded title/format/min/max/stepSize/showGridLines/position/logarithmic, dropping `field`) and the removal overrides it rather than contradicting it: the ruling took the whole key, presentation included, because `xAxis.field` was a live MEMBERSHIP channel — the renderer synthesised a series from an authored axis when the chart declared none, so one authored field could silently re-point a dataset-bound series at another column. Select the dataset dimension instead: `dimensions: ['stage']` on the widget. ⚠️ The key is NOT retired from `ChartConfigSchema` itself: the react `` tier still publishes it (an inline-data chart has no dataset to derive structure from) and `ReportChartSchema` keeps its own narrowed axes — this row is the DASHBOARD widget's coordinate and only that. " }, "yAxis": { - "status": "live", - "verifiedAt": "2026-09-12", + "status": "dead", + "verifiedAt": "2026-09-20", "evidenceScope": "cross-repo", - "evidence": "objectui @53ded82bf: packages/core/src/utils/chart-presentation.ts#mergeAuthoredPresentation; objectui @53ded82bf: packages/core/src/utils/chart-presentation.ts#axisPresentation; objectui @53ded82bf: packages/plugin-dashboard/src/DatasetWidget.tsx#DatasetWidget (calls mergeAuthoredPresentation, then chartConfigPresentation, and spreads both results onto the chart schema handed to SchemaRenderer); objectui @53ded82bf: packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx", - "note": "The same `axisPresentation` per array entry, plus one thing the x-axis has no analogue for: the COUNT of entries is itself presentation — it is what turns on a secondary axis — so every declared entry keeps its slot even when it carried nothing but the dropped `field`. Pinned by name: \"declares two y-axes and binds each series to the authored one\" and \"keeps an axis slot that declared only its `field`\"." + "note": "REMOVED 2026-09-20 (ADR-0021 · ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」) — tombstoned at the schema on THIS carrier (`DashboardWidgetChartConfigSchema`; retiredKey carries the prescription, so authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 `dashboard-widget-chart-config-structure-removed` conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). Previously `live` on the presentation half, per array entry, with the entry COUNT declaring the secondary axis; the ruling took the key whole for the same membership reason as `xAxis`. Select the dataset measures instead: `values: ['amount']` on the widget, one entry per mark — a second axis is a second measure. ⚠️ The key is NOT retired from `ChartConfigSchema` itself: the react `` tier still publishes it (an inline-data chart has no dataset to derive structure from) and `ReportChartSchema` keeps its own narrowed axes — this row is the DASHBOARD widget's coordinate and only that. " }, "series": { - "status": "live", - "verifiedAt": "2026-09-12", + "status": "dead", + "verifiedAt": "2026-09-20", "evidenceScope": "cross-repo", - "evidence": "objectui @53ded82bf: packages/core/src/utils/chart-presentation.ts#mergeAuthoredPresentation; objectui @53ded82bf: packages/core/src/utils/chart-presentation.ts#mergeAuthoredSeries; objectui @53ded82bf: packages/core/src/utils/chart-presentation.ts#seriesPresentation; objectui @53ded82bf: packages/plugin-dashboard/src/DatasetWidget.tsx#DatasetWidget (calls mergeAuthoredPresentation, then chartConfigPresentation, and spreads both results onto the chart schema handed to SchemaRenderer); objectui @53ded82bf: packages/plugin-dashboard/src/__tests__/DatasetWidget.comboPresentation.test.tsx", - "note": "Merged onto the bindings the dataset selection derived, matched by `name` against the derived `dataKey`, first entry wins. `seriesPresentation` carries `type` (narrowed to bar/line/area) / `yAxis` / `label` / `color` / `stack` / `variant` / `dashArray` / `opacity` and drops `name`, so an author can restyle a derived series but cannot add, remove or re-point one: \"ignores an authored series naming a measure outside the selection\", \"leaves a derived series with no authored entry untouched\", \"lets a bar widget declare one line series (a derived combo)\"." + "note": "REMOVED 2026-09-20 (ADR-0021 · ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」) — tombstoned at the schema on THIS carrier (`DashboardWidgetChartConfigSchema`; retiredKey carries the prescription, so authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 `dashboard-widget-chart-config-structure-removed` conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). Previously `live` on the presentation half (`mergeAuthoredSeries` → `seriesPresentation` matched authored entries to the derived dataKey by name and forwarded mark/axis-side/label/color/stack/variant/dashArray/opacity, dropping `name`); the ruling took the key whole because membership belongs to the dataset. Select the measures and the split instead: `values` declares one series per measure and a second `dimensions` entry splits them. The per-series mark type that made a dataset-bound COMBO chart authorable goes with the key — a ruled cost, recorded here so it is not rediscovered as a bug. ⚠️ The key is NOT retired from `ChartConfigSchema` itself: the react `` tier still publishes it (an inline-data chart has no dataset to derive structure from) and `ReportChartSchema` keeps its own narrowed axes — this row is the DASHBOARD widget's coordinate and only that. " }, "colors": { "status": "live", @@ -146,7 +143,7 @@ "status": "dead", "verifiedAt": "2026-09-15", "evidenceScope": "cross-repo", - "note": "REMOVED 2026-09-15 (ADR-0049 enforce-or-remove; maintainer decision batch #118 item 2, 2026-09-12, recommendation C) \u2014 tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 `chart-config-aria-removed` conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the accessible name that IS applied on this same chart config is its sibling `description`, and the shared `AriaProps` block stays live on `page.aria` / `page.components[].aria` / the list view `aria`. The two alias spellings that pointed here, `accessibility` and `ariaProps`, became guidance refusals rather than renames onto a tombstone \u2014 an alias whose target accepts nothing is what `shared/alias-integrity.test.ts` refuses by name. RE-MEASURED 2026-09-15 before the retirement, first-hand and in both directions: at pin 53ded82bf7a4 `packages/core/src/utils/chart-presentation.ts` mentions `aria` only in prose (its own docblock calls it \"the one declared key with no reader at all\") against a `showLegend` control reading 3 hits in the same file, and at objectui HEAD ff1d5ea8d every `.aria` hit on the chart paths is a NEGATIVE pin (\"ignores chartConfig.aria\", \"an authored chartConfig.aria reaches no attribute on this surface\") against a control read that does exist one surface over (`plugin-view/src/ObjectView.tsx`, 1 hit \u2014 the VIEW's). No objectui PRODUCTION source authors `chartConfig.aria` at the pin, so the Console Pin Gate (which builds, and does not run objectui's tests) is unaffected; the two objectui negative pins that DO author it are test files and are objectui#4044's half. THE READING THAT PRECEDED THE RETIREMENT, kept because it is the evidence: no reader on EITHER face, which is what separates this row from `type` beside it. `chartConfigPresentation`'s docblock names it as \"the one declared key with no reader at all\": `AdvancedChartImpl` declares no `aria` prop, and `SchemaRenderer`'s ARIA injection reads the FLAT `ariaLabel` / `ariaDescribedBy` / `role`, never a nested `aria` object. It is left unforwarded on purpose rather than papered over with a caller-side flattening, which would also collide with the accessible name `description` already sets on this very chart. On the react tier it is not published either — `packages/spec/src/ui/react-blocks.ts` lists thirteen `dataProps` for `` and `aria` is the one `ChartConfigSchema` key missing from that list. Pinned as a negative from both spellings: \"ignores chartConfig.aria — no accessible name appears anywhere\" and \"ignores aria — nested and flattened\". Same false-compliance shape as the widget-level `aria` two rows up and as the dashboard-level `aria` the #3896 sweep removed — an accessibility guarantee an author can declare and nothing honours. That verdict is now EXECUTED: #17751 was named by the 2026-09-12 ruling on decision batch #121 as the sibling spec card, and it is the card this row's REMOVED sentence above records. Removing rather than enforcing was the ruling's point — wiring `aria` would have put a second accessible-name source on an element `description` already names, behind a precedence rule nobody has written." + "note": "REMOVED 2026-09-15 (ADR-0049 enforce-or-remove; maintainer decision batch #118 item 2, 2026-09-12, recommendation C) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 `chart-config-aria-removed` conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the accessible name that IS applied on this same chart config is its sibling `description`, and the shared `AriaProps` block stays live on `page.aria` / `page.components[].aria` / the list view `aria`. The two alias spellings that pointed here, `accessibility` and `ariaProps`, became guidance refusals rather than renames onto a tombstone — an alias whose target accepts nothing is what `shared/alias-integrity.test.ts` refuses by name. RE-MEASURED 2026-09-15 before the retirement, first-hand and in both directions: at pin 53ded82bf7a4 `packages/core/src/utils/chart-presentation.ts` mentions `aria` only in prose (its own docblock calls it \"the one declared key with no reader at all\") against a `showLegend` control reading 3 hits in the same file, and at objectui HEAD ff1d5ea8d every `.aria` hit on the chart paths is a NEGATIVE pin (\"ignores chartConfig.aria\", \"an authored chartConfig.aria reaches no attribute on this surface\") against a control read that does exist one surface over (`plugin-view/src/ObjectView.tsx`, 1 hit — the VIEW's). No objectui PRODUCTION source authors `chartConfig.aria` at the pin, so the Console Pin Gate (which builds, and does not run objectui's tests) is unaffected; the two objectui negative pins that DO author it are test files and are objectui#4044's half. THE READING THAT PRECEDED THE RETIREMENT, kept because it is the evidence: no reader on EITHER face, which is what separates this row from `type` beside it. `chartConfigPresentation`'s docblock names it as \"the one declared key with no reader at all\": `AdvancedChartImpl` declares no `aria` prop, and `SchemaRenderer`'s ARIA injection reads the FLAT `ariaLabel` / `ariaDescribedBy` / `role`, never a nested `aria` object. It is left unforwarded on purpose rather than papered over with a caller-side flattening, which would also collide with the accessible name `description` already sets on this very chart. On the react tier it is not published either — `packages/spec/src/ui/react-blocks.ts` lists thirteen `dataProps` for `` and `aria` is the one `ChartConfigSchema` key missing from that list. Pinned as a negative from both spellings: \"ignores chartConfig.aria — no accessible name appears anywhere\" and \"ignores aria — nested and flattened\". Same false-compliance shape as the widget-level `aria` two rows up and as the dashboard-level `aria` the #3896 sweep removed — an accessibility guarantee an author can declare and nothing honours. That verdict is now EXECUTED: #17751 was named by the 2026-09-12 ruling on decision batch #121 as the sibling spec card, and it is the card this row's REMOVED sentence above records. Removing rather than enforcing was the ruling's point — wiring `aria` would have put a second accessible-name source on an element `description` already names, behind a precedence rule nobody has written." } } }, diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 7c5f099e687..197e43df3dd 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -41,7 +41,7 @@ for both corollaries. | `page` | 22 | 0 | 0 | 1 | 1 | 24 | | `view` | 78 | 0 | 0 | 11 | 0 | 89 | | `report` | 21 | 0 | 0 | 0 | 0 | 21 | -| `dashboard` | 45 | 0 | 0 | 10 | 0 | 55 | +| `dashboard` | 42 | 0 | 0 | 13 | 0 | 55 | | `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | | `query` | 16 | 0 | 0 | 5 | 0 | 21 | | `datasource` | 30 | 0 | 0 | 0 | 0 | 30 | @@ -66,4 +66,4 @@ for both corollaries. | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 20 | 0 | 0 | 53 | 1 | 74 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **915** | **5** | **1** | **160** | **11** | **1092** | +| **total** | **912** | **5** | **1** | **163** | **11** | **1092** | diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index f051021d3ca..c2f850cb1bf 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -994,20 +994,28 @@ describe('check:liveness — the drill recurses past one level (#17424)', () => // depth-two container is now VISIBLE. Before the walk recursed, a container // sitting under a drilled child was neither classified, nor deferred, nor // recorded — it was not in any population at all, which is why nothing could - // ever have gone red about it. `dashboard/widgets.chartConfig` was the #17385 - // coordinate that could not be drilled until this landed — and it has since - // BEEN drilled, on that card, which is why this pin no longer names it. Two - // coordinates stand in its place and the pair is deliberate: `widgets.compareTo` - // is its exact structural replacement (a container that is a drilled child, the - // shape that was invisible before the recursion), and `widgets.chartConfig.xAxis` - // is one level deeper again — a container under TWO drilled levels, which exists - // only because the #17385 drill landed. A pin naming a coordinate that a card is - // about goes stale the moment that card lands; naming the SHAPE does not. + // ever have gone red about it. Two coordinates are pinned and the pair is + // deliberate: `widgets.compareTo` is the exact structural replacement (a + // container that is a drilled child, the shape that was invisible before the + // recursion), and `widgets.chartConfig.annotations` is one level deeper again + // — a container under TWO drilled levels. + // + // ⚠️ The depth-3 half has now gone stale TWICE for the same reason, so read + // the warning the previous author wrote here rather than only the assertion: + // a pin naming a coordinate that a card is about goes stale the moment that + // card lands; naming the SHAPE does not. It named `widgets.chartConfig` until + // the #17385 drill landed, then `widgets.chartConfig.xAxis` until #17385's + // second half tombstoned that key on the dashboard carrier (a tombstone has no + // children, so the container left the population). `annotations` is chosen + // because it is an appearance key — the half of `chartConfig` the ownership + // ruling deliberately leaves with the author — so the next card about chart + // STRUCTURE cannot move it. Any depth-3 container serves; what is pinned is + // that one EXISTS in the population. it('SEES a container that sits under a drilled child, at either depth', () => { const r = report(); const seen = [...r.undrilled.map((u: any) => u.key), ...r.deferredContainers.map((d: string) => d.split(' → ')[0])]; expect(seen).toContain('dashboard/widgets.compareTo'); - expect(seen).toContain('dashboard/widgets.chartConfig.xAxis'); + expect(seen).toContain('dashboard/widgets.chartConfig.annotations'); }); it('is green against a verbatim copy of the shipped ledgers', () => { diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index bf9903c99ac..09c6f1f7bdb 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -76,9 +76,6 @@ "dashboard/header", "dashboard/widgets.chartConfig.annotations", "dashboard/widgets.chartConfig.interaction", - "dashboard/widgets.chartConfig.series", - "dashboard/widgets.chartConfig.xAxis", - "dashboard/widgets.chartConfig.yAxis", "dashboard/widgets.compareTo", "dashboard/widgets.layout", "dashboard/widgets.options", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 3c7081708aa..e52675a0d29 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -9812,7 +9812,11 @@ const chartConfigAriaRemoved: MetadataConversion = { type: 'chart', dataset: 'orders', values: ['total'], - chartConfig: { type: 'bar', description: 'Orders by month', aria: { ariaLabel: 'Orders by month' } }, + // ⚠️ No `type` here: it was tombstoned on this carrier by + // `dashboard-widget-chart-config-structure-removed`, and a fixture that + // still wrote it would be stripped by THAT entry too — the fixture + // disjointness contract. The report charts below keep theirs. + chartConfig: { description: 'Orders by month', aria: { ariaLabel: 'Orders by month' } }, }], }], reports: [{ @@ -9832,7 +9836,7 @@ const chartConfigAriaRemoved: MetadataConversion = { type: 'chart', dataset: 'orders', values: ['total'], - chartConfig: { type: 'bar', description: 'Orders by month' }, + chartConfig: { description: 'Orders by month' }, }], }], reports: [{ @@ -9850,6 +9854,99 @@ const chartConfigAriaRemoved: MetadataConversion = { }, }; +/** + * `dashboard.widgets[].chartConfig` loses its four STRUCTURE keys (ADR-0021; + * maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」). + * + * The mechanical half only. On a dataset-bound widget the dataset decides which + * series exist and which column each reads, so the stored `type`/`xAxis`/ + * `yAxis`/`series` cannot be carried forward into the selection by a walker — + * `xAxis.field` names a dataset dimension the widget may not have selected, and + * a `series[]` entry may name a measure outside `values` entirely. So this + * strips them and the paired D3 semantic entry + * `dashboard-widget-chart-config-structure-refused` carries the judgement. + * + * ⚠️ Scoped to DASHBOARD widgets. `report.chart` / `report.blocks[].chart` keep + * their own `xAxis`/`yAxis` (narrowed to dataset dimension/measure NAMES by + * `ReportChartSchema`), and the react `` tier keeps all four — the + * ruling leaves the inline-data face alone. A conversion that walked `reports` + * as well would strip keys that are still authorable there. + */ +const dashboardWidgetChartConfigStructureRemoved: MetadataConversion = { + id: 'dashboard-widget-chart-config-structure-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: + 'dashboard.widgets[].chartConfig.type / dashboard.widgets[].chartConfig.xAxis / ' + + 'dashboard.widgets[].chartConfig.yAxis / dashboard.widgets[].chartConfig.series', + summary: + "dataset-bound dashboard widget chart-config keys 'type'/'xAxis'/'yAxis'/'series' removed " + + '(ADR-0021 — the dataset decides which series exist and which column each one reads; the ' + + "widget's own 'type' is the chart family, and 'dimensions'/'values' are the selection, so " + + 'an authored axis could only agree with the dataset or silently re-point a series at ' + + 'another column)', + 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 config = (w as Record).chartConfig; + if (!config || typeof config !== 'object' || Array.isArray(config)) return w; + const cleaned = stripKeys( + config as Record, + ['type', 'xAxis', 'yAxis', 'series'], + emit, + `${path}.widgets[${i}].chartConfig`, + ); + if (cleaned === config) return w; + touched = true; + return { ...(w as Record), chartConfig: cleaned }; + }); + if (!touched) return d; + return { ...d, widgets: rebuilt }; + }); + }, + fixture: { + before: { + dashboards: [{ + name: 'pipeline', + widgets: [{ + id: 'rev_by_stage', + type: 'bar', + dataset: 'opportunity_metrics', + dimensions: ['stage'], + values: ['amount'], + chartConfig: { + type: 'bar', + xAxis: { field: 'stage' }, + yAxis: [{ field: 'amount' }], + series: [{ name: 'amount' }], + title: 'Revenue by stage', + }, + }], + }], + }, + after: { + dashboards: [{ + name: 'pipeline', + widgets: [{ + id: 'rev_by_stage', + type: 'bar', + dataset: 'opportunity_metrics', + dimensions: ['stage'], + values: ['amount'], + chartConfig: { title: 'Revenue by stage' }, + }], + }], + }, + // `stripKeys` emits one notice per KEY it removed, and all four sit on one + // chart config — so four, not one per site. + expectedNotices: 4, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -9952,6 +10049,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly` tier publishes it in +// that block's `dataProps`, and an inline-data chart has no dataset to derive +// it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The +// tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. +// D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: +// `dashboard-widget-chart-config-structure-refused`. +export const entry = 'ui/DashboardWidgetChartConfig:series'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__DashboardWidgetChartConfig__type.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__DashboardWidgetChartConfig__type.ts new file mode 100644 index 00000000000..529bcf24d4b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__DashboardWidgetChartConfig__type.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0021 (the dataset is the single author-facing analytics shape on the +// dashboard face) + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, +// decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the +// dataset decides which series exist and which column each one reads, and +// `chartConfig` carries appearance — so this structure key is tombstoned on the +// widget carrier. Nothing on this face ever read it: the dashboard renderer +// maps the WIDGET's own `type` to the chart family, and the presentation +// lowering carries no `type` branch at all — so an author who wrote a family +// here got the widget's. +// Write the family on the widget instead. +// ⚠️ Scoped to THIS carrier. The same key stays authorable on the base +// `ChartConfigSchema` (the react `` tier publishes it in +// that block's `dataProps`, and an inline-data chart has no dataset to derive +// it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The +// tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. +// D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: +// `dashboard-widget-chart-config-structure-refused`. +export const entry = 'ui/DashboardWidgetChartConfig:type'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__DashboardWidgetChartConfig__xAxis.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__DashboardWidgetChartConfig__xAxis.ts new file mode 100644 index 00000000000..eb5863c7f87 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__DashboardWidgetChartConfig__xAxis.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0021 (the dataset is the single author-facing analytics shape on the +// dashboard face) + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, +// decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the +// dataset decides which series exist and which column each one reads, and +// `chartConfig` carries appearance — so this structure key is tombstoned on the +// widget carrier. `xAxis.field` named the plotted column, which the widget's +// `dimensions` selection already names — and it was a LIVE membership channel: +// the renderer synthesised a series from an authored axis when the chart +// declared none, so one authored field could silently re-point a dataset-bound +// series at another column. +// Select the dimension instead. +// ⚠️ Scoped to THIS carrier. The same key stays authorable on the base +// `ChartConfigSchema` (the react `` tier publishes it in +// that block's `dataProps`, and an inline-data chart has no dataset to derive +// it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The +// tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. +// D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: +// `dashboard-widget-chart-config-structure-refused`. +export const entry = 'ui/DashboardWidgetChartConfig:xAxis'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__DashboardWidgetChartConfig__yAxis.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__DashboardWidgetChartConfig__yAxis.ts new file mode 100644 index 00000000000..0d1a8365d20 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__DashboardWidgetChartConfig__yAxis.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0021 (the dataset is the single author-facing analytics shape on the +// dashboard face) + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, +// decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the +// dataset decides which series exist and which column each one reads, and +// `chartConfig` carries appearance — so this structure key is tombstoned on the +// widget carrier. Each `yAxis[].field` named a plotted measure, which the +// widget's `values` selection already names; the array length also declared +// the secondary axis, which follows from the measures selected. +// Select the measures instead. +// ⚠️ Scoped to THIS carrier. The same key stays authorable on the base +// `ChartConfigSchema` (the react `` tier publishes it in +// that block's `dataProps`, and an inline-data chart has no dataset to derive +// it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The +// tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. +// D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: +// `dashboard-widget-chart-config-structure-refused`. +export const entry = 'ui/DashboardWidgetChartConfig:yAxis'; diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-chart-config-structure-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-chart-config-structure-refused.ts new file mode 100644 index 00000000000..331975ac8ff --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-chart-config-structure-refused.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The judgement half of `dashboard-widget-chart-config-structure-removed`. The +// D2 conversion strips the four keys mechanically; what they CARRIED cannot be +// moved by a walker, because the intent lands one level up in a selection the +// stripped widget may not hold — an authored `xAxis.field` can name a dataset +// dimension the widget never selected, and a `series[]` entry can name a +// measure outside `values` entirely. +export const entry: SemanticMigration = { + id: 'dashboard-widget-chart-config-structure-refused', + surface: + '`dashboard.widgets[].chartConfig.type` / `.xAxis` / `.yAxis` / `.series` — the four keys ' + + 'that said which chart family to draw, which series exist and which column each one reads ' + + 'on a DATASET-BOUND widget (REMOVED)', + replacement: + 'the widget’s own `type` and its ADR-0021 dataset selection. `chartConfig.type` becomes the ' + + 'widget’s `type` (the chart family has always been the widget’s — the dashboard renderer ' + + 'maps the widget type to the chart family and never read the chart config’s). ' + + '`chartConfig.xAxis.field` becomes an entry in the widget’s `dimensions`: the dataset ' + + 'dimension the category axis plots. Each `chartConfig.yAxis[].field` becomes an entry in ' + + 'the widget’s `values`: the dataset measure that axis plots, one entry per mark, and a ' + + 'second axis is a second measure rather than a second axis declaration. Each ' + + '`chartConfig.series[].name` is the same measure name, so a series list that matched ' + + '`values` needs nothing and one that did not was already being ignored. What has NO ' + + 'replacement, and is the reason this is a TODO rather than a rewrite: the PRESENTATION ' + + 'those objects carried alongside the binding — `ChartAxis.title` / `format` / `min` / ' + + '`max` / `stepSize` / `showGridLines` / `position` / `logarithmic`, and ' + + '`ChartSeries.label` / `color` / `type` / `yAxis` / `stack` / `dashArray` / `opacity`. ' + + 'The dataset’s own dimension and measure declarations are what label and format a ' + + 'dataset-bound chart now; `colors` on the same chart config remains the palette channel, ' + + 'and a per-series mark type (the combo chart a widget could author through ' + + '`series[].type`) has no authoring channel on this face at all.', + reason: + 'Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」, on options ' + + 'C+D together: the protocol states the ownership split AND refuses the structural keys by ' + + 'name, because stating it without refusing them leaves the declared-but-inert shape ' + + 'ADR-0049 exists to end, and refusing them without stating it leaves an author with no ' + + 'reason. The defect being closed is not cosmetic: an authored `yAxis[].field` was a LIVE ' + + 'MEMBERSHIP CHANNEL — the renderer synthesised a series from the authored axes when the ' + + 'chart declared none — so one authored axis could silently re-point a dataset-bound ' + + 'series at a different column while the chart still drew, which reads as a true statement ' + + 'about the data. ⛔ Not mechanically convertible: the D2 conversion can delete the keys ' + + 'from a stored widget, but moving what they MEANT into the dataset selection needs facts ' + + 'the item does not carry — whether the dataset declares a dimension by that name, whether ' + + 'the measure is in the dataset at all, and whether the author wanted the axis they wrote ' + + 'or the one the selection derives. An authored field naming a column outside the ' + + 'selection is exactly the case where a walker guessing would produce a different chart ' + + 'rather than a refused one. The keys are NOT retired from the chart config itself: ' + + '`ReportChartSchema` keeps its own `xAxis`/`yAxis` (narrowed to its bound dataset’s ' + + 'dimension and measure names), and the react `` tier keeps all ' + + 'four, because an inline-data chart has no dataset to derive structure from and the ' + + 'author’s axes are the only ones there are.', + acceptanceCriteria: + 'Measured against the shipped schema, not restated from the card. (1) No dashboard widget ' + + 'carries `chartConfig.type`, `.xAxis`, `.yAxis` or `.series`: the D2 conversion ' + + '`dashboard-widget-chart-config-structure-removed` strips them from authored sources on a ' + + 'chain replay and `os migrate meta --stored --apply` covers rows already at rest, and a ' + + 'value that reaches a parse is refused at that key’s own path with the prescription ' + + 'naming the dataset selection. (2) For every widget that carried one, the chart it draws ' + + 'after the migration is the chart the author meant: the family is the widget’s `type`, ' + + 'the category axis plots the dimension named in `dimensions`, and there is one mark per ' + + 'measure named in `values` — verified by rendering the dashboard, not by reading the ' + + 'metadata, because the pre-migration chart may have been plotting a column the selection ' + + 'never named. (3) A widget whose authored axes AGREED with its selection renders ' + + 'identically before and after, and that is the expected case; a widget that renders ' + + 'differently was relying on the membership channel this removes and is the case the ' + + 'ruling was made about. (4) Axis titles, number formats, axis bounds, grid lines and ' + + 'per-series labels/colours/mark types are gone from the widget and are NOT expected back: ' + + 'a dataset-bound chart takes them from the dataset’s dimension and measure declarations. ' + + 'A combo chart that was authored through `series[].type` on a dataset-bound widget has no ' + + 'authoring channel on this face after the change — that capability loss is ruled, not ' + + 'incidental, and an inline-data react `` is where a per-series mark type is ' + + 'still authored.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e5e14ba76f0..879b24e4951 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5509,7 +5509,20 @@ const step18: MigrationStep = { + 'from all three authored sites (`dashboards[].widgets[].chartConfig`, `reports[].chart`, ' + '`reports[].blocks[].chart`) as a pure lossless delete — it never had an effect to lose. ' + 'The two alias spellings that pointed at it, `accessibility` and `ariaProps`, became ' - + 'refusals carrying the same prescription rather than renames onto a tombstone.', + + 'refusals carrying the same prescription rather than renames onto a tombstone. ' + + 'It also states, and enforces, who owns a dataset-bound chart\'s STRUCTURE ' + + '(ADR-0021; maintainer ruling 2026-09-12, decision batch #121 item 1): the dataset ' + + 'decides which series exist and which column each one reads, `chartConfig` carries ' + + 'appearance, and `dashboard.widgets[].chartConfig`\'s `type`, `xAxis`, `yAxis` and ' + + '`series` are refused by name on that carrier — the widget\'s own `type` is the chart ' + + 'family and `dimensions`/`values` are the selection. An authored `yAxis[].field` was a ' + + 'live membership channel: the renderer synthesised a series from it when the chart ' + + 'declared none, so one authored axis could silently re-point a dataset-bound series at ' + + 'another column and the chart still drew. The D2 conversion strips the four keys from ' + + 'dashboard widgets only — `ReportChartSchema` and the inline-data react `` ' + + 'tier keep their own axes — and the paired semantic entry carries what the stripped ' + + 'keys were saying, because an authored axis field may name a column the widget never ' + + 'selected and no walker can move that intent into the dataset.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5541,6 +5554,7 @@ const step18: MigrationStep = { 'list-view-sort-string-clause-to-array', 'page-assigned-profiles-removed', 'chart-config-aria-removed', + 'dashboard-widget-chart-config-structure-removed', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -6803,6 +6817,78 @@ const step18: MigrationStep = { + '`.` target instead. Clicking each converted button opens the intended ' + 'page or form rather than a refusal dialog.', }, + // The judgement half of `dashboard-widget-chart-config-structure-removed`. The + // D2 conversion strips the four keys mechanically; what they CARRIED cannot be + // moved by a walker, because the intent lands one level up in a selection the + // stripped widget may not hold — an authored `xAxis.field` can name a dataset + // dimension the widget never selected, and a `series[]` entry can name a + // measure outside `values` entirely. + { + id: 'dashboard-widget-chart-config-structure-refused', + surface: + '`dashboard.widgets[].chartConfig.type` / `.xAxis` / `.yAxis` / `.series` — the four keys ' + + 'that said which chart family to draw, which series exist and which column each one reads ' + + 'on a DATASET-BOUND widget (REMOVED)', + replacement: + 'the widget’s own `type` and its ADR-0021 dataset selection. `chartConfig.type` becomes the ' + + 'widget’s `type` (the chart family has always been the widget’s — the dashboard renderer ' + + 'maps the widget type to the chart family and never read the chart config’s). ' + + '`chartConfig.xAxis.field` becomes an entry in the widget’s `dimensions`: the dataset ' + + 'dimension the category axis plots. Each `chartConfig.yAxis[].field` becomes an entry in ' + + 'the widget’s `values`: the dataset measure that axis plots, one entry per mark, and a ' + + 'second axis is a second measure rather than a second axis declaration. Each ' + + '`chartConfig.series[].name` is the same measure name, so a series list that matched ' + + '`values` needs nothing and one that did not was already being ignored. What has NO ' + + 'replacement, and is the reason this is a TODO rather than a rewrite: the PRESENTATION ' + + 'those objects carried alongside the binding — `ChartAxis.title` / `format` / `min` / ' + + '`max` / `stepSize` / `showGridLines` / `position` / `logarithmic`, and ' + + '`ChartSeries.label` / `color` / `type` / `yAxis` / `stack` / `dashArray` / `opacity`. ' + + 'The dataset’s own dimension and measure declarations are what label and format a ' + + 'dataset-bound chart now; `colors` on the same chart config remains the palette channel, ' + + 'and a per-series mark type (the combo chart a widget could author through ' + + '`series[].type`) has no authoring channel on this face at all.', + reason: + 'Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」, on options ' + + 'C+D together: the protocol states the ownership split AND refuses the structural keys by ' + + 'name, because stating it without refusing them leaves the declared-but-inert shape ' + + 'ADR-0049 exists to end, and refusing them without stating it leaves an author with no ' + + 'reason. The defect being closed is not cosmetic: an authored `yAxis[].field` was a LIVE ' + + 'MEMBERSHIP CHANNEL — the renderer synthesised a series from the authored axes when the ' + + 'chart declared none — so one authored axis could silently re-point a dataset-bound ' + + 'series at a different column while the chart still drew, which reads as a true statement ' + + 'about the data. ⛔ Not mechanically convertible: the D2 conversion can delete the keys ' + + 'from a stored widget, but moving what they MEANT into the dataset selection needs facts ' + + 'the item does not carry — whether the dataset declares a dimension by that name, whether ' + + 'the measure is in the dataset at all, and whether the author wanted the axis they wrote ' + + 'or the one the selection derives. An authored field naming a column outside the ' + + 'selection is exactly the case where a walker guessing would produce a different chart ' + + 'rather than a refused one. The keys are NOT retired from the chart config itself: ' + + '`ReportChartSchema` keeps its own `xAxis`/`yAxis` (narrowed to its bound dataset’s ' + + 'dimension and measure names), and the react `` tier keeps all ' + + 'four, because an inline-data chart has no dataset to derive structure from and the ' + + 'author’s axes are the only ones there are.', + acceptanceCriteria: + 'Measured against the shipped schema, not restated from the card. (1) No dashboard widget ' + + 'carries `chartConfig.type`, `.xAxis`, `.yAxis` or `.series`: the D2 conversion ' + + '`dashboard-widget-chart-config-structure-removed` strips them from authored sources on a ' + + 'chain replay and `os migrate meta --stored --apply` covers rows already at rest, and a ' + + 'value that reaches a parse is refused at that key’s own path with the prescription ' + + 'naming the dataset selection. (2) For every widget that carried one, the chart it draws ' + + 'after the migration is the chart the author meant: the family is the widget’s `type`, ' + + 'the category axis plots the dimension named in `dimensions`, and there is one mark per ' + + 'measure named in `values` — verified by rendering the dashboard, not by reading the ' + + 'metadata, because the pre-migration chart may have been plotting a column the selection ' + + 'never named. (3) A widget whose authored axes AGREED with its selection renders ' + + 'identically before and after, and that is the expected case; a widget that renders ' + + 'differently was relying on the membership channel this removes and is the case the ' + + 'ruling was made about. (4) Axis titles, number formats, axis bounds, grid lines and ' + + 'per-series labels/colours/mark types are gone from the widget and are NOT expected back: ' + + 'a dataset-bound chart takes them from the dataset’s dimension and measure declarations. ' + + 'A combo chart that was authored through `series[].type` on a dataset-bound widget has no ' + + 'authoring channel on this face after the change — that capability loss is ruled, not ' + + 'incidental, and an inline-data react `` is where a per-series mark type is ' + + 'still authored.', + }, { id: 'dashboard-widget-metric-family-multi-measure-refused', surface: 'dashboard widget measure arity — `dashboard.widgets[].values` ' @@ -15437,6 +15523,78 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // actually ships the rename; until then the renderer sees an absent key and // does not start its timer. 'ui/Dashboard:refreshInterval', + // ADR-0021 (the dataset is the single author-facing analytics shape on the + // dashboard face) + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, + // decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the + // dataset decides which series exist and which column each one reads, and + // `chartConfig` carries appearance — so this structure key is tombstoned on the + // widget carrier. `series[].name` named which dataset measure a series read — + // series MEMBERSHIP, which follows from `values` and the split in `dimensions` + // — and an entry naming a measure outside the selection was ignored, so the + // declaration and what rendered could differ silently. + // Select the measures and the split instead. + // ⚠️ Scoped to THIS carrier. The same key stays authorable on the base + // `ChartConfigSchema` (the react `` tier publishes it in + // that block's `dataProps`, and an inline-data chart has no dataset to derive + // it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The + // tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. + // D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: + // `dashboard-widget-chart-config-structure-refused`. + 'ui/DashboardWidgetChartConfig:series', + // ADR-0021 (the dataset is the single author-facing analytics shape on the + // dashboard face) + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, + // decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the + // dataset decides which series exist and which column each one reads, and + // `chartConfig` carries appearance — so this structure key is tombstoned on the + // widget carrier. Nothing on this face ever read it: the dashboard renderer + // maps the WIDGET's own `type` to the chart family, and the presentation + // lowering carries no `type` branch at all — so an author who wrote a family + // here got the widget's. + // Write the family on the widget instead. + // ⚠️ Scoped to THIS carrier. The same key stays authorable on the base + // `ChartConfigSchema` (the react `` tier publishes it in + // that block's `dataProps`, and an inline-data chart has no dataset to derive + // it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The + // tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. + // D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: + // `dashboard-widget-chart-config-structure-refused`. + 'ui/DashboardWidgetChartConfig:type', + // ADR-0021 (the dataset is the single author-facing analytics shape on the + // dashboard face) + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, + // decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the + // dataset decides which series exist and which column each one reads, and + // `chartConfig` carries appearance — so this structure key is tombstoned on the + // widget carrier. `xAxis.field` named the plotted column, which the widget's + // `dimensions` selection already names — and it was a LIVE membership channel: + // the renderer synthesised a series from an authored axis when the chart + // declared none, so one authored field could silently re-point a dataset-bound + // series at another column. + // Select the dimension instead. + // ⚠️ Scoped to THIS carrier. The same key stays authorable on the base + // `ChartConfigSchema` (the react `` tier publishes it in + // that block's `dataProps`, and an inline-data chart has no dataset to derive + // it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The + // tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. + // D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: + // `dashboard-widget-chart-config-structure-refused`. + 'ui/DashboardWidgetChartConfig:xAxis', + // ADR-0021 (the dataset is the single author-facing analytics shape on the + // dashboard face) + ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12, + // decision batch #121 item 1, verbatim 「同意」. On a dataset-bound widget the + // dataset decides which series exist and which column each one reads, and + // `chartConfig` carries appearance — so this structure key is tombstoned on the + // widget carrier. Each `yAxis[].field` named a plotted measure, which the + // widget's `values` selection already names; the array length also declared + // the secondary axis, which follows from the measures selected. + // Select the measures instead. + // ⚠️ Scoped to THIS carrier. The same key stays authorable on the base + // `ChartConfigSchema` (the react `` tier publishes it in + // that block's `dataProps`, and an inline-data chart has no dataset to derive + // it from) and `ReportChartSchema` keeps its own narrowed `xAxis`/`yAxis`. The + // tombstone therefore registers under `ui/DashboardWidgetChartConfig` only. + // D2: `dashboard-widget-chart-config-structure-removed`; D3 semantic: + // `dashboard-widget-chart-config-structure-refused`. + 'ui/DashboardWidgetChartConfig:yAxis', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its // renderers/basic/elements.tsx header deferred the element to "owning plugins" diff --git a/packages/spec/src/ui/chart.test.ts b/packages/spec/src/ui/chart.test.ts index 09efbdb7c36..a95b592a884 100644 --- a/packages/spec/src/ui/chart.test.ts +++ b/packages/spec/src/ui/chart.test.ts @@ -357,8 +357,13 @@ describe('#4001 批 15 — the five closed chart sites', () => { name: 'dash_one', label: 'D', widgets: [{ id: 'w1', type: 'bar', title: 'W', dataset: 'ds', dimensions: ['a'], values: ['b'], chartConfig }], }); - expect(dash!.safeParse(widget({ type: 'bar' })).success, 'control').toBe(true); - const r = dash!.safeParse(widget({ type: 'bar', chartType: 'bar' })); + // ⚠️ The control is APPEARANCE, not `{ type: 'bar' }`, and the reason is + // the rule this door now carries: on a dataset-bound widget `chartConfig` + // parses as `DashboardWidgetChartConfigSchema`, which tombstones the four + // structure keys (ADR-0021; ruling 2026-09-12). A `type` control here would + // assert a document the protocol refuses. + expect(dash!.safeParse(widget({ title: 'W' })).success, 'control').toBe(true); + const r = dash!.safeParse(widget({ title: 'W', chartType: 'bar' })); expect(r.success).toBe(false); expect(JSON.stringify(r.error?.issues)).toContain('widgets'); }); @@ -560,9 +565,30 @@ describe('#4001 批 15 — the two chart sites left open on a measurement, CLOSE expect(rootCount, 'roots must include every metadata type plus ObjectStackSchema').toBeGreaterThan(20); expect(nodeCount, 'the graph must actually have been walked').toBeGreaterThan(1000); - // Positive controls: the five closed sites of this file resolve. - expect(verdict(ChartConfigSchema), 'positive control').toBe('direct'); - expect(verdict(ChartAxisSchema), 'positive control').toBe('direct'); + // Positive controls: the closed sites of this file resolve — but TWO of the + // five verdicts moved when the chart-structure ownership ruling landed + // (2026-09-12, ADR-0021), and the moves are the measurement rather than a + // weakening. They are written out because a reader who finds a non-`direct` + // verdict on a "closed site" will otherwise read it as rot. + // + // `ChartConfigSchema` → `derived-clone`: no root reaches the base shape + // itself any more. `dashboard.widgets[].chartConfig` reaches + // `DashboardWidgetChartConfigSchema` and `report.chart` reaches + // `ReportChartSchema` — both `.extend()` clones, both carrying its + // strictness and its error map. The testkit's vocabulary is explicit that + // `direct` and `derived-clone` both mean there IS a door. + // + // `ChartAxisSchema` → `unreachable`, and this one is a real change of + // FACT, not of route: the dashboard clone tombstones `xAxis`/`yAxis`, and + // `ReportChartSchema` re-declares both as plain dataset-name strings, so no + // authoring path from any metadata root now parses a chart AXIS object. Its + // remaining carrier is the react tier's published `` dataProps + // (`react-blocks.ts`), which is a DECLARATION, not a parse — so the #4583 + // question ("is this strict shape gating anything?") is genuinely open for + // this one shape and is recorded on #17385 rather than answered here by + // quietly weakening the assertion. + expect(verdict(ChartConfigSchema), 'positive control').toBe('derived-clone'); + expect(verdict(ChartAxisSchema), 'moved: no metadata root parses an axis OBJECT any more').toBe('unreachable'); expect(verdict(ChartSeriesSchema), 'positive control').toBe('direct'); expect(verdict(ChartAnnotationSchema), 'positive control').toBe('direct'); expect(verdict(ChartInteractionSchema), 'positive control').toBe('direct'); diff --git a/packages/spec/src/ui/chart.zod.ts b/packages/spec/src/ui/chart.zod.ts index 2384130e921..f6f0346aabd 100644 --- a/packages/spec/src/ui/chart.zod.ts +++ b/packages/spec/src/ui/chart.zod.ts @@ -22,8 +22,30 @@ import { strictObject } from '../shared/strict-object'; // `ChartConfigSchema.extend(...)`). `dashboard` and `report` are both // registered metadata types. // 2. GRAPH — a BFS from all 24 metadata-type roots plus `ObjectStackSchema` -// (the `build-schemas.ts` / #4650 closure) reaches all five as -// `root-graph`. Controls in the same run: `PageSchema` / +// (the `build-schemas.ts` / #4650 closure) reached all five as +// `root-graph`. +// +// ⚠️ RE-MEASURED 2026-09-20, and two of the five verdicts MOVED when the +// chart-structure ownership ruling landed (ADR-0021; maintainer ruling +// 2026-09-12). Amended rather than left standing, because "all five" is +// now false and a later sweep would read the stale sentence as a +// measurement. `ChartConfigSchema` is `derived-clone`: no root reaches +// this base shape itself any more, because the dashboard widget reaches +// `DashboardWidgetChartConfigSchema` and the report reaches +// `ReportChartSchema`, both `.extend()` clones that carry this shape's +// strictness and error map. `ChartAxisSchema` is `unreachable`, which is a +// change of fact rather than of route: the dashboard clone tombstones +// `xAxis`/`yAxis` and `ReportChartSchema` re-declares both as dataset-name +// STRINGS, so no authoring path from a metadata root parses an axis object +// at all. The axis shape's remaining carrier is the react tier's published +// `` dataProps — a DECLARATION, not a parse — so whether its +// `.strict()` still gates anything is the #4583 question, genuinely open +// for this one shape and recorded on #17385. `ChartSeriesSchema`, +// `ChartAnnotationSchema` and `ChartInteractionSchema` are still `direct`. +// Every verdict here is pinned in `chart.test.ts`, so none of this can go +// stale silently a second time. +// +// Controls in the same run: `PageSchema` / // `DashboardSchema` / `ReportSchema` / `WebhookSchema` / // `StateMachineSchema` resolve; 批 13's measured no-door shapes // (`TouchTargetConfigSchema`, `GestureConfigSchema`) did not. (Those two @@ -533,9 +555,46 @@ export const ChartDrillDownSchema = lazySchema(() => strictObject( }, )); +/** + * The sentence every STRUCTURE key on {@link ChartConfigSchema} carries, so the + * three carriers' different answers are readable from the key itself and not + * only from the schema docblock above it. + */ +const STRUCTURE_KEY_NOTE = + ' Structure, not appearance — authorable where the chart has inline data; refused by name on a' + + ' dataset-bound dashboard widget, where the dataset decides it (ADR-0021).'; + /** * Chart Configuration Base * Common configuration for all chart types + * + * ## Who owns STRUCTURE and who owns APPEARANCE (ADR-0021; maintainer ruling + * 2026-09-12, decision batch #121 item 1) + * + * This shape has two kinds of key and the protocol answers them separately, + * because the answer depends on how the chart gets its rows: + * + * - **Appearance — always the author's.** `title`, `subtitle`, `description`, + * `colors`, `height`, `showLegend`, `showDataLabels`, `annotations` and + * `interaction` say how the chart LOOKS. Nothing derives them, on any + * carrier. + * - **Structure — the DATA SOURCE's, wherever there is one.** `type`, + * `xAxis`, `yAxis` and `series` say which series exist and which column + * each one reads. On a DATASET-BOUND widget the dataset already decides + * that (ADR-0021: the widget selects the dataset's `dimensions` and + * `values` by name, and the widget's own `type` is the chart family), so + * those four keys are refused by name there — + * {@link DashboardWidgetChartConfigSchema} in `dashboard.zod.ts` is the + * per-carrier shape that tombstones them, and each refusal points at the + * dataset selection the intent belongs in. On an INLINE-DATA chart — the + * react `` tier, whose `dataProps` publish all four — + * there is no dataset to derive anything from and the author's axes apply + * as they always have. `ReportChartSchema` narrows `xAxis`/`yAxis` to its + * own bound dataset's dimension/measure names, the third answer. + * + * ⛔ This base shape is deliberately NOT the place the refusal lives: it is + * shared by all three carriers, and a tombstone here would take the keys from + * the inline tier the ruling leaves untouched. */ export const ChartConfigSchema = lazySchema(() => strictObject( { @@ -604,7 +663,15 @@ export const ChartConfigSchema = lazySchema(() => strictObject( }, }, { - /** Chart Type */ + /** + * Chart family. + * + * Structure, not appearance. On a dataset-bound dashboard widget the + * WIDGET's own `type` is the family and this key is refused by name + * ({@link DashboardWidgetChartConfigSchema}); on an inline-data react + * `` it is the author's, and it is that tier's SDUI + * component discriminator. + */ type: ChartTypeSchema, /** Titles */ @@ -613,11 +680,14 @@ export const ChartConfigSchema = lazySchema(() => strictObject( description: I18nLabelSchema.optional().describe('Accessibility description — announced to screen readers as the chart’s label'), /** Axes Mapping */ - xAxis: ChartAxisSchema.optional().describe('X-Axis configuration'), - yAxis: z.array(ChartAxisSchema).optional().describe('Y-Axis configuration (support dual axis)'), + xAxis: ChartAxisSchema.optional() + .describe('X-Axis configuration.' + STRUCTURE_KEY_NOTE), + yAxis: z.array(ChartAxisSchema).optional() + .describe('Y-Axis configuration (support dual axis).' + STRUCTURE_KEY_NOTE), /** Series Configuration */ - series: z.array(ChartSeriesSchema).optional().describe('Defined series configuration'), + series: z.array(ChartSeriesSchema).optional() + .describe('Defined series configuration.' + STRUCTURE_KEY_NOTE), /** Appearance. Either a positional palette (string[]) applied per category in * order, or a value→color map ({ value: color }, kanban-style). A value→color diff --git a/packages/spec/src/ui/dashboard-chart-structure-refusal.test.ts b/packages/spec/src/ui/dashboard-chart-structure-refusal.test.ts new file mode 100644 index 00000000000..e920edb1b72 --- /dev/null +++ b/packages/spec/src/ui/dashboard-chart-structure-refusal.test.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { DashboardWidgetSchema, DashboardWidgetChartConfigSchema } from './dashboard.zod'; +import { ChartConfigSchema } from './chart.zod'; +import { ReportChartSchema } from './report.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; + +/** + * Who owns a dataset-bound chart's STRUCTURE (ADR-0021; maintainer ruling + * 2026-09-12, decision batch #121 item 1, verbatim 「同意」). + * + * The ruling has two halves and they fail in opposite directions, so both are + * pinned here rather than one standing in for the other: + * + * 1. On a DATASET-BOUND widget, `chartConfig.type` / `.xAxis` / `.yAxis` / + * `.series` are refused BY NAME, each refusal naming the dataset selection + * the intent belongs in. Without this half the keys parse and are ignored — + * the declared-but-inert shape ADR-0049 exists to end, and in this case + * worse than inert: an authored `yAxis[].field` was a live membership + * channel that could silently re-point a series at another column. + * 2. On an INLINE-DATA chart — the react `` tier, whose + * `dataProps` publish all four — the author's axes apply AS TODAY. Without + * this half the refusal fires on both faces, which is a bug rather than a + * stricter reading: an inline chart has no dataset to derive structure + * from, so refusing its axes leaves it unable to say anything at all. + * + * ⚠️ The second half is the one that cannot be inferred from the first, and it + * is the reason the refusal lives on a per-carrier `.extend()` + * ({@link DashboardWidgetChartConfigSchema}) instead of on + * {@link ChartConfigSchema}. A tombstone on the base shape would pass every + * assertion in half 1 and silently take the keys from the inline tier too. + * + * There is no ADR-0112 `code`/`status` envelope to assert on this rejection + * class: `retiredKey()` is a Zod `never` whose issue carries the guidance as + * its `message`, so here the wording IS the contract (the `aria-carrier- + * tombstones.test.ts` precedent, same reason). + */ + +const STRUCTURE_KEYS = ['type', 'xAxis', 'yAxis', 'series'] as const; + +const VALUE_FOR: Record<(typeof STRUCTURE_KEYS)[number], unknown> = { + type: 'line', + xAxis: { field: 'stage' }, + yAxis: [{ field: 'amount' }], + series: [{ name: 'amount' }], +}; + +const widget = (chartConfig?: Record) => ({ + id: 'rev_by_stage', + type: 'bar' as const, + dataset: 'opportunity_metrics', + dimensions: ['stage'], + values: ['amount'], + layout: { x: 0, y: 0, w: 6, h: 4 }, + ...(chartConfig ? { chartConfig } : {}), +}); + +const issuesAt = (result: z.ZodSafeParseResult, path: string) => + result.success ? [] : result.error.issues.filter((i) => i.path.join('.') === path); + +describe('dataset-bound widget: the dataset owns chart STRUCTURE', () => { + it.each(STRUCTURE_KEYS)('refuses `chartConfig.%s` by name, at that key\'s own path', (key) => { + const r = DashboardWidgetSchema.safeParse(widget({ [key]: VALUE_FOR[key] })); + expect(r.success).toBe(false); + const own = issuesAt(r, `chartConfig.${key}`); + expect(own, `the refusal must land on chartConfig.${key}, not on the widget root`).toHaveLength(1); + expect(own[0]!.message).toContain('`dashboard.widgets[].chartConfig.' + key + '`'); + }); + + it('every refusal points at the dataset selection — the ruling\'s own words', () => { + // Not one message spot-checked: the whole set, because "refused by name" + // without the prescription is an unrecognized-key report with extra steps. + for (const key of STRUCTURE_KEYS) { + const r = DashboardWidgetSchema.safeParse(widget({ [key]: VALUE_FOR[key] })); + const message = issuesAt(r, `chartConfig.${key}`)[0]!.message; + expect(message, key).toMatch(/dataset/i); + expect(message, `${key} must name where to write it instead`).toMatch( + key === 'type' ? /widget/i : /dimensions|values/, + ); + // The house `os migrate meta` sentence, pinned class-wide by + // `shared/retired-key-migrate-sentence.test.ts`; asserted here too so a + // rewrite of these four strings alone cannot drop it. + expect(message, key).toContain('os migrate meta --from 17'); + } + }); + + it('APPEARANCE is still the author\'s on the same widget — the half a blanket refusal would break', () => { + const r = DashboardWidgetSchema.safeParse(widget({ + title: 'Revenue by stage', + subtitle: 'Closed-won only', + description: 'Revenue by pipeline stage', + colors: ['#111', '#222'], + height: 320, + showLegend: false, + showDataLabels: true, + annotations: [{ type: 'line', axis: 'y', value: 100 }], + interaction: { tooltips: false }, + })); + expect(r.success, JSON.stringify(r.success ? null : r.error.issues)).toBe(true); + }); + + it('the refusal reaches the author through the dashboard metadata root, not only the exported schema', () => { + // A strict schema nobody parses gates nothing (#4583). The parse door is + // `getMetadataTypeSchema('dashboard')` — what `MetadataManager.validate`, + // `GET /api/v1/meta` and the Studio form all go through. + const dash = getMetadataTypeSchema('dashboard'); + expect(dash, 'dashboard must resolve a schema — this is the parse door').toBeTruthy(); + const doc = (chartConfig?: Record) => ({ + name: 'pipeline', label: 'Pipeline', widgets: [widget(chartConfig)], + }); + expect(dash!.safeParse(doc({ title: 'Revenue' })).success, 'control').toBe(true); + const r = dash!.safeParse(doc({ xAxis: { field: 'stage' } })); + expect(r.success).toBe(false); + expect(JSON.stringify(r.error?.issues)).toContain('chartConfig'); + }); + + it('a widget with no `chartConfig` at all is untouched', () => { + expect(DashboardWidgetSchema.safeParse(widget()).success).toBe(true); + }); +}); + +describe('the refusal is scoped to the dataset-bound carrier', () => { + it('ChartConfigSchema — the inline-data react contract — still ACCEPTS all four', () => { + // `react-blocks.ts` declares `schema: ChartConfigSchema` for `` + // and publishes `type` / `xAxis` / `yAxis` / `series` in its `dataProps`. + // An inline-data chart has no dataset to derive structure from, so this arm + // MUST stay green: a refusal that fires here is the bug, not a stricter + // reading (ruling item 1, "On an inline-data chart the author's axes apply + // as today"). + const r = ChartConfigSchema.safeParse({ + type: 'line', + xAxis: { field: 'month' }, + yAxis: [{ field: 'revenue' }], + series: [{ name: 'revenue', label: 'Revenue' }], + title: 'Monthly revenue', + }); + expect(r.success, JSON.stringify(r.success ? null : r.error.issues)).toBe(true); + }); + + it.each(STRUCTURE_KEYS)('ChartConfigSchema still accepts `%s` on its own', (key) => { + const body: Record = { type: 'line' }; + body[key] = VALUE_FOR[key]; + expect(ChartConfigSchema.safeParse(body).success).toBe(true); + }); + + it('ReportChartSchema keeps its own narrowed axes', () => { + // A report chart is dataset-bound too, and it answered the same question + // differently and earlier: `xAxis`/`yAxis` are narrowed to the bound + // dataset's dimension and measure NAMES rather than refused. The ruling did + // not touch that carrier, so this pin is what keeps the dashboard-side + // tombstone from being copied onto it by a later sweep reading "refused by + // name" as a property of the key. + expect(ReportChartSchema.safeParse({ type: 'bar', xAxis: 'stage', yAxis: 'amount' }).success).toBe(true); + }); + + it('the tombstones are on the widget carrier only — asserted on the SHAPES, not by re-parsing', () => { + const widgetShape = DashboardWidgetChartConfigSchema.shape; + const baseShape = ChartConfigSchema.shape; + for (const key of STRUCTURE_KEYS) { + // `retiredKey()` is `z.never().optional()`: the input type is `never`, so + // an `undefined` parses and any value is refused. + expect(widgetShape[key]!.safeParse(undefined).success, `${key} absent`).toBe(true); + expect(widgetShape[key]!.safeParse(VALUE_FOR[key]).success, `${key} authored`).toBe(false); + expect(baseShape[key]!.safeParse(VALUE_FOR[key]).success, `${key} on the base`).toBe(true); + } + }); +}); + +/* + * ⭐ ON THE ABSENCE HALF — why this retirement has no tree-scoped TEXT pin, and + * what stands in its place. Written out because the default for a retirement is + * the opposite (`spec-property-retirement` §4: "缺席 pin 一律 tree-scoped"), and + * a reader who finds no such pin here must not conclude one was forgotten. + * + * A text sweep works when the retired key's NAME leaves the tree. These four + * names do not leave: `type`, `xAxis`, `yAxis` and `series` stay authorable on + * `ChartConfigSchema` for the react `` tier and, for the first + * three, on `ReportChartSchema`. `type` alone appears thousands of times across + * this repository as an unrelated key. What is retired is a key IN A POSITION — + * `dashboards[].widgets[].chartConfig.` — which no grep can express and a + * grep that tried would either match everything or, scoped down by hand, match + * only the sites its author already knew about. That is the file-scoped failure + * the tree-scoped rule exists to prevent, wearing a tree-scoped costume. + * + * The instruments that DO cover the position, both repo-wide and both already + * required in CI: + * + * 1. `tsc`. `retiredKey()` types the key `never` on this carrier, so every + * authoring site in the monorepo fails to compile — which is how the five + * example dashboards that wrote these keys were found, not by grep. + * `examples/app-*` each declare `typecheck`, so the example corpus is + * inside that net as well as the packages. + * 2. The parse door. `objectstack validate` (each example app's `validate` + * script) and the metadata-protocol publish-gate tests run authored + * documents through `getMetadataTypeSchema('dashboard')` — the same door + * pinned above — so a widget that reaches a parse with one of these keys is + * refused there too, in JSON sources `tsc` never sees. + */ diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 30c6d785e79..919d93f07fa 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -674,6 +674,121 @@ export function checkDashboardWidgetMetricMeasureArity( }); } +/** + * The prescription each structural `chartConfig` key carries on a dashboard + * widget. One builder rather than four literals, because all four refusals say + * the same thing and differ only in the key and in where the intent belongs — + * and because `retired-key-migrate-sentence.test.ts` pins the closing sentence + * class-wide, so one spelling is one thing to keep right. + * + * ⚠️ The version sentence names the **npm release** this ships in, never the + * protocol major (ADR-0087, 「Amended 2026-09-13 (#18003) — the level half」): + * the migration entries below are numbered at protocol 18 while + * `@objectstack/spec` is on the 17.x line, and the two differ by construction + * while the launch window holds. + */ +const WIDGET_CHART_STRUCTURE_RETIRED = (key: string, carried: string, instead: string): string => + '`dashboard.widgets[].chartConfig.' + key + '` was removed in @objectstack/spec 17.5.0 ' + + '(ADR-0021 · ADR-0049 D2) — on a dataset-bound widget the dataset already decides ' + carried + + ', so the authored value could only agree with the dataset selection or silently ' + + 'disagree with it. Delete the key. ' + instead + + ' The key is NOT gone from the chart config itself: it stays authorable on the react ' + + '`` tier, where the chart is bound to inline `data` and there is no dataset ' + + 'to derive it from. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + +/** + * `dashboard.widgets[].chartConfig` — the chart config as a DATASET-BOUND + * widget may author it (maintainer ruling 2026-09-12, decision batch #121 + * item 1, verbatim 「同意」). + * + * ## The ownership split this shape encodes + * + * ADR-0021 made the dataset the single author-facing analytics shape on this + * face: `DashboardWidgetSchema.dataset` is REQUIRED, so **every** dashboard + * widget is dataset-bound and the dataset owns the structure — which series + * exist, and which column each one reads. What is left for `chartConfig` is + * APPEARANCE: `title`, `subtitle`, `description`, `colors`, `height`, + * `showLegend`, `showDataLabels`, `annotations`, `interaction`. That is the + * division every mainstream BI product draws between its field well and its + * format pane, and this shape is where the protocol states it. + * + * So the four structural keys are tombstoned HERE and only here: + * + * | key | what the dataset owns instead | + * |---|---| + * | `type` | the widget's own `type` — the SDUI family discriminator | + * | `xAxis` | `dimensions` — the dataset dimension the category axis plots | + * | `yAxis` | `values` — the dataset measures the value axis plots | + * | `series` | `values` (+ `dimensions` for a split) — series MEMBERSHIP | + * + * ## Why a per-carrier shape and not a tombstone on `ChartConfigSchema` + * + * The base shape has three carriers and the answer is not the same on all + * three. A react `` has no dataset to derive anything + * from, so its axes are the author's and stay exactly as they are — ruling + * item 1 says so in as many words ("On an inline-data chart the author's axes + * apply as today"), and `react-blocks.ts` publishes all four keys in that + * block's `dataProps`. A tombstone on the base shape would take them from that + * tier too. `ReportChartSchema` is the precedent for the spelling: it is a + * `.extend()` of the same base that re-declares `xAxis`/`yAxis` for ITS + * carrier, and strictness rides the extend. + * + * ## What this costs, stated rather than discovered + * + * `xAxis`/`yAxis`/`series` carried presentation as well as binding — + * `ChartAxis.title`/`format`/`min`/`max`/`showGridLines`, `ChartSeries.label`/ + * `color`/`type`/`stack`. objectui merged that presentation onto the derived + * bindings (`mergeAuthoredPresentation`), dropping `ChartAxis.field` and + * `ChartSeries.name` — the two binding keys — on the way through. Refusing the + * keys by name takes the presentation with the binding, and the combo chart a + * dataset-bound widget could author through `series[].type` goes with it. The + * ruling weighed that against a live silent-column-switch hole (an authored + * `yAxis[].field` synthesised a series and could re-point a dataset-bound + * series at another column, with the chart still drawing) and chose the + * refusal. The D3 semantic entry + * `dashboard-widget-chart-config-structure-refused` is the structured TODO + * that names what each key carried, so an operator can move the intent into + * the dataset selection. + */ +export const DashboardWidgetChartConfigSchema = lazySchema(() => ChartConfigSchema.extend({ + type: retiredKey(WIDGET_CHART_STRUCTURE_RETIRED( + 'type', + 'nothing about the chart family — the WIDGET\'s own `type` does, and it always won: ' + + 'the dashboard renderer maps the widget type to the chart family and never reads this key', + 'Write the family on the widget instead: `type: \'line\'` beside `dataset`, not inside ' + + '`chartConfig`.', + )), + xAxis: retiredKey(WIDGET_CHART_STRUCTURE_RETIRED( + 'xAxis', + 'which column the category axis plots — it is the dataset DIMENSION the widget selects', + 'Select the dimension instead: `dimensions: [\'stage\']` on the widget. Axis APPEARANCE ' + + '(title, number format, min/max, grid lines, log scale) has no home on a dataset-bound ' + + 'widget — the dataset\'s dimension declaration is what labels and formats the axis.', + )), + yAxis: retiredKey(WIDGET_CHART_STRUCTURE_RETIRED( + 'yAxis', + 'which columns the value axis plots — they are the dataset MEASURES the widget selects', + 'Select the measures instead: `values: [\'amount\']` on the widget, one entry per mark. ' + + 'A second axis is a second measure, not a second axis declaration.', + )), + series: retiredKey(WIDGET_CHART_STRUCTURE_RETIRED( + 'series', + 'which series exist and which column each one reads — series membership follows from the ' + + 'measures in `values` and the split in `dimensions`', + 'Select the measures and the split instead: `values` declares one series per measure and a ' + + 'second `dimensions` entry splits them. Per-series appearance (label, colour, mark type, ' + + 'stacking) is not authorable on a dataset-bound widget; `colors` on this same chart config ' + + 'is the palette channel that remains.', + )), +}).describe( + 'Chart APPEARANCE for a dataset-bound widget (ADR-0021): title, subtitle, description, ' + + 'colors, height, legend, data labels, annotations and interaction. The dataset decides ' + + 'which series exist and which column each one reads, so `type`, `xAxis`, `yAxis` and ' + + '`series` are refused here — write them as the widget\'s own `type` and its `dimensions` / ' + + '`values` selection.', +)); + /** * Dashboard Widget Schema * A single component on the dashboard grid. @@ -702,8 +817,23 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ /** Visualization Type */ type: ChartTypeSchema.default(WIDGET_TYPE_DEFAULT).describe('Visualization type').meta({ title: 'Visualization Type' }), - /** Chart Configuration */ - chartConfig: ChartConfigSchema.optional().describe('Chart visualization configuration').meta({ title: 'Chart Configuration' }), + /** + * Chart APPEARANCE — the author's half of a dataset-bound chart (ADR-0021; + * maintainer ruling 2026-09-12, decision batch #121 item 1). + * + * The dataset decides which series exist and which column each one reads; + * this bag carries how they LOOK. `type`, `xAxis`, `yAxis` and `series` are + * refused here by name and each refusal points at the dataset selection that + * owns it — see {@link DashboardWidgetChartConfigSchema}. + */ + chartConfig: DashboardWidgetChartConfigSchema.optional() + .describe( + 'Chart appearance: title, subtitle, description, colors, height, legend, data labels, ' + + 'annotations, interaction. The bound dataset decides which series exist and which ' + + 'column each one reads (ADR-0021), so `type` (the widget\'s own `type` wins), `xAxis`, ' + + '`yAxis` and `series` are refused here — select `dimensions` / `values` instead.', + ) + .meta({ title: 'Chart Configuration' }), /** Color variant for the widget (e.g., KPI card accent color) */ colorVariant: WidgetColorVariantSchema.optional().describe('Widget color variant for theming').meta({ title: 'Color Variant' }), @@ -1396,6 +1526,8 @@ export type DashboardWidget = z.input; /** Post-parse shape of {@link DashboardWidget} — defaults applied, transforms run (ADR-0122). */ export type DashboardWidgetParsed = z.infer; export type DashboardWidgetOptions = z.input; +export type DashboardWidgetChartConfig = z.input; +export type DashboardWidgetChartConfigParsed = z.infer; export type DashboardHeader = z.input; /** Post-parse shape of {@link DashboardHeader} — defaults applied, transforms run (ADR-0122). */ export type DashboardHeaderParsed = z.infer; diff --git a/packages/spec/src/ui/door-reachability.testkit.ts b/packages/spec/src/ui/door-reachability.testkit.ts index 6e5d1708e60..fb2dc1e97a0 100644 --- a/packages/spec/src/ui/door-reachability.testkit.ts +++ b/packages/spec/src/ui/door-reachability.testkit.ts @@ -123,12 +123,19 @@ export interface DoorMeasurement { * shared structure, measured 0.105; the schema itself was retired by #5055 and * the test rebuilds the same 2-of-19 shape to keep the number pinned). * - * Note the chart family this bridge was originally written for measures - * `direct`, not `derived-clone`: `ChartConfigSchema` is in the graph outright. - * So `chart.test.ts` does not exercise this limb at all, and neither does any - * other consumer — which is why the testkit's own test file owns the positive - * control for it. Without that, narrowing the bridge to "never fires" would - * leave every consumer green. + * ⚠️ AMENDED. This paragraph used to read "the chart family this bridge was + * originally written for measures `direct`, not `derived-clone`: + * `ChartConfigSchema` is in the graph outright. So `chart.test.ts` does not + * exercise this limb at all". That was true until the chart-structure ownership + * ruling (2026-09-12) gave the dashboard widget its own + * `DashboardWidgetChartConfigSchema`: no root reaches the base shape directly + * any more — `dashboard.widgets[].chartConfig` and `report.chart` both reach + * `.extend()` clones of it — so `ChartConfigSchema` now measures + * `derived-clone` and `chart.test.ts` DOES exercise this limb. The testkit's + * own test file still owns the positive control for it, because a consumer's + * incidental exercise is not a control: without that file, narrowing the bridge + * to "never fires" would still leave every consumer green (`ChartConfigSchema` + * would simply read `unreachable`, and nothing would say that is wrong). */ const DERIVED_CLONE_MIN_OVERLAP = 0.5; diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 3e5637c38df..8a668e8c7aa 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -93,9 +93,9 @@ Chart over an object’s aggregated data. Bind objectName + aggregate; the axes | `subtitle` | `string \| object` | data | | Chart subtitle | | `description` | `string \| object` | data | | Accessibility description — announced to screen readers as the chart’s label | | `height` | `number` | data | | Fixed plot height in pixels (overrides the container default) | -| `xAxis` | `object` | data | | X-Axis configuration | -| `yAxis` | `object[]` | data | | Y-Axis configuration (support dual axis) | -| `series` | `object[]` | data | | Defined series configuration | +| `xAxis` | `object` | data | | X-Axis configuration. Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, where the da… | +| `yAxis` | `object[]` | data | | Y-Axis configuration (support dual axis). Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard … | +| `series` | `object[]` | data | | Defined series configuration. Structure, not appearance — authorable where the chart has inline data; refused by name on a dataset-bound dashboard widget, wher… | | `colors` | `string[] \| object` | data | | Color palette (string[]) or value→color map ({ value: color }) | | `showLegend` | `boolean` | data | | Display legend | | `showDataLabels` | `boolean` | data | | Display data labels |