diff --git a/examples/storybook/.storybook/main.ts b/examples/storybook/.storybook/main.ts index 4a35a68..ca94d9a 100644 --- a/examples/storybook/.storybook/main.ts +++ b/examples/storybook/.storybook/main.ts @@ -2,11 +2,14 @@ * Storybook main configuration. * * - Framework: @storybook/react-vite - * - Addons: essentials (controls, docs, actions, viewport) + interactions (play functions) + * - Addons: essentials (controls, docs, actions) + interactions (play functions) * - viteFinal: mirrors the react-native-web + Tamagui settings from examples/react-web */ +import { fileURLToPath } from 'node:url' import type { StorybookConfig } from '@storybook/react-vite' +const reactNativeSvgShim = fileURLToPath(new URL('../src/shims/reactNativeSvg.tsx', import.meta.url)) + const config: StorybookConfig = { stories: ['../src/**/*.stories.@(ts|tsx)'], addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], @@ -18,7 +21,7 @@ const config: StorybookConfig = { autodocs: 'tag', }, viteFinal: async (config) => { - // Mirror the Vite settings from examples/react-web so Tamagui + react-native-web resolve + // Mirror the Vite settings from examples/react-web so Tamagui + react-native-web resolve. config.define = { ...config.define, global: 'globalThis', @@ -30,6 +33,7 @@ const config: StorybookConfig = { alias: { ...(config.resolve?.alias as Record | undefined), 'react-native': 'react-native-web', + 'react-native-svg': reactNativeSvgShim, }, } config.optimizeDeps = { diff --git a/examples/storybook/package.json b/examples/storybook/package.json index 25373c9..289605b 100644 --- a/examples/storybook/package.json +++ b/examples/storybook/package.json @@ -16,7 +16,8 @@ "react": "^18.3.0", "react-dom": "^18.3.0", "react-native-web": "^0.19.13", - "viem": "^2.0.0" + "viem": "^2.0.0", + "@goodwidget/governance-widget": "workspace:*" }, "devDependencies": { "@storybook/addon-essentials": "^8.6.17", diff --git a/examples/storybook/src/shims/reactNativeSvg.tsx b/examples/storybook/src/shims/reactNativeSvg.tsx new file mode 100644 index 0000000..09c0d37 --- /dev/null +++ b/examples/storybook/src/shims/reactNativeSvg.tsx @@ -0,0 +1,32 @@ +import React from 'react' + +type SvgElementProps = React.SVGProps & { + accessibilityRole?: string +} + +type SvgGroupProps = React.SVGProps & { + rotation?: string | number + origin?: string +} + +type SvgCircleProps = React.SVGProps & { + onPress?: () => void +} + +/** Storybook web shim for the react-native-svg primitives used by the donut chart. */ +export default function Svg({ accessibilityRole: _accessibilityRole, ...props }: SvgElementProps) { + return +} + +/** Mirrors react-native-svg's G transform props with standard SVG attributes. */ +export function G({ rotation, origin, transform, ...props }: SvgGroupProps) { + const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined + const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ') + + return +} + +/** Maps react-native-svg onPress to the browser SVG onClick event for stories. */ +export function Circle({ onPress, ...props }: SvgCircleProps) { + return +} diff --git a/examples/storybook/src/stories/governance-widget/GovernanceWidget.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceWidget.stories.tsx new file mode 100644 index 0000000..5710b45 --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceWidget.stories.tsx @@ -0,0 +1,257 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Text, XStack, YStack } from '@goodwidget/ui' +import { + AlignmentVotingProposalCard, + BalanceCard, + FundingDistributionChart, + ImpactCard, + OptimisticVotingProposalCard, +} from '@goodwidget/governance-widget' +import type { + FundingProjectAllocation, + RankedVotingOption, + VoteSegment, + VoterPreview, +} from '@goodwidget/governance-widget' + +const meta: Meta = { + title: 'Widgets/GovernanceWidget', + tags: ['autodocs'], + parameters: { + layout: 'centered', + goodWidgetProvider: { useShell: false, defaultTheme: 'light' }, + }, +} + +export default meta +type Story = StoryObj + +const alignmentOptions: RankedVotingOption[] = [ + { id: 'food-chain', label: 'Local Food Chain', percentage: 42 }, + { id: 'web3-literacy', label: 'Web3 Literacy', percentage: 31 }, + { id: 'civic-onboarding', label: 'Civic Onboarding', percentage: 27 }, + { id: 'regenerative-markets', label: 'Regenerative Markets', percentage: 18 }, +] + +const voteSegments: VoteSegment[] = [ + { id: 'for', label: 'For', percentage: 65, tone: 'for' }, + { id: 'against', label: 'Against', percentage: 10, tone: 'against' }, + { id: 'abstain', label: 'Abstain', percentage: 3, tone: 'abstain' }, +] + +const lowQuorumSegments: VoteSegment[] = [ + { id: 'for', label: 'For', percentage: 24, tone: 'for' }, + { id: 'against', label: 'Against', percentage: 18, tone: 'against' }, + { id: 'abstain', label: 'Abstain', percentage: 8, tone: 'abstain' }, +] + +const voters: VoterPreview[] = [ + { id: 'maya', label: 'Maya' }, + { id: 'kenji', label: 'Kenji' }, + { id: 'sol', label: 'Sol' }, + { id: 'ama', label: 'Ama' }, +] + +const fundingProjects: FundingProjectAllocation[] = [ + { id: 'food', name: 'Local Food Chain', amount: { value: 12400, token: 'G$', isStreaming: true }, percentage: 42 }, + { id: 'literacy', name: 'Web3 Literacy for Community Builders', amount: { value: 9100, token: 'G$' }, percentage: 31 }, + { id: 'civic', name: 'Civic Onboarding', amount: { value: 7900, token: 'G$' }, percentage: 27 }, +] + +function GovernanceStoryFrame({ children, width = 520 }: { children: React.ReactNode; width?: number }) { + const [lastAction, setLastAction] = useState('No interaction yet') + + // Mocked handlers make interaction affordances visible without wiring runtime data. + const enhancedChildren = React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child + } + + return React.cloneElement(child, { + onPress: (id: string) => setLastAction(`Opened ${id}`), + onCtaPress: () => setLastAction('CTA pressed'), + onProjectPress: (id: string) => setLastAction(`Opened project ${id}`), + } as Record) + }) + + return ( + + {enhancedChildren} + + {lastAction} + + + ) +} + +export const ImpactLight: Story = { + render: () => ( + + + + ), +} + +export const ImpactDarkLongDisabledMobile: Story = { + parameters: { goodWidgetProvider: { useShell: false, defaultTheme: 'dark' }, viewport: { defaultViewport: 'mobile1' } }, + render: () => ( + + + + ), +} + +export const BalanceVariantsLight: Story = { + render: () => ( + + + + + ), +} + +export const BalanceDarkCompact: Story = { + parameters: { goodWidgetProvider: { useShell: false, defaultTheme: 'dark' } }, + render: () => ( + + + + ), +} + +export const AlignmentDefaultLight: Story = { + render: () => ( + + + + ), +} + +export const AlignmentDarkLongOptions: Story = { + parameters: { goodWidgetProvider: { useShell: false, defaultTheme: 'dark' } }, + render: () => ( + + + + ), +} + +export const OptimisticHighQuorumLight: Story = { + render: () => ( + + + + ), +} + +export const OptimisticDarkLowQuorumMixed: Story = { + parameters: { goodWidgetProvider: { useShell: false, defaultTheme: 'dark' } }, + render: () => ( + + + + ), +} + +export const FundingDistributionLight: Story = { + render: () => ( + + + + ), +} + +export const FundingDistributionDarkEmptyMobile: Story = { + parameters: { goodWidgetProvider: { useShell: false, defaultTheme: 'dark' }, viewport: { defaultViewport: 'mobile1' } }, + render: () => ( + + + + ), +} diff --git a/packages/governance-widget/package.json b/packages/governance-widget/package.json new file mode 100644 index 0000000..007f847 --- /dev/null +++ b/packages/governance-widget/package.json @@ -0,0 +1,39 @@ +{ + "name": "@goodwidget/governance-widget", + "version": "0.1.0", + "description": "Presentational governance homepage components for GoodWidget", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src/", + "clean": "rm -rf dist .turbo" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "dependencies": { + "@goodwidget/ui": "workspace:*", + "react-native-svg": "15.15.5", + "tamagui": "1.121.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tsup": "^8.4.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/governance-widget/src/governanceComponents.tsx b/packages/governance-widget/src/governanceComponents.tsx new file mode 100644 index 0000000..ccce74e --- /dev/null +++ b/packages/governance-widget/src/governanceComponents.tsx @@ -0,0 +1,606 @@ +import React from 'react' +import Svg, { Circle, G } from 'react-native-svg' +import { Stack, useTheme } from 'tamagui' +import { + Badge, + BadgeText, + Button, + ButtonText, + Card, + Heading, + Icon, + Text, + TokenAmount, + XStack, + YStack, +} from '@goodwidget/ui' +import type { IconName } from '@goodwidget/ui' + +export interface GovernanceAmount { + value: string | number + token?: string + isStreaming?: boolean + streamLabel?: string +} + +export interface ImpactCardMetric { + label: string + amount: GovernanceAmount + description?: string +} + +export interface ImpactCardProps { + title: string + metrics: [ImpactCardMetric, ImpactCardMetric] + description: string + ctaLabel?: string + ctaDisabled?: boolean + onCtaPress?: () => void + testID?: string +} + +export interface BalanceCardMetadata { + label: string + tone?: 'default' | 'positive' | 'muted' + icon?: IconName +} + +export interface BalanceCardProps { + icon: IconName + title: string + amount: GovernanceAmount | string | number + amountType?: 'token' | 'raw' + metadata: BalanceCardMetadata + compact?: boolean + testID?: string +} + +export interface RankedVotingOption { + id: string + label: string + percentage: number +} + +export interface AlignmentVotingProposalCardProps { + id: string + categoryLabel: string + title: string + summaryLabel?: string + options: RankedVotingOption[] + maxVisibleOptions?: number + onPress?: (id: string) => void + testID?: string +} + +export interface VoteSegment { + id: string + label: string + percentage: number + tone?: 'for' | 'against' | 'abstain' | 'neutral' +} + +export interface VoterPreview { + id: string + label: string + avatarUrl?: string +} + +export interface OptimisticVotingProposalCardProps { + id: string + categoryLabel: string + title: string + quorumLabel?: string + quorumReachedPercent: number + voteSegments: VoteSegment[] + voters: VoterPreview[] + remainingVoterCountLabel?: string + onPress?: (id: string) => void + testID?: string +} + +export interface FundingProjectAllocation { + id: string + name: string + amount: GovernanceAmount + percentage: number +} + +export interface FundingDistributionChartProps { + totalAmount: GovernanceAmount + projects: FundingProjectAllocation[] + isStreaming?: boolean + onProjectPress?: (id: string) => void + testID?: string +} + +const SEGMENT_TONES: Record, string> = { + for: '$primary', + against: '$error', + abstain: '$placeholderColor', + neutral: '$success', +} + +const DONUT_COLOR_KEYS = ['primary', 'success', 'warning', 'info', 'error'] as const + +function clampPercentage(value: number): number { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.max(0, Math.min(100, value)) +} + +function formatRawValue(value: string | number): string { + if (typeof value === 'string') { + return value + } + + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value) +} + +function isGovernanceAmount(value: GovernanceAmount | string | number): value is GovernanceAmount { + return typeof value === 'object' && value !== null && 'value' in value +} + +function resolveThemeColor(theme: ReturnType, key: string, fallback: string): string { + const themeValue = theme[key as keyof typeof theme] + + if (themeValue && typeof themeValue === 'object' && 'val' in themeValue) { + return String(themeValue.val) + } + + return fallback +} + +function renderGovernanceAmount(amount: GovernanceAmount, size: 'sm' | 'md' | 'lg' | 'xl' = 'lg') { + return ( + + {amount.token ? ( + + ) : ( + {formatRawValue(amount.value)} + )} + {amount.isStreaming ? ( + + {amount.streamLabel ?? 'Live stream'} + + ) : null} + + ) +} + +function MetricBox({ metric }: { metric: ImpactCardMetric }) { + return ( + + + {metric.label} + + {renderGovernanceAmount(metric.amount, 'lg')} + {metric.description ? ( + + {metric.description} + + ) : null} + + ) +} + +function ProposalHeader({ categoryLabel }: { categoryLabel: string }) { + return ( + + + {categoryLabel} + + + + ) +} + +function ProgressBar({ percentage, colorToken = '$primary' }: { percentage: number; colorToken?: string }) { + return ( + + + + ) +} + +function RankedOptionRow({ option }: { option: RankedVotingOption }) { + return ( + + + + {option.label} + + + {clampPercentage(option.percentage)}% + + + + + ) +} + +function StackedProgressBar({ segments }: { segments: VoteSegment[] }) { + return ( + + {segments.map((segment) => ( + + ))} + + ) +} + +function VoterAvatar({ voter, index }: { voter: VoterPreview; index: number }) { + const initial = voter.label.trim().slice(0, 1).toUpperCase() || '?' + + return ( + + {voter.avatarUrl ? ( + {voter.label} + ) : ( + + {initial} + + )} + + ) +} + +function VoterPreviewGroup({ voters, remainingLabel }: { voters: VoterPreview[]; remainingLabel?: string }) { + return ( + + + {voters.slice(0, 4).map((voter, index) => ( + + ))} + + {remainingLabel ? ( + + {remainingLabel} + + ) : null} + + ) +} + +function VoteLegend({ segments }: { segments: VoteSegment[] }) { + return ( + + {segments.map((segment) => ( + + + + {segment.label} {clampPercentage(segment.percentage)}% + + + ))} + + ) +} + +function fundingAmountLabel(amount: GovernanceAmount): string { + const base = amount.token ? `${formatRawValue(amount.value)} ${amount.token}` : formatRawValue(amount.value) + + if (amount.isStreaming) { + return `${base} streaming` + } + + return base +} + +function FundingLegend({ + projects, + colors, + onProjectPress, +}: { + projects: FundingProjectAllocation[] + colors: string[] + onProjectPress?: (id: string) => void +}) { + if (projects.length === 0) { + return ( + + No active funding distribution yet. + + ) + } + + return ( + + {projects.map((project, index) => ( + + ))} + + ) +} + +function FundingDonut({ + projects, + totalAmount, + colors, + onProjectPress, +}: { + projects: FundingProjectAllocation[] + totalAmount: GovernanceAmount + colors: string[] + onProjectPress?: (id: string) => void +}) { + const size = 196 + const strokeWidth = 22 + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + let offset = 0 + + return ( + + + + + {projects.map((project, index) => { + const percentage = clampPercentage(project.percentage) + const dashLength = (percentage / 100) * circumference + const dashOffset = -offset + offset += dashLength + + return ( + onProjectPress(project.id) : undefined} + /> + ) + })} + + + + {renderGovernanceAmount(totalAmount, 'md')} + + Total active funding + + + + ) +} + +export function ImpactCard({ + title, + metrics, + description, + ctaLabel, + ctaDisabled = false, + onCtaPress, + testID, +}: ImpactCardProps) { + return ( + + {title} + + {metrics.map((metric) => ( + + ))} + + {description} + {ctaLabel ? ( + + ) : null} + + ) +} + +export function BalanceCard({ + icon, + title, + amount, + amountType = 'token', + metadata, + compact = false, + testID, +}: BalanceCardProps) { + const amountValue = isGovernanceAmount(amount) ? amount : { value: amount, token: amountType === 'token' ? 'G$' : undefined } + const metadataTone = metadata.tone === 'positive' ? 'default' : metadata.tone === 'muted' ? 'secondary' : 'soft' + + return ( + + + + + {title} + + + {renderGovernanceAmount(amountValue, compact ? 'md' : 'lg')} + + {metadata.icon ? : null} + + {metadata.label} + + + + ) +} + +export function AlignmentVotingProposalCard({ + id, + categoryLabel, + title, + summaryLabel = 'Current top voted', + options, + maxVisibleOptions = 3, + onPress, + testID, +}: AlignmentVotingProposalCardProps) { + const visibleOptions = options.slice(0, maxVisibleOptions) + const hiddenCount = Math.max(0, options.length - visibleOptions.length) + + return ( + onPress(id) : undefined} + role={onPress ? 'button' : undefined} + aria-label={`Open proposal ${title}`} + > + + {title} + + + + {summaryLabel} + + + + {visibleOptions.map((option) => ( + + ))} + {hiddenCount > 0 ? ( + + +{hiddenCount} more options + + ) : null} + + + ) +} + +export function OptimisticVotingProposalCard({ + id, + categoryLabel, + title, + quorumLabel = 'Current vote quorum', + quorumReachedPercent, + voteSegments, + voters, + remainingVoterCountLabel, + onPress, + testID, +}: OptimisticVotingProposalCardProps) { + return ( + onPress(id) : undefined} + role={onPress ? 'button' : undefined} + aria-label={`Open proposal ${title}`} + > + + {title} + + + + {quorumLabel} + + + {clampPercentage(quorumReachedPercent)}% reached + + + + + + + + + + + + + + + ) +} + +export function FundingDistributionChart({ + totalAmount, + projects, + isStreaming = false, + onProjectPress, + testID, +}: FundingDistributionChartProps) { + const theme = useTheme() + const fallbackColors = ['#2563eb', '#16a34a', '#d97706', '#0891b2', '#dc2626'] + const colors = DONUT_COLOR_KEYS.map((key, index) => resolveThemeColor(theme, key, fallbackColors[index])) + const total = { ...totalAmount, isStreaming: totalAmount.isStreaming ?? isStreaming } + + return ( + + + Funding distribution + Current allocation across active governance projects. + + + + + + + ) +} diff --git a/packages/governance-widget/src/index.ts b/packages/governance-widget/src/index.ts new file mode 100644 index 0000000..0ad8bec --- /dev/null +++ b/packages/governance-widget/src/index.ts @@ -0,0 +1,21 @@ +export { + ImpactCard, + BalanceCard, + AlignmentVotingProposalCard, + OptimisticVotingProposalCard, + FundingDistributionChart, +} from './governanceComponents' +export type { + GovernanceAmount, + ImpactCardMetric, + ImpactCardProps, + BalanceCardProps, + BalanceCardMetadata, + RankedVotingOption, + AlignmentVotingProposalCardProps, + VoteSegment, + VoterPreview, + OptimisticVotingProposalCardProps, + FundingProjectAllocation, + FundingDistributionChartProps, +} from './governanceComponents' diff --git a/packages/governance-widget/tsconfig.build.json b/packages/governance-widget/tsconfig.build.json new file mode 100644 index 0000000..54871c4 --- /dev/null +++ b/packages/governance-widget/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "paths": { + "react-native": ["./node_modules/react-native-web"] + } + }, + "include": ["src"] +} diff --git a/packages/governance-widget/tsconfig.json b/packages/governance-widget/tsconfig.json new file mode 100644 index 0000000..c2f740f --- /dev/null +++ b/packages/governance-widget/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "paths": { + "@goodwidget/ui": ["../ui/src/index.ts"], + "react-native": ["./node_modules/react-native-web"] + } + }, + "include": ["src"] +} diff --git a/packages/governance-widget/tsup.config.ts b/packages/governance-widget/tsup.config.ts new file mode 100644 index 0000000..756b6b0 --- /dev/null +++ b/packages/governance-widget/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + tsconfig: 'tsconfig.build.json', + external: ['react', 'react-dom', 'react-native', 'react-native-web', 'react-native-svg'], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04afa8b..3c1446a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: '@goodwidget/core': specifier: workspace:* version: link:../../packages/core + '@goodwidget/governance-widget': + specifier: workspace:* + version: link:../../packages/governance-widget '@goodwidget/ui': specifier: workspace:* version: link:../../packages/ui @@ -344,6 +347,37 @@ importers: specifier: ^5.7.0 version: 5.9.3 + packages/governance-widget: + dependencies: + '@goodwidget/ui': + specifier: workspace:* + version: link:../ui + 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) + tamagui: + specifier: 1.121.0 + version: 1.121.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(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) + devDependencies: + '@types/react': + specifier: ^18.3.0 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.28) + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + tsup: + specifier: ^8.4.0 + version: 8.5.1(@swc/core@1.15.30)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/ui: dependencies: '@tamagui/animations-react-native': @@ -3592,6 +3626,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bplist-creator@0.0.7: resolution: {integrity: sha512-xp/tcaV3T5PCiaY04mXga7o/TE+t95gqeLmADeBI1CvZtdWTbgBt3uLpvh4UWtenKeBhCV6oVxGk38yZr2uYEA==} @@ -3926,6 +3963,17 @@ packages: css-in-js-utils@3.1.0: resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -4067,6 +4115,9 @@ packages: dom-serializer@0.2.2: resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + domelementtype@1.3.1: resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==} @@ -4076,9 +4127,16 @@ packages: domhandler@2.4.2: resolution: {integrity: sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==} + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + domutils@1.7.0: resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==} + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -4130,6 +4188,10 @@ packages: entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + env-editor@0.4.2: resolution: {integrity: sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==} engines: {node: '>=8'} @@ -5407,6 +5469,9 @@ packages: md5@2.3.0: resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -5724,6 +5789,9 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} @@ -6161,6 +6229,12 @@ packages: react: '*' react-native: '*' + react-native-svg@15.15.5: + resolution: {integrity: sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==} + peerDependencies: + react: '*' + react-native: '*' + react-native-web@0.19.13: resolution: {integrity: sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==} peerDependencies: @@ -11819,6 +11893,8 @@ snapshots: big-integer@1.6.52: {} + boolbase@1.0.0: {} + bplist-creator@0.0.7: dependencies: stream-buffers: 2.2.0 @@ -12193,6 +12269,21 @@ snapshots: dependencies: hyphenate-style-name: 1.1.0 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + css.escape@1.5.1: {} csstype@3.2.3: {} @@ -12297,6 +12388,12 @@ snapshots: domelementtype: 2.3.0 entities: 2.2.0 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + domelementtype@1.3.1: {} domelementtype@2.3.0: {} @@ -12305,11 +12402,21 @@ snapshots: dependencies: domelementtype: 1.3.1 + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + domutils@1.7.0: dependencies: dom-serializer: 0.2.2 domelementtype: 1.3.1 + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv-expand@11.0.7: dependencies: dotenv: 16.4.7 @@ -12354,6 +12461,8 @@ snapshots: entities@2.2.0: {} + entities@4.5.0: {} + env-editor@0.4.2: {} error-ex@1.3.4: @@ -13963,6 +14072,8 @@ snapshots: crypt: 0.0.2 is-buffer: 1.1.6 + mdn-data@2.0.14: {} + memoize-one@5.2.1: {} memoize-one@6.0.0: {} @@ -14471,6 +14582,10 @@ snapshots: dependencies: path-key: 3.1.1 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nullthrows@1.1.1: {} nyc@15.1.0: @@ -14931,6 +15046,13 @@ snapshots: 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) warn-once: 0.1.1 + react-native-svg@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): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 18.3.1 + 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-native-web@0.19.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.29.2 diff --git a/tests/widgets/governance-widget/states.spec.ts b/tests/widgets/governance-widget/states.spec.ts new file mode 100644 index 0000000..610da3f --- /dev/null +++ b/tests/widgets/governance-widget/states.spec.ts @@ -0,0 +1,120 @@ +/** + * states.spec.ts — Playwright smoke tests for presentational governance widgets. + * + * The stories use mocked values only; these tests verify screenshot-ready light, + * dark, mobile, long-content, empty, and interaction states without wallet or RPC. + */ +import { test, expect, Page } from '@playwright/test' + +type GovernanceStoryCase = { + id: string + testId: string + screenshot: string + width?: number + height?: number + expectedText: string +} + +const STORY_CASES: GovernanceStoryCase[] = [ + { + id: 'widgets-governancewidget--impact-light', + testId: 'ImpactCard-light', + screenshot: 'tests/widgets/governance-widget/test-results/gw-01-impact-light.png', + expectedText: 'Community impact this month', + }, + { + id: 'widgets-governancewidget--impact-dark-long-disabled-mobile', + testId: 'ImpactCard-dark-mobile-disabled', + screenshot: 'tests/widgets/governance-widget/test-results/gw-02-impact-dark-mobile-disabled.png', + width: 390, + height: 844, + expectedText: 'Coming soon', + }, + { + id: 'widgets-governancewidget--balance-variants-light', + testId: 'BalanceCard-token-growth', + screenshot: 'tests/widgets/governance-widget/test-results/gw-03-balance-variants-light.png', + expectedText: 'Voting balance', + }, + { + id: 'widgets-governancewidget--balance-dark-compact', + testId: 'BalanceCard-dark-compact', + screenshot: 'tests/widgets/governance-widget/test-results/gw-04-balance-dark-compact.png', + width: 390, + height: 844, + expectedText: 'Snapshot in 3 days', + }, + { + id: 'widgets-governancewidget--alignment-default-light', + testId: 'AlignmentVotingProposalCard-default', + screenshot: 'tests/widgets/governance-widget/test-results/gw-05-alignment-default-light.png', + expectedText: 'Current top 3 voted', + }, + { + id: 'widgets-governancewidget--alignment-dark-long-options', + testId: 'AlignmentVotingProposalCard-dark-long', + screenshot: 'tests/widgets/governance-widget/test-results/gw-06-alignment-dark-long-options.png', + expectedText: '+2 more options', + }, + { + id: 'widgets-governancewidget--optimistic-high-quorum-light', + testId: 'OptimisticVotingProposalCard-high-quorum', + screenshot: 'tests/widgets/governance-widget/test-results/gw-07-optimistic-high-quorum-light.png', + expectedText: '78% reached', + }, + { + id: 'widgets-governancewidget--optimistic-dark-low-quorum-mixed', + testId: 'OptimisticVotingProposalCard-low-quorum', + screenshot: 'tests/widgets/governance-widget/test-results/gw-08-optimistic-dark-low-quorum-mixed.png', + expectedText: '+84', + }, + { + id: 'widgets-governancewidget--funding-distribution-light', + testId: 'FundingDistributionChart-populated', + screenshot: 'tests/widgets/governance-widget/test-results/gw-09-funding-distribution-light.png', + width: 760, + height: 720, + expectedText: 'Local Food Chain', + }, + { + id: 'widgets-governancewidget--funding-distribution-dark-empty-mobile', + testId: 'FundingDistributionChart-empty-dark-mobile', + screenshot: 'tests/widgets/governance-widget/test-results/gw-10-funding-distribution-empty-dark-mobile.png', + width: 390, + height: 844, + expectedText: 'No active funding distribution yet.', + }, +] + +async function gotoStory(page: Page, storyId: string): Promise { + await page.goto(`/iframe.html?id=${storyId}&viewMode=story`) + await page.waitForLoadState('domcontentloaded') +} + +for (const storyCase of STORY_CASES) { + test(`${storyCase.id} renders and captures screenshot`, async ({ page }) => { + if (storyCase.width && storyCase.height) { + await page.setViewportSize({ width: storyCase.width, height: storyCase.height }) + } + + await gotoStory(page, storyCase.id) + + const component = page.getByTestId(storyCase.testId) + await expect(component).toBeVisible() + await expect(page.getByText(storyCase.expectedText).first()).toBeVisible() + + await page.screenshot({ path: storyCase.screenshot, fullPage: true }) + }) +} + +test('governance card interactions update mocked action state', async ({ page }) => { + await gotoStory(page, 'widgets-governancewidget--alignment-default-light') + + await page.getByTestId('AlignmentVotingProposalCard-default').click() + await expect(page.getByTestId('GovernanceWidget-last-action')).toContainText('Opened alignment-q3') + + await page.screenshot({ + path: 'tests/widgets/governance-widget/test-results/gw-11-interaction-alignment.png', + fullPage: true, + }) +}) diff --git a/tests/widgets/governance-widget/test-results/gw-01-impact-light.png b/tests/widgets/governance-widget/test-results/gw-01-impact-light.png new file mode 100644 index 0000000..08f67fd Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-01-impact-light.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-02-impact-dark-mobile-disabled.png b/tests/widgets/governance-widget/test-results/gw-02-impact-dark-mobile-disabled.png new file mode 100644 index 0000000..f5d8dd6 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-02-impact-dark-mobile-disabled.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-03-balance-variants-light.png b/tests/widgets/governance-widget/test-results/gw-03-balance-variants-light.png new file mode 100644 index 0000000..7303f44 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-03-balance-variants-light.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-04-balance-dark-compact.png b/tests/widgets/governance-widget/test-results/gw-04-balance-dark-compact.png new file mode 100644 index 0000000..7b3b223 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-04-balance-dark-compact.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-05-alignment-default-light.png b/tests/widgets/governance-widget/test-results/gw-05-alignment-default-light.png new file mode 100644 index 0000000..212f1a1 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-05-alignment-default-light.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-06-alignment-dark-long-options.png b/tests/widgets/governance-widget/test-results/gw-06-alignment-dark-long-options.png new file mode 100644 index 0000000..03b8759 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-06-alignment-dark-long-options.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-07-optimistic-high-quorum-light.png b/tests/widgets/governance-widget/test-results/gw-07-optimistic-high-quorum-light.png new file mode 100644 index 0000000..7bed652 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-07-optimistic-high-quorum-light.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-08-optimistic-dark-low-quorum-mixed.png b/tests/widgets/governance-widget/test-results/gw-08-optimistic-dark-low-quorum-mixed.png new file mode 100644 index 0000000..aa1da68 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-08-optimistic-dark-low-quorum-mixed.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-09-funding-distribution-light.png b/tests/widgets/governance-widget/test-results/gw-09-funding-distribution-light.png new file mode 100644 index 0000000..b2da82f Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-09-funding-distribution-light.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-10-funding-distribution-empty-dark-mobile.png b/tests/widgets/governance-widget/test-results/gw-10-funding-distribution-empty-dark-mobile.png new file mode 100644 index 0000000..7b51fd0 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-10-funding-distribution-empty-dark-mobile.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-11-interaction-alignment.png b/tests/widgets/governance-widget/test-results/gw-11-interaction-alignment.png new file mode 100644 index 0000000..f9bf4fc Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-11-interaction-alignment.png differ