diff --git a/apps/ai-credits-web/src/reactNativeSvgWeb.tsx b/apps/ai-credits-web/src/reactNativeSvgWeb.tsx index dd275cf9..ae3b8d97 100644 --- a/apps/ai-credits-web/src/reactNativeSvgWeb.tsx +++ b/apps/ai-credits-web/src/reactNativeSvgWeb.tsx @@ -4,11 +4,37 @@ type SvgProps = React.SVGProps & { accessibilityRole?: string } +type SvgGroupProps = React.SVGProps & { + rotation?: string | number + origin?: string +} + +type SvgTextProps = React.SVGProps & { + rotation?: string | number + origin?: string +} + export function Svg({ accessibilityRole, ...props }: SvgProps) { void accessibilityRole return } +/** Mirrors react-native-svg's G transform props with a standard SVG transform; used by PieDonutChart/BarChart/LineAreaChart to group arc/bar/line elements. */ +export function G({ rotation, origin, transform, ...props }: SvgGroupProps) { + const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined + const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ') + + return +} + +/** Mirrors react-native-svg's Text rotation/origin props with a standard SVG transform; used by BarChart/LineAreaChart for in-chart axis/tick/value labels. */ +export function Text({ rotation, origin, transform, ...props }: SvgTextProps) { + const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined + const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ') + + return +} + export function Path(props: React.SVGProps) { return } @@ -37,4 +63,21 @@ export function Ellipse(props: React.SVGProps) { return } +/** + * Defs/LinearGradient/Stop passthroughs — used by LineAreaChart's area-fill + * gradients. Plain SVG already understands these tags natively, so (unlike + * G/Text above) no react-native-specific prop translation is needed. + */ +export function Defs(props: React.SVGProps) { + return +} + +export function LinearGradient(props: React.SVGProps) { + return +} + +export function Stop(props: React.SVGProps) { + return +} + export default Svg diff --git a/apps/superfluid-campaign-web/src/reactNativeSvgWeb.tsx b/apps/superfluid-campaign-web/src/reactNativeSvgWeb.tsx index dd275cf9..ae3b8d97 100644 --- a/apps/superfluid-campaign-web/src/reactNativeSvgWeb.tsx +++ b/apps/superfluid-campaign-web/src/reactNativeSvgWeb.tsx @@ -4,11 +4,37 @@ type SvgProps = React.SVGProps & { accessibilityRole?: string } +type SvgGroupProps = React.SVGProps & { + rotation?: string | number + origin?: string +} + +type SvgTextProps = React.SVGProps & { + rotation?: string | number + origin?: string +} + export function Svg({ accessibilityRole, ...props }: SvgProps) { void accessibilityRole return } +/** Mirrors react-native-svg's G transform props with a standard SVG transform; used by PieDonutChart/BarChart/LineAreaChart to group arc/bar/line elements. */ +export function G({ rotation, origin, transform, ...props }: SvgGroupProps) { + const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined + const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ') + + return +} + +/** Mirrors react-native-svg's Text rotation/origin props with a standard SVG transform; used by BarChart/LineAreaChart for in-chart axis/tick/value labels. */ +export function Text({ rotation, origin, transform, ...props }: SvgTextProps) { + const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined + const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ') + + return +} + export function Path(props: React.SVGProps) { return } @@ -37,4 +63,21 @@ export function Ellipse(props: React.SVGProps) { return } +/** + * Defs/LinearGradient/Stop passthroughs — used by LineAreaChart's area-fill + * gradients. Plain SVG already understands these tags natively, so (unlike + * G/Text above) no react-native-specific prop translation is needed. + */ +export function Defs(props: React.SVGProps) { + return +} + +export function LinearGradient(props: React.SVGProps) { + return +} + +export function Stop(props: React.SVGProps) { + return +} + export default Svg diff --git a/examples/html/src/shims/reactNativeSvg.tsx b/examples/html/src/shims/reactNativeSvg.tsx index 496d1e48..524e515e 100644 --- a/examples/html/src/shims/reactNativeSvg.tsx +++ b/examples/html/src/shims/reactNativeSvg.tsx @@ -13,6 +13,11 @@ type SvgCircleProps = React.SVGProps & { onPress?: () => void } +type SvgTextProps = React.SVGProps & { + rotation?: string | number + origin?: string +} + /** Web implementation for the react-native-svg primitives used by GoodWidget dependencies. */ function Svg({ accessibilityRole: _accessibilityRole, ...props }: SvgElementProps) { return @@ -31,6 +36,14 @@ export function Circle({ onPress, ...props }: SvgCircleProps) { return } +/** Mirrors react-native-svg's Text rotation/origin props with a standard SVG transform; used by BarChart/LineAreaChart for in-chart axis/tick/value labels. */ +export function Text({ rotation, origin, transform, ...props }: SvgTextProps) { + const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined + const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ') + + return +} + /** Static SVG primitives used by @tamagui/lucide-icons need no prop translation. */ function createPassthroughSvgPrimitive(tag: string) { return function SvgPrimitive(props: React.SVGProps) { @@ -45,5 +58,15 @@ export const Polygon = createPassthroughSvgPrimitive('polygon') export const Polyline = createPassthroughSvgPrimitive('polyline') export const Ellipse = createPassthroughSvgPrimitive('ellipse') +/** + * Defs/LinearGradient/Stop passthroughs — used by LineAreaChart's area-fill + * gradients. Plain SVG already understands these tags natively via + * React.createElement, so (unlike Circle/G/Text above) no react-native-specific + * prop translation is needed. + */ +export const Defs = createPassthroughSvgPrimitive('defs') +export const LinearGradient = createPassthroughSvgPrimitive('linearGradient') +export const Stop = createPassthroughSvgPrimitive('stop') + export { Svg } export default Svg diff --git a/examples/react-web/src/shims/reactNativeSvg.tsx b/examples/react-web/src/shims/reactNativeSvg.tsx index 496d1e48..524e515e 100644 --- a/examples/react-web/src/shims/reactNativeSvg.tsx +++ b/examples/react-web/src/shims/reactNativeSvg.tsx @@ -13,6 +13,11 @@ type SvgCircleProps = React.SVGProps & { onPress?: () => void } +type SvgTextProps = React.SVGProps & { + rotation?: string | number + origin?: string +} + /** Web implementation for the react-native-svg primitives used by GoodWidget dependencies. */ function Svg({ accessibilityRole: _accessibilityRole, ...props }: SvgElementProps) { return @@ -31,6 +36,14 @@ export function Circle({ onPress, ...props }: SvgCircleProps) { return } +/** Mirrors react-native-svg's Text rotation/origin props with a standard SVG transform; used by BarChart/LineAreaChart for in-chart axis/tick/value labels. */ +export function Text({ rotation, origin, transform, ...props }: SvgTextProps) { + const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined + const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ') + + return +} + /** Static SVG primitives used by @tamagui/lucide-icons need no prop translation. */ function createPassthroughSvgPrimitive(tag: string) { return function SvgPrimitive(props: React.SVGProps) { @@ -45,5 +58,15 @@ export const Polygon = createPassthroughSvgPrimitive('polygon') export const Polyline = createPassthroughSvgPrimitive('polyline') export const Ellipse = createPassthroughSvgPrimitive('ellipse') +/** + * Defs/LinearGradient/Stop passthroughs — used by LineAreaChart's area-fill + * gradients. Plain SVG already understands these tags natively via + * React.createElement, so (unlike Circle/G/Text above) no react-native-specific + * prop translation is needed. + */ +export const Defs = createPassthroughSvgPrimitive('defs') +export const LinearGradient = createPassthroughSvgPrimitive('linearGradient') +export const Stop = createPassthroughSvgPrimitive('stop') + export { Svg } export default Svg diff --git a/examples/storybook/src/shims/reactNativeSvg.tsx b/examples/storybook/src/shims/reactNativeSvg.tsx index 558dc925..64351726 100644 --- a/examples/storybook/src/shims/reactNativeSvg.tsx +++ b/examples/storybook/src/shims/reactNativeSvg.tsx @@ -13,6 +13,11 @@ type SvgCircleProps = React.SVGProps & { onPress?: () => void } +type SvgTextProps = React.SVGProps & { + rotation?: string | number + origin?: string +} + /** Storybook web shim for the react-native-svg primitives used by the donut chart and @tamagui/lucide-icons. */ function Svg({ accessibilityRole: _accessibilityRole, ...props }: SvgElementProps) { return @@ -31,6 +36,14 @@ export function Circle({ onPress, ...props }: SvgCircleProps) { return } +/** Mirrors react-native-svg's Text rotation/origin props with a standard SVG transform; used by BarChart for in-chart axis/tick/value labels. */ +export function Text({ rotation, origin, transform, ...props }: SvgTextProps) { + const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined + const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ') + + return +} + /** * @tamagui/lucide-icons' generated icon components import Path/Line/Rect/Polygon/ * Polyline/Ellipse by name from react-native-svg purely to render static shapes — @@ -50,5 +63,15 @@ export const Polygon = createPassthroughSvgPrimitive('polygon') export const Polyline = createPassthroughSvgPrimitive('polyline') export const Ellipse = createPassthroughSvgPrimitive('ellipse') +/** + * Defs/LinearGradient/Stop passthroughs — used by LineAreaChart's area-fill + * gradients. Plain SVG already understands these tags natively via + * React.createElement, so (unlike Circle/G/Text above) no react-native-specific + * prop translation is needed. + */ +export const Defs = createPassthroughSvgPrimitive('defs') +export const LinearGradient = createPassthroughSvgPrimitive('linearGradient') +export const Stop = createPassthroughSvgPrimitive('stop') + export { Svg } export default Svg diff --git a/examples/storybook/src/stories/design-system/BarChart.stories.tsx b/examples/storybook/src/stories/design-system/BarChart.stories.tsx new file mode 100644 index 00000000..39032f25 --- /dev/null +++ b/examples/storybook/src/stories/design-system/BarChart.stories.tsx @@ -0,0 +1,119 @@ +/** + * BarChart — discrete categorical comparison via bar length, vertical or + * horizontal. Mock datasets are the 5 fixtures from #144. + */ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { BarChart, XStack, YStack } from '@goodwidget/ui' +import { withDefaultPreset } from '../helpers/withDefaultPreset' + +const chains = [ + { category: 'Celo', value: 45200 }, + { category: 'Fuse', value: 32100 }, + { category: 'Ethereum', value: 8500 }, +] + +const houses = [ + { category: 'House of Alignment', value: 450000 }, + { category: 'House of Innovation', value: 320000 }, + { category: 'House of Community', value: 180000 }, +] + +const single = [{ category: 'Total Claims', value: 85800 }] + +const empty: Array<{ category: string; value: number }> = [] + +/** 150 categories — bars become sub-pixel-narrow; must clip/degrade gracefully, not crash. */ +const stress = Array.from({ length: 150 }, (_, i) => ({ + category: `Wallet ${String(i + 1).padStart(3, '0')}`, + value: Math.floor(Math.random() * 100000), +})) + +const meta: Meta = { + title: 'Design System/Primitives/BarChart', + component: BarChart, + tags: ['autodocs', 'showcase'], + parameters: { layout: 'padded' }, + decorators: [withDefaultPreset], + argTypes: { + layout: { + control: 'select', + options: ['vertical', 'horizontal'], + description: 'Bar orientation', + }, + showGrid: { control: 'boolean' }, + showValueLabels: { control: 'boolean' }, + barCornerRadius: { control: 'number' }, + variant: { + control: 'select', + options: ['bare', 'card'], + description: 'Chrome-less vs. card-wrapped face', + }, + }, +} +export default meta +type Story = StoryObj + +/** Claims-by-chain rendered vertical (bare + card) and horizontal, with value labels. */ +export const Default: Story = { + render: () => ( + + + + + + + ), +} + +/** Long category labels — the case horizontal layout exists for. Wider left padding gives labels room. */ +export const HorizontalLongLabels: Story = { + render: () => ( + + ), +} + +export const EmptyState: Story = { + render: () => , +} + +export const SinglePoint: Story = { + render: () => , +} + +/** 150 categories, maxSlices has no equivalent here — exercises sub-pixel bar clipping and label-truncation safety. */ +export const StressTest: Story = { + render: () => , +} + +/** Controllable instance — edit args in the Controls panel. */ +export const Controllable: Story = { + args: { + data: chains, + title: 'Claims by Chain', + width: 320, + variant: 'card', + }, +} diff --git a/examples/storybook/src/stories/design-system/DataTable.stories.tsx b/examples/storybook/src/stories/design-system/DataTable.stories.tsx new file mode 100644 index 00000000..630ee83a --- /dev/null +++ b/examples/storybook/src/stories/design-system/DataTable.stories.tsx @@ -0,0 +1,135 @@ +/** + * DataTable — exact-value display with typed columns, formatting, and + * sorting. Mock datasets are the 5 fixtures from #146 (data-team spec.md). + */ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { DataTable, XStack, YStack } from '@goodwidget/ui' +import type { DataTableColumnDef } from '@goodwidget/ui' +import { withDefaultPreset } from '../helpers/withDefaultPreset' + +const wallets = [ + { address: '0x1a2b...', volume: 1234567, txCount: 847, lastActive: 'Aug 5' }, + { address: '0x3c4d...', volume: 892000, txCount: 623, lastActive: 'Aug 4' }, + { address: '0x5e6f...', volume: 445000, txCount: 312, lastActive: 'Aug 3' }, + { address: '0x7g8h...', volume: 128000, txCount: 95, lastActive: 'Aug 1' }, + { address: '0x9i0j...', volume: 45200, txCount: 42, lastActive: 'Jul 28' }, +] + +const walletColumns: Array> = [ + { key: 'address', label: 'Address', align: 'left', sortable: true }, + { key: 'volume', label: 'Volume', type: 'number', sortable: true }, + { key: 'txCount', label: 'Tx Count', type: 'number', sortable: true }, + { key: 'lastActive', label: 'Last Active' }, +] + +const metrics = [ + { metric: 'Daily Claims', value: 31500, change: '+8.2%' }, + { metric: 'Active Wallets', value: 12400, change: '+3.1%' }, + { metric: 'Reserve Balance', value: 4500000, change: '-1.2%' }, +] + +const metricColumns: Array> = [ + { key: 'metric', label: 'Metric', align: 'left' }, + { key: 'value', label: 'Value', type: 'number' }, + { key: 'change', label: 'Change' }, +] + +const empty: Array<(typeof wallets)[number]> = [] + +const names = [{ name: 'Education Hubs' }, { name: 'Merchant Onboard' }, { name: 'Dev Grants' }] + +const nameColumns: Array> = [{ key: 'name', label: 'Category', align: 'left' }] + +/** A wallet with unreported volume — exercises rule 8 (null/undefined cells render "--"). */ +const walletsWithGaps = [...wallets.slice(0, 3), { address: '0xNULL...', volume: null, txCount: 12, lastActive: 'Jul 20' }] + +/** 150 rows with maxHeight=300 — exercises vertical scroll, sticky header, and sort performance at scale. */ +const stress = Array.from({ length: 150 }, (_, i) => ({ + rank: i + 1, + address: `0x${i.toString(16).padStart(8, '0')}`, + amount: Math.floor(Math.random() * 1000000), + txCount: Math.floor(Math.random() * 500), +})) + +const stressColumns: Array> = [ + { key: 'rank', label: 'Rank', type: 'number', width: 70 }, + { key: 'address', label: 'Address', align: 'left' }, + { key: 'amount', label: 'Amount', type: 'number', sortable: true }, + { key: 'txCount', label: 'Tx Count', type: 'number', sortable: true }, +] + +const meta: Meta = { + title: 'Design System/Primitives/DataTable', + component: DataTable, + tags: ['autodocs', 'showcase'], + parameters: { layout: 'padded' }, + decorators: [withDefaultPreset], + argTypes: { + striped: { control: 'boolean' }, + compact: { control: 'boolean' }, + stickyHeader: { control: 'boolean' }, + variant: { + control: 'select', + options: ['bare', 'card'], + description: 'Chrome-less vs. card-wrapped face', + }, + }, +} +export default meta +type Story = StoryObj + +/** Top wallets, sortable columns, bare and card variants. */ +export const Default: Story = { + render: () => ( + + + + + + + ), +} + +/** Compact metrics summary, rendered with compact=true (reduced padding/font, rule 10). */ +export const CompactMetrics: Story = { + render: () => , +} + +export const EmptyState: Story = { + render: () => , +} + +export const SingleColumn: Story = { + render: () => , +} + +/** Null volume renders "--" (rule 8); onRowPress fires and gives press feedback. */ +export const NullValuesAndRowPress: Story = { + render: () => ( + console.log('DataTable row pressed', row)} + testID="DataTable-nulls" + /> + ), +} + +/** 150-row stress test, maxHeight=300 forces vertical scroll with a sticky header; Amount/Tx Count are sortable. */ +export const StressTest: Story = { + render: () => ( + + ), +} + +/** Controllable instance — edit args in the Controls panel. */ +export const Controllable: Story = { + args: { + data: wallets, + columns: walletColumns, + title: 'Top Wallets', + variant: 'card', + }, +} diff --git a/examples/storybook/src/stories/design-system/LineAreaChart.stories.tsx b/examples/storybook/src/stories/design-system/LineAreaChart.stories.tsx new file mode 100644 index 00000000..d9def7a3 --- /dev/null +++ b/examples/storybook/src/stories/design-system/LineAreaChart.stories.tsx @@ -0,0 +1,165 @@ +/** + * LineAreaChart — time-series trends via connected line segments, optionally + * filled to a gradient area. Mock datasets are the 5 fixtures from #145. + */ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { LineAreaChart, XStack, YStack } from '@goodwidget/ui' +import { withDefaultPreset } from '../helpers/withDefaultPreset' + +const daily = [ + { x: 'Jul 24', y: 18200 }, + { x: 'Jul 25', y: 19400 }, + { x: 'Jul 26', y: 17800 }, + { x: 'Jul 27', y: 21000 }, + { x: 'Jul 28', y: 22500 }, + { x: 'Jul 29', y: 20100 }, + { x: 'Jul 30', y: 23800 }, + { x: 'Jul 31', y: 25200 }, + { x: 'Aug 1', y: 24100 }, + { x: 'Aug 2', y: 26800 }, + { x: 'Aug 3', y: 28400 }, + { x: 'Aug 4', y: 27200 }, + { x: 'Aug 5', y: 30100 }, + { x: 'Aug 6', y: 31500 }, +] + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'] +const CLAIMS_BY_MONTH = [12000, 14500, 13800, 16200, 18400, 17100, 19800] +const PRICE_BY_MONTH = [0.012, 0.011, 0.0125, 0.013, 0.0142, 0.0138, 0.0155] +const multiAxis = MONTHS.flatMap((month, index) => [ + { x: month, y: CLAIMS_BY_MONTH[index], series: 'claims' }, + { x: month, y: PRICE_BY_MONTH[index], series: 'price' }, +]) + +const withGap = [ + { x: 'Day 1', y: 100 }, + { x: 'Day 2', y: 120 }, + { x: 'Day 3', y: null }, + { x: 'Day 4', y: null }, + { x: 'Day 5', y: 150 }, + { x: 'Day 6', y: 160 }, +] + +const empty: Array<{ x: string; y: number | null }> = [] + +const single = [{ x: 'Today', y: 24100 }] + +/** 1095 days (3 years) — line becomes very dense; must render without crash/hang and thin x-labels adaptively. */ +const stress = Array.from({ length: 1095 }, (_, i) => { + const date = new Date(2024, 0, 1) + date.setDate(date.getDate() + i) + return { + x: date.toISOString().slice(0, 10), + y: 10000 + Math.floor(Math.random() * 5000) + i * 10, + } +}) + +const meta: Meta = { + title: 'Design System/Primitives/LineAreaChart', + component: LineAreaChart, + tags: ['autodocs', 'showcase'], + parameters: { layout: 'padded' }, + decorators: [withDefaultPreset], + argTypes: { + type: { + control: 'select', + options: ['linear', 'monotone', 'step'], + description: 'Interpolation curve', + }, + showArea: { control: 'boolean' }, + showDots: { control: 'select', options: [true, false, 'auto'] }, + showGrid: { control: 'boolean' }, + connectNulls: { control: 'boolean' }, + variant: { + control: 'select', + options: ['bare', 'card'], + description: 'Chrome-less vs. card-wrapped face', + }, + }, +} +export default meta +type Story = StoryObj + +/** Daily claims with area fill and a target reference line, shown across linear/monotone/step interpolation and the card variant. */ +export const Default: Story = { + render: () => ( + + + + + + + ), +} + +export const StepInterpolation: Story = { + render: () => , +} + +/** Multi-series with a secondary y-axis for the price series (different scale/units from claims). */ +export const MultiSeriesSecondaryAxis: Story = { + render: () => ( + `$${value}` }} + width={480} + testID="LineAreaChart-multi-axis" + /> + ), +} + +/** connectNulls=false (default, visible gap) vs. true (bridged) side by side. */ +export const WithGap: Story = { + render: () => ( + + + + + ), +} + +export const EmptyState: Story = { + render: () => , +} + +export const SinglePoint: Story = { + render: () => , +} + +/** 1095 daily points (3 years) — dots auto-hide, x-labels thin adaptively, path must not hang the browser. */ +export const StressTest: Story = { + render: () => , +} + +/** Controllable instance — edit args in the Controls panel. */ +export const Controllable: Story = { + args: { + data: daily, + title: 'Daily UBI Claims', + showArea: true, + width: 420, + variant: 'card', + }, +} diff --git a/examples/storybook/src/stories/design-system/PieDonutChart.stories.tsx b/examples/storybook/src/stories/design-system/PieDonutChart.stories.tsx new file mode 100644 index 00000000..ebae092c --- /dev/null +++ b/examples/storybook/src/stories/design-system/PieDonutChart.stories.tsx @@ -0,0 +1,115 @@ +/** + * PieDonutChart — proportional arc segments for categorical data, with + * optional donut center content. Mock datasets are the 5 fixtures from #143. + */ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { PieDonutChart, XStack, YStack } from '@goodwidget/ui' +import { withDefaultPreset } from '../helpers/withDefaultPreset' + +const funding = [ + { label: 'Education Hubs', value: 157500 }, + { label: 'Merchant Onboard', value: 112500 }, + { label: 'Dev Grants', value: 90000 }, + { label: 'Creator Fund', value: 90000 }, +] + +const single = [{ label: 'UBI Distribution', value: 1000000 }] + +const nearEqual = [ + { label: 'Celo', value: 51 }, + { label: 'Fuse', value: 49 }, +] + +const empty: Array<{ label: string; value: number }> = [] + +/** 120 items forces maxSlices=7 aggregation: top 6 kept + one massive "Other". */ +const stress = Array.from({ length: 120 }, (_, i) => ({ + label: `Category ${i + 1}`, + value: Math.floor(Math.random() * 10000) + 100, +})) + +const meta: Meta = { + title: 'Design System/Primitives/PieDonutChart', + component: PieDonutChart, + tags: ['autodocs', 'showcase'], + parameters: { layout: 'padded' }, + decorators: [withDefaultPreset], + argTypes: { + innerRadius: { control: 'number', description: 'Fraction of outer radius that is hollow (0 = pie, >0 = donut)' }, + maxSlices: { control: 'number', description: 'Max segments before aggregating the tail into "Other"' }, + sort: { + control: 'select', + options: ['descending', 'ascending', 'none'], + description: 'Segment display order', + }, + showLegend: { control: 'boolean' }, + showPercentages: { control: 'boolean' }, + variant: { + control: 'select', + options: ['bare', 'card'], + description: 'Chrome-less vs. card-wrapped face', + }, + }, +} +export default meta +type Story = StoryObj + +/** Funding breakdown as a donut (spec default: innerRadius=0.6, center metric visible), bare and card variants. */ +export const Default: Story = { + render: () => ( + + + sum + item.value, 0)} + testID="PieDonutChart-funding-donut-bare" + /> + sum + item.value, 0)} + variant="card" + testID="PieDonutChart-funding-donut-card" + /> + + + ), +} + +/** Classic filled pie (innerRadius=0) — the non-default mode, no center content since there's no hole to hold it. */ +export const PurePie: Story = { + render: () => ( + + ), +} + +export const EmptyState: Story = { + render: () => , +} + +export const SinglePoint: Story = { + render: () => , +} + +export const NearEqualSplit: Story = { + render: () => , +} + +/** 120 categories, maxSlices=7 — exercises aggregation math and legend overflow safety. */ +export const StressTest: Story = { + render: () => , +} + +/** Controllable instance — edit args in the Controls panel. */ +export const Controllable: Story = { + args: { + data: funding, + title: 'Funding Distribution', + innerRadius: 0.6, + variant: 'card', + }, +} diff --git a/examples/storybook/src/stories/design-system/Scorecard.stories.tsx b/examples/storybook/src/stories/design-system/Scorecard.stories.tsx new file mode 100644 index 00000000..c7ed1130 --- /dev/null +++ b/examples/storybook/src/stories/design-system/Scorecard.stories.tsx @@ -0,0 +1,82 @@ +/** + * Scorecard — KPI card showing a single metric with optional trend indicator. + */ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Scorecard, XStack, YStack } from '@goodwidget/ui' +import type { ScorecardProps } from '@goodwidget/ui' +import { withDefaultPreset } from '../helpers/withDefaultPreset' + +/** The 5 mock-data rows from #139, reused to render both the bare and card variants. */ +const MOCK_ROWS: Array<{ slug: string; props: Omit }> = [ + { slug: 'total-spent', props: { label: 'Total G$ Spent', value: 1900, prefix: 'G$', format: 'compact' } }, + { slug: 'ai-credits', props: { label: 'AI Credits Used', value: 284.5, prefix: '$', format: 'decimal', decimals: 2 } }, + { slug: 'active-days', props: { label: 'Active Days', value: 28, format: 'none' } }, + { + slug: 'unique-wallets', + props: { label: 'Unique Wallets', value: 47, trend: { value: 15.3, direction: 'up' }, trendLabel: 'vs last 7d' }, + }, + { slug: 'daily-flow-rate', props: { label: 'Daily Flow Rate', value: 2450000, prefix: 'G$', suffix: '/day', format: 'compact' } }, +] + +const meta: Meta = { + title: 'Design System/Primitives/Scorecard', + component: Scorecard, + tags: ['autodocs', 'showcase'], + parameters: { layout: 'padded' }, + decorators: [withDefaultPreset], + argTypes: { + value: { control: 'number', description: 'The metric value to display' }, + label: { control: 'text', description: 'What the metric represents' }, + prefix: { control: 'text', description: 'Unit before the value' }, + suffix: { control: 'text', description: 'Unit after the value' }, + format: { + control: 'select', + options: ['compact', 'decimal', 'none'], + description: 'Number formatting mode', + }, + decimals: { control: 'number', description: 'Decimal precision' }, + variant: { + control: 'select', + options: ['bare', 'card'], + description: 'Chrome-less vs. card-wrapped face', + }, + size: { + control: 'select', + options: ['sm', 'md', 'lg'], + description: 'Typography size preset', + }, + }, +} +export default meta +type Story = StoryObj + +/** All 5 mock-data rows from #139, each rendered in both the bare and card variant. */ +export const Default: Story = { + render: () => ( + + + {MOCK_ROWS.map(({ slug, props }) => ( + + ))} + + + {MOCK_ROWS.map(({ slug, props }) => ( + + ))} + + + ), +} + +/** Controllable instance — edit args in the Controls panel. */ +export const Controllable: Story = { + args: { + label: 'Total G$ Spent', + value: 1900, + prefix: 'G$', + format: 'compact', + variant: 'card', + size: 'md', + }, +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 3a6982dd..86a135c5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -21,6 +21,7 @@ "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.76.0", + "react-native-svg": ">=15.0.0", "@react-native-clipboard/clipboard": ">=1.14.0" }, "peerDependenciesMeta": { @@ -42,6 +43,7 @@ "@types/react": "^18.3.0", "react": "^18.3.0", "react-native": "0.76.9", + "react-native-svg": "15.15.5", "react-native-web": "^0.19.13", "tsup": "^8.4.0", "typescript": "^5.7.0" diff --git a/packages/ui/src/components/BarChart.tsx b/packages/ui/src/components/BarChart.tsx new file mode 100644 index 00000000..c1d85129 --- /dev/null +++ b/packages/ui/src/components/BarChart.tsx @@ -0,0 +1,482 @@ +/** + * BarChart — discrete categorical comparison via proportional bar length, + * vertical or horizontal. Third of 5 planned analytics chart components + * (Scorecard and PieDonutChart shipped first, in PR #142). + * + * Follows Scorecard.tsx's structural patterns (createComponent, useTheme, + * formatMetricValue, golden-ratio spacing) and PieDonutChart.tsx's SVG/theme + * conventions (resolveThemeColor, accessible={false} on data shapes with a + * single descriptive label on the root Svg). + */ +import React from 'react' +import Svg, { G, Line, Path, Text as SvgText } from 'react-native-svg' +import { Text as TamaguiText, useTheme, YStack } from 'tamagui' +import { createComponent } from '../createComponent' +import { Card } from './Card' +import { CHART_FONT_FAMILY } from '../utils/chartFontFamily' +import { formatMetricValue } from '../utils/formatMetricValue' +import { resolveThemeColor } from '../utils/resolveThemeColor' + +export type BarChartVariant = 'bare' | 'card' +export type BarChartLayout = 'vertical' | 'horizontal' + +export interface BarChartDataItem { + category: string + value: number +} + +export interface BarChartPadding { + top: number + right: number + bottom: number + left: number +} + +export interface BarChartProps { + data: BarChartDataItem[] + title?: string + layout?: BarChartLayout + showGrid?: boolean + showValueLabels?: boolean + valueFormatter?: (value: number) => string + xAxisLabel?: string + yAxisLabel?: string + barCornerRadius?: number + onBarPress?: (item: BarChartDataItem, index: number) => void + variant?: BarChartVariant + testID?: string + accessibilityLabel?: string + width?: number | string + height?: number + padding?: Partial +} + +/** + * Golden-ratio scale/spacing constants, matching Scorecard.tsx's values so + * all analytics components breathe identically. Scorecard's own constants + * are private (not exported) and out of this task's scope to modify, so + * these are re-declared locally, mirroring PieDonutChart's precedent. + */ +const CHART_BASE_SIZE_PX = 24 +const GOLDEN_RATIO = 1.618 +const MIN_FONT_SIZE_PX = 12 +const clampFontSize = (px: number): number => Math.max(px, MIN_FONT_SIZE_PX) + +const TITLE_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX) +const AXIS_TITLE_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX / GOLDEN_RATIO) +const TICK_LABEL_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX / GOLDEN_RATIO ** 2) +const VALUE_LABEL_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX / GOLDEN_RATIO ** 2) + +const TITLE_TO_CHART_GAP_PX = CHART_BASE_SIZE_PX / GOLDEN_RATIO +const CARD_PADDING_PX = CHART_BASE_SIZE_PX + +const DEFAULT_PADDING: BarChartPadding = { top: 16, right: 16, bottom: 40, left: 48 } +/** Reference viewBox width used when `width` is a percentage/string — the Svg's own + * `width` prop stays the raw string so it stretches to fill its container, while this + * fixed coordinate space keeps bar/tick geometry math well-defined without needing a + * cross-platform layout measurement API. */ +const REFERENCE_WIDTH_PX = 400 + +const DESIRED_TICK_COUNT = 5 +/** "Nice" axis step multiples per spec — 1/2/5/10 at any power-of-ten magnitude + * (20, 50, 100, 1K, ... are just 2/5/10 one magnitude up). */ +const NICE_STEP_MULTIPLES = [1, 2, 5, 10] as const + +/** Hides a value label once its bar shrinks below this length — an unlabeled sliver reads better than overlapping text. */ +const MIN_BAR_LENGTH_FOR_VALUE_LABEL_PX = 20 +const VALUE_LABEL_GAP_PX = 4 +const BAR_FILL_FRACTION = 0.7 + +interface BarChartItem { + category: string + value: number +} + +interface AxisScale { + min: number + max: number + step: number + ticks: number[] +} + +/** Rounds a rough step up to the nearest 1/2/5/10 * 10^k so tick intervals read as round numbers. */ +function computeNiceStep(roughStep: number): number { + if (roughStep <= 0) return 1 + const magnitude = 10 ** Math.floor(Math.log10(roughStep)) + const normalized = roughStep / magnitude + const niceMultiple = NICE_STEP_MULTIPLES.find((multiple) => multiple >= normalized) ?? 10 + return niceMultiple * magnitude +} + +/** + * Zero-inclusive axis scale (behavioral rule 1): positive-only data starts at + * 0, data with negatives extends the axis below 0. Max/min are both snapped + * to the nice step so ticks land on round numbers (rule 2). + */ +function computeNiceAxisScale(values: number[]): AxisScale { + const maxValue = Math.max(0, ...values) + const minValue = Math.min(0, ...values) + const paddedMax = maxValue * 1.1 + const paddedMin = minValue * 1.1 + const step = computeNiceStep((paddedMax - paddedMin) / (DESIRED_TICK_COUNT - 1) || 1) + + const max = Math.ceil((paddedMax || step) / step) * step + const min = minValue < 0 ? Math.floor(paddedMin / step) * step : 0 + + const ticks: number[] = [] + for (let tick = min; tick <= max + step / 2; tick += step) { + ticks.push(Math.round(tick / step) * step) + } + + return { min, max, step, ticks } +} + +/** Filters NaN/Infinity/null values silently (rule 10) — invalid items are dropped, not rendered as zero. */ +function filterValidItems(data: BarChartDataItem[]): BarChartItem[] { + return data.filter((item): item is BarChartItem => Number.isFinite(item.value)) +} + +/** + * SVG uses en-dash-free character estimation rather than real text + * measurement (unavailable cross-platform in react-native-svg without a + * canvas). Truncates with an ellipsis once the label can't fit the + * available width at the given font size (behavioral rule 8). + */ +function truncateLabelToWidth(label: string, maxWidthPx: number, fontSizePx: number): string { + const approxCharWidthPx = fontSizePx * 0.6 + const maxChars = Math.floor(maxWidthPx / approxCharWidthPx) + + if (maxChars <= 0) return '' + if (label.length <= maxChars) return label + if (maxChars === 1) return label.slice(0, 1) + return `${label.slice(0, maxChars - 1)}…` +} + +type RoundedEdge = 'top' | 'bottom' | 'right' | 'left' + +/** + * Path for a bar rect rounded only on its "outer" edge — the end farthest + * from the zero baseline (top for upward bars, bottom for downward/negative + * bars, right/left for horizontal bars). A plain rounds all four + * corners, which reads wrong where a bar meets its baseline. + */ +function buildBarPath(x: number, y: number, width: number, height: number, radius: number, roundedEdge: RoundedEdge): string { + const r = Math.max(0, Math.min(radius, width / 2, height / 2)) + + if (r <= 0) { + return `M ${x} ${y} L ${x + width} ${y} L ${x + width} ${y + height} L ${x} ${y + height} Z` + } + + switch (roundedEdge) { + case 'top': + return `M ${x} ${y + r} A ${r} ${r} 0 0 1 ${x + r} ${y} L ${x + width - r} ${y} A ${r} ${r} 0 0 1 ${x + width} ${y + r} L ${x + width} ${y + height} L ${x} ${y + height} Z` + case 'bottom': + return `M ${x} ${y} L ${x + width} ${y} L ${x + width} ${y + height - r} A ${r} ${r} 0 0 1 ${x + width - r} ${y + height} L ${x + r} ${y + height} A ${r} ${r} 0 0 1 ${x} ${y + height - r} Z` + case 'right': + return `M ${x} ${y} L ${x + width - r} ${y} A ${r} ${r} 0 0 1 ${x + width} ${y + r} L ${x + width} ${y + height - r} A ${r} ${r} 0 0 1 ${x + width - r} ${y + height} L ${x} ${y + height} Z` + case 'left': + return `M ${x + r} ${y} A ${r} ${r} 0 0 0 ${x} ${y + r} L ${x} ${y + height - r} A ${r} ${r} 0 0 0 ${x + r} ${y + height} L ${x + width} ${y + height} L ${x + width} ${y} Z` + } +} + +const BarChartFrame = createComponent(YStack, { + name: 'BarChart', + alignItems: 'center', +}) + +const BarChartTitleText = createComponent(TamaguiText, { + name: 'BarChartTitleText', + fontFamily: '$body', + fontWeight: '700', + color: '$color', + fontSize: TITLE_SIZE_PX, + textAlign: 'center', + marginBottom: TITLE_TO_CHART_GAP_PX, +}) + +function mergePadding(padding: Partial | undefined): BarChartPadding { + return { ...DEFAULT_PADDING, ...padding } +} + +function BarChartContent({ + data, + title, + layout = 'vertical', + showGrid = true, + showValueLabels = false, + valueFormatter = formatMetricValue, + xAxisLabel, + yAxisLabel, + barCornerRadius = 0, + onBarPress, + testID, + accessibilityLabel, + width = '100%', + height = 200, + padding, +}: Omit) { + const theme = useTheme() + const barColor = resolveThemeColor(theme, 'primary') + const gridColor = resolveThemeColor(theme, 'borderColor') + const axisLabelColor = resolveThemeColor(theme, 'placeholderColor') + const textColor = resolveThemeColor(theme, 'color') + + const items = filterValidItems(data) + const isEmpty = items.length === 0 + + const viewBoxWidth = typeof width === 'number' ? width : REFERENCE_WIDTH_PX + const resolvedPadding = mergePadding(padding) + const plotWidth = viewBoxWidth - resolvedPadding.left - resolvedPadding.right + const plotHeight = height - resolvedPadding.top - resolvedPadding.bottom + + const scale = computeNiceAxisScale(items.map((item) => item.value)) + const valueRange = scale.max - scale.min || 1 + + const resolvedAccessibilityLabel = + accessibilityLabel ?? `${title ?? 'Bar chart'}, ${items.length} ${items.length === 1 ? 'category' : 'categories'}` + + // Position along the value axis for a given raw value, within the plot rect. + const valueToPixel = (value: number, axisLengthPx: number): number => ((value - scale.min) / valueRange) * axisLengthPx + + const isVertical = layout === 'vertical' + const slotSize = (isVertical ? plotWidth : plotHeight) / Math.max(items.length, 1) + const barThickness = slotSize * BAR_FILL_FRACTION + + // Zero baseline position within the plot rect, in each layout's value-axis direction. + const zeroOffset = valueToPixel(0, isVertical ? plotHeight : plotWidth) + + return ( + + {title ? {title} : null} + + {isEmpty ? ( + + + + No data + + + ) : ( + <> + {showGrid ? ( + + {scale.ticks.map((tick) => { + const tickOffset = valueToPixel(tick, isVertical ? plotHeight : plotWidth) + const isZeroTick = tick === 0 + + return isVertical ? ( + + ) : ( + + ) + })} + + ) : null} + + + {scale.ticks.map((tick) => { + const tickOffset = valueToPixel(tick, isVertical ? plotHeight : plotWidth) + const formattedTick = valueFormatter(tick) + + return isVertical ? ( + + {formattedTick} + + ) : ( + + {formattedTick} + + ) + })} + + + {items.map((item, index) => { + const barLength = valueToPixel(item.value, isVertical ? plotHeight : plotWidth) - zeroOffset + const isPositive = item.value >= 0 + + if (isVertical) { + const slotStart = resolvedPadding.left + index * slotSize + const barX = slotStart + (slotSize - barThickness) / 2 + const baselineY = resolvedPadding.top + plotHeight - zeroOffset + const barY = isPositive ? baselineY - barLength : baselineY + const barHeight = Math.abs(barLength) + const categoryLabel = truncateLabelToWidth(item.category, barThickness, TICK_LABEL_SIZE_PX) + + return ( + + onBarPress(item, index) : undefined} + /> + + {categoryLabel} + + {showValueLabels && barHeight >= MIN_BAR_LENGTH_FOR_VALUE_LABEL_PX ? ( + + {valueFormatter(item.value)} + + ) : null} + + ) + } + + const slotStart = resolvedPadding.top + index * slotSize + const barY = slotStart + (slotSize - barThickness) / 2 + const baselineX = resolvedPadding.left + zeroOffset + const barX = isPositive ? baselineX : baselineX - Math.abs(barLength) + const barWidth = Math.abs(barLength) + const categoryLabelMaxWidth = resolvedPadding.left - 12 + const categoryLabel = truncateLabelToWidth(item.category, categoryLabelMaxWidth, TICK_LABEL_SIZE_PX) + + return ( + + onBarPress(item, index) : undefined} + /> + + {categoryLabel} + + {showValueLabels && barWidth >= MIN_BAR_LENGTH_FOR_VALUE_LABEL_PX ? ( + + {valueFormatter(item.value)} + + ) : null} + + ) + })} + + )} + + {xAxisLabel ? ( + + {xAxisLabel} + + ) : null} + {yAxisLabel ? ( + + {yAxisLabel} + + ) : null} + + + ) +} + +export function BarChart({ variant = 'bare', ...contentProps }: BarChartProps) { + if (variant === 'card') { + return ( + + + + ) + } + + return +} diff --git a/packages/ui/src/components/DataTable.tsx b/packages/ui/src/components/DataTable.tsx new file mode 100644 index 00000000..20c6577a --- /dev/null +++ b/packages/ui/src/components/DataTable.tsx @@ -0,0 +1,369 @@ +/** + * DataTable — exact-value display with typed columns, formatting, and + * sorting. Fourth and final analytics chart component (Scorecard, + * PieDonutChart, BarChart, LineAreaChart shipped first, in PR #142 / + * feat/analytics-components). The complement to the SVG-drawn charts: those + * are fast at showing a pattern, this is fast at finding one exact number. + * + * Pure Tamagui layout — no react-native-svg, no resolveThemeColor. Unlike the + * three SVG-based charts, every color/spacing value here is a Tamagui theme + * token used directly as a component prop, since YStack/XStack/Text (unlike + * SVG fill/stroke) already understand "$token" references natively. + * + * Follows Scorecard.tsx's structural patterns (createComponent, useTheme, + * golden-ratio spacing, formatMetricValue) and CreditsManagementCard.tsx's + * StatCell aesthetic ($backgroundHover cells, rounded corners). + */ +import React, { useMemo, useState } from 'react' +import { Text as TamaguiText, XStack, YStack } from 'tamagui' +import { createComponent } from '../createComponent' +import { Card } from './Card' +import { Text } from './Text' +import { formatMetricValue } from '../utils/formatMetricValue' + +export type DataTableVariant = 'bare' | 'card' +export type DataTableColumnType = 'text' | 'number' | 'date' | 'currency' +export type DataTableColumnAlign = 'left' | 'center' | 'right' +export type DataTableSortDirection = 'asc' | 'desc' + +export interface DataTableColumnDef = Record> { + key: string + label: string + type?: DataTableColumnType + align?: DataTableColumnAlign + width?: number | string + minWidth?: number + formatter?: (value: unknown, row: TRow) => string + sortable?: boolean + truncate?: boolean +} + +export interface DataTableSort { + key: string + direction: DataTableSortDirection +} + +export interface DataTableProps = Record> { + data: TRow[] + columns: Array> + title?: string + striped?: boolean + compact?: boolean + stickyHeader?: boolean + maxHeight?: number + defaultSort?: DataTableSort + onSort?: (key: string, direction: DataTableSortDirection | null) => void + emptyMessage?: string + onRowPress?: (row: TRow, index: number) => void + variant?: DataTableVariant + testID?: string + accessibilityLabel?: string +} + +/** + * Golden-ratio scale/spacing constants, matching Scorecard/BarChart/ + * LineAreaChart's values so all analytics components breathe identically. + * Re-declared locally since the source constants are private to Scorecard.tsx. + */ +const TABLE_BASE_SIZE_PX = 24 +const GOLDEN_RATIO = 1.618 +const MIN_FONT_SIZE_PX = 12 +const clampFontSize = (px: number): number => Math.max(px, MIN_FONT_SIZE_PX) + +const TITLE_SIZE_PX = clampFontSize(TABLE_BASE_SIZE_PX) +const CELL_TEXT_SIZE_PX = clampFontSize(TABLE_BASE_SIZE_PX / GOLDEN_RATIO ** 2) +const COMPACT_CELL_TEXT_SIZE_PX = clampFontSize(CELL_TEXT_SIZE_PX / GOLDEN_RATIO) + +const TITLE_TO_TABLE_GAP_PX = TABLE_BASE_SIZE_PX / GOLDEN_RATIO +const CARD_PADDING_PX = TABLE_BASE_SIZE_PX +const DEFAULT_ROW_PADDING_PX = TABLE_BASE_SIZE_PX / GOLDEN_RATIO +const COMPACT_ROW_PADDING_PX = DEFAULT_ROW_PADDING_PX / GOLDEN_RATIO +const DEFAULT_MIN_COLUMN_WIDTH_PX = 60 +const SORT_ARROW_GAP_PX = 4 + +const ALIGN_TO_JUSTIFY: Record = { + left: 'flex-start', + center: 'center', + right: 'flex-end', +} + +const ALIGN_TO_TEXT_ALIGN: Record = { + left: 'left', + center: 'center', + right: 'right', +} + +const SORT_ARROW: Record = { + asc: '▲', + desc: '▼', +} + +/** Null/undefined always renders as "--" (behavioral rule 8) — checked before any custom/type formatter runs. */ +const NULL_CELL_DISPLAY = '--' + +/** + * Default formatter per column type (behavioral rule 2). "currency" is + * intentionally identical to "number" here — the spec's own prefix (e.g. + * "$") is applied by the consumer's own `formatter`, not built in. + */ +function formatCellValue>(column: DataTableColumnDef, value: unknown, row: TRow): string { + if (value === null || value === undefined) { + return NULL_CELL_DISPLAY + } + + if (column.formatter) { + return column.formatter(value, row) + } + + switch (column.type ?? 'text') { + case 'number': + case 'currency': + return typeof value === 'number' ? formatMetricValue(value) : String(value) + case 'date': + return String(value) + case 'text': + default: + return String(value) + } +} + +/** Numeric compare for numbers, locale-aware string compare otherwise; nullish values sort to the front. */ +function compareRowsByColumn>(a: TRow, b: TRow, key: string): number { + const aValue = a[key] + const bValue = b[key] + + if (aValue === bValue) return 0 + if (aValue === null || aValue === undefined) return -1 + if (bValue === null || bValue === undefined) return 1 + if (typeof aValue === 'number' && typeof bValue === 'number') return aValue - bValue + + return String(aValue).localeCompare(String(bValue)) +} + +/** Tap cycles ascending -> descending -> clear (behavioral rule 3). A tap on a different column always starts at ascending. */ +function nextSortForColumn(current: DataTableSort | null, columnKey: string): DataTableSort | null { + if (current?.key !== columnKey) return { key: columnKey, direction: 'asc' } + if (current.direction === 'asc') return { key: columnKey, direction: 'desc' } + return null +} + +function resolveColumnFlexStyle>( + column: DataTableColumnDef, +): { width?: number | string; minWidth: number; flex?: number; flexShrink?: number } { + const minWidth = column.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH_PX + + if (column.width !== undefined) { + return { width: column.width, minWidth, flexShrink: 0 } + } + + return { flex: 1, minWidth } +} + +const DataTableFrame = createComponent(YStack, { + name: 'DataTable', + width: '100%', +}) + +const DataTableTitleText = createComponent(TamaguiText, { + name: 'DataTableTitleText', + fontFamily: '$body', + fontWeight: '700', + color: '$color', + fontSize: TITLE_SIZE_PX, + marginBottom: TITLE_TO_TABLE_GAP_PX, +}) + +/** + * Single scroll container for both axes: vertical scroll comes from + * maxHeight + overflow:auto here; horizontal scroll comes from the inner + * content row (DataTableScrollContent) being allowed to grow past this + * container's width. Web-only CSS (overflow/maxHeight as plain numbers), + * matching the precedent already set by MiniAppShell/ScrollArea in this package. + */ +const DataTableScrollContainer = createComponent(YStack, { + name: 'DataTableScrollContainer', + width: '100%', + overflow: 'auto' as const, + borderRadius: '$2', +}) + +function HeaderCell>({ + column, + sort, + compact, + onPress, +}: { + column: DataTableColumnDef + sort: DataTableSort | null + compact: boolean + onPress: () => void +}) { + const align = column.align ?? 'center' + const isActiveSort = sort?.key === column.key + const flexStyle = resolveColumnFlexStyle(column) + + return ( + + {/* Weight 700 per spec's behavioral rule 4, but muted $placeholderColor + (not full-contrast $color) — headers orient the reader to what a + column is, same role as axis tick labels in the 3 SVG charts, so + they get the same muted treatment even though they stay bold for + table-header legibility. */} + + {column.label} + + {isActiveSort ? ( + + {SORT_ARROW[sort.direction]} + + ) : null} + + ) +} + +function DataCell>({ + column, + row, + compact, +}: { + column: DataTableColumnDef + row: TRow + compact: boolean +}) { + const align = column.align ?? 'center' + const flexStyle = resolveColumnFlexStyle(column) + const displayValue = formatCellValue(column, row[column.key], row) + const shouldTruncate = column.truncate ?? true + + return ( + + {/* Data values are the hero — full-contrast $color, mirroring the value + labels (bar values, center metrics) in the other 3 chart components. */} + + {displayValue} + + + ) +} + +function DataTableContent>({ + data, + columns, + title, + striped = true, + compact = false, + stickyHeader = true, + maxHeight, + defaultSort, + onSort, + emptyMessage = 'No data', + onRowPress, + testID, + accessibilityLabel, +}: Omit, 'variant'>) { + const [sort, setSort] = useState(defaultSort ?? null) + const isEmpty = data.length === 0 + + const sortedData = useMemo(() => { + if (!sort) return data + const direction = sort.direction === 'asc' ? 1 : -1 + return [...data].sort((a, b) => direction * compareRowsByColumn(a, b, sort.key)) + }, [data, sort]) + + const resolvedAccessibilityLabel = + accessibilityLabel ?? `${title ?? 'Data table'}, ${data.length} ${data.length === 1 ? 'row' : 'rows'}` + + function handleHeaderPress(column: DataTableColumnDef) { + if (!column.sortable) return + const next = nextSortForColumn(sort, column.key) + setSort(next) + onSort?.(column.key, next?.direction ?? null) + } + + return ( + + {title ? {title} : null} + + {/* width: 'max-content' lets this content grow past the scroll container so wide + column sets trigger horizontal scroll instead of squeezing every column down. */} + + + {columns.map((column) => ( + handleHeaderPress(column)} /> + ))} + + + {isEmpty ? ( + + + {emptyMessage} + + + ) : ( + sortedData.map((row, index) => ( + onRowPress(row, index) : undefined} + > + {columns.map((column) => ( + + ))} + + )) + )} + + + + ) +} + +export function DataTable = Record>({ + variant = 'bare', + ...contentProps +}: DataTableProps) { + if (variant === 'card') { + return ( + + + + ) + } + + return +} diff --git a/packages/ui/src/components/LineAreaChart.tsx b/packages/ui/src/components/LineAreaChart.tsx new file mode 100644 index 00000000..020cefb2 --- /dev/null +++ b/packages/ui/src/components/LineAreaChart.tsx @@ -0,0 +1,719 @@ +/** + * LineAreaChart — time-series and continuous-data trends via connected line + * segments, optionally filled to a gradient area below the line. Third of 5 + * planned analytics chart components (Scorecard, PieDonutChart, BarChart + * shipped first, in PR #142) and the most complex of the family: multi-series + * overlay, three interpolation modes, gap handling, a secondary y-axis, and + * reference lines. + * + * Follows Scorecard.tsx's structural patterns (createComponent, useTheme, + * formatMetricValue, golden-ratio spacing) and BarChart.tsx's axis/grid/ + * nice-tick/in-SVG-label conventions. + */ +import React, { useId } from 'react' +import Svg, { Circle, Defs, G, Line, LinearGradient, Path, Stop, Text as SvgText } from 'react-native-svg' +import { Text as TamaguiText, useTheme, XStack, YStack } from 'tamagui' +import { createComponent } from '../createComponent' +import { Card } from './Card' +import { CHART_FONT_FAMILY } from '../utils/chartFontFamily' +import { formatMetricValue } from '../utils/formatMetricValue' +import { resolveThemeColor } from '../utils/resolveThemeColor' + +export type LineAreaChartVariant = 'bare' | 'card' +export type LineAreaChartInterpolation = 'linear' | 'monotone' | 'step' + +export interface LineAreaChartDataItem { + x: string | number + y: number | null + series?: string +} + +export interface LineAreaChartSeriesDef { + key: string + label: string + color?: string + strokeDasharray?: string +} + +export interface LineAreaChartReferenceLine { + value: number + label?: string + color?: string +} + +export interface LineAreaChartSecondaryAxis { + key: string + label?: string + formatter?: (value: number) => string +} + +export interface LineAreaChartPadding { + top: number + right: number + bottom: number + left: number +} + +export interface LineAreaChartProps { + data: LineAreaChartDataItem[] + title?: string + series?: LineAreaChartSeriesDef[] + type?: LineAreaChartInterpolation + showArea?: boolean + areaOpacity?: number + showDots?: boolean | 'auto' + showGrid?: boolean + connectNulls?: boolean + strokeWidth?: number + xAxisLabel?: string + yAxisLabel?: string + xAxisFormatter?: (value: string | number) => string + yAxisFormatter?: (value: number) => string + yAxisDomain?: [number | 'auto', number | 'auto'] + secondaryYAxis?: LineAreaChartSecondaryAxis + referenceLines?: LineAreaChartReferenceLine[] + onPointPress?: (point: LineAreaChartDataItem, seriesKey: string) => void + variant?: LineAreaChartVariant + testID?: string + accessibilityLabel?: string + width?: number | string + height?: number + padding?: Partial +} + +/** + * Golden-ratio scale/spacing constants, matching Scorecard.tsx's values so + * all analytics components breathe identically. Scorecard's own constants + * are private (not exported) and out of this task's scope to modify, so + * these are re-declared locally rather than imported (same as PieDonutChart + * and BarChart before it). + */ +const CHART_BASE_SIZE_PX = 24 +const GOLDEN_RATIO = 1.618 +const MIN_FONT_SIZE_PX = 12 +const clampFontSize = (px: number): number => Math.max(px, MIN_FONT_SIZE_PX) + +const TITLE_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX) +const AXIS_TITLE_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX / GOLDEN_RATIO) +const TICK_LABEL_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX / GOLDEN_RATIO ** 2) +const REFERENCE_LABEL_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX / GOLDEN_RATIO ** 2) +const LEGEND_LABEL_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX / GOLDEN_RATIO) + +const TITLE_TO_CHART_GAP_PX = CHART_BASE_SIZE_PX / GOLDEN_RATIO +const CHART_TO_LEGEND_GAP_PX = CHART_BASE_SIZE_PX / GOLDEN_RATIO +const CARD_PADDING_PX = CHART_BASE_SIZE_PX + +/** Multi-category palette, resolved to raw colors via useTheme() at render time. */ +const CHART_COLOR_KEYS = ['primary', 'success', 'warning', 'colorDim', 'error'] as const + +const DEFAULT_PADDING: LineAreaChartPadding = { top: 16, right: 16, bottom: 40, left: 48 } +// Mirrors DEFAULT_PADDING.left — secondary-axis tick labels need the same room on the right that primary labels get on the left. +const SECONDARY_AXIS_RIGHT_PADDING_PX = 48 +const REFERENCE_WIDTH_PX = 400 + +const DESIRED_TICK_COUNT = 5 +const NICE_STEP_MULTIPLES = [1, 2, 5, 10] as const +/** "Y extends 10% beyond data range" per spec rule 7. */ +const DOMAIN_PADDING_FRACTION = 0.1 + +const DOT_RADIUS_PX = 3 +const DOT_AUTO_THRESHOLD = 20 +/** Invisible larger hit-target so pressable dots still meet the 44pt touch-target baseline. */ +const DOT_TOUCH_TARGET_RADIUS_PX = 22 +const LEGEND_SWATCH_SIZE_PX = 10 +/** Floor width budget per x-axis label — actual formatted labels (e.g. full ISO dates) can need more, see estimateTextWidthPx. */ +const X_LABEL_APPROX_WIDTH_PX = 56 +/** Gap between adjacent x-axis labels so wide formatted labels never touch. */ +const X_LABEL_GAP_PX = 8 + +function isValidY(value: number | null | undefined): value is number { + return value !== null && value !== undefined && Number.isFinite(value) +} + +/** + * SVG uses en-dash-free character estimation rather than real text + * measurement (unavailable cross-platform in react-native-svg without a + * canvas). Mirrors BarChart's truncateLabelToWidth approach. + */ +function estimateTextWidthPx(text: string, fontSizePx: number): number { + return text.length * fontSizePx * 0.6 +} + +/** Truncates a label with an ellipsis once it can't fit the available width at the given font size (carries forward BarChart's long-label rule). */ +function truncateLabelToWidth(label: string, maxWidthPx: number, fontSizePx: number): string { + const approxCharWidthPx = fontSizePx * 0.6 + const maxChars = Math.floor(maxWidthPx / approxCharWidthPx) + + if (maxChars <= 0) return '' + if (label.length <= maxChars) return label + if (maxChars === 1) return label.slice(0, 1) + return `${label.slice(0, maxChars - 1)}…` +} + +interface AxisScale { + min: number + max: number + step: number + ticks: number[] +} + +function computeNiceStep(roughStep: number): number { + if (roughStep <= 0) return 1 + const magnitude = 10 ** Math.floor(Math.log10(roughStep)) + const normalized = roughStep / magnitude + const niceMultiple = NICE_STEP_MULTIPLES.find((multiple) => multiple >= normalized) ?? 10 + return niceMultiple * magnitude +} + +/** + * Nice-number y-scale: pads the data range 10% each direction (or honors an + * explicit domain override), then rounds to a human-friendly step. Unlike + * Bar's scale this does not force zero-inclusion — line/area charts commonly + * show a windowed range where zero isn't meaningful (e.g. a price series). + */ +function computeNiceAxisScale(values: number[], domainOverride?: [number | 'auto', number | 'auto']): AxisScale { + const dataMin = Math.min(...values) + const dataMax = Math.max(...values) + const range = dataMax - dataMin || Math.abs(dataMax) || 1 + + const paddedMin = dataMin - range * DOMAIN_PADDING_FRACTION + const paddedMax = dataMax + range * DOMAIN_PADDING_FRACTION + + const requestedMin = domainOverride?.[0] + const requestedMax = domainOverride?.[1] + const rawMin = requestedMin !== undefined && requestedMin !== 'auto' ? requestedMin : paddedMin + const rawMax = requestedMax !== undefined && requestedMax !== 'auto' ? requestedMax : paddedMax + + const step = computeNiceStep((rawMax - rawMin) / (DESIRED_TICK_COUNT - 1) || 1) + const min = Math.floor(rawMin / step) * step + const max = Math.ceil(rawMax / step) * step + + const ticks: number[] = [] + for (let tick = min; tick <= max + step / 2; tick += step) { + ticks.push(Math.round(tick / step) * step) + } + + return { min, max, step, ticks } +} + +/** Unique, order-preserving x categories across the whole dataset (series may interleave the same x values, as in the multi-axis mock). */ +function buildXCategories(data: LineAreaChartDataItem[]): Array { + const seen = new Set() + const categories: Array = [] + for (const item of data) { + const key = String(item.x) + if (!seen.has(key)) { + seen.add(key) + categories.push(item.x) + } + } + return categories +} + +interface ResolvedSeriesPoint { + x: string | number + y: number | null +} + +interface ResolvedSeries { + key: string + label: string + color: string + strokeDasharray?: string + points: ResolvedSeriesPoint[] +} + +/** Groups raw data rows by series key (auto-detected from `item.series`, defaulting to a single implicit series), realigned onto the shared x-category axis so every series has one entry per category (null where that series has no row for that x). */ +function resolveSeries( + data: LineAreaChartDataItem[], + seriesDefs: LineAreaChartSeriesDef[] | undefined, + xCategories: Array, + colors: readonly string[], +): ResolvedSeries[] { + const seriesKeys = seriesDefs?.map((definition) => definition.key) ?? Array.from(new Set(data.map((item) => item.series ?? 'default'))) + + return seriesKeys.map((key, index) => { + const definition = seriesDefs?.find((candidate) => candidate.key === key) + const valueByX = new Map() + data + .filter((item) => (item.series ?? 'default') === key) + .forEach((item) => { + valueByX.set(String(item.x), isValidY(item.y) ? item.y : null) + }) + + return { + key, + label: definition?.label ?? key, + color: definition?.color ?? colors[index % colors.length], + strokeDasharray: definition?.strokeDasharray, + points: xCategories.map((x) => ({ x, y: valueByX.get(String(x)) ?? null })), + } + }) +} + +interface PixelPoint { + x: number + y: number | null +} + +/** Splits a series' points into contiguous drawable runs. connectNulls=true collapses all valid points into one run (bridging gaps); false (default) breaks the path at each null, leaving a visible gap. */ +function buildRuns(points: PixelPoint[], connectNulls: boolean): Array> { + if (connectNulls) { + const valid = points.filter((point): point is { x: number; y: number } => point.y !== null) + return valid.length > 0 ? [valid] : [] + } + + const runs: Array> = [] + let current: Array<{ x: number; y: number }> = [] + for (const point of points) { + if (point.y !== null) { + current.push(point as { x: number; y: number }) + } else if (current.length > 0) { + runs.push(current) + current = [] + } + } + if (current.length > 0) runs.push(current) + return runs +} + +function buildLinearPath(points: Array<{ x: number; y: number }>): string { + return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' ') +} + +/** 'step' = hold value until next point (H then V), per spec rule 2. */ +function buildStepPath(points: Array<{ x: number; y: number }>): string { + const segments = [`M ${points[0].x} ${points[0].y}`] + for (let i = 1; i < points.length; i++) { + segments.push(`H ${points[i].x}`, `V ${points[i].y}`) + } + return segments.join(' ') +} + +/** + * Fritsch–Carlson monotone cubic Hermite spline: a smooth curve through every + * point that is mathematically guaranteed not to overshoot past neighboring + * values (unlike 'natural'/'basis' splines, which the spec explicitly + * forbids for that reason). + */ +function buildMonotonePath(points: Array<{ x: number; y: number }>): string { + const n = points.length + if (n < 2) return '' + if (n === 2) return buildLinearPath(points) + + const dx: number[] = [] + const slope: number[] = [] + for (let i = 0; i < n - 1; i++) { + dx[i] = points[i + 1].x - points[i].x + slope[i] = dx[i] === 0 ? 0 : (points[i + 1].y - points[i].y) / dx[i] + } + + const tangent: number[] = [slope[0]] + for (let i = 1; i < n - 1; i++) { + tangent[i] = slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2 + } + tangent[n - 1] = slope[n - 2] + + for (let i = 0; i < n - 1; i++) { + if (slope[i] === 0) { + tangent[i] = 0 + tangent[i + 1] = 0 + continue + } + const alpha = tangent[i] / slope[i] + const beta = tangent[i + 1] / slope[i] + if (alpha < 0) tangent[i] = 0 + if (beta < 0) tangent[i + 1] = 0 + + const magnitude = alpha * alpha + beta * beta + if (magnitude > 9) { + const rescale = 3 / Math.sqrt(magnitude) + tangent[i] = rescale * alpha * slope[i] + tangent[i + 1] = rescale * beta * slope[i] + } + } + + const segments = [`M ${points[0].x} ${points[0].y}`] + for (let i = 0; i < n - 1; i++) { + const third = dx[i] / 3 + const cp1x = points[i].x + third + const cp1y = points[i].y + tangent[i] * third + const cp2x = points[i + 1].x - third + const cp2y = points[i + 1].y - tangent[i + 1] * third + segments.push(`C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${points[i + 1].x} ${points[i + 1].y}`) + } + return segments.join(' ') +} + +function buildPath(points: Array<{ x: number; y: number }>, type: LineAreaChartInterpolation): string { + if (points.length < 2) return '' + if (type === 'step') return buildStepPath(points) + if (type === 'monotone') return buildMonotonePath(points) + return buildLinearPath(points) +} + +/** Closes the line path down to the baseline and back to its start, ready to be filled by the vertical gradient. */ +function buildAreaPath(points: Array<{ x: number; y: number }>, type: LineAreaChartInterpolation, baselineY: number): string { + if (points.length < 2) return '' + const linePath = buildPath(points, type) + const first = points[0] + const last = points[points.length - 1] + return `${linePath} L ${last.x} ${baselineY} L ${first.x} ${baselineY} Z` +} + +/** Single data point (rule 12) or an explicit showDots=false both still need a visible dot when a series has exactly one plottable value — otherwise it would render nothing at all. */ +function resolveShowDotsForSeries(showDots: boolean | 'auto', validPointCount: number): boolean { + if (validPointCount === 1) return true + if (showDots === 'auto') return validPointCount > 0 && validPointCount < DOT_AUTO_THRESHOLD + return showDots +} + +/** Adaptive x-label thinning (rule 7): shows every Nth category label so labels don't collide as category count grows. */ +function computeLabelSkipFactor(categoryCount: number, plotWidth: number, labelSlotWidthPx: number): number { + if (categoryCount <= 1) return 1 + const maxLabels = Math.max(1, Math.floor(plotWidth / labelSlotWidthPx)) + return Math.max(1, Math.ceil(categoryCount / maxLabels)) +} + +function mergePadding(padding: Partial | undefined, hasSecondaryAxis: boolean): LineAreaChartPadding { + const defaults = hasSecondaryAxis ? { ...DEFAULT_PADDING, right: SECONDARY_AXIS_RIGHT_PADDING_PX } : DEFAULT_PADDING + return { ...defaults, ...padding } +} + +const LineAreaFrame = createComponent(YStack, { + name: 'LineAreaChart', + alignItems: 'center', +}) + +const LineAreaTitleText = createComponent(TamaguiText, { + name: 'LineAreaChartTitleText', + fontFamily: '$body', + fontWeight: '700', + color: '$color', + fontSize: TITLE_SIZE_PX, + textAlign: 'center', + marginBottom: TITLE_TO_CHART_GAP_PX, +}) + +const LineAreaLegendLabelText = createComponent(TamaguiText, { + name: 'LineAreaChartLegendLabelText', + fontFamily: '$body', + color: '$color', + fontSize: LEGEND_LABEL_SIZE_PX, +}) + +const LineAreaSwatch = createComponent(YStack, { + name: 'LineAreaChartSwatch', + width: LEGEND_SWATCH_SIZE_PX, + height: LEGEND_SWATCH_SIZE_PX, + borderRadius: '$full', +}) + +function LineAreaLegend({ seriesList }: { seriesList: ResolvedSeries[] }) { + return ( + + {seriesList.map((series) => ( + + + {series.label} + + ))} + + ) +} + +function LineAreaChartContent({ + data, + title, + series: seriesDefs, + type = 'linear', + showArea = false, + areaOpacity = 0.15, + showDots = 'auto', + showGrid = true, + connectNulls = false, + strokeWidth = 2, + xAxisLabel, + yAxisLabel, + xAxisFormatter = (value) => String(value), + yAxisFormatter = formatMetricValue, + yAxisDomain, + secondaryYAxis, + referenceLines = [], + onPointPress, + testID, + accessibilityLabel, + width = '100%', + height = 200, + padding, +}: Omit) { + const theme = useTheme() + const gradientIdPrefix = useId() + const colors = CHART_COLOR_KEYS.map((key) => resolveThemeColor(theme, key)) + const gridColor = resolveThemeColor(theme, 'borderColor') + const axisLabelColor = resolveThemeColor(theme, 'placeholderColor') + + const isEmpty = data.length === 0 + const xCategories = buildXCategories(data) + const resolvedSeriesList = resolveSeries(data, seriesDefs, xCategories, colors) + + const secondarySeries = secondaryYAxis ? resolvedSeriesList.find((series) => series.key === secondaryYAxis.key) : undefined + const primarySeriesList = resolvedSeriesList.filter((series) => series !== secondarySeries) + + const primaryValues = primarySeriesList.flatMap((series) => series.points.map((point) => point.y).filter(isValidY)) + const secondaryValues = secondarySeries?.points.map((point) => point.y).filter(isValidY) ?? [] + + const primaryScale = computeNiceAxisScale(primaryValues.length > 0 ? primaryValues : referenceLines.map((line) => line.value), yAxisDomain) + const secondaryScale = secondaryValues.length > 0 ? computeNiceAxisScale(secondaryValues) : null + + const viewBoxWidth = typeof width === 'number' ? width : REFERENCE_WIDTH_PX + const resolvedPadding = mergePadding(padding, secondaryScale !== null) + const plotWidth = viewBoxWidth - resolvedPadding.left - resolvedPadding.right + const plotHeight = height - resolvedPadding.top - resolvedPadding.bottom + + const resolvedAccessibilityLabel = + accessibilityLabel ?? `${title ?? 'Line chart'}, ${resolvedSeriesList.length} ${resolvedSeriesList.length === 1 ? 'series' : 'series'}, ${xCategories.length} data points` + + const categoryCount = xCategories.length + const xPixelForIndex = (index: number): number => (categoryCount > 1 ? (index / (categoryCount - 1)) * plotWidth : plotWidth / 2) + + const yPixelForValue = (value: number, scale: AxisScale): number => { + const domain = scale.max - scale.min || 1 + return plotHeight - ((value - scale.min) / domain) * plotHeight + } + + // Base the skip factor on the widest *formatted* label actually in use (e.g. full ISO dates), not a fixed short-label guess. + const widestXLabelWidthPx = xCategories.reduce( + (widest, category) => Math.max(widest, estimateTextWidthPx(xAxisFormatter(category), TICK_LABEL_SIZE_PX)), + X_LABEL_APPROX_WIDTH_PX, + ) + const labelSkipFactor = computeLabelSkipFactor(categoryCount, plotWidth, widestXLabelWidthPx + X_LABEL_GAP_PX) + const xLabelSlotWidthPx = (plotWidth / Math.max(1, categoryCount - 1)) * labelSkipFactor + + return ( + + {title ? {title} : null} + + + {resolvedSeriesList.map((seriesItem) => ( + + + + + ))} + + + {isEmpty ? ( + + + + No data + + + ) : ( + + {showGrid ? ( + + {primaryScale.ticks.map((tick) => { + const tickY = yPixelForValue(tick, primaryScale) + const isZeroTick = tick === 0 + return ( + + ) + })} + + ) : null} + + + {primaryScale.ticks.map((tick) => ( + + {yAxisFormatter(tick)} + + ))} + {secondaryScale + ? secondaryScale.ticks.map((tick) => ( + + {(secondaryYAxis?.formatter ?? formatMetricValue)(tick)} + + )) + : null} + {xCategories.map((category, index) => { + if (index % labelSkipFactor !== 0) return null + // Clamp the first/last labels' anchor so a wide label centered at the plot edge can't overhang past the SVG boundary. + const labelHalfWidthPx = widestXLabelWidthPx / 2 + const xPixel = xPixelForIndex(index) + const isNearLeftEdge = xPixel < labelHalfWidthPx + const isNearRightEdge = xPixel > plotWidth - labelHalfWidthPx + const textAnchor = isNearLeftEdge ? 'start' : isNearRightEdge ? 'end' : 'middle' + const labelX = isNearLeftEdge ? 0 : isNearRightEdge ? plotWidth : xPixel + + return ( + + {truncateLabelToWidth(xAxisFormatter(category), xLabelSlotWidthPx, TICK_LABEL_SIZE_PX)} + + ) + })} + + + + {referenceLines.map((line, index) => { + const lineY = yPixelForValue(line.value, primaryScale) + const lineColor = line.color ?? resolveThemeColor(theme, 'colorDim') + return ( + + + {line.label ? ( + + {line.label} + + ) : null} + + ) + })} + + + {resolvedSeriesList.map((seriesItem) => { + const scale = seriesItem === secondarySeries && secondaryScale ? secondaryScale : primaryScale + const pixelPoints: PixelPoint[] = seriesItem.points.map((point, index) => ({ + x: xPixelForIndex(index), + y: isValidY(point.y) ? yPixelForValue(point.y, scale) : null, + })) + const runs = buildRuns(pixelPoints, connectNulls) + const validPointCount = pixelPoints.filter((point) => point.y !== null).length + const showSeriesDots = resolveShowDotsForSeries(showDots, validPointCount) + + return ( + + {showArea + ? runs.map((run, runIndex) => { + const areaPath = buildAreaPath(run, type, plotHeight) + return areaPath ? ( + + ) : null + }) + : null} + {runs.map((run, runIndex) => { + const linePath = buildPath(run, type) + return linePath ? ( + + ) : null + })} + {showSeriesDots + ? seriesItem.points.map((point, index) => { + const pixel = pixelPoints[index] + if (pixel.y === null) return null + return ( + + {onPointPress ? ( + onPointPress(point, seriesItem.key)} + /> + ) : null} + + + ) + }) + : null} + + ) + })} + + )} + + {xAxisLabel ? ( + + {xAxisLabel} + + ) : null} + {yAxisLabel ? ( + + {yAxisLabel} + + ) : null} + + {resolvedSeriesList.length > 1 && !isEmpty ? ( + + + + ) : null} + + ) +} + +export function LineAreaChart({ variant = 'bare', ...contentProps }: LineAreaChartProps) { + if (variant === 'card') { + return ( + + + + ) + } + return +} diff --git a/packages/ui/src/components/PieDonutChart.tsx b/packages/ui/src/components/PieDonutChart.tsx new file mode 100644 index 00000000..96256636 --- /dev/null +++ b/packages/ui/src/components/PieDonutChart.tsx @@ -0,0 +1,438 @@ +/** + * PieDonutChart — categorical data as proportional arc segments. + * `innerRadius > 0` renders as a donut with an optional center metric; + * `innerRadius = 0` renders as a classic pie. Second of 5 planned analytics + * chart components (Scorecard shipped first, in PR #142). + * + * Generalizes governance-widget's FundingDistributionChart SVG arc technique + * (react-native-svg Circle + strokeDasharray/strokeDashoffset) into a + * themeable packages/ui primitive any widget can compose, following + * Scorecard.tsx's structural patterns (createComponent, useTheme, + * formatMetricValue, golden-ratio spacing). + */ +import React from 'react' +import Svg, { Circle, G } from 'react-native-svg' +import { Text as TamaguiText, useTheme, XStack, YStack } from 'tamagui' +import { createComponent } from '../createComponent' +import { Card } from './Card' +import { formatMetricValue } from '../utils/formatMetricValue' +import { resolveThemeColor } from '../utils/resolveThemeColor' + +export type PieDonutChartVariant = 'bare' | 'card' +export type PieDonutChartSort = 'descending' | 'ascending' | 'none' + +export interface PieDonutChartDataItem { + label: string + value: number + color?: string +} + +export interface PieDonutChartProps { + data: PieDonutChartDataItem[] + title?: string + innerRadius?: number + centerLabel?: string + centerValue?: string | number + centerValueFormatter?: (value: number) => string + centerSubLabel?: string + maxSlices?: number + otherLabel?: string + sort?: PieDonutChartSort + showLegend?: boolean + showPercentages?: boolean + onSegmentPress?: (item: PieDonutChartDataItem, index: number) => void + variant?: PieDonutChartVariant + testID?: string + accessibilityLabel?: string + width?: number + height?: number +} + +/** + * Golden-ratio scale/spacing constants, matching Scorecard.tsx's values so + * all analytics components breathe identically. Scorecard's own constants + * are private (not exported) and out of this task's scope to modify, so + * these are re-declared locally rather than imported. + */ +const CHART_BASE_SIZE_PX = 24 +const GOLDEN_RATIO = 1.618 +const MIN_FONT_SIZE_PX = 12 +const clampFontSize = (px: number): number => Math.max(px, MIN_FONT_SIZE_PX) + +const TITLE_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX) +const CENTER_VALUE_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX) +const CENTER_LABEL_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX / GOLDEN_RATIO ** 2) +const LEGEND_LABEL_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX / GOLDEN_RATIO) +const LEGEND_PERCENT_SIZE_PX = clampFontSize(CHART_BASE_SIZE_PX / GOLDEN_RATIO ** 2) + +const TITLE_TO_CHART_GAP_PX = CHART_BASE_SIZE_PX / GOLDEN_RATIO +const CHART_TO_LEGEND_GAP_PX = CHART_BASE_SIZE_PX / GOLDEN_RATIO +const LEGEND_ROW_GAP_PX = CHART_BASE_SIZE_PX / GOLDEN_RATIO ** 2 +const CARD_PADDING_PX = CHART_BASE_SIZE_PX + +/** Multi-category palette, resolved to raw colors via useTheme() at render time. */ +const CHART_COLOR_KEYS = ['primary', 'success', 'warning', 'colorDim', 'error'] as const + +/** Legend rows are pressable when onSegmentPress is set — kept >=44pt tall for touch targets. */ +const LEGEND_ROW_MIN_HEIGHT_PX = 44 +const LEGEND_SWATCH_SIZE_PX = 10 + +/** Margin so rounded stroke caps don't get clipped against the SVG viewBox edge. */ +const ARC_EDGE_MARGIN_PX = 3 +/** Above this fraction the ring becomes imperceptibly thin; clamp keeps it visible. */ +const MAX_INNER_RADIUS_FRACTION = 0.92 + +interface PieDonutChartSegment { + label: string + value: number + color: string + isAggregated: boolean +} + +/** + * Rounds to 1 decimal first, then checks for a whole number — avoids + * floating-point noise (e.g. 24.999999) turning a should-be-integer + * percentage into "25.0%" instead of "25%". + */ +function formatSegmentPercentage(value: number, total: number): string { + const rounded = Math.round((value / total) * 1000) / 10 + return Number.isInteger(rounded) ? `${rounded}%` : `${rounded.toFixed(1)}%` +} + +/** + * Filters invalid values, aggregates the smallest items beyond `maxSlices` + * into a single "Other" segment (using the last palette color), then applies + * the requested display sort. Aggregation always targets the smallest values + * by magnitude regardless of `sort`, so "Other" consistently represents the + * long tail rather than an arbitrary slice of it. + */ +function buildSegments( + data: PieDonutChartDataItem[], + maxSlices: number, + otherLabel: string, + sort: PieDonutChartSort, + colors: readonly string[], +): PieDonutChartSegment[] { + const validItems = data.filter((item) => Number.isFinite(item.value) && item.value > 0) + const sortedByValueDescending = [...validItems].sort((a, b) => b.value - a.value) + + const exceedsMax = validItems.length > maxSlices + const keepCount = Math.max(maxSlices - 1, 1) + const keptItems = exceedsMax ? sortedByValueDescending.slice(0, keepCount) : sortedByValueDescending + const overflowItems = exceedsMax ? sortedByValueDescending.slice(keepCount) : [] + const colorIndexByItem = new Map(keptItems.map((item, index) => [item, index])) + + // 'none' preserves the caller's original ordering for kept items rather + // than the by-value order used above just to pick the aggregation set. + const orderedKeptItems = sort === 'none' ? validItems.filter((item) => colorIndexByItem.has(item)) : keptItems + + const keptSegments: PieDonutChartSegment[] = orderedKeptItems.map((item) => ({ + label: item.label, + value: item.value, + color: item.color ?? colors[(colorIndexByItem.get(item) ?? 0) % colors.length], + isAggregated: false, + })) + + const segments = + overflowItems.length > 0 + ? [ + ...keptSegments, + { + label: otherLabel, + value: overflowItems.reduce((sum, item) => sum + item.value, 0), + color: colors[colors.length - 1], + isAggregated: true, + }, + ] + : keptSegments + + if (sort === 'descending') return [...segments].sort((a, b) => b.value - a.value) + if (sort === 'ascending') return [...segments].sort((a, b) => a.value - b.value) + return segments +} + +interface ArcGeometry { + size: number + strokeWidth: number + ringRadius: number + circumference: number +} + +/** + * `innerRadius` is a fraction of the outer radius (0 = filled pie, closer to + * 1 = thin ring). Ring thickness and radius are both derived from it so the + * arc always spans exactly [holeRadius, outerRadius] with no manual tuning. + */ +function computeArcGeometry(width: number, height: number, innerRadius: number): ArcGeometry { + const size = Math.min(width, height) + const outerRadius = (size - ARC_EDGE_MARGIN_PX * 2) / 2 + const holeFraction = Math.min(Math.max(innerRadius, 0), MAX_INNER_RADIUS_FRACTION) + const strokeWidth = outerRadius * (1 - holeFraction) + const ringRadius = outerRadius - strokeWidth / 2 + + return { size, strokeWidth, ringRadius, circumference: 2 * Math.PI * ringRadius } +} + +const PieDonutFrame = createComponent(YStack, { + name: 'PieDonutChart', + alignItems: 'center', +}) + +const PieDonutTitleText = createComponent(TamaguiText, { + name: 'PieDonutChartTitleText', + fontFamily: '$body', + fontWeight: '700', + color: '$color', + fontSize: TITLE_SIZE_PX, + textAlign: 'center', + marginBottom: TITLE_TO_CHART_GAP_PX, +}) + +const PieDonutCenterLabelText = createComponent(TamaguiText, { + name: 'PieDonutChartCenterLabelText', + fontFamily: '$body', + color: '$placeholderColor', + fontSize: CENTER_LABEL_SIZE_PX, + textAlign: 'center', +}) + +const PieDonutCenterValueText = createComponent(TamaguiText, { + name: 'PieDonutChartCenterValueText', + fontFamily: '$body', + fontWeight: '700', + color: '$color', + fontSize: CENTER_VALUE_SIZE_PX, + textAlign: 'center', +}) + +const PieDonutLegendLabelText = createComponent(TamaguiText, { + name: 'PieDonutChartLegendLabelText', + fontFamily: '$body', + color: '$color', + fontSize: LEGEND_LABEL_SIZE_PX, + flex: 1, +}) + +const PieDonutLegendPercentText = createComponent(TamaguiText, { + name: 'PieDonutChartLegendPercentText', + fontFamily: '$body', + color: '$placeholderColor', + fontSize: LEGEND_PERCENT_SIZE_PX, +}) + +const PieDonutSwatch = createComponent(YStack, { + name: 'PieDonutChartSwatch', + width: LEGEND_SWATCH_SIZE_PX, + height: LEGEND_SWATCH_SIZE_PX, + borderRadius: '$full', +}) + +function PieDonutCenterContent({ + centerLabel, + centerValue, + centerValueFormatter, + centerSubLabel, + maxWidth, +}: { + centerLabel?: string + centerValue?: string | number + centerValueFormatter?: (value: number) => string + centerSubLabel?: string + maxWidth: number +}) { + if (centerLabel === undefined && centerValue === undefined && centerSubLabel === undefined) { + return null + } + + // Default to 0 decimals for the center metric specifically: it sits in a + // fixed-size hole, and "450K" fits that space far more reliably than + // formatMetricValue's own default of 1 decimal ("450.0K"), which was + // clipping at the default chart size even after correcting maxWidth above. + const formattedValue = + typeof centerValue === 'number' + ? (centerValueFormatter ?? ((value: number) => formatMetricValue(value, 'compact', 0)))(centerValue) + : centerValue + + return ( + + {centerLabel ? ( + + {centerLabel} + + ) : null} + {formattedValue !== undefined ? ( + + {formattedValue} + + ) : null} + {centerSubLabel ? ( + + {centerSubLabel} + + ) : null} + + ) +} + +function PieDonutLegend({ + segments, + totalValue, + showPercentages, + onSegmentPress, +}: { + segments: PieDonutChartSegment[] + totalValue: number + showPercentages: boolean + onSegmentPress?: (item: PieDonutChartDataItem, index: number) => void +}) { + return ( + + {segments.map((segment, index) => ( + onSegmentPress({ label: segment.label, value: segment.value }, index) : undefined} + role={onSegmentPress ? 'button' : undefined} + aria-label={onSegmentPress ? `View ${segment.label} detail` : undefined} + > + + + {segment.label} + + {showPercentages ? ( + {formatSegmentPercentage(segment.value, totalValue)} + ) : null} + + ))} + + ) +} + +function PieDonutChartContent({ + data, + title, + innerRadius = 0.6, + centerLabel, + centerValue, + centerValueFormatter, + centerSubLabel, + maxSlices = 7, + otherLabel = 'Other', + sort = 'descending', + showLegend = true, + showPercentages = true, + onSegmentPress, + testID, + accessibilityLabel, + width = 188, + height = 188, +}: Omit) { + const theme = useTheme() + const colors = CHART_COLOR_KEYS.map((key) => resolveThemeColor(theme, key)) + const emptyRingColor = resolveThemeColor(theme, 'borderColor') + + const segments = buildSegments(data, maxSlices, otherLabel, sort, colors) + const totalValue = segments.reduce((sum, segment) => sum + segment.value, 0) + const isEmpty = segments.length === 0 + + const geometry = computeArcGeometry(width, height, innerRadius) + const center = geometry.size / 2 + // Available space for center label/value is the hole, not the ring: hole + // radius is the ring's inner edge (ringRadius - strokeWidth / 2), and the + // largest square that fits inside a circle of that radius has side + // holeRadius * sqrt(2). The previous formula measured off ringRadius (the + // stroke's centerline, not the hole) and shrank as innerRadius grew instead + // of growing, which is why large center values were clipped to "450...". + const holeRadius = geometry.ringRadius - geometry.strokeWidth / 2 + const centerContentMaxWidth = holeRadius * Math.SQRT2 + + const resolvedAccessibilityLabel = + accessibilityLabel ?? + `${title ?? 'Pie chart'}, ${segments.length} ${segments.length === 1 ? 'category' : 'categories'}${ + totalValue > 0 ? `, total ${formatMetricValue(totalValue)}` : '' + }` + + let cumulativeDashOffset = 0 + + return ( + + {title ? {title} : null} + + + + {isEmpty ? ( + + ) : ( + segments.map((segment, index) => { + const dashLength = (segment.value / totalValue) * geometry.circumference + const dashOffset = -cumulativeDashOffset + cumulativeDashOffset += dashLength + + return ( + onSegmentPress({ label: segment.label, value: segment.value }, index) : undefined} + /> + ) + }) + )} + + + {isEmpty ? ( + {centerLabel ?? 'No data'} + ) : innerRadius > 0 ? ( + + ) : null} + + {showLegend && !isEmpty ? ( + + + + ) : null} + + ) +} + +export function PieDonutChart({ variant = 'bare', ...contentProps }: PieDonutChartProps) { + if (variant === 'card') { + return ( + + + + ) + } + + return +} diff --git a/packages/ui/src/components/Scorecard.tsx b/packages/ui/src/components/Scorecard.tsx new file mode 100644 index 00000000..9185fa97 --- /dev/null +++ b/packages/ui/src/components/Scorecard.tsx @@ -0,0 +1,329 @@ +/** + * Scorecard — reusable KPI card: a single metric value with a label and an + * optional trend indicator. First of 5 planned analytics chart components + * (packages/ui hosts all of them, per #139/#141). + * + * Structural pattern follows FundingDistributionChart (governance-widget): + * @goodwidget/ui primitives + useTheme() for color, react-native-svg for the + * one graphical element (the trend arrow), so it renders identically on + * React web, React Native, and Web Components. + */ +import React from 'react' +import Svg, { Path } from 'react-native-svg' +import { Text as TamaguiText, useTheme, XStack, YStack } from 'tamagui' +import { createComponent } from '../createComponent' +import { Card } from './Card' +import { formatMetricValue } from '../utils/formatMetricValue' +import type { MetricFormat } from '../utils/formatMetricValue' + +export type ScorecardVariant = 'bare' | 'card' +export type ScorecardSize = 'sm' | 'md' | 'lg' + +export interface ScorecardTrend { + value: number + direction: 'up' | 'down' | 'neutral' +} + +export interface ScorecardProps { + value: number + label: string + prefix?: string + suffix?: string + format?: MetricFormat + decimals?: number + trend?: ScorecardTrend + trendLabel?: string + variant?: ScorecardVariant + size?: ScorecardSize + testID?: string +} + +/** + * Golden-ratio modular type scale: every size step is the base value + * multiplied or divided by GOLDEN_RATIO, so the whole scale can be re-tuned + * later by adjusting these two constants instead of per-step pixel values. + */ +const SCORECARD_BASE_SIZE_PX = 24 +const GOLDEN_RATIO = 1.618 +const MIN_FONT_SIZE_PX = 12 + +const clampFontSize = (px: number): number => Math.max(px, MIN_FONT_SIZE_PX) + +const VALUE_SIZE_PX: Record = { + lg: clampFontSize(SCORECARD_BASE_SIZE_PX * GOLDEN_RATIO), + md: clampFontSize(SCORECARD_BASE_SIZE_PX), + sm: clampFontSize(SCORECARD_BASE_SIZE_PX / GOLDEN_RATIO), +} + +/** Label and trend text share the row below the value, one ratio step down. */ +const SECONDARY_SIZE_PX: Record = { + lg: clampFontSize(VALUE_SIZE_PX.lg / GOLDEN_RATIO), + md: clampFontSize(VALUE_SIZE_PX.md / GOLDEN_RATIO), + sm: clampFontSize(VALUE_SIZE_PX.sm / GOLDEN_RATIO), +} + +/** + * Vertical rhythm derived from the same base/ratio as the type scale, so + * spacing and typography stay on one proportional system instead of mixing + * in unrelated design tokens. + */ +const LABEL_TO_VALUE_GAP_PX = SCORECARD_BASE_SIZE_PX / GOLDEN_RATIO ** 2 +const VALUE_TO_TREND_GAP_PX = SCORECARD_BASE_SIZE_PX / GOLDEN_RATIO +const CARD_PADDING_PX = SCORECARD_BASE_SIZE_PX + +/** Semi-transparent white applied over the card background to lift it off the canvas by lightness rather than a border. */ +const CARD_ELEVATION_OVERLAY_COLOR = 'rgba(255,255,255,0.045)' +/** Simulates a light source hitting the card's top edge, replacing a hard border. */ +const CARD_TOP_HIGHLIGHT_COLOR = 'rgba(255,255,255,0.06)' + +const TREND_DIRECTION_COLOR_TOKEN: Record = { + up: '$success', + down: '$error', + neutral: '$colorDim', +} + +/** + * Unwraps a Tamagui theme token to its raw color string. react-native-svg's + * fill/stroke props aren't part of Tamagui's styling system, so they need + * the resolved value rather than a "$token" reference. + * + * Falls back to the theme's base `$color` token if the requested token is + * missing, so a bad token renders in a visible (if wrong) color instead of + * silently disappearing as black-on-web / transparent-on-native. + */ +function resolveThemeColor(theme: ReturnType, token: string): string { + const themeRecord = theme as unknown as Record + const key = token.replace('$', '') + const themeValue = themeRecord[key] + const resolved = + themeValue && typeof themeValue === 'object' && 'val' in themeValue + ? String(themeValue.val) + : typeof themeValue === 'string' + ? themeValue + : undefined + + if (resolved) { + return resolved + } + + console.warn(`Scorecard: theme token "${token}" not found, falling back to "$color"`) + + const fallback = themeRecord.color + return fallback && typeof fallback === 'object' && 'val' in fallback ? String(fallback.val) : '#000000' +} + +const ScorecardFrame = createComponent(YStack, { + name: 'Scorecard', + alignItems: 'center', + justifyContent: 'center', +}) + +const ScorecardLabelText = createComponent(TamaguiText, { + name: 'ScorecardLabelText', + fontFamily: '$body', + color: '$placeholderColor', + textAlign: 'center', + + variants: { + size: { + sm: { fontSize: SECONDARY_SIZE_PX.sm }, + md: { fontSize: SECONDARY_SIZE_PX.md }, + lg: { fontSize: SECONDARY_SIZE_PX.lg }, + }, + } as const, + + defaultVariants: { size: 'md' }, +}) + +const ScorecardValueRow = createComponent(XStack, { + name: 'ScorecardValueRow', + alignItems: 'baseline', + gap: '$1', + marginTop: LABEL_TO_VALUE_GAP_PX, +}) + +const ScorecardValueText = createComponent(TamaguiText, { + name: 'ScorecardValueText', + fontFamily: '$body', + fontWeight: '700', + // Theme text color, not $primary — $primary reads as an interactive/link + // hue, and the value is a headline, not a call to action. + color: '$color', + + variants: { + size: { + sm: { fontSize: VALUE_SIZE_PX.sm }, + md: { fontSize: VALUE_SIZE_PX.md }, + lg: { fontSize: VALUE_SIZE_PX.lg }, + }, + } as const, + + defaultVariants: { size: 'md' }, +}) + +/** + * Prefix/suffix (e.g. "G$", "/day") — same font size as the value since it's + * still part of the value row, but lighter weight and dimmer color so it + * stays subordinate to the value instead of competing with it. + */ +const ScorecardAffixText = createComponent(TamaguiText, { + name: 'ScorecardAffixText', + fontFamily: '$body', + fontWeight: '400', + color: '$placeholderColor', + + variants: { + size: { + sm: { fontSize: VALUE_SIZE_PX.sm }, + md: { fontSize: VALUE_SIZE_PX.md }, + lg: { fontSize: VALUE_SIZE_PX.lg }, + }, + } as const, + + defaultVariants: { size: 'md' }, +}) + +const ScorecardTrendRow = createComponent(XStack, { + name: 'ScorecardTrendRow', + alignItems: 'center', + gap: '$1', + marginTop: VALUE_TO_TREND_GAP_PX, +}) + +const ScorecardTrendText = createComponent(TamaguiText, { + name: 'ScorecardTrendText', + fontFamily: '$body', + fontWeight: '500', + + variants: { + size: { + sm: { fontSize: SECONDARY_SIZE_PX.sm }, + md: { fontSize: SECONDARY_SIZE_PX.md }, + lg: { fontSize: SECONDARY_SIZE_PX.lg }, + }, + } as const, + + defaultVariants: { size: 'md' }, +}) + +/** + * Up/down/neutral arrow glyph, drawn with react-native-svg for cross-platform + * rendering. Marked decorative (accessible={false} on native, aria-hidden on + * web) since the adjacent trend text already conveys the direction in words. + */ +function TrendGlyph({ direction, color, size }: { direction: ScorecardTrend['direction']; color: string; size: number }) { + const path = + direction === 'up' + ? 'M6 2 L10.5 9 L1.5 9 Z' + : direction === 'down' + ? 'M6 10 L10.5 3 L1.5 3 Z' + : 'M2 6 H10' + + if (direction === 'neutral') { + return ( + + + + ) + } + + return ( + + + + ) +} + +/** + * Elevation-by-lightness overlay for the card variant: an absolutely + * positioned semi-transparent white layer over the canvas-colored card, + * standing in for a shadow/border so depth reads from tone, not an outline. + * Sits as a sibling behind ScorecardContent inside a position:relative Card. + */ +const ScorecardCardOverlay = createComponent(YStack, { + name: 'ScorecardCardOverlay', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: CARD_ELEVATION_OVERLAY_COLOR, +}) + +function formatTrendPercentage(trendValue: number, direction: ScorecardTrend['direction']): string { + const magnitude = Math.abs(trendValue).toFixed(1) + if (direction === 'up') return `+${magnitude}%` + if (direction === 'down') return `-${magnitude}%` + return `${magnitude}%` +} + +function ScorecardContent({ + value, + label, + prefix, + suffix, + format = 'compact', + decimals, + trend, + trendLabel, + size = 'md', + testID, +}: Omit) { + const theme = useTheme() + const formattedValue = formatMetricValue(value, format, decimals) + + return ( + // testID (React Native) and data-testid (web/DOM) both set so the same + // identifier works with either platform's test tooling. + + {label} + + {prefix ? {prefix} : null} + {formattedValue} + {suffix ? {suffix} : null} + + {trend ? ( + + + + {formatTrendPercentage(trend.value, trend.direction)} + {trendLabel ? ` ${trendLabel}` : ''} + + + ) : null} + + ) +} + +export function Scorecard({ variant = 'bare', ...contentProps }: ScorecardProps) { + if (variant === 'card') { + return ( + // Overrides scoped to this call site only — Card.ts itself stays + // untouched since it's shared by ~26 other widget-package consumers. + // position:relative + overflow:hidden host the absolutely positioned + // elevation overlay; justifyContent:center keeps content centered + // whether or not a trend row is present, so cards with/without a + // trend row still align evenly in a row layout. + + + + + ) + } + + return +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index faa698e9..38993f6e 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -95,6 +95,38 @@ export { } from './components/Dialog' export type { DialogConfig, DialogStatus } from './components/Dialog' +// Analytics +export { Scorecard } from './components/Scorecard' +export type { ScorecardProps, ScorecardTrend, ScorecardVariant } from './components/Scorecard' +export { formatMetricValue } from './utils/formatMetricValue' +export type { MetricFormat } from './utils/formatMetricValue' +export { resolveThemeColor } from './utils/resolveThemeColor' +export { PieDonutChart } from './components/PieDonutChart' +export type { PieDonutChartProps, PieDonutChartDataItem, PieDonutChartVariant, PieDonutChartSort } from './components/PieDonutChart' +export { BarChart } from './components/BarChart' +export type { BarChartProps, BarChartDataItem, BarChartVariant, BarChartLayout, BarChartPadding } from './components/BarChart' +export { LineAreaChart } from './components/LineAreaChart' +export type { + LineAreaChartProps, + LineAreaChartDataItem, + LineAreaChartSeriesDef, + LineAreaChartReferenceLine, + LineAreaChartSecondaryAxis, + LineAreaChartVariant, + LineAreaChartInterpolation, + LineAreaChartPadding, +} from './components/LineAreaChart' +export { DataTable } from './components/DataTable' +export type { + DataTableProps, + DataTableColumnDef, + DataTableSort, + DataTableVariant, + DataTableColumnType, + DataTableColumnAlign, + DataTableSortDirection, +} from './components/DataTable' + // Web3 export { AddressDisplay } from './components-test/AddressDisplay' export { TokenAmount } from './components/TokenAmount' diff --git a/packages/ui/src/utils/chartFontFamily.ts b/packages/ui/src/utils/chartFontFamily.ts new file mode 100644 index 00000000..5c2e08c8 --- /dev/null +++ b/packages/ui/src/utils/chartFontFamily.ts @@ -0,0 +1,9 @@ +/** + * CHART_FONT_FAMILY — shared sans-serif font stack for analytics chart SVG + * text. react-native-svg's isn't part of Tamagui's styling system (the + * same reason resolveThemeColor exists for fill/stroke), so it can't resolve + * the `$body` token the rest of the UI uses and falls back to the browser's + * default serif font. Mirrors the default preset's typography.body.family + * (packages/ui/src/presets.ts) so SVG-drawn text matches the rest of the UI. + */ +export const CHART_FONT_FAMILY = 'Avenir Next, Inter, system-ui, -apple-system, sans-serif' diff --git a/packages/ui/src/utils/formatMetricValue.ts b/packages/ui/src/utils/formatMetricValue.ts new file mode 100644 index 00000000..c153b38f --- /dev/null +++ b/packages/ui/src/utils/formatMetricValue.ts @@ -0,0 +1,90 @@ +/** + * formatMetricValue — shared numeric formatter for analytics chart components + * (Scorecard is the first of 5 planned; all need the same compact K/M/B/T rules). + */ + +export type MetricFormat = 'compact' | 'decimal' | 'none' + +/** Non-finite values (NaN, Infinity) have no sensible numeric rendering. */ +const NON_FINITE_FALLBACK = '--' + +/** Ordered largest-first so the first matching threshold wins. */ +const COMPACT_THRESHOLDS = [ + { threshold: 1_000_000_000_000, suffix: 'T' }, + { threshold: 1_000_000_000, suffix: 'B' }, + { threshold: 1_000_000, suffix: 'M' }, + { threshold: 1_000, suffix: 'K' }, +] as const + +function assertNonNegativeInteger(decimals: number): void { + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error(`formatMetricValue: "decimals" must be a non-negative integer, received ${decimals}`) + } +} + +function formatCompact(value: number, decimals: number): string { + const absValue = Math.abs(value) + let thresholdIndex = COMPACT_THRESHOLDS.findIndex(({ threshold }) => absValue >= threshold) + + if (thresholdIndex === -1) { + // Below the smallest compact threshold: whole-number metrics (wallet + // counts, day counts, etc.) render without decimal places regardless of + // the requested precision — "47", not "47.0". + return Number.isInteger(value) ? String(value) : value.toFixed(decimals) + } + + // Rounding the scaled value can carry it up to the next unit (e.g. 999_950 → "1000.0K" + // instead of "1.0M"). Walk up to the next larger threshold (lower index) until the + // rounded value fits under 1000, or there's no larger unit left. + while (thresholdIndex > 0) { + const scaled = Number((value / COMPACT_THRESHOLDS[thresholdIndex].threshold).toFixed(decimals)) + + if (Math.abs(scaled) < 1000) { + break + } + + thresholdIndex -= 1 + } + + const { threshold, suffix } = COMPACT_THRESHOLDS[thresholdIndex] + const scaledValue = value / threshold + + // A compacted value that lands on a whole unit (892_000 -> 892K, not + // "892.0K") renders without decimal places regardless of the requested + // precision, same rule as the below-threshold branch above. + const formattedScaledValue = Number.isInteger(scaledValue) ? String(scaledValue) : scaledValue.toFixed(decimals) + + return `${formattedScaledValue}${suffix}` +} + +function formatDecimal(value: number, decimals: number): string { + // Intentionally hardcoded to en-US for v1; take a locale param once i18n is in scope. + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: true, + }).format(value) +} + +/** + * Formats a raw metric value per the K/M/B/T "compact" scale, a full + * comma-grouped "decimal" form, or a "none" passthrough. + * + * `decimals` defaults to 1 for "compact" and 2 for "decimal" (per #139's spec) + * and must be a non-negative integer — invalid input is a developer error, + * not a runtime state, so it throws rather than silently clamping. + */ +export function formatMetricValue(value: number, format: MetricFormat = 'compact', decimals?: number): string { + if (!Number.isFinite(value)) { + return NON_FINITE_FALLBACK + } + + if (format === 'none') { + return String(value) + } + + const resolvedDecimals = decimals ?? (format === 'compact' ? 1 : 2) + assertNonNegativeInteger(resolvedDecimals) + + return format === 'compact' ? formatCompact(value, resolvedDecimals) : formatDecimal(value, resolvedDecimals) +} diff --git a/packages/ui/src/utils/resolveThemeColor.ts b/packages/ui/src/utils/resolveThemeColor.ts new file mode 100644 index 00000000..fb4544f2 --- /dev/null +++ b/packages/ui/src/utils/resolveThemeColor.ts @@ -0,0 +1,37 @@ +/** + * resolveThemeColor — shared theme-token-to-raw-color resolver for analytics + * chart components. react-native-svg's fill/stroke props aren't part of + * Tamagui's styling system, so SVG-drawn chart elements (arcs, bars, lines, + * grid) need the resolved color value rather than a "$token" reference. + * + * Extracted from Scorecard.tsx's private implementation once a second + * consumer (PieDonutChart) needed the same logic, mirroring how + * formatMetricValue was already shared rather than duplicated. + */ +import type { useTheme } from 'tamagui' + +/** + * Falls back to the theme's base `$color` token if the requested token is + * missing, so a bad token renders in a visible (if wrong) color instead of + * silently disappearing as black-on-web / transparent-on-native. + */ +export function resolveThemeColor(theme: ReturnType, token: string): string { + const themeRecord = theme as unknown as Record + const key = token.replace('$', '') + const themeValue = themeRecord[key] + const resolved = + themeValue && typeof themeValue === 'object' && 'val' in themeValue + ? String(themeValue.val) + : typeof themeValue === 'string' + ? themeValue + : undefined + + if (resolved) { + return resolved + } + + console.warn(`resolveThemeColor: theme token "${token}" not found, falling back to "$color"`) + + const fallback = themeRecord.color + return fallback && typeof fallback === 'object' && 'val' in fallback ? String(fallback.val) : '#000000' +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33a08a66..cb6a4890 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: link:../../packages/ui '@reown/appkit': specifier: ^1.8.22 - version: 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@4.1.11) + version: 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@3.25.76) '@tamagui/core': specifier: 1.121.0 version: 1.121.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -491,19 +491,19 @@ importers: version: 2.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@reown/appkit': specifier: ^1.8.22 - version: 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76) + version: 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11) '@reown/appkit-adapter-wagmi': specifier: ^1.8.22 - version: 1.8.22(isknxadknpp2cdfgti7v5vosu4) + version: 1.8.22(v6adbyiuvbtrgqm65ozlnb246m) '@tanstack/react-query': specifier: ^5.101.2 version: 5.101.2(react@18.3.1) viem: specifier: ^2.0.0 - version: 2.48.4(typescript@5.9.3)(zod@3.25.76) + version: 2.48.4(typescript@5.9.3)(zod@4.1.11) wagmi: specifier: ^3.7.1 - version: 3.7.1(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@3.25.76))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@3.25.76))(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)) + version: 3.7.1(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@4.1.11))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@4.1.11))(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) devDependencies: '@types/react': specifier: ^18.3.0 @@ -739,6 +739,9 @@ importers: react-native: specifier: 0.76.9 version: 0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1) + react-native-svg: + specifier: 15.15.5 + version: 15.15.5(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) react-native-web: specifier: ^0.19.13 version: 0.19.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2048,7 +2051,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} @@ -10728,7 +10731,31 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@base-org/account@2.4.0(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76)': + '@base-org/account@2.4.0(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11)': + dependencies: + '@coinbase/cdp-sdk': 1.52.0(typescript@5.9.3) + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@5.9.3)(zod@4.1.11) + preact: 10.24.2 + viem: 2.48.4(typescript@5.9.3)(zod@4.1.11) + zustand: 5.0.3(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - debug + - fastestsmallesttextencoderdecoder + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + optional: true + + '@base-org/account@2.4.0(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@3.25.76)': dependencies: '@coinbase/cdp-sdk': 1.52.0(typescript@5.9.3) '@noble/hashes': 1.4.0 @@ -10738,7 +10765,7 @@ snapshots: ox: 0.6.9(typescript@5.9.3)(zod@3.25.76) preact: 10.24.2 viem: 2.48.4(typescript@5.9.3)(zod@3.25.76) - zustand: 5.0.3(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) + zustand: 5.0.3(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) transitivePeerDependencies: - '@types/react' - bufferutil @@ -10802,7 +10829,28 @@ snapshots: - utf-8-validate optional: true - '@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76)': + '@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11)': + dependencies: + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@5.9.3)(zod@4.1.11) + preact: 10.24.2 + viem: 2.48.4(typescript@5.9.3)(zod@4.1.11) + zustand: 5.0.3(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + optional: true + + '@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@3.25.76)': dependencies: '@noble/hashes': 1.4.0 clsx: 1.2.1 @@ -10811,7 +10859,7 @@ snapshots: ox: 0.6.9(typescript@5.9.3)(zod@3.25.76) preact: 10.24.2 viem: 2.48.4(typescript@5.9.3)(zod@3.25.76) - zustand: 5.0.3(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) + zustand: 5.0.3(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) transitivePeerDependencies: - '@types/react' - bufferutil @@ -12359,22 +12407,22 @@ snapshots: '@renovatebot/pep440@4.2.1': {} - '@reown/appkit-adapter-wagmi@1.8.22(isknxadknpp2cdfgti7v5vosu4)': + '@reown/appkit-adapter-wagmi@1.8.22(v6adbyiuvbtrgqm65ozlnb246m)': dependencies: - '@reown/appkit': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76) - '@reown/appkit-common': 1.8.22(typescript@5.9.3)(zod@3.25.76) - '@reown/appkit-controllers': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@3.25.76) + '@reown/appkit': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11) + '@reown/appkit-common': 1.8.22(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-controllers': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@4.1.11) '@reown/appkit-polyfills': 1.8.22 - '@reown/appkit-scaffold-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76) - '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@4.1.11) + '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@4.1.11) '@reown/appkit-wallet': 1.8.22(typescript@5.9.3) - '@wagmi/core': 3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)) - '@walletconnect/universal-provider': 2.23.7(@vercel/blob@2.4.0)(typescript@5.9.3)(zod@3.25.76) + '@wagmi/core': 3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) + '@walletconnect/universal-provider': 2.23.7(@vercel/blob@2.4.0)(typescript@5.9.3)(zod@4.1.11) valtio: 2.1.7(@types/react@18.3.28)(react@18.3.1) - viem: 2.48.4(typescript@5.9.3)(zod@3.25.76) - wagmi: 3.7.1(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@3.25.76))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@3.25.76))(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)) + viem: 2.48.4(typescript@5.9.3)(zod@4.1.11) + wagmi: 3.7.1(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@4.1.11))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@4.1.11))(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) optionalDependencies: - '@wagmi/connectors': 8.0.8(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@3.25.76))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@3.25.76))(@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)) + '@wagmi/connectors': 8.0.8(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@4.1.11))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@4.1.11))(@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -12518,12 +12566,52 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-pay@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76)': + '@reown/appkit-pay@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11)': + dependencies: + '@reown/appkit-common': 1.8.22(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-controllers': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@4.1.11) + lit: 3.3.0 + valtio: 2.1.7(@types/react@18.3.28)(react@18.3.1) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react + - typescript + - uploadthing + - use-sync-external-store + - utf-8-validate + - zod + + '@reown/appkit-pay@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.8.22(typescript@5.9.3)(zod@3.25.76) '@reown/appkit-controllers': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@3.25.76) '@reown/appkit-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@3.25.76) - '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76) + '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76) lit: 3.3.0 valtio: 2.1.7(@types/react@18.3.28)(react@18.3.1) transitivePeerDependencies: @@ -12602,13 +12690,55 @@ snapshots: dependencies: buffer: 6.0.3 - '@reown/appkit-scaffold-ui@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76)': + '@reown/appkit-scaffold-ui@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@4.1.11)': + dependencies: + '@reown/appkit-common': 1.8.22(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-controllers': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-pay': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11) + '@reown/appkit-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@4.1.11) + '@reown/appkit-wallet': 1.8.22(typescript@5.9.3) + lit: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react + - typescript + - uploadthing + - use-sync-external-store + - utf-8-validate + - valtio + - zod + + '@reown/appkit-scaffold-ui@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.8.22(typescript@5.9.3)(zod@3.25.76) '@reown/appkit-controllers': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@3.25.76) - '@reown/appkit-pay': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76) + '@reown/appkit-pay': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@3.25.76) '@reown/appkit-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@3.25.76) - '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76) + '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76) '@reown/appkit-wallet': 1.8.22(typescript@5.9.3) lit: 3.3.0 transitivePeerDependencies: @@ -12758,7 +12888,55 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-utils@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76)': + '@reown/appkit-utils@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@4.1.11)': + dependencies: + '@reown/appkit-common': 1.8.22(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-controllers': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-polyfills': 1.8.22 + '@reown/appkit-wallet': 1.8.22(typescript@5.9.3) + '@wallet-standard/wallet': 1.1.0 + '@walletconnect/logger': 3.0.2 + '@walletconnect/universal-provider': 2.23.7(@vercel/blob@2.4.0)(typescript@5.9.3)(zod@4.1.11) + valtio: 2.1.7(@types/react@18.3.28)(react@18.3.1) + viem: 2.48.4(typescript@5.9.3)(zod@4.1.11) + optionalDependencies: + '@base-org/account': 2.4.0(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11) + '@coinbase/wallet-sdk': 4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11) + '@safe-global/safe-apps-provider': 0.18.6(typescript@5.9.3)(zod@4.1.11) + '@safe-global/safe-apps-sdk': 9.1.0(typescript@5.9.3)(zod@4.1.11) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react + - typescript + - uploadthing + - use-sync-external-store + - utf-8-validate + - zod + + '@reown/appkit-utils@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.8.22(typescript@5.9.3)(zod@3.25.76) '@reown/appkit-controllers': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@3.25.76) @@ -12770,8 +12948,8 @@ snapshots: valtio: 2.1.7(@types/react@18.3.28)(react@18.3.1) viem: 2.48.4(typescript@5.9.3)(zod@3.25.76) optionalDependencies: - '@base-org/account': 2.4.0(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76) - '@coinbase/wallet-sdk': 4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76) + '@base-org/account': 2.4.0(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@3.25.76) + '@coinbase/wallet-sdk': 4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@3.25.76) '@safe-global/safe-apps-provider': 0.18.6(typescript@5.9.3)(zod@3.25.76) '@safe-global/safe-apps-sdk': 9.1.0(typescript@5.9.3)(zod@3.25.76) transitivePeerDependencies: @@ -12865,15 +13043,64 @@ snapshots: - typescript - utf-8-validate - '@reown/appkit@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76)': + '@reown/appkit@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11)': + dependencies: + '@reown/appkit-common': 1.8.22(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-controllers': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-pay': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11) + '@reown/appkit-polyfills': 1.8.22 + '@reown/appkit-scaffold-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@4.1.11) + '@reown/appkit-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@4.1.11) + '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@4.1.11) + '@reown/appkit-wallet': 1.8.22(typescript@5.9.3) + '@walletconnect/universal-provider': 2.23.7(@vercel/blob@2.4.0)(typescript@5.9.3)(zod@4.1.11) + bs58: 6.0.0 + semver: 7.7.2 + valtio: 2.1.7(@types/react@18.3.28)(react@18.3.1) + viem: 2.48.4(typescript@5.9.3)(zod@4.1.11) + optionalDependencies: + '@lit/react': 1.0.8(@types/react@18.3.28) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react + - typescript + - uploadthing + - use-sync-external-store + - utf-8-validate + - zod + + '@reown/appkit@1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@3.25.76)': dependencies: '@reown/appkit-common': 1.8.22(typescript@5.9.3)(zod@3.25.76) '@reown/appkit-controllers': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@3.25.76) - '@reown/appkit-pay': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76) + '@reown/appkit-pay': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@3.25.76) '@reown/appkit-polyfills': 1.8.22 - '@reown/appkit-scaffold-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76) + '@reown/appkit-scaffold-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76) '@reown/appkit-ui': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(zod@3.25.76) - '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76) + '@reown/appkit-utils': 1.8.22(@types/react@18.3.28)(@vercel/blob@2.4.0)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(valtio@2.1.7(@types/react@18.3.28)(react@18.3.1))(zod@3.25.76) '@reown/appkit-wallet': 1.8.22(typescript@5.9.3) '@walletconnect/universal-provider': 2.23.7(@vercel/blob@2.4.0)(typescript@5.9.3)(zod@3.25.76) bs58: 6.0.0 @@ -15993,14 +16220,14 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 - '@wagmi/connectors@8.0.22(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@3.25.76))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@3.25.76))(@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@3.25.76))': + '@wagmi/connectors@8.0.22(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@4.1.11))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@4.1.11))(@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))': dependencies: - '@wagmi/core': 3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)) - viem: 2.48.4(typescript@5.9.3)(zod@3.25.76) + '@wagmi/core': 3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) + viem: 2.48.4(typescript@5.9.3)(zod@4.1.11) optionalDependencies: - '@coinbase/wallet-sdk': 4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76) - '@safe-global/safe-apps-provider': 0.18.6(typescript@5.9.3)(zod@3.25.76) - '@safe-global/safe-apps-sdk': 9.1.0(typescript@5.9.3)(zod@3.25.76) + '@coinbase/wallet-sdk': 4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11) + '@safe-global/safe-apps-provider': 0.18.6(typescript@5.9.3)(zod@4.1.11) + '@safe-global/safe-apps-sdk': 9.1.0(typescript@5.9.3)(zod@4.1.11) typescript: 5.9.3 '@wagmi/connectors@8.0.22(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(zod@4.1.11))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@4.1.11))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@4.1.11))(@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))': @@ -16013,32 +16240,17 @@ snapshots: '@safe-global/safe-apps-sdk': 9.1.0(typescript@5.9.3)(zod@4.1.11) typescript: 5.9.3 - '@wagmi/connectors@8.0.8(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@3.25.76))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@3.25.76))(@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@3.25.76))': + '@wagmi/connectors@8.0.8(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@4.1.11))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@4.1.11))(@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))': dependencies: - '@wagmi/core': 3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)) - viem: 2.48.4(typescript@5.9.3)(zod@3.25.76) + '@wagmi/core': 3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) + viem: 2.48.4(typescript@5.9.3)(zod@4.1.11) optionalDependencies: - '@coinbase/wallet-sdk': 4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76) - '@safe-global/safe-apps-provider': 0.18.6(typescript@5.9.3)(zod@3.25.76) - '@safe-global/safe-apps-sdk': 9.1.0(typescript@5.9.3)(zod@3.25.76) + '@coinbase/wallet-sdk': 4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11) + '@safe-global/safe-apps-provider': 0.18.6(typescript@5.9.3)(zod@4.1.11) + '@safe-global/safe-apps-sdk': 9.1.0(typescript@5.9.3)(zod@4.1.11) typescript: 5.9.3 optional: true - '@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@3.25.76))': - dependencies: - eventemitter3: 5.0.1 - mipd: 0.0.7(typescript@5.9.3) - viem: 2.48.4(typescript@5.9.3)(zod@3.25.76) - zustand: 5.0.0(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) - optionalDependencies: - '@tanstack/query-core': 5.101.2 - typescript: 5.9.3 - transitivePeerDependencies: - - '@types/react' - - immer - - react - - use-sync-external-store - '@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11))': dependencies: eventemitter3: 5.0.1 @@ -21921,14 +22133,14 @@ snapshots: w-json@1.3.11: {} - wagmi@3.7.1(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@3.25.76))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@3.25.76))(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)): + wagmi@3.7.1(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@4.1.11))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@4.1.11))(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)): dependencies: '@tanstack/react-query': 5.101.2(react@18.3.1) - '@wagmi/connectors': 8.0.22(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@3.25.76))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@3.25.76))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@3.25.76))(@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)) - '@wagmi/core': 3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@3.25.76)) + '@wagmi/connectors': 8.0.22(@coinbase/wallet-sdk@4.3.6(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(zod@4.1.11))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@4.1.11))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@4.1.11))(@wagmi/core@3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)))(typescript@5.9.3)(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) + '@wagmi/core': 3.6.1(@tanstack/query-core@5.101.2)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.48.4(typescript@5.9.3)(zod@4.1.11)) react: 18.3.1 use-sync-external-store: 1.4.0(react@18.3.1) - viem: 2.48.4(typescript@5.9.3)(zod@3.25.76) + viem: 2.48.4(typescript@5.9.3)(zod@4.1.11) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: diff --git a/tests/design-system/smoke.spec.ts b/tests/design-system/smoke.spec.ts index f9297c25..20b87470 100644 --- a/tests/design-system/smoke.spec.ts +++ b/tests/design-system/smoke.spec.ts @@ -110,3 +110,225 @@ test('Stepper/Default story renders active-step hierarchy', async ({ page }) => await expect(frame.getByTestId('Stepper-default')).toBeVisible() await screenshotStory(page, 'tests/design-system/test-results/story-stepper-default.png') }) + +test('Scorecard/Default story renders all 5 mock-data rows in both variants', async ({ page }) => { + // Taller than the default viewport so both the bare and card rows fit + // without clipping — 10 cards across two rows need more vertical space + // than a single-row story. 1000 (rather than 900) accounts for the + // increased card padding/spacing from the golden-ratio spacing pass. + await page.setViewportSize({ width: 1280, height: 1000 }) + await gotoStory(page, 'design-system-primitives-scorecard--default') + const frame = getStoryFrame(page) + await expect(frame.getByTestId('Scorecard-default')).toBeVisible() + + const rowSlugs = ['total-spent', 'ai-credits', 'active-days', 'unique-wallets', 'daily-flow-rate'] + for (const slug of rowSlugs) { + await expect(frame.getByTestId(`Scorecard-${slug}-bare`)).toBeVisible() + await expect(frame.getByTestId(`Scorecard-${slug}-card`)).toBeVisible() + } + + await screenshotStory(page, 'tests/design-system/test-results/story-scorecard-default.png') +}) + +test('PieDonutChart/Default story renders donut (innerRadius=0.6) with center metric, bare and card variants', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 700 }) + await gotoStory(page, 'design-system-primitives-piedonutchart--default') + const frame = getStoryFrame(page) + await expect(frame.getByTestId('PieDonutChart-default')).toBeVisible() + await expect(frame.getByTestId('PieDonutChart-funding-donut-bare')).toBeVisible() + await expect(frame.getByTestId('PieDonutChart-funding-donut-card')).toBeVisible() + await expect(frame.getByTestId('PieDonutChart-funding-donut-bare').getByText('Total')).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-piedonutchart-default.png') +}) + +test('PieDonutChart/PurePie story renders innerRadius=0 as a filled pie, no center content', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-piedonutchart--pure-pie') + const frame = getStoryFrame(page) + const chart = frame.getByTestId('PieDonutChart-funding-pie-bare') + await expect(chart).toBeVisible() + await expect(chart.getByText('Education Hubs')).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-piedonutchart-purepie.png') +}) + +test('PieDonutChart/EmptyState story renders grey ring with "No data"', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-piedonutchart--empty-state') + const frame = getStoryFrame(page) + const chart = frame.getByTestId('PieDonutChart-empty') + await expect(chart).toBeVisible() + await expect(chart.getByText('No data', { exact: true })).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-piedonutchart-empty.png') +}) + +test('PieDonutChart/StressTest story renders 120-item aggregation without crashing', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 700 }) + await gotoStory(page, 'design-system-primitives-piedonutchart--stress-test') + const frame = getStoryFrame(page) + const chart = frame.getByTestId('PieDonutChart-stress') + await expect(chart).toBeVisible() + await expect(chart.getByText('Other')).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-piedonutchart-stress.png') +}) + +test('BarChart/Default story renders vertical bars, bare and card variants', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 700 }) + await gotoStory(page, 'design-system-primitives-barchart--default') + const frame = getStoryFrame(page) + await expect(frame.getByTestId('BarChart-default')).toBeVisible() + await expect(frame.getByTestId('BarChart-chains-vertical-bare')).toBeVisible() + await expect(frame.getByTestId('BarChart-chains-vertical-card')).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-barchart-default.png') +}) + +test('BarChart/HorizontalLongLabels story renders swapped axes', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-barchart--horizontal-long-labels') + const frame = getStoryFrame(page) + await expect(frame.getByTestId('BarChart-houses-horizontal')).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-barchart-horizontal.png') +}) + +test('BarChart/EmptyState story renders zero line with "No data"', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-barchart--empty-state') + const frame = getStoryFrame(page) + const chart = frame.getByTestId('BarChart-empty') + await expect(chart).toBeVisible() + await expect(chart.getByText('No data', { exact: true })).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-barchart-empty.png') +}) + +test('BarChart/StressTest story renders 150 categories without crashing', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 700 }) + await gotoStory(page, 'design-system-primitives-barchart--stress-test') + const frame = getStoryFrame(page) + const chart = frame.getByTestId('BarChart-stress') + await expect(chart).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-barchart-stress.png') +}) + +test('LineAreaChart/Default story renders area fill, reference line, linear and monotone variants', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 700 }) + await gotoStory(page, 'design-system-primitives-lineareachart--default') + const frame = getStoryFrame(page) + await expect(frame.getByTestId('LineAreaChart-default')).toBeVisible() + await expect(frame.getByTestId('LineAreaChart-daily-linear')).toBeVisible() + await expect(frame.getByTestId('LineAreaChart-daily-monotone-card')).toBeVisible() + await expect(frame.getByTestId('LineAreaChart-daily-linear').getByText('Target')).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-lineareachart-default.png') +}) + +test('LineAreaChart/StepInterpolation story renders a stepped curve', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-lineareachart--step-interpolation') + const frame = getStoryFrame(page) + const chart = frame.getByTestId('LineAreaChart-step') + await expect(chart).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-lineareachart-step.png') +}) + +test('LineAreaChart/MultiSeriesSecondaryAxis story renders both series with a legend and right-side axis', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-lineareachart--multi-series-secondary-axis') + const frame = getStoryFrame(page) + const chart = frame.getByTestId('LineAreaChart-multi-axis') + await expect(chart).toBeVisible() + await expect(chart.getByText('Claims', { exact: true })).toBeVisible() + await expect(chart.getByText('G$ Price', { exact: true })).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-lineareachart-multiaxis.png') +}) + +test('LineAreaChart/WithGap story renders both visible-gap and bridged variants', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-lineareachart--with-gap') + const frame = getStoryFrame(page) + await expect(frame.getByTestId('LineAreaChart-gap-visible')).toBeVisible() + await expect(frame.getByTestId('LineAreaChart-gap-bridged')).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-lineareachart-gap.png') +}) + +test('LineAreaChart/EmptyState story renders axes with "No data"', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-lineareachart--empty-state') + const frame = getStoryFrame(page) + const chart = frame.getByTestId('LineAreaChart-empty') + await expect(chart).toBeVisible() + await expect(chart.getByText('No data', { exact: true })).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-lineareachart-empty.png') +}) + +test('LineAreaChart/SinglePoint story renders a single dot', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-lineareachart--single-point') + const frame = getStoryFrame(page) + const chart = frame.getByTestId('LineAreaChart-single') + await expect(chart).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-lineareachart-single.png') +}) + +test('LineAreaChart/StressTest story renders 1095 daily points without crashing', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 700 }) + await gotoStory(page, 'design-system-primitives-lineareachart--stress-test') + const frame = getStoryFrame(page) + const chart = frame.getByTestId('LineAreaChart-stress') + await expect(chart).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-lineareachart-stress.png') +}) + +test('DataTable/Default story renders wallets with sortable headers, bare and card variants', async ({ page }) => { + // Tall enough that the card variant (wrapped below the bare table) renders + // within the same full-page iframe capture instead of being cropped out. + await page.setViewportSize({ width: 1280, height: 1800 }) + await gotoStory(page, 'design-system-primitives-datatable--default') + const frame = getStoryFrame(page) + await expect(frame.getByTestId('DataTable-default')).toBeVisible() + const bare = frame.getByTestId('DataTable-wallets-bare') + const card = frame.getByTestId('DataTable-wallets-card') + await expect(bare).toBeVisible() + await expect(card).toBeVisible() + // formatMetricValue applied to the number column: 1234567 compacts to "1.2M" + await expect(bare.getByText('1.2M')).toBeVisible() + + // Sort cycle on the sortable "Volume" header: asc -> desc, arrow indicator appears. + const volumeHeader = bare.getByText('Volume', { exact: true }) + await volumeHeader.click() + await expect(bare.getByText('▲')).toBeVisible() + await volumeHeader.click() + await expect(bare.getByText('▼')).toBeVisible() + + await screenshotStory(page, 'tests/design-system/test-results/story-datatable-default.png') +}) + +test('DataTable/CompactMetrics story renders reduced padding/font metrics summary', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-datatable--compact-metrics') + const frame = getStoryFrame(page) + const table = frame.getByTestId('DataTable-metrics-compact') + await expect(table).toBeVisible() + await expect(table.getByText('Daily Claims')).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-datatable-compact.png') +}) + +test('DataTable/EmptyState story renders header with "No data" message', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-datatable--empty-state') + const frame = getStoryFrame(page) + const table = frame.getByTestId('DataTable-empty') + await expect(table).toBeVisible() + await expect(table.getByText('No data', { exact: true })).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-datatable-empty.png') +}) + +test('DataTable/NullValuesAndRowPress story renders "--" for null cells and fires onRowPress', async ({ page }) => { + await gotoStory(page, 'design-system-primitives-datatable--null-values-and-row-press') + const frame = getStoryFrame(page) + const table = frame.getByTestId('DataTable-nulls') + await expect(table).toBeVisible() + await expect(table.getByText('--', { exact: true })).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-datatable-nulls.png') + + const consoleMessages: string[] = [] + page.on('console', (message) => consoleMessages.push(message.text())) + await table.getByText('0xNULL...').click() + await expect.poll(() => consoleMessages.some((text) => text.includes('DataTable row pressed'))).toBe(true) +}) + +test('DataTable/StressTest story renders 150 rows with sticky header and scroll without crashing', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 700 }) + await gotoStory(page, 'design-system-primitives-datatable--stress-test') + const frame = getStoryFrame(page) + const table = frame.getByTestId('DataTable-stress') + await expect(table).toBeVisible() + await expect(table.getByText('0x00000000')).toBeVisible() + await screenshotStory(page, 'tests/design-system/test-results/story-datatable-stress.png') +}) diff --git a/tests/design-system/test-results/story-barchart-default.png b/tests/design-system/test-results/story-barchart-default.png new file mode 100644 index 00000000..76493fb7 Binary files /dev/null and b/tests/design-system/test-results/story-barchart-default.png differ diff --git a/tests/design-system/test-results/story-barchart-empty.png b/tests/design-system/test-results/story-barchart-empty.png new file mode 100644 index 00000000..4fff0a82 Binary files /dev/null and b/tests/design-system/test-results/story-barchart-empty.png differ diff --git a/tests/design-system/test-results/story-barchart-horizontal.png b/tests/design-system/test-results/story-barchart-horizontal.png new file mode 100644 index 00000000..e28dd56d Binary files /dev/null and b/tests/design-system/test-results/story-barchart-horizontal.png differ diff --git a/tests/design-system/test-results/story-barchart-stress.png b/tests/design-system/test-results/story-barchart-stress.png new file mode 100644 index 00000000..73da4583 Binary files /dev/null and b/tests/design-system/test-results/story-barchart-stress.png differ diff --git a/tests/design-system/test-results/story-datatable-compact.png b/tests/design-system/test-results/story-datatable-compact.png new file mode 100644 index 00000000..f6286df4 Binary files /dev/null and b/tests/design-system/test-results/story-datatable-compact.png differ diff --git a/tests/design-system/test-results/story-datatable-default.png b/tests/design-system/test-results/story-datatable-default.png new file mode 100644 index 00000000..d38d4e6c Binary files /dev/null and b/tests/design-system/test-results/story-datatable-default.png differ diff --git a/tests/design-system/test-results/story-datatable-empty.png b/tests/design-system/test-results/story-datatable-empty.png new file mode 100644 index 00000000..93970b40 Binary files /dev/null and b/tests/design-system/test-results/story-datatable-empty.png differ diff --git a/tests/design-system/test-results/story-datatable-nulls.png b/tests/design-system/test-results/story-datatable-nulls.png new file mode 100644 index 00000000..e6b2a0c3 Binary files /dev/null and b/tests/design-system/test-results/story-datatable-nulls.png differ diff --git a/tests/design-system/test-results/story-datatable-stress.png b/tests/design-system/test-results/story-datatable-stress.png new file mode 100644 index 00000000..11e954cc Binary files /dev/null and b/tests/design-system/test-results/story-datatable-stress.png differ diff --git a/tests/design-system/test-results/story-lineareachart-default.png b/tests/design-system/test-results/story-lineareachart-default.png new file mode 100644 index 00000000..2b93385e Binary files /dev/null and b/tests/design-system/test-results/story-lineareachart-default.png differ diff --git a/tests/design-system/test-results/story-lineareachart-empty.png b/tests/design-system/test-results/story-lineareachart-empty.png new file mode 100644 index 00000000..4fff0a82 Binary files /dev/null and b/tests/design-system/test-results/story-lineareachart-empty.png differ diff --git a/tests/design-system/test-results/story-lineareachart-gap.png b/tests/design-system/test-results/story-lineareachart-gap.png new file mode 100644 index 00000000..ff988faa Binary files /dev/null and b/tests/design-system/test-results/story-lineareachart-gap.png differ diff --git a/tests/design-system/test-results/story-lineareachart-multiaxis.png b/tests/design-system/test-results/story-lineareachart-multiaxis.png new file mode 100644 index 00000000..4a35a221 Binary files /dev/null and b/tests/design-system/test-results/story-lineareachart-multiaxis.png differ diff --git a/tests/design-system/test-results/story-lineareachart-single.png b/tests/design-system/test-results/story-lineareachart-single.png new file mode 100644 index 00000000..7d74ae68 Binary files /dev/null and b/tests/design-system/test-results/story-lineareachart-single.png differ diff --git a/tests/design-system/test-results/story-lineareachart-step.png b/tests/design-system/test-results/story-lineareachart-step.png new file mode 100644 index 00000000..3b74cd16 Binary files /dev/null and b/tests/design-system/test-results/story-lineareachart-step.png differ diff --git a/tests/design-system/test-results/story-lineareachart-stress.png b/tests/design-system/test-results/story-lineareachart-stress.png new file mode 100644 index 00000000..70213300 Binary files /dev/null and b/tests/design-system/test-results/story-lineareachart-stress.png differ diff --git a/tests/design-system/test-results/story-piedonutchart-default.png b/tests/design-system/test-results/story-piedonutchart-default.png new file mode 100644 index 00000000..4c61fd1e Binary files /dev/null and b/tests/design-system/test-results/story-piedonutchart-default.png differ diff --git a/tests/design-system/test-results/story-piedonutchart-empty.png b/tests/design-system/test-results/story-piedonutchart-empty.png new file mode 100644 index 00000000..ffc0f845 Binary files /dev/null and b/tests/design-system/test-results/story-piedonutchart-empty.png differ diff --git a/tests/design-system/test-results/story-piedonutchart-purepie.png b/tests/design-system/test-results/story-piedonutchart-purepie.png new file mode 100644 index 00000000..eec0aa11 Binary files /dev/null and b/tests/design-system/test-results/story-piedonutchart-purepie.png differ diff --git a/tests/design-system/test-results/story-piedonutchart-stress.png b/tests/design-system/test-results/story-piedonutchart-stress.png new file mode 100644 index 00000000..fb84daf9 Binary files /dev/null and b/tests/design-system/test-results/story-piedonutchart-stress.png differ diff --git a/tests/design-system/test-results/story-scorecard-default.png b/tests/design-system/test-results/story-scorecard-default.png new file mode 100644 index 00000000..42b722d1 Binary files /dev/null and b/tests/design-system/test-results/story-scorecard-default.png differ