From 5d68aecbb1a1891c5c67d74bda2d42af3a2abfa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 21:11:25 +0000 Subject: [PATCH] fix(charts): honour series opacity and dashArray on every series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/spec` declares `ChartSeries.opacity` and `ChartSeries.dashArray` as unconditional per-series overrides. `normalizeSeries` read both off every series and `comparisonStyle` then discarded them for anything but a `variant: 'comparison'` overlay, so an authored key on a primary series drew exactly like no key at all. Two gaps, not one. The `variant` guard was the visible half; the second only showed on `dashArray`, which `comparisonStyle` already returned for every family while the Bar and Scatter marks passed `fillOpacity` only and dropped `strokeDasharray` / `strokeOpacity` — broken on those families even for a comparison series. `comparisonStyle` becomes `seriesStyle`: the authored values apply whatever the variant, the muted overlay DEFAULTS stay gated on `variant: 'comparison'`, and Bar / Scatter now pass all three channels. The two stroke defaults no mark ever consumed (bar, scatter — neither is stroked here) are spelled `undefined` so opening those props hands them no default they never had; every value a comparison series painted before is unchanged. The `ChartDataSeries` mirror docs and the plugin-charts reference stated the comparison-only condition as an interim measure; both are corrected here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- ...-series-opacity-dasharray-unconditional.md | 41 +++ content/docs/plugins/plugin-charts.mdx | 6 +- .../plugin-charts/src/AdvancedChartImpl.tsx | 64 ++-- .../ChartRenderer.seriesPresentation.test.tsx | 284 ++++++++++++++++++ packages/types/src/data-display.ts | 62 ++-- 5 files changed, 406 insertions(+), 51 deletions(-) create mode 100644 .changeset/7698-series-opacity-dasharray-unconditional.md create mode 100644 packages/plugin-charts/src/ChartRenderer.seriesPresentation.test.tsx diff --git a/.changeset/7698-series-opacity-dasharray-unconditional.md b/.changeset/7698-series-opacity-dasharray-unconditional.md new file mode 100644 index 0000000000..bcbf53fd4e --- /dev/null +++ b/.changeset/7698-series-opacity-dasharray-unconditional.md @@ -0,0 +1,41 @@ +--- +'@object-ui/plugin-charts': minor +'@object-ui/types': patch +--- + +Chart `series[].opacity` and `series[].dashArray` are honoured on every series, +not only on a `variant: 'comparison'` one (objectui#7698). + +`@objectstack/spec` declares `ChartSeries.opacity` ("Override series opacity") +and `ChartSeries.dashArray` ("Override stroke dash pattern") as unconditional +per-series overrides, and `normalizeSeries` read both off every series. The +renderer then honoured them on a comparison overlay only, so an author who +wrote `{ name: 'cost', opacity: 0.6 }` or `{ name: 'cost', dashArray: '4 4' }` +on a primary series got a mark drawn exactly as if the key were absent. Fixed +in the renderer rather than by narrowing the published declaration to match: +the spec is the contract of record, and a renderer's partial implementation +does not get to dictate it (AGENTS.md #0.1). + +**Two gaps, not one.** The `variant` guard was the visible half — `comparisonStyle` +returned `null` for any other variant. The second half only showed on +`dashArray`: that helper already returned an AUTHORED dash for every family +(the `??` takes the left side whatever the kind), and the **Bar and Scatter +marks** then passed `fillOpacity` only, dropping `strokeDasharray` and +`strokeOpacity` on the floor — so an authored dash was lost on those two +families even on a comparison series. A fix aimed at the guard alone would have +left that untouched. `comparisonStyle` is now `seriesStyle`, and the Bar and +Scatter marks pass all three channels. + +**Comparison series are unaffected.** The authored branch already won over the +muted defaults, and those defaults stay gated on `variant: 'comparison'`: a +comparison series carrying neither key keeps its lower opacity and its `'4 4'` +line/area dash exactly as before. The two stroke defaults no mark ever consumed +(bar and scatter — neither is stroked by this renderer) are now spelled +`undefined`, so opening `strokeOpacity` on those marks does not hand them a +default they never had. + +Only a stroked mark can show a dash, so on a `bar` or `scatter` mark an +authored `dashArray` reaches the mark and paints nothing — the mark's geometry, +not a condition on the key. The `ChartDataSeries` mirror docs and the +plugin-charts reference, which stated the comparison-only condition as an +interim measure, are corrected in the same change. diff --git a/content/docs/plugins/plugin-charts.mdx b/content/docs/plugins/plugin-charts.mdx index 14406b0d46..093ea97352 100644 --- a/content/docs/plugins/plugin-charts.mdx +++ b/content/docs/plugins/plugin-charts.mdx @@ -190,12 +190,12 @@ 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 **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. | +| `dashArray` | `string` | SVG `stroke-dasharray`, e.g. `"4 4"` for a dashed line. Applies on **any** series, whatever its `variant`, and overrides the comparison overlay's default `"4 4"`. Only a mark with a stroke can show a dash, and this renderer strokes `line` and `area` marks (the chart's `chartType`, or a per-series `type` override) — on a `bar` or `scatter` mark the value reaches the mark and paints nothing, which is the mark's geometry rather than a condition on the key. | +| `opacity` | `number` | Stroke and fill opacity override — any finite number (the spec bounds it to 0–1). Applies on **any** series, whatever its `variant`, on every mark family — the fill of a `bar` or `scatter` mark, the stroke of a `line` mark, both on an `area` mark — and overrides the comparison overlay's per-family default. | `chartType` is **not** a series key. It is the renderer's internal spelling of `type` — `@objectstack/spec`'s `ChartSeries` lists it as an alias — and the validator refuses it by name (`Unrecognized key(s) on this chart series: chartType. Did you mean chartType → type?`) instead of dropping it silently, which is what it used to do. Write `type`. -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). +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 dash would still reach the mark, but a bar has no stroke to dash). Neither key needs `variant: 'comparison'` — a primary series may carry them too, and then it is the overlay's defaults, not the keys, that stay comparison-only. ```tsx const schema = { diff --git a/packages/plugin-charts/src/AdvancedChartImpl.tsx b/packages/plugin-charts/src/AdvancedChartImpl.tsx index 63a5e34c9e..ae52b1d010 100644 --- a/packages/plugin-charts/src/AdvancedChartImpl.tsx +++ b/packages/plugin-charts/src/AdvancedChartImpl.tsx @@ -84,16 +84,36 @@ const TW_COLORS: Record = { const resolveColor = (color: string) => TW_COLORS[color] || color; /** - * Default visual treatment for a `variant: 'comparison'` series. Returns - * overrides per chart family so the comparison overlay reads as muted - * (dashed line, lower fill opacity) while still being color-matched to - * the primary series. Series-level `opacity` / `dashArray` win over defaults. + * Per-series presentation overrides, in two halves that are deliberately NOT + * gated the same way (objectui#7698). + * + * - The AUTHORED keys — `@objectstack/spec`'s `ChartSeries.opacity` ("Override + * series opacity") and `ChartSeries.dashArray` ("Override stroke dash + * pattern") — are declared with NO condition, so they apply on every series + * whatever its `variant`. + * - The muted comparison treatment (lower opacity, a `'4 4'` dash) is a + * DEFAULT and stays gated on `variant: 'comparison'`, so the overlay still + * reads as muted while a primary series carrying neither key is untouched. + * + * This function used to return `null` for anything but a comparison series, so + * an authored `opacity` / `dashArray` on a primary series was read by + * `normalizeSeries` and then discarded here — the renderer honouring a + * spec-declared unconditional override on one variant only. Narrowing the + * published declaration to match would have been the renderer's tolerance + * dictating the contract, which is the direction AGENTS.md #0.1 forbids. + * + * Comparison series keep every value they had. The authored branch already won + * over the defaults, and the two STROKE defaults no mark ever consumed — bar + * and scatter, neither of which this renderer strokes — are now spelled + * `undefined` rather than reaching the Bar and Scatter marks that gained + * `strokeOpacity` / `strokeDasharray` in the same change. */ -const comparisonStyle = (s: any, kind: 'line' | 'area' | 'bar' | 'scatter') => { - if (s?.variant !== 'comparison') return null; - const strokeOpacity = typeof s.opacity === 'number' ? s.opacity : (kind === 'line' || kind === 'scatter' ? 0.5 : 0.6); - const fillOpacity = typeof s.opacity === 'number' ? s.opacity : (kind === 'bar' ? 0.4 : kind === 'area' ? 0.2 : 0.5); - const strokeDasharray = s.dashArray ?? (kind === 'line' || kind === 'area' ? '4 4' : undefined); +const seriesStyle = (s: any, kind: 'line' | 'area' | 'bar' | 'scatter') => { + const muted = s?.variant === 'comparison'; + const authoredOpacity = typeof s?.opacity === 'number' ? s.opacity : undefined; + const strokeOpacity = authoredOpacity ?? (muted ? (kind === 'line' ? 0.5 : kind === 'area' ? 0.6 : undefined) : undefined); + const fillOpacity = authoredOpacity ?? (muted ? (kind === 'bar' ? 0.4 : kind === 'area' ? 0.2 : 0.5) : undefined); + const strokeDasharray = s?.dashArray ?? (muted && (kind === 'line' || kind === 'area') ? '4 4' : undefined); return { strokeOpacity, fillOpacity, strokeDasharray }; }; @@ -1822,14 +1842,16 @@ function AdvancedChartImplInner({ {series.map((s: any, index: number) => { const palette = getPalette(); const color = resolveColor(config[s.dataKey]?.color || palette[index % palette.length]); - const cmp = comparisonStyle(s, 'scatter'); + const pres = seriesStyle(s, 'scatter'); return ( @@ -1889,26 +1911,26 @@ function AdvancedChartImplInner({ : comboSeriesBase ? 'left' : (seriesType === 'bar' ? 'left' : 'right'); - const cmp = comparisonStyle(s, seriesType as any); + const pres = seriesStyle(s, seriesType as any); const stackProps = s.stack ? { stackId: String(s.stack) } : {}; const valueFormatter = formatterFor((yAxisId === 'right' ? secondaryY : primaryY)?.format); if (seriesType === 'line') { return ( - + {dataLabel(valueFormatter)} ); } if (seriesType === 'area') { return ( - + {dataLabel(valueFormatter)} ); } return ( - + {dataLabel(valueFormatter)} ); @@ -2032,9 +2054,9 @@ function AdvancedChartImplInner({ // per series for visual consistency. const primaryCount = series.filter((p: any) => p.variant !== 'comparison').length; const colorPerCategory = primaryCount === 1 && !isComparison && series.length === 1 && data.length > 1; - const cmp = comparisonStyle(s, 'bar'); + const pres = seriesStyle(s, 'bar'); return ( - + {colorPerCategory && data.map((entry, idx) => ( ))} @@ -2043,17 +2065,17 @@ function AdvancedChartImplInner({ ); } if (chartType === 'line') { - const cmp = comparisonStyle(s, 'line'); + const pres = seriesStyle(s, 'line'); return ( - + {dataLabel(valueFormatter)} ); } if (chartType === 'area') { - const cmp = comparisonStyle(s, 'area'); + const pres = seriesStyle(s, 'area'); return ( - + {dataLabel(valueFormatter)} ); diff --git a/packages/plugin-charts/src/ChartRenderer.seriesPresentation.test.tsx b/packages/plugin-charts/src/ChartRenderer.seriesPresentation.test.tsx new file mode 100644 index 0000000000..5a4d73df9e --- /dev/null +++ b/packages/plugin-charts/src/ChartRenderer.seriesPresentation.test.tsx @@ -0,0 +1,284 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#7698 — a series-level `opacity` / `dashArray` is an UNCONDITIONAL + * override, not a comparison-only one. + * + * `@objectstack/spec` declares `ChartSeries.opacity` as "Override series + * opacity (0–1)" and `ChartSeries.dashArray` as "Override stroke dash + * pattern" — neither carries a condition. `normalizeSeries` read both on every + * series, and then `comparisonStyle` returned `null` for anything but a + * `variant: 'comparison'` series, so on a primary series both values were read + * and discarded. Narrowing the published declaration to match would have been + * the renderer's tolerance dictating the contract (AGENTS.md #0.1), so the + * renderer is what moves. + * + * The card's body located the whole defect at that `variant` guard. It is in + * TWO places, and the second one is why `dashArray` needs its own pins here: + * `comparisonStyle` already returned an AUTHORED `dashArray` for every family + * (`s.dashArray ?? (line|area ? '4 4' : undefined)` takes the left side + * whatever the kind), but the Bar and Scatter MARKS passed `fillOpacity` only + * and dropped `strokeDasharray` / `strokeOpacity` on the floor. A fix aimed at + * the guard alone would have left `dashArray` broken on bar and scatter even + * on a comparison series — so the bar/scatter dash cases below are the ones + * that fail for a *different* reason than the primary-series cases. + * + * These render through `ChartRenderer` (not `seriesStyle` directly) because the + * defect was a value surviving normalization and then dying at the mark: only + * the DOM says whether the mark applied it. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; + +// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0×0 +// under the headless DOM, so nothing paints. Fix its size. +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: any) => + React.cloneElement(children, { width: 480, height: 320 }), + }; +}); + +import { ChartRenderer } from './ChartRenderer'; +// `ChartRenderer` lazy-loads its implementation +// (`React.lazy(() => import('./AdvancedChartImpl'))`). Import it eagerly with +// the SAME specifier so the cost lands in the import phase rather than inside +// `waitFor`'s 1000ms budget (AGENTS.md §测试纪律). +import './AdvancedChartImpl'; + +afterEach(cleanup); + +const DATA = [ + { month: 'Jan', revenue: 120, revenue_prev: 100 }, + { month: 'Feb', revenue: 80, revenue_prev: 140 }, +]; + +const POINTS = [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, +]; + +/** `AdvancedChartImpl` is lazy — wait for the real plot, not the skeleton. */ +const plot = async (c: HTMLElement) => { + await waitFor(() => expect(c.querySelector('.recharts-surface')).toBeTruthy()); + return c; +}; + +/** + * The three presentation attributes as the mark actually painted them. + * `null` (the DOM's answer for an absent attribute) is a real reading here — + * it is what an undefined prop leaves behind, and several pins below assert + * exactly that. + */ +const paint = (el: Element | null) => ({ + fillOpacity: el?.getAttribute('fill-opacity') ?? null, + strokeOpacity: el?.getAttribute('stroke-opacity') ?? null, + strokeDasharray: el?.getAttribute('stroke-dasharray') ?? null, +}); + +const barRect = (c: HTMLElement, i = 0) => + c.querySelectorAll('.recharts-bar')[i]?.querySelector('.recharts-rectangle') ?? null; +const lineCurve = (c: HTMLElement, i = 0) => + c.querySelectorAll('.recharts-line')[i]?.querySelector('.recharts-line-curve') ?? null; +const areaFill = (c: HTMLElement, i = 0) => + c.querySelectorAll('.recharts-area')[i]?.querySelector('.recharts-area-area') ?? null; +const areaStroke = (c: HTMLElement, i = 0) => + c.querySelectorAll('.recharts-area')[i]?.querySelector('.recharts-area-curve') ?? null; +const scatterSymbol = (c: HTMLElement) => + c.querySelector('.recharts-scatter')?.querySelector('.recharts-symbols') ?? null; + +const chart = (extra: Record) => ( + +); + +describe('objectui#7698 — an authored `opacity` / `dashArray` on a PRIMARY series', () => { + it('reaches a bar mark (fill), where it used to be read and discarded', async () => { + const { container } = render( + chart({ chartType: 'bar', series: [{ name: 'revenue', opacity: 0.3 }] }), + ); + expect(paint(barRect(await plot(container)))).toMatchObject({ fillOpacity: '0.3' }); + }); + + it('reaches a line mark (stroke) together with its dash', async () => { + const { container } = render( + chart({ chartType: 'line', series: [{ name: 'revenue', opacity: 0.3, dashArray: '2 6' }] }), + ); + expect(paint(lineCurve(await plot(container)))).toMatchObject({ + strokeOpacity: '0.3', + strokeDasharray: '2 6', + }); + }); + + it('reaches BOTH channels of an area mark', async () => { + const { container } = render( + chart({ chartType: 'area', series: [{ name: 'revenue', opacity: 0.3, dashArray: '2 6' }] }), + ); + const c = await plot(container); + expect(paint(areaFill(c))).toMatchObject({ fillOpacity: '0.3' }); + expect(paint(areaStroke(c))).toMatchObject({ strokeOpacity: '0.3', strokeDasharray: '2 6' }); + }); + + it('reaches a scatter mark', async () => { + const { container } = render( + , + ); + expect(paint(scatterSymbol(await plot(container)))).toMatchObject({ + fillOpacity: '0.3', + strokeOpacity: '0.3', + strokeDasharray: '2 6', + }); + }); + + it('reaches a per-series `type` override inside a combo chart', async () => { + const { container } = render( + chart({ + chartType: 'bar', + series: [ + { name: 'revenue', opacity: 0.3 }, + { name: 'revenue_prev', type: 'line', opacity: 0.25, dashArray: '1 3' }, + ], + }), + ); + const c = await plot(container); + expect(paint(barRect(c))).toMatchObject({ fillOpacity: '0.3' }); + expect(paint(lineCurve(c))).toMatchObject({ strokeOpacity: '0.25', strokeDasharray: '1 3' }); + }); +}); + +describe('objectui#7698 — `dashArray` on a mark that used to drop it entirely', () => { + // These are the cases the card's body understates: `comparisonStyle` handed + // the authored dash to every family, and the Bar / Scatter marks then never + // passed it through. Broken for BOTH variants before this change — so a + // comparison bar is pinned here too, not only a primary one. + it('reaches a primary bar mark', async () => { + const { container } = render( + chart({ chartType: 'bar', series: [{ name: 'revenue', dashArray: '2 6' }] }), + ); + expect(paint(barRect(await plot(container)))).toMatchObject({ strokeDasharray: '2 6' }); + }); + + it('reaches a COMPARISON bar mark (a fix at the `variant` guard alone would not)', async () => { + const { container } = render( + chart({ + chartType: 'bar', + series: [ + { name: 'revenue' }, + { name: 'revenue_prev', variant: 'comparison', dashArray: '8 4' }, + ], + }), + ); + expect(paint(barRect(await plot(container), 1))).toMatchObject({ strokeDasharray: '8 4' }); + }); + + it('reaches a primary scatter mark', async () => { + const { container } = render( + , + ); + expect(paint(scatterSymbol(await plot(container)))).toMatchObject({ strokeDasharray: '2 6' }); + }); +}); + +describe('objectui#7698 — the comparison DEFAULTS stay gated on `variant`', () => { + it('keeps the muted line overlay for a comparison series carrying no keys', async () => { + const { container } = render( + chart({ + chartType: 'line', + series: [{ name: 'revenue' }, { name: 'revenue_prev', variant: 'comparison' }], + }), + ); + const c = await plot(container); + expect(paint(lineCurve(c, 1))).toMatchObject({ strokeOpacity: '0.5', strokeDasharray: '4 4' }); + }); + + it('keeps the muted bar and area fills for a comparison series carrying no keys', async () => { + const bar = render( + chart({ + chartType: 'bar', + series: [{ name: 'revenue' }, { name: 'revenue_prev', variant: 'comparison' }], + }), + ); + expect(paint(barRect(await plot(bar.container), 1))).toMatchObject({ fillOpacity: '0.4' }); + cleanup(); + + const area = render( + chart({ + chartType: 'area', + series: [{ name: 'revenue' }, { name: 'revenue_prev', variant: 'comparison' }], + }), + ); + const c = await plot(area.container); + expect(paint(areaFill(c, 1))).toMatchObject({ fillOpacity: '0.2' }); + expect(paint(areaStroke(c, 1))).toMatchObject({ strokeOpacity: '0.6', strokeDasharray: '4 4' }); + }); + + it('gives a comparison bar NO dash and NO stroke fade — the defaults are per family, and the marks that just gained these props must not inherit one', async () => { + const { container } = render( + chart({ + chartType: 'bar', + series: [{ name: 'revenue' }, { name: 'revenue_prev', variant: 'comparison' }], + }), + ); + expect(paint(barRect(await plot(container), 1))).toMatchObject({ + strokeOpacity: null, + strokeDasharray: null, + }); + }); + + it('leaves a primary series carrying neither key completely unstyled', async () => { + const { container } = render( + chart({ chartType: 'line', series: [{ name: 'revenue' }] }), + ); + expect(paint(lineCurve(await plot(container)))).toEqual({ + fillOpacity: null, + strokeOpacity: null, + strokeDasharray: null, + }); + }); + + it('lets an authored `opacity` override the comparison default, as it always did', async () => { + const { container } = render( + chart({ + chartType: 'line', + series: [ + { name: 'revenue' }, + { name: 'revenue_prev', variant: 'comparison', opacity: 0.9, dashArray: '8 4' }, + ], + }), + ); + expect(paint(lineCurve(await plot(container), 1))).toMatchObject({ + strokeOpacity: '0.9', + strokeDasharray: '8 4', + }); + }); +}); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 3dfa3600a0..f437c6247d 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1514,38 +1514,46 @@ export interface ChartDataSeries { */ variant?: 'primary' | 'comparison'; /** - * Stroke and fill opacity. Read by `num()` (`normalizeChartSchema.ts:248` + * Stroke and fill opacity. Read by `num()` (`normalizeChartSchema.ts:365` * — any finite number; the mirror refuses `NaN`, `Infinity` and strings the - * 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, 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. + * same way) and applied by `seriesStyle` (`AdvancedChartImpl.tsx:114-115`), + * which hands it to EVERY mark family: `fillOpacity` on a Bar or Scatter + * mark, `strokeOpacity` on a Line mark, both on an Area mark (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. + * + * UNCONDITIONAL, as the spec declares it: it applies whatever the series' + * `variant`. It used to be honoured only on a `variant: 'comparison'` series + * — `comparisonStyle` returned `null` for any other variant, so elsewhere the + * value was read and then discarded — and objectui#7698 closed that in the + * RENDERER rather than by narrowing this face to match (AGENTS.md #0.1). + * What is still gated on `variant: 'comparison'` is the DEFAULT this key + * overrides: the muted treatment a comparison overlay gets when it carries + * no `opacity` of its own. */ opacity?: number; /** * SVG `stroke-dasharray` override, e.g. `"4 4"` for a dashed line - * (`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). + * (`normalizeChartSchema.ts:367`; applied by `seriesStyle`, + * `AdvancedChartImpl.tsx:116`) (objectui#7546). + * + * UNCONDITIONAL, as the spec declares it, and reaching every mark family — + * but only a STROKED mark shows a dash, and this renderer strokes Line and + * Area marks, so on a Bar or Scatter mark the value reaches the DOM and + * paints nothing. That is the mark's own geometry, not a condition on the + * key, and it is the one asymmetry with {@link ChartDataSeries.opacity}, + * which paints on every family. + * + * TWO gaps used to narrow it, and objectui#7698 closed both: the + * `variant: 'comparison'` guard that discarded it on a primary series, and + * the Bar and Scatter marks, which passed `fillOpacity` only and dropped + * `strokeDasharray` (and `strokeOpacity`) even where a comparison series had + * authored one — so a fix aimed at the guard alone would have left this key + * broken on those two families. What is still gated on + * `variant: 'comparison'` is the DEFAULT it overrides: the overlay's `'4 4'` + * dash on a Line or Area mark. */ dashArray?: string; /**