diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index e251708ce3..ae0ef08baa 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 9.13.0 ((8/11/2026, 09:52 AM PST)) + +This is an artificial version bump with no new change. + ## 9.12.3 ((8/10/2026, 09:17 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/common/package.json b/packages/common/package.json index 2d7e739104..b26cbf22a1 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "9.12.3", + "version": "9.13.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 595629d00f..8a419e93e6 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 9.13.0 ((8/11/2026, 09:52 AM PST)) + +This is an artificial version bump with no new change. + ## 9.12.3 ((8/10/2026, 09:17 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 3e19277abd..6d159dea5d 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "9.12.3", + "version": "9.13.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index a63a562b47..391b8861a5 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 9.13.0 (8/11/2026 PST) + +#### 🚀 Updates + +- Add a lightweight static render path for non-animated LineChart/CartesianChart (animate={false} uses a cheap rectangular clip instead of the anti-aliased path clip); ScrubberProvider now skips the pan gesture and animated reaction when scrubbing is disabled; add a strokeWidth prop to ReferenceLine. [[#840](https://github.com/coinbase/cds/pull/840)] + ## 9.12.3 (8/10/2026 PST) #### 🐞 Fixes diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 6148726fd0..b721ba58ff 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "9.12.3", + "version": "9.13.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/visualizations/chart/CartesianChart.tsx b/packages/mobile/src/visualizations/chart/CartesianChart.tsx index 29ab07d017..5f0cf2fab0 100644 --- a/packages/mobile/src/visualizations/chart/CartesianChart.tsx +++ b/packages/mobile/src/visualizations/chart/CartesianChart.tsx @@ -118,7 +118,8 @@ export type CartesianChartBaseProps = Omit & */ layout?: CartesianChartLayout; /** - * Whether to animate the chart. + * Whether to animate the chart. When `false`, lines render via a lightweight static path + * (a cheap rectangular clip) suited to non-interactive charts like row sparklines. * @default true */ animate?: boolean; diff --git a/packages/mobile/src/visualizations/chart/Path.tsx b/packages/mobile/src/visualizations/chart/Path.tsx index 4fd78a16a2..1e85ebefd6 100644 --- a/packages/mobile/src/visualizations/chart/Path.tsx +++ b/packages/mobile/src/visualizations/chart/Path.tsx @@ -208,7 +208,8 @@ const AnimatedPath = memo< }, ); -export const Path = memo((props) => { +// Animated path: reanimated clip-reveal + shared values. Used when the chart animates (default). +const AnimatedChartPath = memo((props) => { const { animate: animateProp, clipRect, @@ -349,39 +350,7 @@ export const Path = memo((props) => { return undefined; }, [clipPathProp, animateClip, targetClipPath]); - // Convert SVG path string to SkPath for static rendering - const staticPath = useDerivedValue(() => { - const dValue = unwrapAnimatedValue(d); - if (!dValue) return Skia.Path.Make(); - return Skia.Path.MakeFromSVGString(dValue) ?? Skia.Path.Make(); - }, [d]); - - const isFilled = fill !== undefined && fill !== 'none'; - const isStroked = stroke !== undefined && stroke !== 'none'; - - const content = !animate ? ( - <> - {isFilled && ( - - {children} - - )} - {isStroked && ( - - {children} - - )} - - ) : ( + const content = ( ((props) => { ); }); + +// Non-animated path: skips the clip-reveal animation and uses a cheap rectangular clip. +const StaticChartPath = memo< + Omit +>( + ({ + clipRect, + clipPath: clipPathProp, + clipOffset = 0, + d = '', + fill, + fillOpacity, + stroke, + strokeOpacity, + strokeWidth, + strokeCap, + strokeJoin, + children, + ...pathProps + }) => { + const context = useCartesianChartContext(); + const rect = clipRect ?? context.drawingArea; + + // Derived (not memoized) so an animated `d` — e.g. a reference line — still tracks scrubbing. + const path = useDerivedValue(() => { + const dValue = unwrapAnimatedValue(d); + if (!dValue) return Skia.Path.Make(); + return Skia.Path.MakeFromSVGString(dValue) ?? Skia.Path.Make(); + }, [d]); + + // Rect clip routes to canvas.clipRect (a cheap GPU scissor), not the anti-aliased path clip. + const clip = useMemo(() => { + if (clipPathProp !== undefined) return clipPathProp; + if (!rect) return null; + return { + x: rect.x - clipOffset, + y: rect.y - clipOffset, + width: rect.width + clipOffset * 2, + height: rect.height + clipOffset * 2, + }; + }, [clipPathProp, rect, clipOffset]); + + const isFilled = fill !== undefined && fill !== 'none'; + const isStroked = stroke !== undefined && stroke !== 'none'; + + const content = ( + <> + {isFilled && ( + + {children} + + )} + {isStroked && ( + + {children} + + )} + + ); + + if (clip === null) { + return {content}; + } + + return {content}; + }, +); + +/** + * Renders a chart path. Delegates to a lightweight static renderer when the chart is not + * animating (no reanimated hooks, cheap rect clip) and to the animated renderer otherwise. + */ +export const Path = memo( + ({ animate: animateProp, initialPath, transition, transitions, ...staticProps }) => { + const context = useCartesianChartContext(); + const animate = animateProp ?? context.animate; + + if (animate) { + return ( + + ); + } + + // Animation-only props are omitted from the static renderer. + return ; + }, +); diff --git a/packages/mobile/src/visualizations/chart/__tests__/Path.test.tsx b/packages/mobile/src/visualizations/chart/__tests__/Path.test.tsx new file mode 100644 index 0000000000..6b27cd5efe --- /dev/null +++ b/packages/mobile/src/visualizations/chart/__tests__/Path.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react-native'; + +import { useCartesianChartContext } from '../ChartProvider'; +import { Path } from '../Path'; + +type MockSkPath = { type: string; addRect: jest.Mock }; + +const makePath = (): MockSkPath => ({ type: 'SkPath', addRect: jest.fn() }); + +jest.mock('@shopify/react-native-skia', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + // Surface the `clip` prop so tests can distinguish a rect clip from an SkPath clip. + Group: ({ children, clip }: { children?: React.ReactNode; clip?: unknown }) => + React.createElement(View, { testID: 'group', clip }, children), + Path: ({ style }: { style?: string }) => React.createElement(View, { testID: `path-${style}` }), + Skia: { + Path: { + Make: jest.fn(makePath), + MakeFromSVGString: jest.fn((str: string) => ({ ...makePath(), svgString: str })), + }, + }, + usePathInterpolation: jest.fn(() => makePath()), + }; +}); + +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + isSharedValue: jest.fn(() => false), + useSharedValue: jest.fn((v: unknown) => ({ value: v })), + useDerivedValue: jest.fn((fn: () => unknown) => ({ value: fn() })), +})); + +jest.mock('../ChartProvider', () => ({ useCartesianChartContext: jest.fn() })); + +const mockedUseContext = useCartesianChartContext as unknown as jest.Mock; + +const drawingArea = { x: 0, y: 0, width: 100, height: 40 }; + +function mockContext(animate: boolean) { + mockedUseContext.mockReturnValue({ + animate, + layout: 'vertical', + drawingArea, + getXScale: () => (value: number) => value, + }); +} + +describe('Path interactive/static rendering', () => { + afterEach(() => jest.clearAllMocks()); + + it('uses a cheap rectangular clip (not a path clip) when the chart is not animating', () => { + mockContext(false); + render(); + + const clip = screen.getByTestId('group').props.clip; + // A rect clip is a plain object with numeric bounds; it routes to canvas.clipRect. + expect(typeof clip.width).toBe('number'); + expect(typeof clip.height).toBe('number'); + expect(clip.type).toBeUndefined(); + }); + + it('honors an explicit clipPath in static mode', () => { + mockContext(false); + render(); + + // clipPath={null} disables clipping entirely. + expect(screen.getByTestId('group').props.clip).toBeUndefined(); + }); + + it('renders the animated path (SkPath clip) when the chart is animating', () => { + mockContext(true); + render(); + + const clip = screen.getByTestId('group').props.clip; + // The animated renderer clips with an SkPath, not a plain rect. + expect(clip?.type ?? clip?.svgString !== undefined).toBeTruthy(); + }); +}); diff --git a/packages/mobile/src/visualizations/chart/line/LineChart.tsx b/packages/mobile/src/visualizations/chart/line/LineChart.tsx index 3f5479a0af..404e996a97 100644 --- a/packages/mobile/src/visualizations/chart/line/LineChart.tsx +++ b/packages/mobile/src/visualizations/chart/line/LineChart.tsx @@ -96,6 +96,10 @@ export type LineChartProps = LineChartBaseProps & scrubberAccessibilityLabelStep?: number; }; +/** + * A line chart built on `CartesianChart`. For non-interactive usage (e.g. row sparklines), pass + * `animate={false}` for a lightweight static render (cheap rectangular clip, no entrance animation). + */ export const LineChart = memo( ({ ref, diff --git a/packages/mobile/src/visualizations/chart/line/ReferenceLine.tsx b/packages/mobile/src/visualizations/chart/line/ReferenceLine.tsx index d985f496f3..2e02c3fb72 100644 --- a/packages/mobile/src/visualizations/chart/line/ReferenceLine.tsx +++ b/packages/mobile/src/visualizations/chart/line/ReferenceLine.tsx @@ -110,6 +110,11 @@ export type ReferenceLineBaseProps = { * @default theme.color.bgLine */ stroke?: string; + /** + * Width of the line. + * @default the line component's default (2) + */ + strokeWidth?: number; /** * Opacity applied to both the line and label. * @default 1 @@ -169,6 +174,7 @@ export const ReferenceLine = memo( labelVerticalAlignment, labelBoundsInset, stroke, + strokeWidth, opacity = 1, }) => { const theme = useTheme(); @@ -235,6 +241,7 @@ export const ReferenceLine = memo( d={horizontalLine} stroke={effectiveLineStroke} strokeOpacity={opacity} + strokeWidth={strokeWidth} /> {label && ( ( d={verticalLine} stroke={effectiveLineStroke} strokeOpacity={opacity} + strokeWidth={strokeWidth} /> {label && ( ), }, + { + title: 'Lightweight (Static)', + component: ( + + {/* Curve weaves through the reference line (negative + positive values). */} + + + ), + }, { title: 'Horizontal Layout', component: , diff --git a/packages/mobile/src/visualizations/chart/line/__tests__/LineChart.test.tsx b/packages/mobile/src/visualizations/chart/line/__tests__/LineChart.test.tsx new file mode 100644 index 0000000000..c009858863 --- /dev/null +++ b/packages/mobile/src/visualizations/chart/line/__tests__/LineChart.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react-native'; + +import { DefaultThemeProvider } from '../../../../utils/testHelpers'; +import { LineChart } from '../LineChart'; + +type MockSkPath = { type: string; addRect: jest.Mock; interpolate: jest.Mock }; + +const makePath = (): MockSkPath => ({ + type: 'SkPath', + addRect: jest.fn(), + interpolate: jest.fn(() => makePath()), +}); + +jest.mock('@shopify/react-native-skia', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + Canvas: ({ children, style }: { children: React.ReactNode; style?: unknown }) => + React.createElement(View, { style, testID: 'skia-canvas' }, children), + Group: ({ children }: { children?: React.ReactNode }) => children ?? null, + Path: () => null, + ClipOp: { Intersect: 0 }, + Skia: { + Path: { + Make: jest.fn(makePath), + MakeFromSVGString: jest.fn((str: string) => ({ ...makePath(), svgString: str })), + }, + TypefaceFontProvider: { Make: jest.fn(() => ({})) }, + }, + usePathInterpolation: jest.fn(() => makePath()), + notifyChange: jest.fn(), + }; +}); + +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + isSharedValue: jest.fn(() => false), + useSharedValue: jest.fn((v: unknown) => ({ value: v })), +})); + +jest.mock('../../ChartContextBridge', () => { + const React = require('react'); + return { + ChartBridgeProvider: ({ children }: { children: React.ReactNode }) => children, + useChartContextBridge: + () => + ({ children }: { children: React.ReactNode }) => + children, + }; +}); + +const series = [{ id: 'a', data: [1, 2, 3, 2, 4], color: 'green' }]; + +describe('LineChart', () => { + it('renders a static (animate=false) chart shell', () => { + render( + + + , + ); + + expect(screen.getByTestId('skia-canvas')).toBeTruthy(); + }); +}); diff --git a/packages/mobile/src/visualizations/chart/scrubber/ScrubberProvider.tsx b/packages/mobile/src/visualizations/chart/scrubber/ScrubberProvider.tsx index baa52ff3fc..a1b77bd9d2 100644 --- a/packages/mobile/src/visualizations/chart/scrubber/ScrubberProvider.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/ScrubberProvider.tsx @@ -22,13 +22,9 @@ export type ScrubberProviderProps = Partial void; }; -/** - * A component which encapsulates the ScrubberContext. - * It depends on a ChartContext in order to provide accurate touch tracking. - */ -export const ScrubberProvider: React.FC = ({ +// Sets up the pan gesture + animated reaction. Only mounted when scrubbing is enabled. +const EnabledScrubberProvider: React.FC = ({ children, - enableScrubbing, onScrubberPositionChange, allowOverflowGestures, }) => { @@ -154,15 +150,11 @@ export const ScrubberProvider: React.FC = ({ } }) .onEnd(function onEnd() { - if (enableScrubbing) { - runOnJS(handleStartEndHaptics)(); - scrubberPosition.value = undefined; - } + runOnJS(handleStartEndHaptics)(); + scrubberPosition.value = undefined; }) .onTouchesCancelled(function onTouchesCancelled() { - if (enableScrubbing) { - scrubberPosition.value = undefined; - } + scrubberPosition.value = undefined; }); }, [ allowOverflowGestures, @@ -170,25 +162,40 @@ export const ScrubberProvider: React.FC = ({ getDataIndexFromPosition, categoryAxisIsX, scrubberPosition, - enableScrubbing, ]); const contextValue: ScrubberContextValue = useMemo( - () => ({ - enableScrubbing: !!enableScrubbing, - scrubberPosition, - }), - [enableScrubbing, scrubberPosition], + () => ({ enableScrubbing: true, scrubberPosition }), + [scrubberPosition], + ); + + return ( + + {children} + ); +}; + +// Supplies the ScrubberContext without the gesture/animated reaction (the common, disabled case). +const DisabledScrubberProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const scrubberPosition = useSharedValue(undefined); - const content = ( - {children} + const contextValue = useMemo( + () => ({ enableScrubbing: false, scrubberPosition }), + [scrubberPosition], ); - // Wrap with gesture handler only if scrubbing is enabled - if (enableScrubbing) { - return {content}; + return {children}; +}; + +/** + * A component which encapsulates the ScrubberContext. + * It depends on a ChartContext in order to provide accurate touch tracking. + */ +export const ScrubberProvider: React.FC = (props) => { + if (props.enableScrubbing) { + return ; } - return content; + return {props.children}; }; diff --git a/packages/mobile/src/visualizations/chart/scrubber/__tests__/ScrubberProvider.test.tsx b/packages/mobile/src/visualizations/chart/scrubber/__tests__/ScrubberProvider.test.tsx new file mode 100644 index 0000000000..8eb32f514b --- /dev/null +++ b/packages/mobile/src/visualizations/chart/scrubber/__tests__/ScrubberProvider.test.tsx @@ -0,0 +1,81 @@ +import { Text } from 'react-native'; +import { render, screen } from '@testing-library/react-native'; + +import { useCartesianChartContext } from '../../ChartProvider'; +import { ScrubberProvider } from '../ScrubberProvider'; + +jest.mock('react-native-gesture-handler', () => { + const React = require('react'); + const { View } = require('react-native'); + // Chainable Gesture.Pan() mock — every builder method returns the gesture. + const makeGesture = () => { + const gesture: Record unknown> = {}; + for (const method of [ + 'activateAfterLongPress', + 'shouldCancelWhenOutside', + 'failOffsetY', + 'failOffsetX', + 'onStart', + 'onUpdate', + 'onEnd', + 'onTouchesCancelled', + ]) { + gesture[method] = () => gesture; + } + return gesture; + }; + return { + Gesture: { Pan: makeGesture }, + GestureDetector: ({ children }: { children: React.ReactNode }) => + React.createElement(View, { testID: 'gesture-detector' }, children), + }; +}); + +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + useSharedValue: jest.fn((v: unknown) => ({ value: v })), + useAnimatedReaction: jest.fn(), + runOnJS: (fn: unknown) => fn, +})); + +jest.mock('../../ChartProvider', () => ({ useCartesianChartContext: jest.fn() })); + +const mockedUseContext = useCartesianChartContext as unknown as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + mockedUseContext.mockReturnValue({ + layout: 'vertical', + getXSerializableScale: () => undefined, + getYSerializableScale: () => undefined, + getXAxis: () => undefined, + getYAxis: () => undefined, + }); +}); + +describe('ScrubberProvider', () => { + it('wires up the pan gesture when scrubbing is enabled', () => { + render( + + content + , + ); + + expect(screen.getByTestId('gesture-detector')).toBeTruthy(); + expect(screen.getByTestId('child')).toBeTruthy(); + }); + + it('skips the gesture and animated reaction when scrubbing is disabled', () => { + const { useAnimatedReaction } = require('react-native-reanimated'); + + render( + + content + , + ); + + expect(screen.queryByTestId('gesture-detector')).toBeNull(); + expect(screen.getByTestId('child')).toBeTruthy(); + expect(useAnimatedReaction).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 8fdeacb630..5fbc8da6c8 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 9.13.0 ((8/11/2026, 09:52 AM PST)) + +This is an artificial version bump with no new change. + ## 9.12.3 ((8/10/2026, 09:17 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/web/package.json b/packages/web/package.json index fad7bd555f..e73760e279 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "9.12.3", + "version": "9.13.0", "description": "Coinbase Design System - Web", "repository": { "type": "git",