From 593be2ee9526e61f69f051f4c17c59c1d6ea08d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:09:17 +0000 Subject: [PATCH 1/3] fix(types): ChartDataSeriesSchema declares the six series keys the renderer reads (objectui#7546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ChartDataSeriesSchema` is a non-strict `z.object`, so `label`, `variant`, `opacity`, `dashArray`, `stack` and `yAxis` — all read by `normalizeSeries` and all doing real work in `AdvancedChartImpl` — were stripped in silence while `safeParse` reported success. Reproduced red on a472b071: the card's fixture parsed to `{ name }`. Both faces (TS twin and Zod mirror) now declare the six with the read's own value domains: `label` as the spec's `I18nLabel`, `variant` as the three values the normalizer honours, `opacity` finite, `yAxis` left/right. Measured per key with lit controls, per the PM ruling. `chartType` — the seventh key the review found — is deliberately NOT declared: it is the renderer's internal spelling of `type`, the spec lists it as an alias of `type` and refuses it by name, and nothing on this authoring face writes it. Reported for its own card; the pin test holds that gap visible. Docs: the schema-reference `series` row and a per-series options section in the plugin-charts guide. Changeset graded `minor` — the mirror now refuses a malformed value the renderer used to ignore. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .changeset/7546-chart-series-keys-declared.md | 80 ++++++ content/docs/api/schema-reference.md | 2 +- content/docs/plugins/plugin-charts.mdx | 32 +++ .../__tests__/chart-series-keys-7546.test.ts | 239 ++++++++++++++++++ packages/types/src/data-display.ts | 67 ++++- packages/types/src/zod/data-display.zod.ts | 30 ++- 6 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 .changeset/7546-chart-series-keys-declared.md create mode 100644 packages/types/src/__tests__/chart-series-keys-7546.test.ts diff --git a/.changeset/7546-chart-series-keys-declared.md b/.changeset/7546-chart-series-keys-declared.md new file mode 100644 index 0000000000..f0f2f51de6 --- /dev/null +++ b/.changeset/7546-chart-series-keys-declared.md @@ -0,0 +1,80 @@ +--- +'@object-ui/types': minor +--- + +`ChartDataSeriesSchema` (and its TS twin `ChartDataSeries`) declares the six series keys the +renderer reads — `label`, `variant`, `opacity`, `dashArray`, `stack`, `yAxis` — which the +non-strict Zod object had been **stripping in silence** while `safeParse` reported success +(objectui#7546, `domain:ui` PM ruling: measure per key, declare what is live, report the rest). + +⚠️ Shipped as `minor`, not `patch`. The declared value domains are the read's own, so the accept +set widens toward what already renders — but one document class that validated before now +**refuses**: a series carrying one of these keys with a value the renderer drops in silence +(`variant: 'bogus'`, `yAxis: 'top'`, `opacity: '0.4'` / `Infinity`, a non-string `stack` / +`dashArray`, a non-string non-map `label`). Such a document draws a chart today — the normalizer +ignores the bad value — so this is a narrowing away from something that renders, which is the +distinction objectui#6939's grading language turns on, and it takes the level objectui#6896 and +objectui#7113 set for the same transition in this same file. This repository's `major` is a +cross-repo pin to `@objectstack`'s major, not a severity dial; the change is announced here. + +## The defect, measured + +`ChartDataSeriesSchema` is a non-strict `z.object` — not `.passthrough()` like `BaseSchema` — so an +undeclared key is removed, not kept. Reproduced red on `origin/main` `a472b071` before the change: + +``` +input : { name, label, stack, yAxis, opacity, dashArray, variant } +parse : success = true +output: { name } +``` + +Every one of the six is read by `normalizeSeries` (`@object-ui/plugin-charts`, +`normalizeChartSchema.ts:242-255`) and does real work in `AdvancedChartImpl.tsx` — `label` names +the legend entry, `variant === 'comparison'` selects the muted overlay, `opacity` / `dashArray` set +stroke and fill, `stack` becomes Recharts' `stackId`, `yAxis` binds the secondary axis. Any consumer +of the parse output — `objectui check` / `objectui validate` via `safeValidateSchema`, a JSON +schema derived from the mirror, or any pipeline that keeps `parse()`'s result — lost them outright. + +## Per-key liveness, not a blanket declare + +"The renderer reads it" was ruled insufficient (a read leg can sit on a value nothing produces — +objectui#7642), so each key was measured on producers, real work in the reader, and consumer +surprise, with a lit control on every count. The six are `@objectstack/spec`'s own +`ChartSeriesSchema` members under the same names and value domains; this node's `series` +accepts the spec shape by design; in-repo producers write them onto `type: 'chart'` nodes +(`DashboardRenderer`, `ObjectChart`, `DatasetWidget`, `core/utils/chart-presentation`); and +the `variant` / `yAxis` narrowings are design intent the reader already enforces. + +**`chartType` — the seventh key the review found — is deliberately NOT declared.** It is the first +limb of `str(raw.chartType) ?? str(raw.type)`, but it is the renderer's *internal* spelling of the +declared `type`; the spec's `ChartSeriesSchema` lists it as an alias of `type` and refuses it by +name; and zero documents, fixtures, catalog entries or designer inputs write it on this face +(controls lit). Declaring it would mint a second writable name for one override. It is reported +for its own card; the mirror still strips it, and the pin test holds that gap visible. + +## FROM → TO + +```ts +// ChartDataSeries — all optional, all additive on the TS face ++ label?: string | I18nLabel; // spec I18nLabel: string | inline locale map ++ variant?: 'primary' | 'comparison' | 'current'; // the three normalizeSeries honours ++ opacity?: number; // finite; NaN / Infinity / strings refused ++ dashArray?: string; ++ stack?: string; ++ yAxis?: 'left' | 'right'; +``` + +`variant` is one value wider than the spec's `'primary' | 'comparison'`: `current` is what this +repository's own comparison producers write (`ObjectChart`, `DatasetWidget`) and what the renderer +reads, and it draws identically to `primary`. + +## Unchanged, deliberately + +The object stays non-strict — a truly undeclared key is still stripped, exactly as +`chart-inline-data-retired.test.ts` pins; this change declares what is read, it does not close +the object. The `data` tombstone (objectui#6896) and the at-least-one-binding refinement +(objectui#6939 / #7113) are untouched. No reader changed. + +Pinned in `packages/types/src/__tests__/chart-series-keys-7546.test.ts` — the card's fixture +surviving byte-for-byte, each key on the mirror's own `.shape`, each value domain refusing at its +own path, the TS face in lockstep, and the `chartType` gap. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 5f36bd0247..ad2a1b9c19 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -479,7 +479,7 @@ A chart visualization supporting multiple chart types. | `title` | `string` | Chart title. | | `description` | `string` | Chart description / subtitle. | | `categories` | `string[]` | X-axis category labels. | -| `series` | `ChartSeries[]` | Data series, each with `name`, `data` array, and optional `color`. | +| `series` | `ChartDataSeries[]` | Data series. Each names the column it plots within the chart-level `data` rows (`name`, or `dataKey`) and may carry `label`, `type` (`bar` / `line` / `area` per-series override), `color`, `stack`, `yAxis` (`left` / `right`), `variant` (`primary` / `comparison` / `current`), `dashArray` and `opacity`. | | `height` / `width` | `string \| number` | Chart dimensions. | | `showLegend` | `boolean` | Display the legend. | | `showGrid` | `boolean` | Display grid lines. | diff --git a/content/docs/plugins/plugin-charts.mdx b/content/docs/plugins/plugin-charts.mdx index e2c7dc393a..e08ccd9547 100644 --- a/content/docs/plugins/plugin-charts.mdx +++ b/content/docs/plugins/plugin-charts.mdx @@ -178,6 +178,38 @@ const schema = { } ``` +##### Per-series options + +Each `series` entry names the column it plots (`dataKey`, or the spec spelling `name`) and may carry the presentation keys the renderer reads — the same keys as `@objectstack/spec`'s `ChartSeries`: + +| Key | Type | Effect | +|---|---|---| +| `label` | `string` or inline locale map | Legend / tooltip name; defaults to the column key. | +| `type` | `'bar' \| 'line' \| 'area'` | Per-series family override — a combo chart is a series disagreeing with the chart's `chartType`. | +| `color` | `string` | Series colour; wins over the positional palette. | +| `stack` | `string` | Stack group id — series sharing one id stack together. | +| `yAxis` | `'left' \| 'right'` | Which y-axis the series binds to, on a chart that declares a second y-axis. | +| `variant` | `'primary' \| 'comparison' \| 'current'` | `comparison` draws the muted period-over-period overlay. | +| `dashArray` | `string` | SVG `stroke-dasharray`, e.g. `"4 4"` for a dashed line. | +| `opacity` | `number` | Stroke and fill opacity, 0–1. | + +```tsx +const schema = { + type: 'chart', + chartType: 'bar', + data: [ + { month: 'Jan', revenue: 400, cost: 250, revenue_prev: 360 }, + { month: 'Feb', revenue: 300, cost: 210, revenue_prev: 330 } + ], + xAxisKey: 'month', + series: [ + { name: 'revenue', label: 'Revenue', stack: 'money' }, + { name: 'cost', label: 'Cost', stack: 'money', opacity: 0.6 }, + { name: 'revenue_prev', label: 'Revenue (previous period)', variant: 'comparison', dashArray: '4 4' } + ] +} +``` + ## Examples ### Revenue Dashboard diff --git a/packages/types/src/__tests__/chart-series-keys-7546.test.ts b/packages/types/src/__tests__/chart-series-keys-7546.test.ts new file mode 100644 index 0000000000..931058d174 --- /dev/null +++ b/packages/types/src/__tests__/chart-series-keys-7546.test.ts @@ -0,0 +1,239 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7546 — `ChartDataSeriesSchema` declares the SIX series keys its + * renderer reads and honours: `label`, `variant`, `opacity`, `dashArray`, + * `stack`, `yAxis`. The seventh key the review found, `chartType`, is measured + * NOT live on this authoring face and is deliberately left undeclared — see the + * last block, which pins that decision so it is visible rather than silent. + * + * ## The defect (measured RED on origin/main a472b071 before the declaration) + * + * `ChartDataSeriesSchema` is a NON-STRICT `z.object`, unlike `BaseSchema`'s + * `.passthrough()`: an undeclared key is STRIPPED in silence and `safeParse` + * still reports success. The card's own fixture reproduced exactly: + * + * input : { name, label, stack, yAxis, opacity, dashArray, variant } + * parse : success = true + * output: { name } + * + * Every one of the six is read by `normalizeSeries` + * (`@object-ui/plugin-charts`, `normalizeChartSchema.ts:242-255`) and does real + * work downstream in `AdvancedChartImpl.tsx`: `label` names the legend entry + * (:1364, :1592; `ChartRenderer.tsx:157`), `variant === 'comparison'` selects + * the muted overlay treatment (:2010-2033), `opacity` / `dashArray` set the + * stroke and fill (:94-96), `stack` becomes Recharts' `stackId` (:1893, :2023) + * and `yAxis` binds the series to the secondary axis (:1887, :2019, :2025). + * + * ## The ruling (comment 5548523375): measure per key, declare what is live + * + * "The renderer reads it" was ruled INSUFFICIENT evidence of liveness (a read + * leg can sit on a value nothing produces — objectui#7642). So each key was + * measured on three axes, with a lit control on every count: (1) producers, + * (2) real work in the reader, (3) whether declaring it could surprise a + * consumer. The six are the spec's own canonical `ChartSeriesSchema` members + * (`@objectstack/spec/ui`, `chart.zod.ts:243-276`) under the same names and + * with the same value domains; this node's `series` accepts the spec shape by + * design ("both shapes" — `ChartRenderer.tsx:55-66`), in-repo producers write + * them onto `type: 'chart'` nodes (`DashboardRenderer.tsx:648-652` `label`; + * `ObjectChart.tsx:852-856` and `DatasetWidget.tsx:1444-1447` `variant`, + * `chartType`, `yAxis`; `core/utils/chart-presentation.ts:126-131` all six on + * the dataset path), and the narrowings (`variant`, `yAxis`) are design intent + * the reader already enforces. Declaring them widens the accept set only + * toward what already renders. + * + * ## The seventh — `chartType` — measured, and NOT declared + * + * The same instrument reads it differently on every axis. (1) Zero producers + * on this authoring face — no doc, fixture, catalog entry or designer input + * writes `chartType` on a series (controls `name` / `dataKey` / `color` lit in + * the same query); its only literal producers pass the renderer's INTERNAL + * shape directly. (2) The spec's `ChartSeriesSchema` does not declare it — it + * lists `chartType` in its `aliases` map as a spelling of `type`, refused by + * name (`chart.zod.ts:231`), and this package's own `ChartDataSeries.type` + * docblock (objectui#6121) calls `type` the AUTHOR spelling and `chartType` + * the internal one. (3) Declaring it would mint a second writable name for the + * same override, against the spec's alias posture — the N-dialects hazard + * AGENTS.md #0.1 names. So it is REPORTED, per the ruling, not declared and + * not retired: the right shape for it (a named alias refusal pointing at + * `type`, as the spec does; or a fold) is a contract decision for its own card. + */ + +import { describe, it, expect } from 'vitest'; +import type { ChartDataSeries } from '../data-display'; +import { ChartDataSeriesSchema } from '../zod/data-display.zod'; + +/** The card's own fixture — byte-for-byte the input it measured. */ +const CARD_FIXTURE = { + name: 'Revenue', + label: 'Revenue (USD)', + stack: 'money', + yAxis: 'right', + opacity: 0.4, + dashArray: '4 4', + variant: 'comparison', +} as const; + +const SIX = ['label', 'variant', 'opacity', 'dashArray', 'stack', 'yAxis'] as const; + +const shapeOf = (schema: unknown): Record => + (schema as { shape: Record }).shape; + +const refusalPaths = (input: unknown): string[] => { + const r = ChartDataSeriesSchema.safeParse(input); + return r.success ? [] : r.error.issues.map((i) => i.path.join('.')); +}; + +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +/* ── (a) the reproduction: the card's fixture, every key surviving ────────── */ + +describe('objectui#7546 — the card fixture parses green AND every authored key survives', () => { + it('reproduces the card measurement, inverted: output equals input, not `{ name }`', () => { + // RED on the untouched base: success is true and the output is `{ name: 'Revenue' }`. + const r = ChartDataSeriesSchema.safeParse(CARD_FIXTURE); + expect(r.success).toBe(true); + if (r.success) expect(r.data).toEqual(CARD_FIXTURE); + }); + + it.each(SIX)('`%s` survives the parse with its authored value', (key) => { + const r = ChartDataSeriesSchema.safeParse(CARD_FIXTURE); + expect(r.success).toBe(true); + if (r.success) expect(r.data).toHaveProperty(key, CARD_FIXTURE[key]); + }); + + it('CONTROL — a key declared BEFORE this change survives under the same query', () => { + // `name` / `type` / `color` were declared on the base; if this fails the + // instrument is dark, and the six readings above say nothing. + const r = ChartDataSeriesSchema.safeParse({ name: 'Revenue', type: 'line', color: '#3b82f6' }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data).toHaveProperty('name', 'Revenue'); + expect(r.data).toHaveProperty('type', 'line'); + expect(r.data).toHaveProperty('color', '#3b82f6'); + } + }); + + it('the six are on the mirror\'s OWN shape — what `zod-mirror-parity` reads', () => { + for (const key of SIX) expect(shapeOf(ChartDataSeriesSchema)).toHaveProperty(key); + }); +}); + +/* ── (b) the declarations narrow to what the renderer honours ─────────────── */ + +describe('objectui#7546 — each declaration is the read\'s own value domain, not `unknown`', () => { + it.each(['primary', 'comparison', 'current'] as const)('`variant: %s` is accepted — the three `normalizeSeries` honours', (v) => { + expect(ChartDataSeriesSchema.safeParse({ name: 'r', variant: v }).success).toBe(true); + }); + + it('`variant` refuses a value the renderer would drop in silence, at its own path', () => { + expect(refusalPaths({ name: 'r', variant: 'bogus' })).toEqual(['variant']); + }); + + it.each(['left', 'right'] as const)('`yAxis: %s` is accepted', (v) => { + expect(ChartDataSeriesSchema.safeParse({ name: 'r', yAxis: v }).success).toBe(true); + }); + + it('`yAxis` refuses a side the renderer does not bind', () => { + expect(refusalPaths({ name: 'r', yAxis: 'top' })).toEqual(['yAxis']); + }); + + it('`opacity` is a FINITE number — exactly `num()`\'s `Number.isFinite` gate', () => { + expect(ChartDataSeriesSchema.safeParse({ name: 'r', opacity: 0.4 }).success).toBe(true); + expect(refusalPaths({ name: 'r', opacity: '0.4' })).toEqual(['opacity']); + expect(refusalPaths({ name: 'r', opacity: Number.POSITIVE_INFINITY })).toEqual(['opacity']); + }); + + it('`dashArray` and `stack` are strings', () => { + expect(ChartDataSeriesSchema.safeParse({ name: 'r', dashArray: '4 2', stack: 'g' }).success).toBe(true); + expect(refusalPaths({ name: 'r', dashArray: 4 })).toEqual(['dashArray']); + expect(refusalPaths({ name: 'r', stack: 1 })).toEqual(['stack']); + }); + + it('`label` takes the spec\'s `I18nLabel` — a string OR an inline locale map, as `label()` reads', () => { + expect(ChartDataSeriesSchema.safeParse({ name: 'r', label: 'Revenue' }).success).toBe(true); + const map = ChartDataSeriesSchema.safeParse({ name: 'r', label: { en: 'Revenue', 'zh-CN': '收入' } }); + expect(map.success).toBe(true); + if (map.success) expect(map.data.label).toEqual({ en: 'Revenue', 'zh-CN': '收入' }); + expect(refusalPaths({ name: 'r', label: 42 })).toEqual(['label']); + }); +}); + +/* ── (c) the TypeScript face moves in lockstep ────────────────────────────── */ + +describe('objectui#7546 — the `ChartDataSeries` interface declares the same six', () => { + it('a series authored with all six type-checks and round-trips', () => { + const series: ChartDataSeries = { + name: 'Revenue', + label: { en: 'Revenue' }, + variant: 'comparison', + opacity: 0.4, + dashArray: '4 4', + stack: 'money', + yAxis: 'right', + }; + expect(ChartDataSeriesSchema.parse(series)).toEqual(series); + }); + + it('the unions are the reader\'s, pinned at the type level', () => { + const variant: Eq = true; + const yAxis: Eq = true; + expect(variant && yAxis).toBe(true); + }); +}); + +/* ── (d) the seventh key: measured NOT live here, reported — and pinned ────── */ + +describe('objectui#7546 — `chartType` is NOT declared on the series (reported, not retired)', () => { + it('is still stripped — the split is pinned so it stays visible, not so it lasts', () => { + // If this goes red, someone declared `chartType` on the series. That is a + // contract decision this card explicitly did not take (see the file + // header): either its own card landed — then move this pin there — or it + // was added by hand, and the spec's alias posture (`chartType` -> `type`) + // is the thing to re-read first. + const r = ChartDataSeriesSchema.safeParse({ name: 'r', chartType: 'line' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data).not.toHaveProperty('chartType'); + expect(shapeOf(ChartDataSeriesSchema)).not.toHaveProperty('chartType'); + }); + + it('the TS face agrees — `chartType` is an excess property on `ChartDataSeries`', () => { + // @ts-expect-error `chartType` is the renderer's INTERNAL spelling of `type`; not a member here (objectui#7546) + const s: ChartDataSeries = { name: 'r', chartType: 'line' }; + expect(s.name).toBe('r'); + }); + + it('CONTROL — `type`, the author spelling of the same override, is declared and survives', () => { + const r = ChartDataSeriesSchema.safeParse({ name: 'r', type: 'line' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data).toHaveProperty('type', 'line'); + }); +}); + +/* ── (e) neighbours unchanged: the fix is declaration, not `.strict()` ────── */ + +describe('objectui#7546 — what did NOT change', () => { + it('the `data` tombstone (objectui#6896) still refuses with its remedy', () => { + const r = ChartDataSeriesSchema.safeParse({ name: 'r', data: [1, 2, 3] }); + expect(r.success).toBe(false); + if (!r.success) expect(r.error.issues[0]?.message).toMatch(/RETIRED \(objectui#6896\)/); + }); + + it('a series binding to neither `name` nor `dataKey` is still refused at `name` (objectui#6939)', () => { + expect(refusalPaths({ color: '#fff' })).toContain('name'); + }); + + it('a truly undeclared key is still stripped — the object is still non-strict', () => { + // Deliberate: this change declares the keys the renderer reads; it does not + // close the object. `chart-inline-data-retired.test.ts` pins the same fact. + const r = ChartDataSeriesSchema.safeParse({ name: 'r', notAKeyAtAll: 'x' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data).not.toHaveProperty('notAKeyAtAll'); + }); +}); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 681dfc58be..8abbf7fedd 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -15,7 +15,7 @@ * @packageDocumentation */ -import type { ChartType as SpecChartType } from '@objectstack/spec/ui'; +import type { ChartType as SpecChartType, I18nLabel } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; import type { BreadcrumbSchema } from './navigation.js'; @@ -1407,6 +1407,71 @@ export interface ChartDataSeries { * Series color */ color?: string; + /** + * Legend / tooltip name for this series — the spec's `I18nLabel`: a plain + * string, or an inline locale map (`{ en: 'Revenue', 'zh-CN': '收入' }`), + * the same spelling as {@link BaseSchema.label}. + * + * Declared by objectui#7546. `normalizeSeries` reads it through `label()` + * (`normalizeChartSchema.ts:242` — a string as-is, the first string value of + * a map), and the legend takes it at `ChartRenderer.tsx:157` + * (`s.label || s.dataKey`) and `AdvancedChartImpl.tsx:1364`. Until this + * declaration the Zod mirror STRIPPED it in silence — `ChartDataSeriesSchema` + * is a non-strict `z.object` — so a parsed series lost its name and the + * legend fell back to the column key. + */ + label?: string | I18nLabel; + /** + * Visual role. `'comparison'` is the muted period-over-period overlay + * (`AdvancedChartImpl.tsx:2010-2033` — lower opacity, dashed stroke, and it + * is left out when the primary series are counted); `'primary'` and + * `'current'` both mean "not the overlay" and draw identically. + * + * ⚠️ The union is the THREE values `normalizeSeries` honours + * (`normalizeChartSchema.ts:246-247`) — one wider than the spec's own + * `ChartSeries.variant` (`'primary' | 'comparison'`), because `current` is + * the spelling this repository's own comparison producers write + * (`ObjectChart.tsx:852`, `DatasetWidget.tsx:1450`) and the renderer reads. + * Any other value is dropped in silence by the normalizer, so the mirror + * refuses it by name instead (objectui#7546). + */ + variant?: 'primary' | 'comparison' | 'current'; + /** + * Stroke and fill opacity, 0–1. Read by `num()` (`normalizeChartSchema.ts:248` + * — any finite number; the mirror refuses `NaN`, `Infinity` and strings the + * same way) and applied at `AdvancedChartImpl.tsx:94-95`, where it overrides + * the per-family default (objectui#7546). + */ + opacity?: number; + /** + * SVG `stroke-dasharray` override, e.g. `"4 4"` for a dashed line + * (`normalizeChartSchema.ts:250`; applied at `AdvancedChartImpl.tsx:96`) + * (objectui#7546). + */ + dashArray?: string; + /** + * Stack group id — series sharing one id stack together. Becomes Recharts' + * `stackId` (`normalizeChartSchema.ts:252`; `AdvancedChartImpl.tsx:1893`, + * `:2023`) (objectui#7546). + */ + stack?: string; + /** + * Which y-axis this series binds to on a dual-axis chart. Narrowed to the two + * sides the renderer binds (`normalizeChartSchema.ts:254-255`; + * `AdvancedChartImpl.tsx:1887`, `:2019`) — the spec's `ChartSeries.yAxis` + * carries the same union (objectui#7546). + */ + yAxis?: 'left' | 'right'; + // ⛔ NOT declared: `chartType`. It is the first limb of `normalizeSeries`' + // `str(raw.chartType) ?? str(raw.type)` (`normalizeChartSchema.ts:244`), but + // it is the renderer's INTERNAL spelling of `type` above, the spec's + // `ChartSeriesSchema` lists it as an alias of `type` and refuses it by name + // (`@objectstack/spec` `ui/chart.zod.ts:231`), and no document, fixture or + // designer input on this face writes it (objectui#7546 — measured with lit + // controls). Declaring it would mint a second writable name for one override; + // its shape — a named alias refusal like the spec's, or a fold — is a contract + // decision for its own card. Until then the mirror still strips it, and + // `__tests__/chart-series-keys-7546.test.ts` pins that gap so it stays visible. } /** diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 1178da417a..f6769b2c13 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -17,7 +17,7 @@ */ import { z } from 'zod'; -import { ChartTypeSchema as SpecChartTypeSchema } from '@objectstack/spec/ui'; +import { ChartTypeSchema as SpecChartTypeSchema, I18nLabelSchema } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { TABLE_COLUMN_TYPES } from '../data-display.js'; @@ -385,6 +385,34 @@ export const ChartDataSeriesSchema = z.object({ // the TS declaration for the read this narrowness is taken from. type: z.enum(['bar', 'line', 'area']).optional().describe('Per-series chart family override (combo charts)'), color: z.string().optional().describe('Series color'), + // THE SIX KEYS THE RENDERER READS (objectui#7546). Each was undeclared, and + // because this object is NON-STRICT the mirror STRIPPED it in silence while + // `safeParse` reported success — the card's own measurement: + // `{ name, label, stack, yAxis, opacity, dashArray, variant }` parsed to + // `{ name }`. Every one is read by `normalizeSeries` + // (`normalizeChartSchema.ts:242-255`) and does real work in + // `AdvancedChartImpl.tsx`; each value domain below is the read's own, so the + // accept set widens only toward what already renders, and a value the + // renderer would drop in silence is refused by name instead. They are the + // spec's `ChartSeriesSchema` members under the same names; the TS twin's + // docblocks carry the read sites and the liveness measurement. + // + // ⛔ `chartType` is NOT among them — deliberately. It is the renderer's + // INTERNAL spelling of `type` (the first limb of + // `str(raw.chartType) ?? str(raw.type)`), the spec refuses it by name as an + // alias of `type`, and nothing on this authoring face writes it. Declaring it + // is a contract decision for its own card; `chart-series-keys-7546.test.ts` + // pins the gap so it stays visible. + label: I18nLabelSchema.optional().describe( + 'Legend / tooltip name for this series — a plain string or an inline locale map; defaults to the column key', + ), + variant: z.enum(['primary', 'comparison', 'current']).optional().describe( + 'Visual role — `comparison` draws the muted period-over-period overlay; `primary` and `current` both mean the normal treatment', + ), + opacity: z.number().optional().describe('Stroke and fill opacity override, 0–1'), + dashArray: z.string().optional().describe('SVG stroke-dasharray override, e.g. "4 4" for a dashed line'), + stack: z.string().optional().describe('Stack group id — series sharing one id stack together'), + yAxis: z.enum(['left', 'right']).optional().describe('Which y-axis this series binds to on a dual-axis chart'), }).superRefine((series, ctx) => { // `normalizeSeries` returns `undefined` when NEITHER spelling resolves // (`normalizeChartSchema.ts:240`, `if (!dataKey) return undefined`) and the From 493b52eb3a9010a2190c1eba7cdf171def0f6570 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 06:57:32 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(types):=20ChartDataSeries.variant=20nar?= =?UTF-8?q?rows=20to=20the=20spec=20pair=20=E2=80=94=20current=20is=20rend?= =?UTF-8?q?erer-internal=20(objectui#7546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract-review remediation on PR #7684 (comment 5548861339). - variant: z.enum(['primary', 'comparison']) on the Zod mirror and the matching TS union. The third value, 'current', is the renderer's internal default: written only by ObjectChart.tsx:852 and DatasetWidget.tsx:1450 onto dataKey-shaped arrays handed straight to ChartRenderer / SchemaRenderer (hand-written validateSchema, no zod), so it never meets this mirror; docs, fixtures and designer inputs write it 0 times (controls lit); the installed spec 17.2.0 refuses it. Declaring it would fossilise a renderer-side tolerance into a second contract (AGENTS.md #0.1) — the same reading that excluded chartType. No reader changed: the normalizer's tolerance stays for objectui#7682. - Pin test: it.each over the pair, a new refusal pin for 'current', the Eq pin narrowed; both doc pages and the changeset drop 'current'. - opacity stays z.number() (the read's own finite domain; SVG clamps, nothing is dropped); the doc row and describe now say "any finite number (the spec bounds it to 0–1)" instead of claiming a bound the mirror does not enforce. - Docs no longer teach opacity on a primary series: the renderer reads opacity / dashArray only inside comparisonStyle (objectui#7698, filed). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .changeset/7546-chart-series-keys-declared.md | 13 +++-- content/docs/api/schema-reference.md | 2 +- content/docs/plugins/plugin-charts.mdx | 10 ++-- .../__tests__/chart-series-keys-7546.test.ts | 27 +++++++--- packages/types/src/data-display.ts | 50 ++++++++++++------- packages/types/src/zod/data-display.zod.ts | 6 +-- 6 files changed, 71 insertions(+), 37 deletions(-) diff --git a/.changeset/7546-chart-series-keys-declared.md b/.changeset/7546-chart-series-keys-declared.md index f0f2f51de6..307a915a79 100644 --- a/.changeset/7546-chart-series-keys-declared.md +++ b/.changeset/7546-chart-series-keys-declared.md @@ -57,16 +57,19 @@ for its own card; the mirror still strips it, and the pin test holds that gap vi ```ts // ChartDataSeries — all optional, all additive on the TS face + label?: string | I18nLabel; // spec I18nLabel: string | inline locale map -+ variant?: 'primary' | 'comparison' | 'current'; // the three normalizeSeries honours -+ opacity?: number; // finite; NaN / Infinity / strings refused ++ variant?: 'primary' | 'comparison'; // the spec's own pair ++ opacity?: number; // finite; NaN / Infinity / strings refused (the spec's 0–1 bound is not enforced — the read's domain) + dashArray?: string; + stack?: string; + yAxis?: 'left' | 'right'; ``` -`variant` is one value wider than the spec's `'primary' | 'comparison'`: `current` is what this -repository's own comparison producers write (`ObjectChart`, `DatasetWidget`) and what the renderer -reads, and it draws identically to `primary`. +`variant` is the spec's own pair. The normalizer also tolerates a third spelling, `current`, but that +is the renderer's internal default — written only by the compare-to producers (`ObjectChart`, +`DatasetWidget`) onto internal-shape arrays that never pass this mirror, and by nothing an author +writes (docs, fixtures, designer inputs: 0, controls lit) — so it is not a member here: declaring it +would have fossilised a renderer-side tolerance into a second contract. The normalizer's tolerance +is unchanged; objectui#7682 owns that decision. ## Unchanged, deliberately diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 4d260e0acf..1be46b4385 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -491,7 +491,7 @@ numbers of its own: `ChartDataSeries.data` is a retirement tombstone | `title` | `string` | Chart title. | | `description` | `string` | Chart description / subtitle. | | `categories` | `string[]` | An **alternative series list** — column names to plot, read only when `series` is absent, and ignored outright when it is present. Not axis labels: the category axis comes from `xAxisKey`. | -| `series` | `ChartDataSeries[]` | Data series. Each entry's `name` (or `dataKey`) names the column it plots within a `data` row; optional `label`, `color`, a per-series `type` (`"bar"`, `"line"`, `"area"`) for combo charts, `stack`, `yAxis` (`"left"` / `"right"`), `variant` (`"primary"` / `"comparison"` / `"current"`), `dashArray` and `opacity`. | +| `series` | `ChartDataSeries[]` | Data series. Each entry's `name` (or `dataKey`) names the column it plots within a `data` row; optional `label`, `color`, a per-series `type` (`"bar"`, `"line"`, `"area"`) for combo charts, `stack`, `yAxis` (`"left"` / `"right"`), `variant` (`"primary"` / `"comparison"`), `dashArray` and `opacity`. | | `data` | `Array>` | Rows to plot — one object per row, keyed by column name. | | `xAxisKey` | `string` | Row key holding the category (x) axis. The bare-string `xAxis: "month"` spelling folds onto this key at parse. | | `height` / `width` | `string \| number` | Chart dimensions. | diff --git a/content/docs/plugins/plugin-charts.mdx b/content/docs/plugins/plugin-charts.mdx index e08ccd9547..0edaca0341 100644 --- a/content/docs/plugins/plugin-charts.mdx +++ b/content/docs/plugins/plugin-charts.mdx @@ -189,9 +189,9 @@ Each `series` entry names the column it plots (`dataKey`, or the spec spelling ` | `color` | `string` | Series colour; wins over the positional palette. | | `stack` | `string` | Stack group id — series sharing one id stack together. | | `yAxis` | `'left' \| 'right'` | Which y-axis the series binds to, on a chart that declares a second y-axis. | -| `variant` | `'primary' \| 'comparison' \| 'current'` | `comparison` draws the muted period-over-period overlay. | -| `dashArray` | `string` | SVG `stroke-dasharray`, e.g. `"4 4"` for a dashed line. | -| `opacity` | `number` | Stroke and fill opacity, 0–1. | +| `variant` | `'primary' \| 'comparison'` | `comparison` draws the muted period-over-period overlay; `primary` (the default) is the normal treatment. | +| `dashArray` | `string` | SVG `stroke-dasharray`, e.g. `"4 4"` for a dashed line. Today the renderer applies it only on a `variant: 'comparison'` series, where it overrides the overlay's default dash; on a primary series it is read and unused. | +| `opacity` | `number` | Stroke and fill opacity override — any finite number (the spec bounds it to 0–1). Today the renderer applies it only on a `variant: 'comparison'` series, where it overrides the overlay's per-family default; on a primary series it is read and unused. | ```tsx const schema = { @@ -204,8 +204,8 @@ const schema = { xAxisKey: 'month', series: [ { name: 'revenue', label: 'Revenue', stack: 'money' }, - { name: 'cost', label: 'Cost', stack: 'money', opacity: 0.6 }, - { name: 'revenue_prev', label: 'Revenue (previous period)', variant: 'comparison', dashArray: '4 4' } + { name: 'cost', label: 'Cost', stack: 'money' }, + { name: 'revenue_prev', label: 'Revenue (previous period)', variant: 'comparison', dashArray: '4 4', opacity: 0.6 } ] } ``` diff --git a/packages/types/src/__tests__/chart-series-keys-7546.test.ts b/packages/types/src/__tests__/chart-series-keys-7546.test.ts index 931058d174..01a1a24a5b 100644 --- a/packages/types/src/__tests__/chart-series-keys-7546.test.ts +++ b/packages/types/src/__tests__/chart-series-keys-7546.test.ts @@ -43,10 +43,13 @@ * design ("both shapes" — `ChartRenderer.tsx:55-66`), in-repo producers write * them onto `type: 'chart'` nodes (`DashboardRenderer.tsx:648-652` `label`; * `ObjectChart.tsx:852-856` and `DatasetWidget.tsx:1444-1447` `variant`, - * `chartType`, `yAxis`; `core/utils/chart-presentation.ts:126-131` all six on - * the dataset path), and the narrowings (`variant`, `yAxis`) are design intent - * the reader already enforces. Declaring them widens the accept set only - * toward what already renders. + * `chartType`, `yAxis` — internal-shape callers, see block (b); + * `core/utils/chart-presentation.ts:126-131` all six on the dataset path), + * and the narrowings (`variant`, `yAxis`) are design intent the reader already + * enforces. Declaring them widens the accept set only toward what already + * renders. `variant` is the spec's PAIR, not the normalizer's three: the third + * spelling, `current`, is the renderer's internal default written only by the + * two internal-shape producers above, which never meet this mirror. * * ## The seventh — `chartType` — measured, and NOT declared * @@ -128,10 +131,22 @@ describe('objectui#7546 — the card fixture parses green AND every authored key /* ── (b) the declarations narrow to what the renderer honours ─────────────── */ describe('objectui#7546 — each declaration is the read\'s own value domain, not `unknown`', () => { - it.each(['primary', 'comparison', 'current'] as const)('`variant: %s` is accepted — the three `normalizeSeries` honours', (v) => { + it.each(['primary', 'comparison'] as const)('`variant: %s` is accepted — the spec\'s own pair', (v) => { expect(ChartDataSeriesSchema.safeParse({ name: 'r', variant: v }).success).toBe(true); }); + it('`variant` refuses the renderer-internal `current` spelling — not a member of the published face', () => { + // `normalizeSeries` tolerates `current` (`normalizeChartSchema.ts:247`), but + // it is written only by the compare-to producers onto `dataKey`-shaped + // arrays handed straight to `ChartRenderer` (`ObjectChart.tsx:852`, + // `DatasetWidget.tsx:1450`) — they never meet this mirror — and by nothing + // an author writes. The spec's `ChartSeries.variant` is the pair; declaring + // a third value here would fossilise a renderer-side tolerance into a + // second contract (AGENTS.md #0.1). The normalizer's tolerance itself is + // objectui#7682's decision, untouched by this card. + expect(refusalPaths({ name: 'r', variant: 'current' })).toEqual(['variant']); + }); + it('`variant` refuses a value the renderer would drop in silence, at its own path', () => { expect(refusalPaths({ name: 'r', variant: 'bogus' })).toEqual(['variant']); }); @@ -182,7 +197,7 @@ describe('objectui#7546 — the `ChartDataSeries` interface declares the same si }); it('the unions are the reader\'s, pinned at the type level', () => { - const variant: Eq = true; + const variant: Eq = true; const yAxis: Eq = true; expect(variant && yAxis).toBe(true); }); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 8abbf7fedd..41c5f6694b 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1424,29 +1424,45 @@ export interface ChartDataSeries { /** * Visual role. `'comparison'` is the muted period-over-period overlay * (`AdvancedChartImpl.tsx:2010-2033` — lower opacity, dashed stroke, and it - * is left out when the primary series are counted); `'primary'` and - * `'current'` both mean "not the overlay" and draw identically. - * - * ⚠️ The union is the THREE values `normalizeSeries` honours - * (`normalizeChartSchema.ts:246-247`) — one wider than the spec's own - * `ChartSeries.variant` (`'primary' | 'comparison'`), because `current` is - * the spelling this repository's own comparison producers write - * (`ObjectChart.tsx:852`, `DatasetWidget.tsx:1450`) and the renderer reads. - * Any other value is dropped in silence by the normalizer, so the mirror - * refuses it by name instead (objectui#7546). - */ - variant?: 'primary' | 'comparison' | 'current'; - /** - * Stroke and fill opacity, 0–1. Read by `num()` (`normalizeChartSchema.ts:248` + * is left out when the primary series are counted); `'primary'` (the + * default) is the normal treatment. + * + * The union is the spec's own `ChartSeries.variant` pair. The normalizer + * also tolerates a third spelling, `'current'` (`normalizeChartSchema.ts:247`), + * but that is the renderer's INTERNAL default — written only by the + * compare-to producers onto `dataKey`-shaped arrays handed straight to + * `ChartRenderer` (`ObjectChart.tsx:852`, `DatasetWidget.tsx:1450`), which + * never pass through this mirror — and by nothing an author writes (docs, + * fixtures and designer inputs: 0, controls lit). It is NOT a member here, + * so the published face does not fossilise a renderer-side tolerance into a + * second contract (AGENTS.md #0.1); the normalizer's own tolerance is + * objectui#7682's decision and is unchanged by this. Any other value is + * dropped in silence by the normalizer, so the mirror refuses it by name + * instead (objectui#7546). + */ + variant?: 'primary' | 'comparison'; + /** + * Stroke and fill opacity. Read by `num()` (`normalizeChartSchema.ts:248` * — any finite number; the mirror refuses `NaN`, `Infinity` and strings the - * same way) and applied at `AdvancedChartImpl.tsx:94-95`, where it overrides - * the per-family default (objectui#7546). + * same way) and applied at `AdvancedChartImpl.tsx:94-95` inside + * `comparisonStyle`, where it overrides the comparison overlay's per-family + * default (objectui#7546). Declared as the read's own domain: the spec's + * `ChartSeries.opacity` bounds it to 0–1, a bound the renderer does not + * enforce (SVG clamps at paint, nothing is dropped), so the mirror does not + * refuse an out-of-range value either. + * + * ⚠️ Today the renderer honours it ONLY on a `variant: 'comparison'` series — + * `comparisonStyle` returns `null` for any other variant and every mark reads + * `cmp?.fillOpacity` / `cmp?.strokeOpacity`, so on a primary series the value + * is read and unused. The spec declares it as an unconditional override, so + * that is a renderer gap (objectui#7698), not a reason to narrow this face. */ opacity?: number; /** * SVG `stroke-dasharray` override, e.g. `"4 4"` for a dashed line * (`normalizeChartSchema.ts:250`; applied at `AdvancedChartImpl.tsx:96`) - * (objectui#7546). + * (objectui#7546). Same condition as {@link ChartDataSeries.opacity}: today + * only a `variant: 'comparison'` series reaches that read (objectui#7698). */ dashArray?: string; /** diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index f6769b2c13..f5c89fe404 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -406,10 +406,10 @@ export const ChartDataSeriesSchema = z.object({ label: I18nLabelSchema.optional().describe( 'Legend / tooltip name for this series — a plain string or an inline locale map; defaults to the column key', ), - variant: z.enum(['primary', 'comparison', 'current']).optional().describe( - 'Visual role — `comparison` draws the muted period-over-period overlay; `primary` and `current` both mean the normal treatment', + variant: z.enum(['primary', 'comparison']).optional().describe( + 'Visual role — `comparison` draws the muted period-over-period overlay; `primary` (the default) is the normal treatment. The spec pair: the renderer-internal `current` spelling is not a member (objectui#7682)', ), - opacity: z.number().optional().describe('Stroke and fill opacity override, 0–1'), + opacity: z.number().optional().describe('Stroke and fill opacity override — any finite number, the read\'s own domain; the spec bounds it to 0–1'), dashArray: z.string().optional().describe('SVG stroke-dasharray override, e.g. "4 4" for a dashed line'), stack: z.string().optional().describe('Stack group id — series sharing one id stack together'), yAxis: z.enum(['left', 'right']).optional().describe('Which y-axis this series binds to on a dual-axis chart'), From 5f7e032a8554742a082864f143d821e32f5c8345 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 07:29:47 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(types,=20plugin-charts):=20dashArray's?= =?UTF-8?q?=20condition=20stated=20in=20full=20=E2=80=94=20comparison=20AN?= =?UTF-8?q?D=20a=20line/area=20mark=20(objectui#7546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second contract review (PR comment 5550238043) refused on one item: the "Per-series options" example in plugin-charts.mdx was a chartType 'bar' chart whose comparison series carried dashArray '4 4' — a position where the key reaches nothing. Re-measured through the real ChartRenderer under happy-dom, predictions before probes, a lit control on every probe: - bar, the old example verbatim: the comparison rects carry fill-opacity="0.6" (lit, same entry); the surface has zero stroke-dasharray attributes. - area, the new example: stroke-dasharray="8 4", stroke-opacity="0.6" and fill-opacity="0.6" on the comparison mark (both its area and curve paths); the primaries keep fill-opacity 1 and no dash; stack is live (the stacked cost area's lower edge is the revenue area's upper edge; unstacked both sit on the baseline). - line: stroke-dasharray="2 6" + stroke-opacity="0.6"; scatter: fill-opacity="0.6" on every symbol, no dash; a comparison line INSIDE a bar chart (per-series type) takes the dash — the condition is the mark, not chartType. Primary series of any family: nothing reaches the DOM. Mechanism (AdvancedChartImpl.tsx, blob 63a5e34c9 on main and here): comparisonStyle :96 returns the AUTHORED dashArray for every family — the undefined half at :96 is only the fallback — and the mark drops it: Scatter :1832, combo Bar :1911 and Bar :2037 pass fillOpacity only; Line :1898/:2048 pass strokeOpacity + strokeDasharray; Area :1905/:2056 pass all three. So opacity is live on every family (fill on bar/scatter, stroke on line, both on area) while dashArray is live on line/area only — the two keys split. Changes, wording only — no contract, reader or changeset-level change: - plugin-charts.mdx: the example moves to chartType 'area' (the one family where stack, variant, dashArray and opacity are ALL live), with a non-default dash '8 4' so the override visibly does work; the dashArray row states comparison AND line/area (bar/scatter take opacity only); the opacity row says every mark family, fill vs stroke per family. - data-display.ts: the dashArray docblock carries the full condition with the mark sites; the opacity docblock says it reaches every family. - changeset: the refusal list names variant 'current' — refused, but NOT a value the renderer drops (the normalizer keeps it and draws it as primary). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .changeset/7546-chart-series-keys-declared.md | 7 ++++-- content/docs/plugins/plugin-charts.mdx | 11 +++++--- packages/types/src/data-display.ts | 25 +++++++++++++------ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/.changeset/7546-chart-series-keys-declared.md b/.changeset/7546-chart-series-keys-declared.md index 307a915a79..123b480a0f 100644 --- a/.changeset/7546-chart-series-keys-declared.md +++ b/.changeset/7546-chart-series-keys-declared.md @@ -11,8 +11,11 @@ non-strict Zod object had been **stripping in silence** while `safeParse` report set widens toward what already renders — but one document class that validated before now **refuses**: a series carrying one of these keys with a value the renderer drops in silence (`variant: 'bogus'`, `yAxis: 'top'`, `opacity: '0.4'` / `Infinity`, a non-string `stack` / -`dashArray`, a non-string non-map `label`). Such a document draws a chart today — the normalizer -ignores the bad value — so this is a narrowing away from something that renders, which is the +`dashArray`, a non-string non-map `label`) — and, separately, `variant: 'current'`, which the +renderer does NOT drop: the normalizer keeps that renderer-internal spelling and draws it exactly as +`primary`, but it is not a member of the published pair, so it now refuses at `variant` (below). +Such a document draws a chart today — the normalizer ignores the bad value, or honours `current` +— so this is a narrowing away from something that renders, which is the distinction objectui#6939's grading language turns on, and it takes the level objectui#6896 and objectui#7113 set for the same transition in this same file. This repository's `major` is a cross-repo pin to `@objectstack`'s major, not a severity dial; the change is announced here. diff --git a/content/docs/plugins/plugin-charts.mdx b/content/docs/plugins/plugin-charts.mdx index 0edaca0341..bf2680f6bf 100644 --- a/content/docs/plugins/plugin-charts.mdx +++ b/content/docs/plugins/plugin-charts.mdx @@ -190,13 +190,15 @@ Each `series` entry names the column it plots (`dataKey`, or the spec spelling ` | `stack` | `string` | Stack group id — series sharing one id stack together. | | `yAxis` | `'left' \| 'right'` | Which y-axis the series binds to, on a chart that declares a second y-axis. | | `variant` | `'primary' \| 'comparison'` | `comparison` draws the muted period-over-period overlay; `primary` (the default) is the normal treatment. | -| `dashArray` | `string` | SVG `stroke-dasharray`, e.g. `"4 4"` for a dashed line. Today the renderer applies it only on a `variant: 'comparison'` series, where it overrides the overlay's default dash; on a primary series it is read and unused. | -| `opacity` | `number` | Stroke and fill opacity override — any finite number (the spec bounds it to 0–1). Today the renderer applies it only on a `variant: 'comparison'` series, where it overrides the overlay's per-family default; on a primary series it is read and unused. | +| `dashArray` | `string` | SVG `stroke-dasharray`, e.g. `"4 4"` for a dashed line. Today the renderer applies it only on a `variant: 'comparison'` series **and** only on a mark that has a stroke to dash — a `line` or `area` mark (the chart's `chartType`, or a per-series `type` override), where it overrides the overlay's default `"4 4"`; a comparison `bar` or `scatter` mark takes `opacity` only and drops the dash, and on a primary series of any family it is read and unused. | +| `opacity` | `number` | Stroke and fill opacity override — any finite number (the spec bounds it to 0–1). Today the renderer applies it only on a `variant: 'comparison'` series, where it overrides the overlay's per-family default on every mark family — the fill of a `bar` or `scatter` mark, the stroke of a `line` mark, both on an `area` mark; on a primary series it is read and unused. | + +An `area` chart keeps every key in this example live: `stack` stacks the two primary areas, and the comparison overlay takes both the dash and the opacity (on a `bar` chart the overlay would take `opacity` only). ```tsx const schema = { type: 'chart', - chartType: 'bar', + chartType: 'area', data: [ { month: 'Jan', revenue: 400, cost: 250, revenue_prev: 360 }, { month: 'Feb', revenue: 300, cost: 210, revenue_prev: 330 } @@ -205,7 +207,8 @@ const schema = { series: [ { name: 'revenue', label: 'Revenue', stack: 'money' }, { name: 'cost', label: 'Cost', stack: 'money' }, - { name: 'revenue_prev', label: 'Revenue (previous period)', variant: 'comparison', dashArray: '4 4', opacity: 0.6 } + // '8 4' overrides the overlay's default '4 4' dash; 0.6 lifts its 0.2 fill default (its stroke default is already 0.6) + { name: 'revenue_prev', label: 'Revenue (previous period)', variant: 'comparison', dashArray: '8 4', opacity: 0.6 } ] } ``` diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 41c5f6694b..9b356dc979 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1452,17 +1452,28 @@ export interface ChartDataSeries { * refuse an out-of-range value either. * * ⚠️ Today the renderer honours it ONLY on a `variant: 'comparison'` series — - * `comparisonStyle` returns `null` for any other variant and every mark reads - * `cmp?.fillOpacity` / `cmp?.strokeOpacity`, so on a primary series the value - * is read and unused. The spec declares it as an unconditional override, so - * that is a renderer gap (objectui#7698), not a reason to narrow this face. + * `comparisonStyle` returns `null` for any other variant, so on a primary + * series the value is read and unused. On a comparison series it reaches + * EVERY mark family: `fillOpacity` on a Bar (`:1911`, `:2037`) or Scatter + * (`:1832`) mark, `strokeOpacity` on a Line mark (`:1898`, `:2048`), both on + * an Area mark (`:1905`, `:2056`). The spec declares it as an unconditional + * override, so the primary-series half is a renderer gap (objectui#7698), + * not a reason to narrow this face. */ opacity?: number; /** * SVG `stroke-dasharray` override, e.g. `"4 4"` for a dashed line - * (`normalizeChartSchema.ts:250`; applied at `AdvancedChartImpl.tsx:96`) - * (objectui#7546). Same condition as {@link ChartDataSeries.opacity}: today - * only a `variant: 'comparison'` series reaches that read (objectui#7698). + * (`normalizeChartSchema.ts:250`; read at `AdvancedChartImpl.tsx:96`) + * (objectui#7546). + * + * ⚠️ Narrower condition than {@link ChartDataSeries.opacity}: today it is + * honoured only on a `variant: 'comparison'` series AND only on a mark with + * a stroke to dash — a Line (`:1898`, `:2048`) or Area (`:1905`, `:2056`) + * mark, whether from the chart's `chartType` or a per-series `type` + * override. `comparisonStyle` returns the authored value for every family, + * but a Bar (`:1911`, `:2037`) or Scatter (`:1832`) mark passes + * `fillOpacity` only and drops `strokeDasharray` (and `strokeOpacity`); on + * a primary series of any family it is read and unused (objectui#7698). */ dashArray?: string; /**