From 01b67f117c4304f37028ce0191f28882c1863027 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:48:29 +0000 Subject: [PATCH] feat(spec,lint): 's author contract is the spec ChartConfig shape again (#3729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3701 trimmed xAxis/yAxis/series out of the contract because the renderer read xAxisKey/series[].dataKey and silently dropped the ChartConfig shapes — an honest record of the runtime gap, not the target state. objectui#2880 closed the gap the other way round (the renderer now honors ChartConfig through one normalization boundary), so the contract follows the protocol again (ADR-0082 D1: the spec schema IS the protocol). Contract: `type`, `xAxis`, `yAxis`, `series`, `subtitle`, `showDataLabels`, `annotations` and `interaction` are published from ChartConfigSchema; the internal chartType/xAxisKey/series[].dataKey spellings leave the author contract. `annotations` and `interaction` gained the .describe() they never had, so the generated contract stops publishing bare `object[]` with no meaning. The `type` exception: ChartConfig.type is the chart family, but on any surface that flattens chart config into a props bag `type` is already the SDUI envelope's component discriminator — an author writing type="bar" used to replace object-chart and the block stopped resolving. The collision is created by the flattening and is resolved there (objectui's react-page wrapper), so the contract can publish `type` as the spec spells it. The generator's blanket `type` skip is now overridable by an explicit dataProps allow-list, since for this one block `type` is a real author prop. Lint: validate-react-page-props reads the axes in the spec spelling — xAxis.field, yAxis[].field, series[].name — and keeps accepting the internal spellings silently, because dashboards and the console's own chart-view wiring emit them. react-chart-axis-inert is retired: the props it warned about are honored now, so the warning would be false. The three binding-integrity rules from #3701 are unchanged. Spec: chart-aggregate.ts records the constraint the whole result-column convention rests on — an inline aggregate is SINGLE-MEASURE. Keying rows by the raw field name only works because there is exactly one measure to key; two measures over one field would collide, and resolving that needs an author-chosen name per measure, which is what a dataset is. The chart taxonomy note is corrected too: grouped/stacked bar and stacked area are absent from ChartTypeSchema not because they render as their base chart, but because stacking is a property of the SERIES (ChartSeries.stack), not a chart family. ChartInteraction.zoom is marked declared-not-delivered rather than reading as shipped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UMHZBHTjH4rw8xmDYirFC7 --- ...objectchart-contract-back-to-spec-shape.md | 50 ++++++++++++ .../docs/deployment/validating-metadata.mdx | 15 ++-- content/docs/references/ui/chart.mdx | 10 +-- content/docs/references/ui/report.mdx | 4 +- .../src/ui/pages/renewals-pipeline.page.ts | 11 +-- packages/lint/src/index.ts | 1 - .../src/validate-react-page-props.test.ts | 40 ++++++++-- .../lint/src/validate-react-page-props.ts | 64 ++++++++------- .../scripts/build-react-blocks-contract.ts | 11 ++- packages/spec/src/ui/chart-aggregate.ts | 14 ++++ packages/spec/src/ui/chart.zod.ts | 32 +++++--- packages/spec/src/ui/react-blocks.ts | 24 +++--- skills/objectstack-ui/SKILL.md | 19 +++-- .../contracts/react-blocks.contract.json | 79 +++++++++++++------ .../evals/analytics-inline-vs-dataset.json | 2 +- .../objectstack-ui/references/react-blocks.md | 13 ++- 16 files changed, 280 insertions(+), 109 deletions(-) create mode 100644 .changeset/objectchart-contract-back-to-spec-shape.md diff --git a/.changeset/objectchart-contract-back-to-spec-shape.md b/.changeset/objectchart-contract-back-to-spec-shape.md new file mode 100644 index 0000000000..efff639d57 --- /dev/null +++ b/.changeset/objectchart-contract-back-to-spec-shape.md @@ -0,0 +1,50 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +``'s author contract is the spec `ChartConfig` shape again (issue #3729) + +#3701 trimmed `xAxis`/`yAxis`/`series` out of the `` contract +because the renderer read `xAxisKey`/`series[].dataKey` and silently dropped the +ChartConfig shapes — an honest record of the runtime gap, not the target state. +objectui#2880 closed the gap the other way round (the renderer now honors +`ChartConfig` through one normalization boundary), so the contract follows the +protocol again (ADR-0082 D1: the spec schema IS the protocol). + +**Contract.** `type`, `xAxis`, `yAxis`, `series`, `subtitle`, `showDataLabels`, +`annotations` and `interaction` are published from `ChartConfigSchema`; the +internal `chartType`/`xAxisKey`/`series[].dataKey` spellings leave the author +contract. `annotations` and `interaction` gained the `.describe()` they never +had, so the generated contract stops publishing bare `object[]` with no meaning. + +**The `type` exception.** `ChartConfig.type` is the chart family, but on any +surface that flattens chart config into a props bag `type` is already the SDUI +envelope's component discriminator — an author writing `type="bar"` used to +replace `object-chart` and the block stopped resolving. The collision is created +by the flattening and is resolved there (objectui's react-page wrapper), so the +contract can publish `type` as the spec spells it. The contract generator's +blanket `type` skip is now overridable by an explicit `dataProps` allow-list, +since for this one block `type` is a real author prop. + +**Lint.** `validate-react-page-props` reads the axes in the spec spelling — +`xAxis.field`, `yAxis[].field`, `series[].name` — and keeps accepting the +internal spellings silently, because dashboards and the console's own chart-view +wiring emit them. `react-chart-axis-inert` is retired: the props it warned about +are honored now, so the warning would be false. The three binding-integrity +rules from #3701 are unchanged. + +**Spec.** `chart-aggregate.ts` records the constraint the whole result-column +convention rests on: an inline `aggregate` is SINGLE-MEASURE. Keying rows by the +raw field name only works because there is exactly one measure to key; two +measures over one field would collide, and resolving that needs an author-chosen +name per measure — which is what a dataset is. Widening `ChartAggregateSchema` +into a measures array would silently invalidate every axis binding these rules +validate, so the boundary is now written down rather than left to be rediscovered. + +The chart taxonomy note is corrected too: grouped/stacked bar and stacked area +are absent from `ChartTypeSchema` not because they render as their base chart, +but because stacking is a property of the SERIES (`ChartSeries.stack`), not a +chart family — one `bar` family plus a series stack group expresses all three. +`ChartInteraction.zoom` is now marked declared-not-delivered in its own +description rather than reading as shipped. diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 51367511fa..b86d1c24a8 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -191,18 +191,17 @@ column, `field` names the value column (the literal `count` for a fieldless two conventions up is the usual cause of a chart that renders axes and no bars. ```jsx - -// ↑ a dataset-style measure name; the rows -// are keyed `total` → error + xAxis={{ field: 'status' }} series={[{ name: 'sum_total' }]} /> +// ↑ a dataset-style measure name; the rows +// are keyed `total` → error ``` Both halves are checked: `aggregate.field` / `aggregate.groupBy` must be fields -the object declares, and `xAxisKey` / `series[].dataKey` must name a column the -aggregate actually returns (plus `__comparison` when a comparison overlay -is on). `ChartConfig`'s `xAxis` / `yAxis` shapes are **not** read by this block -and are reported as inert (warning). +the object declares, and the axes — `xAxis.field`, `yAxis[].field`, +`series[].name` — must name a column the aggregate actually returns (plus +`__comparison` when a comparison overlay is on). Skipped, to keep false positives at zero: any prop whose value is not a static literal (it comes from React state or a variable), a usage carrying a `{...spread}`, diff --git a/content/docs/references/ui/chart.mdx b/content/docs/references/ui/chart.mdx index d9e8acbbb7..e17a0c02cd 100644 --- a/content/docs/references/ui/chart.mdx +++ b/content/docs/references/ui/chart.mdx @@ -108,8 +108,8 @@ Inline aggregation for an object-bound chart | **height** | `number` | optional | Fixed height in pixels | | **showLegend** | `boolean` | ✅ | Display legend | | **showDataLabels** | `boolean` | ✅ | Display data labels | -| **annotations** | `{ type: Enum<'line' \| 'region'>; axis: Enum<'x' \| 'y'>; value: number \| string; endValue?: number \| string; … }[]` | optional | | -| **interaction** | `{ tooltips: boolean; zoom: boolean; brush: boolean; clickAction?: string }` | optional | | +| **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; zoom: boolean; brush: boolean; clickAction?: string }` | optional | Interaction toggles: `{ tooltips?, brush?, zoom?, clickAction? }` | | **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -150,9 +150,9 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **tooltips** | `boolean` | ✅ | | -| **zoom** | `boolean` | ✅ | | -| **brush** | `boolean` | ✅ | | +| **tooltips** | `boolean` | ✅ | Show the hover tooltip | +| **zoom** | `boolean` | ✅ | Pan/zoom the plot — NOT implemented by the default renderer; use `brush` | +| **brush** | `boolean` | ✅ | Show the range selector under the plot | | **clickAction** | `string` | optional | Action ID to trigger on click | diff --git a/content/docs/references/ui/report.mdx b/content/docs/references/ui/report.mdx index a15f2289c2..ba7d583b57 100644 --- a/content/docs/references/ui/report.mdx +++ b/content/docs/references/ui/report.mdx @@ -90,8 +90,8 @@ const result = JoinedReportBlock.parse(data); | **height** | `number` | optional | Fixed height in pixels | | **showLegend** | `boolean` | ✅ | Display legend | | **showDataLabels** | `boolean` | ✅ | Display data labels | -| **annotations** | `{ type: Enum<'line' \| 'region'>; axis: Enum<'x' \| 'y'>; value: number \| string; endValue?: number \| string; … }[]` | optional | | -| **interaction** | `{ tooltips: boolean; zoom: boolean; brush: boolean; clickAction?: string }` | optional | | +| **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; zoom: boolean; brush: boolean; clickAction?: string }` | optional | Interaction toggles: `{ tooltips?, brush?, zoom?, clickAction? }` | | **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts index de55230dae..2335986aee 100644 --- a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts +++ b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts @@ -18,10 +18,11 @@ import { definePage } from '@objectstack/spec/ui'; * cross-object reads declaratively (zero data code). * (This comparison absorbed the former Account Cockpit page.) * - * The chart's axes are bound explicitly (#3701) to show the object-bound - * result-column rule: an inline `aggregate` returns rows keyed by the RAW - * FIELD NAMES — `status` (its `groupBy`) and `total` (its `field`) — not by a - * dataset-style measure name. `os validate` now checks both halves. + * The chart is written in the spec `ChartConfig` shape (#3729) and its axes are + * bound explicitly (#3701) to show the object-bound result-column rule: an + * inline `aggregate` returns rows keyed by the RAW FIELD NAMES — `status` (its + * `groupBy`) and `total` (its `field`) — not by a dataset-style measure name. + * `os validate` checks both halves. * * Styling (ADR-0065): no Tailwind — inline `style={{}}` with `hsl(var(--token))`; * data blocks and the drawer bring their own compiled styling. The drawer sets @@ -119,7 +120,7 @@ function Page() { - + diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 05643cd139..fff95f4495 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -80,7 +80,6 @@ export { REACT_CHART_FIELD_UNKNOWN, REACT_CHART_AGGREGATE_INVALID, REACT_CHART_AXIS_UNKNOWN, - REACT_CHART_AXIS_INERT, } from './validate-react-page-props.js'; export type { ReactPropFinding, ReactPropSeverity } from './validate-react-page-props.js'; export { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js'; diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts index c76503a8de..71f194f115 100644 --- a/packages/lint/src/validate-react-page-props.test.ts +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -5,7 +5,6 @@ import { REACT_CHART_FIELD_UNKNOWN, REACT_CHART_AGGREGATE_INVALID, REACT_CHART_AXIS_UNKNOWN, - REACT_CHART_AXIS_INERT, } from './validate-react-page-props.js'; const page = (source: string) => ({ pages: [{ name: 'p', kind: 'react', source }] }); @@ -154,13 +153,42 @@ describe('validateReactPageProps — bindings (#3701)', () => { expect(f).toEqual([]); }); - it('warns that the ChartConfig axis shapes are inert on this block', () => { + // ── the spec ChartConfig shape is the contract again (#3729) ──────────── + + it('accepts the spec axis shape', () => { + const f = validateReactPageProps( + chartPage(chart(`objectName="invoice" type="bar" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxis={{ field: 'status' }} yAxis={[{ field: 'total' }]}`)), + ); + expect(f).toEqual([]); + }); + + it('flags a spec yAxis[].field that is not a result column', () => { + const f = validateReactPageProps( + chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} yAxis={[{ field: 'sum_total' }]}`)), + ); + expect(f.some((x) => x.rule === REACT_CHART_AXIS_UNKNOWN && /sum_total/.test(x.message))).toBe(true); + }); + + it('flags a spec series[].name that is not a result column', () => { const f = validateReactPageProps( - chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} yAxis={[{ field: 'total' }]}`)), + chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} series={[{ name: 'sum_total' }]}`)), ); - const inert = f.find((x) => x.rule === REACT_CHART_AXIS_INERT); - expect(inert?.severity).toBe('warning'); - expect(inert?.hint).toMatch(/series=/); + const hit = f.find((x) => x.rule === REACT_CHART_AXIS_UNKNOWN); + expect(hit?.hint).toMatch(/series\[\]\.name/); + }); + + it('flags a spec xAxis.field bound to the VALUE column', () => { + const f = validateReactPageProps( + chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxis={{ field: 'total' }}`)), + ); + expect(f.some((x) => x.rule === REACT_CHART_AXIS_UNKNOWN && /VALUE column/.test(x.message))).toBe(true); + }); + + it('still accepts the internal spelling — dashboards emit it', () => { + const f = validateReactPageProps( + chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxisKey="status" series={[{ dataKey: 'total' }]}`)), + ); + expect(f).toEqual([]); }); // ── false-positive guards ─────────────────────────────────────────────── diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index cf04721765..a2b052d4fd 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -14,9 +14,9 @@ // (e.g. `onSucces` → `onSuccess`) → warning. We do NOT flag arbitrary // unknown props (the contract's data props are a curated subset) — only the // likely typos, to keep false positives near zero. -// - 's data BINDINGS, by reading the attribute VALUES (#3701). -// See the block comment above `checkObjectChart` for why that block, and -// why only now. +// - 's data BINDINGS, by reading the attribute VALUES (#3701, +// retargeted to the spec shape in #3729). See the block comment above +// `checkObjectChart`. // // Reading values is opt-in per block and per prop: everything below evaluates // only STATIC literals (`objectName="invoice"`, an `aggregate={{…}}` object @@ -185,13 +185,20 @@ function attrValue(tsc: typeof ts, sf: ts.SourceFile, attr: ts.JsxAttribute): un // // * `aggregate.field` / `aggregate.groupBy` are RAW FIELD names → check them // against the object's declared fields. -// * `xAxisKey` / `series[].dataKey` are RESULT COLUMN names → check them -// against what this aggregate produces. +// * the axes are RESULT COLUMN names → check them against what this +// aggregate produces. +// +// The axes are read in the SPEC spelling — `xAxis.field`, `yAxis[].field`, +// `series[].name` (#3729). #3701 had to read `xAxisKey`/`series[].dataKey` +// because those were the only spellings the renderer honored; objectui#2880 +// made it honor ChartConfig, so the gate follows the protocol again. The +// internal spellings are still accepted, silently: dashboards and the console's +// own chart-view wiring emit them, and they remain a valid (if unpublished) +// way to write the same binding. export const REACT_CHART_FIELD_UNKNOWN = 'react-chart-field-unknown'; export const REACT_CHART_AGGREGATE_INVALID = 'react-chart-aggregate-invalid'; export const REACT_CHART_AXIS_UNKNOWN = 'react-chart-axis-unknown'; -export const REACT_CHART_AXIS_INERT = 'react-chart-axis-inert'; const CHART_FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'] as const; @@ -261,21 +268,6 @@ function checkObjectChart( const push = (severity: ReactPropSeverity, rule: string, message: string, hint: string) => findings.push({ severity, rule, where, path, message, hint }); - // The ChartConfig axis shapes reach this block but are never read by it — - // it consumes `xAxisKey` + `series[].dataKey`. Silently inert props are what - // ADR-0078 forbids, so say so rather than let the chart render blank. - for (const inert of ['xAxis', 'yAxis'] as const) { - if (!values.has(inert)) continue; - push( - 'warning', - REACT_CHART_AXIS_INERT, - ` prop "${inert}" is not read by this block — it renders nothing.`, - inert === 'xAxis' - ? 'Use xAxisKey="" (the aggregate\'s groupBy).' - : 'Use series={[{ dataKey: "" }]} (the aggregate\'s field).', - ); - } - // Inline `data` wins over the aggregate query: the columns then come from // the author's own rows, which this rule cannot see. Nothing further to say. if (values.has('data')) return; @@ -353,23 +345,41 @@ function checkObjectChart( ); }; - axisRef(strOf(values.get('xAxisKey')), 'xAxisKey'); + // The category axis: spec `xAxis: { field }`, the report surface's bare + // string, or the internal `xAxisKey`. All three name the same column. + const xAxisRaw = values.get('xAxis'); + const categoryAxis = + strOf(values.get('xAxisKey')) ?? + strOf(xAxisRaw) ?? + (isRec(xAxisRaw) ? strOf(xAxisRaw.field) : undefined); + const categoryProp = values.has('xAxisKey') ? 'xAxisKey' : 'xAxis.field'; + axisRef(categoryAxis, categoryProp); + + // The value axes: spec `yAxis: [{ field }]` (or a single object / bare + // string) and spec `series: [{ name }]` / internal `series: [{ dataKey }]`. + const yAxisRaw = values.get('yAxis'); + const yAxisList = Array.isArray(yAxisRaw) ? yAxisRaw : yAxisRaw !== undefined ? [yAxisRaw] : []; + for (const a of yAxisList) { + axisRef(strOf(a) ?? (isRec(a) ? strOf(a.field) : undefined), 'yAxis[].field'); + } + const series = values.get('series'); if (Array.isArray(series)) { for (const s of series) { - if (isRec(s)) axisRef(strOf(s.dataKey), 'series[].dataKey'); + if (!isRec(s)) continue; + const dataKey = strOf(s.dataKey); + axisRef(dataKey ?? strOf(s.name), dataKey ? 'series[].dataKey' : 'series[].name'); } } // A category axis bound to anything but the groupBy is always wrong, and the // check above lets it through when it happens to equal the VALUE column. - const xAxisKey = strOf(values.get('xAxisKey')); - if (xAxisKey && keys.category && xAxisKey !== keys.category && xAxisKey === keys.value) { + if (categoryAxis && keys.category && categoryAxis !== keys.category && categoryAxis === keys.value) { push( 'error', REACT_CHART_AXIS_UNKNOWN, - `xAxisKey "${xAxisKey}" is the aggregate's VALUE column, not its category column.`, - `The category axis is keyed by groupBy — use xAxisKey="${keys.category}".`, + `${categoryProp} "${categoryAxis}" is the aggregate's VALUE column, not its category column.`, + `The category axis is keyed by groupBy — bind it to "${keys.category}".`, ); } } diff --git a/packages/spec/scripts/build-react-blocks-contract.ts b/packages/spec/scripts/build-react-blocks-contract.ts index d90aab3afa..b1b758ca5c 100644 --- a/packages/spec/scripts/build-react-blocks-contract.ts +++ b/packages/spec/scripts/build-react-blocks-contract.ts @@ -66,8 +66,15 @@ function dataProps(schema: any, allow?: string[]): Prop[] { } const props = js?.properties ?? {}; const required: string[] = Array.isArray(js?.required) ? js.required : []; - const SKIP = new Set(['aria', 'type', 'id', 'className', 'style']); - let entries = Object.entries(props).filter(([name]) => !SKIP.has(name)); + // Envelope/plumbing props that are never part of an author contract. `type` + // is the awkward one: for most blocks it IS the SDUI component discriminator, + // but `ChartConfig.type` is the chart family and a real author prop — so an + // explicit `dataProps` allow-list wins over the skip (#3729). + const SKIP = new Set(['aria', 'id', 'className', 'style']); + const allowed = new Set(allow ?? []); + let entries = Object.entries(props).filter( + ([name]) => !(SKIP.has(name) || (name === 'type' && !allowed.has('type'))), + ); if (allow && allow.length) { const order = new Map(allow.map((n, i) => [n, i])); entries = entries.filter(([n]) => order.has(n)).sort((a, b) => order.get(a[0])! - order.get(b[0])!); diff --git a/packages/spec/src/ui/chart-aggregate.ts b/packages/spec/src/ui/chart-aggregate.ts index e45a347583..ed811c64e3 100644 --- a/packages/spec/src/ui/chart-aggregate.ts +++ b/packages/spec/src/ui/chart-aggregate.ts @@ -58,6 +58,20 @@ * `chart-measure-unknown` catches. The two paths key their rows differently * because only one of them has an author-chosen name to key by. * + * ## The constraint this convention rests on: inline aggregate is SINGLE-MEASURE + * + * Keying rows by the raw field name only works because an inline `aggregate` + * computes exactly ONE measure — one `function` over one `field`. Two measures + * over the same field (`sum(amount)` and `avg(amount)`) would collide on the + * column name, and the only way out would be a derived name (`sum_amount`) — + * i.e. re-inventing what a dataset `DatasetMeasure.name` already is. + * + * So do not "just add" a measures array here. Multi-measure IS the dataset + * path's job, and the boundary between the two is exactly this: a chart that + * needs more than one measure needs an author-chosen name per measure, which + * means it needs a dataset (ADR-0021 Level B). Widening `ChartAggregateSchema` + * would silently invalidate every axis binding this convention validates. + * * ## No business logic here (Prime Directive #2) * * The helpers below are pure contract derivations — they map a declaration to diff --git a/packages/spec/src/ui/chart.zod.ts b/packages/spec/src/ui/chart.zod.ts index 61949e31c1..ff623a7818 100644 --- a/packages/spec/src/ui/chart.zod.ts +++ b/packages/spec/src/ui/chart.zod.ts @@ -61,12 +61,18 @@ export const ChartTypeSchema = lazySchema(() => z.enum([ // (choropleth/bubble-map/gl-map), or extra renderers (sunburst, heatmap, // word-cloud, waterfall). // 2. VARIANTS that only render as their base chart, so advertising them lies -// about the output: grouped-bar / stacked-bar / bi-polar-bar (→ bar, no -// multi-series grouping/stacking), stacked-area (→ area), step-line / spline -// (→ line), pyramid (→ funnel), bubble (→ scatter, no size encoding). +// about the output: bi-polar-bar (→ bar), step-line / spline (→ line), +// pyramid (→ funnel), bubble (→ scatter, no size encoding). // // Both can return via an opt-in renderer once there is a real renderer and a -// data model to back them. (`metric`/`kpi` are kept as honest single-value +// data model to back them. +// +// Grouped/stacked bar and stacked area are absent for a DIFFERENT reason, and +// a better one: stacking is not a chart family, it is a property of the series +// (`ChartSeries.stack` — series sharing a group id stack, otherwise they +// group). One `bar` family plus a series-level stack group expresses all three +// without multiplying the taxonomy. The renderer honors it (objectui#2880); +// before that it did not, which is why they once sat in the list above. (`metric`/`kpi` are kept as honest single-value // synonyms; `gauge`/`solid-gauge`/`bullet` render a value today and gain a dial // when a gauge renderer lands.) @@ -159,9 +165,15 @@ export const ChartAnnotationSchema = lazySchema(() => z.object({ * Chart Interaction Schema */ export const ChartInteractionSchema = lazySchema(() => z.object({ - tooltips: z.boolean().default(true), - zoom: z.boolean().default(false), - brush: z.boolean().default(false), + tooltips: z.boolean().default(true).describe('Show the hover tooltip'), + /** + * ⚠️ Declared, not delivered. The default Recharts renderer has no zoom + * primitive, so this draws nothing today — `brush` is the shipped way to + * narrow a range. Kept in the schema because an opt-in renderer can honor it; + * tracked with the rest of the chart-contract work in framework#3729. + */ + zoom: z.boolean().default(false).describe('Pan/zoom the plot — NOT implemented by the default renderer; use `brush`'), + brush: z.boolean().default(false).describe('Show the range selector under the plot'), clickAction: z.string().optional().describe('Action ID to trigger on click'), })); @@ -201,10 +213,12 @@ export const ChartConfigSchema = lazySchema(() => z.object({ showDataLabels: z.boolean().default(false).describe('Display data labels'), /** Annotations & Reference Lines */ - annotations: z.array(ChartAnnotationSchema).optional(), + annotations: z.array(ChartAnnotationSchema).optional() + .describe('Reference lines/bands drawn over the plot: { type: "line" | "region", axis: "x" | "y", value, endValue?, color?, label?, style? }'), /** Interactions */ - interaction: ChartInteractionSchema.optional(), + interaction: ChartInteractionSchema.optional() + .describe('Interaction toggles: { tooltips?, brush?, zoom?, clickAction? }'), /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index 9617775ffd..387354b36b 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -105,20 +105,22 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ schemaType: 'object-chart', summary: 'Chart over an object’s aggregated data. Bind objectName + aggregate; the axes name the aggregate’s RESULT COLUMNS (see chartAggregateResultKeys).', schema: ChartConfigSchema, - // `xAxis`/`yAxis`/`series` from ChartConfigSchema are deliberately NOT - // surfaced here (#3701): this block reads `xAxisKey` + `series[].dataKey`, - // so the ChartConfig axis shapes were silently inert — advertising them - // violated ADR-0078 (a prop the author writes is honored or rejected, - // never silently dropped). The real bindings are declared in the overlay - // below; `validate-react-page-props` rejects the ChartConfig spellings. - dataProps: ['title', 'colors', 'showLegend'], + // The spec ChartConfig shape IS the author contract again (#3729): the + // renderer honors `type`/`xAxis.field`/`yAxis[].field`/`series[].name` + // plus the axis presentation props (objectui#2880). #3701 had trimmed + // them out because they were silently inert — that was a record of the + // runtime gap, not the target state (ADR-0082 D1: spec is the protocol). + // + // `type` is the one field that cannot ride the props bag: it is the SDUI + // envelope's component discriminator on every FLATTENED surface, so the + // react-page wrapper parks an author `type` beside it and the renderer + // reads it back. It is published here as the spec spells it; the internal + // `chartType` spelling is no longer part of the author contract. + dataProps: ['type', 'title', 'subtitle', 'xAxis', 'yAxis', 'series', 'colors', 'showLegend', 'showDataLabels', 'annotations', 'interaction'], interactions: [ OBJECT_NAME, { name: 'filter', type: 'FilterArray', kind: 'controlled', description: 'ObjectQL filter scoping the data; drive from React state.' }, - { name: 'aggregate', type: "{ field?: string; function: 'count' | 'sum' | 'avg' | 'min' | 'max'; groupBy: string | { field: string; dateGranularity?: 'day' | 'week' | 'month' | 'quarter' | 'year' } }", kind: 'binding', description: 'Aggregation run against objectName. Result rows are keyed by the RAW FIELD NAMES: one column named after groupBy (the category) and one named after field (the value; the literal "count" for a fieldless count). Bind the axes to those names.' }, - { name: 'chartType', type: "'bar' | 'column' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter' | 'funnel' | 'combo' | 'treemap' | 'sankey'", kind: 'binding', description: 'Which chart to draw (default bar).' }, - { name: 'xAxisKey', type: 'string', kind: 'binding', description: 'Result column plotted on the category axis. Defaults to the aggregate’s groupBy — set it only to be explicit, and only to that name.' }, - { name: 'series', type: 'Array<{ dataKey: string; label?: string }>', kind: 'binding', description: 'Plotted series. Each dataKey names a result column — the aggregate’s field (or "count"), plus "__comparison" when a comparison overlay is on. NOT the ChartConfig series shape.' }, + { name: 'aggregate', type: "{ field?: string; function: 'count' | 'sum' | 'avg' | 'min' | 'max'; groupBy: string | { field: string; dateGranularity?: 'day' | 'week' | 'month' | 'quarter' | 'year' } }", kind: 'binding', description: 'Aggregation run against objectName. Result rows are keyed by the RAW FIELD NAMES: one column named after groupBy (the category) and one named after field (the value; the literal "count" for a fieldless count). Bind xAxis.field / yAxis[].field / series[].name to those names.' }, { name: 'data', type: 'any[]', kind: 'binding', description: 'Static/precomputed data to chart directly instead of binding via objectName + aggregate.' }, ], }, diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 0ad75e28eb..61e69ee841 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -646,17 +646,24 @@ dataset, so Level B only surfaces in Studio previews and hand-coded react-page OBJECT-bound inline `aggregate` has no such name, so its rows are keyed by the RAW FIELD NAMES it was given: `groupBy` for the category column, `field` for the value column (the literal `count` for a fieldless `count`). Bind - ``'s `xAxisKey` / `series[].dataKey` to *those* names — never to - a `sum_`-style measure name, and never to a field the aggregate did not - select. `os validate` checks both halves (`react-chart-field-unknown`, - `react-chart-axis-unknown`). + ``'s `xAxis.field` / `yAxis[].field` / `series[].name` to *those* + names — never to a `sum_`-style measure name, and never to a field the + aggregate did not select. `os validate` checks both halves + (`react-chart-field-unknown`, `react-chart-axis-unknown`). ```jsx - + xAxis={{ field: 'status' }} yAxis={[{ field: 'total', format: '$0,0' }]} + series={[{ name: 'total' }]} /> // rows: [{ status: 'open', total: 1200 }, …] ← keyed by the raw field names ``` +- **Charts speak the spec `ChartConfig` shape on every surface** — the same + `type` / `xAxis` / `yAxis` / `series` you write on a dashboard widget or a + report. Axis presentation rides on the axis (`format`, `min`/`max`, + `logarithmic`, `title`); a second `yAxis` entry plus `series[].yAxis: 'right'` + gives a dual axis; `series[].stack` groups a stacked bar; `annotations` draw + reference lines/bands. - Studio's Dashboard Widget Inspector can author per-widget `dataset`, `dimensions`, and `values`; curated metadata-admin forms merge server-only fields back into the payload, so saving through Studio should diff --git a/skills/objectstack-ui/contracts/react-blocks.contract.json b/skills/objectstack-ui/contracts/react-blocks.contract.json index 1485ef6fe3..9561b30291 100644 --- a/skills/objectstack-ui/contracts/react-blocks.contract.json +++ b/skills/objectstack-ui/contracts/react-blocks.contract.json @@ -304,28 +304,7 @@ "type": "{ field?: string; function: 'count' | 'sum' | 'avg' | 'min' | 'max'; groupBy: string | { field: string; dateGranularity?: 'day' | 'week' | 'month' | 'quarter' | 'year' } }", "kind": "binding", "required": false, - "description": "Aggregation run against objectName. Result rows are keyed by the RAW FIELD NAMES: one column named after groupBy (the category) and one named after field (the value; the literal \"count\" for a fieldless count). Bind the axes to those names." - }, - { - "name": "chartType", - "type": "'bar' | 'column' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter' | 'funnel' | 'combo' | 'treemap' | 'sankey'", - "kind": "binding", - "required": false, - "description": "Which chart to draw (default bar)." - }, - { - "name": "xAxisKey", - "type": "string", - "kind": "binding", - "required": false, - "description": "Result column plotted on the category axis. Defaults to the aggregate’s groupBy — set it only to be explicit, and only to that name." - }, - { - "name": "series", - "type": "Array<{ dataKey: string; label?: string }>", - "kind": "binding", - "required": false, - "description": "Plotted series. Each dataKey names a result column — the aggregate’s field (or \"count\"), plus \"__comparison\" when a comparison overlay is on. NOT the ChartConfig series shape." + "description": "Aggregation run against objectName. Result rows are keyed by the RAW FIELD NAMES: one column named after groupBy (the category) and one named after field (the value; the literal \"count\" for a fieldless count). Bind xAxis.field / yAxis[].field / series[].name to those names." }, { "name": "data", @@ -341,6 +320,13 @@ "required": false, "description": "ObjectQL filter scoping the data; drive from React state." }, + { + "name": "type", + "type": "'bar' | 'horizontal-bar' | 'column' | 'line' | 'area' | 'pie' | 'donut' | 'funnel' | 'scatter' | 'treemap' | 'sankey' | 'gauge' | 'solid-gauge' | 'metric' | 'kpi' | 'bullet' | 'radar' | 'table' | 'pivot'", + "kind": "data", + "required": true, + "description": "" + }, { "name": "title", "type": "string", @@ -348,6 +334,34 @@ "required": false, "description": "Chart title" }, + { + "name": "subtitle", + "type": "string", + "kind": "data", + "required": false, + "description": "Chart subtitle" + }, + { + "name": "xAxis", + "type": "object", + "kind": "data", + "required": false, + "description": "X-Axis configuration" + }, + { + "name": "yAxis", + "type": "object[]", + "kind": "data", + "required": false, + "description": "Y-Axis configuration (support dual axis)" + }, + { + "name": "series", + "type": "object[]", + "kind": "data", + "required": false, + "description": "Defined series configuration" + }, { "name": "colors", "type": "string[] | object", @@ -361,6 +375,27 @@ "kind": "data", "required": false, "description": "Display legend" + }, + { + "name": "showDataLabels", + "type": "boolean", + "kind": "data", + "required": false, + "description": "Display data labels" + }, + { + "name": "annotations", + "type": "object[]", + "kind": "data", + "required": false, + "description": "Reference lines/bands drawn over the plot: { type: \"line\" | \"region\", axis: \"x\" | \"y\", value, endValue?, color?, label?, style? }" + }, + { + "name": "interaction", + "type": "object", + "kind": "data", + "required": false, + "description": "Interaction toggles: { tooltips?, brush?, zoom?, clickAction? }" } ] }, diff --git a/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json b/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json index 601f9e1989..85f5871777 100644 --- a/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json +++ b/skills/objectstack-ui/evals/analytics-inline-vs-dataset.json @@ -46,7 +46,7 @@ { "id": 5, "prompt": "In a kind:'react' page, drop a quick one-off bar chart of tasks grouped by status. Write the block.", - "expected_output": "Uses the inline block directly — an ad-hoc in-page chart does not require a dataset, and the react ObjectChart block has no dataset prop. Naming a dataset would only be for reuse/governance (Level B), not needed here. Note the axes name the aggregate's RESULT COLUMNS: `status` (its groupBy) and `count` (the fieldless-count column) — a fieldless count needs no field.", + "expected_output": "Uses the inline block directly — an ad-hoc in-page chart does not require a dataset, and the react ObjectChart block has no dataset prop. Naming a dataset would only be for reuse/governance (Level B), not needed here. Note the axes name the aggregate's RESULT COLUMNS: `status` (its groupBy) and `count` (the fieldless-count column) — a fieldless count needs no field.", "files": [], "assertions": { "must_contain": ["ObjectChart", "aggregate", "groupBy"], diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 35de808379..1afbaa9434 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -70,15 +70,20 @@ Chart over an object’s aggregated data. Bind objectName + aggregate; the axes | prop | type | kind | required | description | |------|------|------|:--------:|-------------| | `objectName` | `string` | binding | ✓ | The object this block binds to (server-connected). | -| `aggregate` | `{ field?: string; function: 'count' \| 'sum' \| 'avg' \| 'min' \| 'max'; groupBy: string \| { field: string; dateGranularity?: 'day' \| 'week' \| 'month' \| 'quarter' \| 'year' } }` | binding | | Aggregation run against objectName. Result rows are keyed by the RAW FIELD NAMES: one column named after groupBy (the category) and one named after field (the value; the literal "count" for a fieldless count). Bind the axes to those names. | -| `chartType` | `'bar' \| 'column' \| 'horizontal-bar' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'radar' \| 'scatter' \| 'funnel' \| 'combo' \| 'treemap' \| 'sankey'` | binding | | Which chart to draw (default bar). | -| `xAxisKey` | `string` | binding | | Result column plotted on the category axis. Defaults to the aggregate’s groupBy — set it only to be explicit, and only to that name. | -| `series` | `Array<{ dataKey: string; label?: string }>` | binding | | Plotted series. Each dataKey names a result column — the aggregate’s field (or "count"), plus "__comparison" when a comparison overlay is on. NOT the ChartConfig series shape. | +| `aggregate` | `{ field?: string; function: 'count' \| 'sum' \| 'avg' \| 'min' \| 'max'; groupBy: string \| { field: string; dateGranularity?: 'day' \| 'week' \| 'month' \| 'quarter' \| 'year' } }` | binding | | Aggregation run against objectName. Result rows are keyed by the RAW FIELD NAMES: one column named after groupBy (the category) and one named after field (the value; the literal "count" for a fieldless count). Bind xAxis.field / yAxis[].field / series[].name to those names. | | `data` | `any[]` | binding | | Static/precomputed data to chart directly instead of binding via objectName + aggregate. | | `filter` | `FilterArray` | controlled | | ObjectQL filter scoping the data; drive from React state. | +| `type` | `'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| 'funnel' \| 'scatter' \| 'treemap' \| 'sankey' \| 'gauge' \| 'solid-gauge' \| 'metric' \| 'kpi' \| 'bullet' \| 'radar' \| 'table' \| 'pivot'` | data | ✓ | | | `title` | `string` | data | | Chart title | +| `subtitle` | `string` | data | | Chart subtitle | +| `xAxis` | `object` | data | | X-Axis configuration | +| `yAxis` | `object[]` | data | | Y-Axis configuration (support dual axis) | +| `series` | `object[]` | data | | Defined series configuration | | `colors` | `string[] \| object` | data | | Color palette (string[]) or value→color map ({ value: color }) | | `showLegend` | `boolean` | data | | Display legend | +| `showDataLabels` | `boolean` | data | | Display data labels | +| `annotations` | `object[]` | data | | Reference lines/bands drawn over the plot: { type: "line" \| "region", axis: "x" \| "y", value, endValue?, color?, label?, style? } | +| `interaction` | `object` | data | | Interaction toggles: { tooltips?, brush?, zoom?, clickAction? } | ## `` — `record:details`