fix(dashboard): honour a widget's declared chartConfig on the inline chart relays - #9202
Conversation
…chart relays `DashboardWidget.chartConfig` is declared as the spec's full `ChartConfigSchema` on every dashboard widget, but only the ADR-0021 dataset path (`DatasetWidget`) ever read it. `DashboardRenderer` and `DashboardGridLayout` — which compose the chart node for a widget bound to inline rows or to a `provider: 'object'` aggregate — mentioned `chartConfig` zero times, so an author who wrote `chartConfig.title` / `.colors` / `.height` on such a widget parsed clean and got nothing. Both relays now lower the authored config through the same `chartConfigPresentation` whitelist `DatasetWidget` uses, so one chart config means the same thing on every dashboard surface. Widgets that declare no `chartConfig` compose exactly what they composed before. Still refused, each for a measured reason: `type` (the widget's own type picks the family on this path); `xAxis` / `yAxis` / `series` (whether an authored axis beats the dataset derivation is an open protocol question, objectstack-ai/objectstack#17385); and `aria` (nothing on this path reads it — the chart's one accessible name comes from `description`). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
…face itself
The forwarding commit before this one left every plot-internal key
(`colors` and its `categoryColors` arm, `showDataLabels`, `annotations`,
`interaction`, `showLegend`) without a dashboard-surface rendering
assertion. Its drawn evidence lived in plugin-charts'
`ChartRenderer.dashboardChartConfig.test.tsx`, which hand-builds its own
schema — measured: delete the forwarding from BOTH relays and every test
in that file still passes. The ruling on objectui#4044 asks for a
rendering assertion on the dashboard face for each forwarded key, so
those six had none.
`DashboardChart.chartConfigMarks-4044.test.tsx` adds them: widget
metadata in, drawn Recharts marks out, both relays, no renderer stub in
the chain. The plot gets a box without a `recharts` module mock (which
this package cannot install — `require.resolve('recharts')` from
`packages/plugin-dashboard` is MODULE_NOT_FOUND): `ResponsiveContainer`
seeds its size from `getBoundingClientRect()` on its own element, read
synchronously in its resize effect, so stubbing that ONE element is
enough. Scoped rather than blanket, and that is measured too — a blanket
stub also answers Recharts' axis-label measurement, the x-axis then
claims the whole box, and the plot renders with `height="0"` and no
marks while `.recharts-surface` is present.
Ablation, per key, mutating both relays and running the three dashboard
files: `title`, `subtitle`, `description`, `height`, `colors`,
`categoryColors`, `showLegend`, `showDataLabels`, `annotations`,
`interaction` each redden a seam assertion AND a rendering assertion;
deleting the whole forwarding reddens 46 assertions across the three,
and none in plugin-charts.
`aria` stays refused, now with the measurement rather than an assertion:
forwarding it on purpose — nested, and again flattened onto the node's
own `ariaLabel`/`ariaDescribedBy`/`role`, which `BaseSchema` declares and
`SchemaRenderer` already converts to DOM attributes — changed no
attribute on screen in either run, because `ChartRenderer` destructures
`{ schema, onChartClick }` and drops the rest. Delivering it needs a
reader inside `@object-ui/plugin-charts`, which is a separate decision.
Also corrects three comments that now read false: the DOM file's claim
that the marks could only be pinned inside plugin-charts, the
plugin-charts header's claim to cover three seams, and the changeset's
"three keys" count (it lists five).
Part of objectui#4044
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
…ssue-4044-dashboard-chartconfig-forwarding
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract review — PASS. Recorded now so the arm is a pure CI gate.
That is the distinction that decides The five refused keys each carry a measured reason rather than a silence, which is the whole point of the card:
Verified independently before this verdict
⇒ On CI reaching Generated by Claude Code |
Part of #4044
Continues the branch a previous seat pushed before it was killed mid-flight (commit
dd461a0d3, which left no report). Nothing in that commit was inherited on trust: every claim it made is re-measured below on this tree, and one of them turned out to be wrong in a way that mattered.Dispatched on the ruling in objectui#4044 comments 5617466747 and 5617614527, not on the card body's three-route menu, which the ruling supersedes.
Clause-2: no.What lands
DashboardWidget.chartConfigis declared as the spec's fullChartConfigSchema— 14 authorable keys, measured off@objectstack/spec@17.4.0at runtime — on every dashboard widget. The two INLINE relays,DashboardRendererandDashboardGridLayout(they compose the chart node for a widget bound to inline rows or to aprovider: 'object'aggregate, neither of which is an ADR-0021 dataset), mentionedchartConfigzero times. Both now lower it throughchartConfigPresentation— the same@object-ui/corewhitelist the dataset path already uses, imported rather than forked.Two commits:
dd461a0d3— the forwarding itself, plus a seam pin, a DOM pin and ashowLegenddrawn pin.139997756— the evidence that commit was missing: a dashboard-surface drawn pin for the six plot-internal keys, theariameasurement, and three comments corrected because they now read false.A. Per-key measurement — the card's premise is stale in BOTH directions
The card (filed 2026-08-04, migrated 2026-08-10) says only
showLegendlands on the dashboard path. Measured at the merge-base with one command, every zero carrying a lit control in the same sweep:So the premise is wrong twice over. The dataset path is no longer a one-key path — objectstack#7016 landed the per-key forwarding there on 2026-08-09, five days after the card was written, and
DatasetWidget.tsxis byte-identical before and after this PR. The relays were worse than the card says: not "onlyshowLegend", but nothing at all,showLegendincluded.Emitted keys measured functionally, by calling
chartConfigPresentationwith each declared key alone:titlesubtitledescriptionrole="img"+aria-labelon the chart containercolors(array)colors(record)categoryColorsheightshowLegendshowDataLabelsannotationsinteractiontypexAxis/yAxis/seriesariaNine keys land on the relays (ten counting the
categoryColorsarm). Eight of the nine on the ruling's DO-NOW list are delivered; the ninth isaria.B.
ariaandtype— the CALL measured, not the nameariais refused, and here is the measurement behind the refusalariais on the ruling's DO-NOW list, so "nothing reads it" is measured rather than asserted, in both spellings it could travel in:{ ariaLabel, ariaDescribedBy, role }on the chart node;ariaLabel/ariaDescribedBy/role— membersBaseSchemaalready declares andSchemaRenderer.resolveAriaPropsalready converts intoaria-label/aria-describedby/role. That route needs no new declaration anywhere, which is exactly why it had to be measured rather than dismissed.Rendering the real chain with each spelling on the chart node produced, for both:
and the lit control in the same probe —
description, travelling the same whitelist onto the same node — produced:Then the same thing from the other end: two ablation legs that forward
ariaon purpose from both relays, nested and flattened. In both runs the only red was the seam refusal assertion; the DOM assertion "an authored chartConfig.aria reaches no attribute on this surface" stayed green. Forwarding it changes nothing on screen.Cause:
ChartRendererdestructures{ schema, onChartClick }and drops every other prop;AdvancedChartImpldeclares noariaprop;normalizeChartSchemanames neither. The chart's one accessible name comes fromdescription.So delivering
arianeeds a READER inside@object-ui/plugin-charts— a new member on a published face — and it also has to answer to the accessible namedescriptionalready sets on the same element. The claim comment on this card leaves exactly this door open, and this is it: reported, not guessed, and not implemented. Forwarding it without a reader would have moved declared-but-not-delivered one layer down, which is the disease this card exists to cure.typeis refused, and that is measurable tooNot on the ruling's DO-NOW list. On this path the chart family travels as
chartType, set from the widget's owntypethroughclassifyWidgetType; the node'stypeis the component-registry dispatch key (chart/object-chart). Both ablations confirm the refusal pin can fail: emittingchartConfig.typeaschartType, and emitting it under its own nametype, each redden "emits no chart family from chartConfig.type — the widget type still picks it" and nothing else.CONTROL — the react tier's
ObjectChartflat-prop pathNamed by the ruling, measured as blob identity against the merge-base rather than by reading the diff:
Every file on that path is byte-identical.
@object-ui/core's whitelist is untouched too, so the dataset path and the report renderer are unchanged by construction. Behavioural half:packages/plugin-charts/andpackages/plugin-dashboard/run green in full (below).The pin shape, and the thing the first commit got wrong
The ruling asks for a rendering assertion on the dashboard surface for every forwarded key, never a "the prop was passed" assertion. The first commit left the six plot-internal keys (
colors,categoryColors,showDataLabels,annotations,interaction,showLegend) with drawn evidence only inplugin-charts/src/ChartRenderer.dashboardChartConfig.test.tsx, which hand-builds its schema. Measured: delete the forwarding from both relays and every test in that file still passes. It pins the chart block, never this seam.DashboardChart.chartConfigMarks-4044.test.tsxcloses that: widget metadata in, drawn Recharts marks out, both relays, no renderer stub anywhere in the chain.Its header comment claimed the marks could only be pinned inside
plugin-charts, because arechartsmodule mock is needed andrechartsresolves there alone. The premise is still true — re-measured,require.resolve('recharts')frompackages/plugin-dashboardis MODULE_NOT_FOUND — but the conclusion was wrong.ResponsiveContainerseeds its size fromgetBoundingClientRect()on its own element, read synchronously inside its resize effect, consultingResizeObserveronly for later changes; the repo's happy-dom polyfill for that observer is a no-op, which is why nothing painted. Sizing that one element is enough, and needs no module mock.Scoped to that element, never blanket — also measured, because the blanket form looks like it works: with every element answering 480x320, Recharts' own axis-label measurement reads 480x320 too, the x-axis claims the whole box, and the plot renders with a clip rect of
height="0"and no marks while.recharts-surfaceis present. A file that waited on the surface and then asserted "no data labels" would have passed for that reason.Ablation matrix
Every leg mutates both relays at the same anchor, runs the three dashboard pin files, then restores with
git checkout HEAD -- pathand proves the restore by blob hash and an emptygit diff HEAD. Each mutation is proven on disk before the run (anchor count 1 before, 0 after; marker count 1 after).titlesubtitledescriptionheightcolorscolorsoverride the relay default palettecolorspalettecategoryColorscolorsas the per-category map, not as the palettecolorsmapshowLegendshowDataLabelsannotationsinteractionaria(nested)aria(flattened)type(aschartType)type(astype)No forwarded key has an assertion that survives its own deletion. The control arms (undeclared / off) deliberately stay green in every leg — that is what makes the positive arms readable.
Verification
Everything below ran on the final head
9adbdfaeb(this branch merged withorigin/mainat1f4e02995; synced by merge, never rebase).pnpm exec vitest run packages/plugin-dashboard/ packages/plugin-charts/under the shared verify lock: 174 files, 1623 tests, all passed, lock verdict lineVERDICT command-exit 0.pnpm turbo run type-check --filter=@object-ui/plugin-dashboard --filter=@object-ui/plugin-charts(tsc --noEmitplustsc -p tsconfig.test.json, whose include namessrc/**/*.test.tsx, so the new pin is type-checked): 15 tasks successful,VERDICT command-exit 0.eslint .in each of the two packages: exit 0, 0 errors (470 and 368 pre-existingno-explicit-anywarnings, unchanged in kind).package.jsonand.github/workflows/(this repo has noscripts/pm/dispatch-gates.mjs), each read off its own printed verdict line, never a bare exit variable:check-changeset-presence(6 source files of 2 released packages changed, 1 changeset declared),check-changeset-claims,check-control-bytes(7404 files),check-test-path-roots,check-new-cross-file-line-citations(0 new citations),check-vi-mock-specifiers,check-vi-mock-inherit,check-vi-mock-override-shape,markdown-test-inputs.mjs --audit(47 candidates, all adjudicated — the new pin reads no markdown, soADJUDICATEDneeds no entry, and the gate's own audit is what says so),check-governed-queue-guard --teston the changed paths (NOT GOVERNED).Acceptance notes
DatasetWidget.chartConfig.dom.test.tsxpins onlytitle/subtitle/description/height; its five plot-internal keys, forwarded since objectstack#7016, still have no dashboard-surface drawn assertion. The technique in the new marks file transfers verbatim. Out of this card's scope (the ruling's DO-NOW is the not-yet-forwarded keys, and the relays are what was unforwarded), so: noted, not filed.ariameasurement written into it.package: plugin-dashboardlabel in this repository, so the package labels here are what exists.Not enqueued, no auto-merge, stays in draft — the dispatching seat arms it.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Generated by Claude Code