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/.storybook/preview.tsx b/examples/storybook/.storybook/preview.tsx index e4ea369..9a0df4c 100644 --- a/examples/storybook/.storybook/preview.tsx +++ b/examples/storybook/.storybook/preview.tsx @@ -11,6 +11,7 @@ import { MiniAppShell } from '@goodwidget/ui' interface StoryGoodWidgetParameters { config?: GoodWidgetConfig defaultTheme?: 'light' | 'dark' + useProvider?: boolean useShell?: boolean } @@ -26,16 +27,20 @@ const preview: Preview = { (Story, context) => { const params = (context.parameters.goodWidgetProvider ?? {}) as StoryGoodWidgetParameters const story = + const content = + params.useShell === false ? ( + story + ) : ( + + {story} + + ) - return ( + return params.useProvider === false ? ( + content + ) : ( - {params.useShell === false ? ( - story - ) : ( - - {story} - - )} + {content} ) }, 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..c2e6fb5 --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceWidget.stories.tsx @@ -0,0 +1,322 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Text, XStack, YStack } from '@goodwidget/ui' +import { + AlignmentVotingProposalCard, + BalanceCard, + FundingDistributionChart, + GovernanceWidgetProvider, + ImpactCard, + OptimisticVotingProposalCard, +} from '@goodwidget/governance-widget' +import type { + FundingProjectAllocation, + RankedVotingOption, + VoteSegment, + VoterPreview, +} from '@goodwidget/governance-widget' +import type { GoodWidgetThemeOverrides } from '@goodwidget/core' + +const meta: Meta = { + title: 'Widgets/GovernanceWidget', + tags: ['autodocs'], + parameters: { + layout: 'centered', + goodWidgetProvider: { useShell: false, useProvider: false }, + }, +} + +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 mayaAvatar = + 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2264%22 height=%2264%22 viewBox=%220 0 64 64%22%3E%3Crect width=%2264%22 height=%2264%22 rx=%2232%22 fill=%22%232563eb%22/%3E%3Ctext x=%2232%22 y=%2239%22 text-anchor=%22middle%22 font-family=%22Arial%22 font-size=%2224%22 font-weight=%22700%22 fill=%22white%22%3EM%3C/text%3E%3C/svg%3E' + +const voters: VoterPreview[] = [ + { id: 'maya', label: 'Maya', avatarUrl: mayaAvatar }, + { id: 'kenji', label: 'Kenji' }, + { id: 'sol', label: 'Sol' }, + { id: 'ama', label: 'Ama' }, +] + +const fundingProjects: FundingProjectAllocation[] = [ + { id: 'education', name: 'Education Hubs', amount: { value: 157500, token: 'G$' }, percentage: 35 }, + { id: 'merchant', name: 'Merchant Onboard', amount: { value: 112500, token: 'G$' }, percentage: 25 }, + { id: 'grants', name: 'Dev Grants', amount: { value: 90000, token: 'G$' }, percentage: 20 }, + { id: 'creator', name: 'Creator Fund', amount: { value: 90000, token: 'G$' }, percentage: 20 }, +] + +function GovernanceStoryFrame({ + children, + width = 520, + defaultTheme = 'light', + themeOverrides, +}: { + children: React.ReactNode + width?: number + defaultTheme?: 'light' | 'dark' + themeOverrides?: GoodWidgetThemeOverrides +}) { + 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: { viewport: { defaultViewport: 'mobile1' } }, + render: () => ( + + + + ), +} + +export const BalanceVariantsLight: Story = { + render: () => ( + + + + + + + ), +} + +export const BalanceDarkCompact: Story = { + render: () => ( + + + + ), +} + +export const AlignmentDefaultLight: Story = { + render: () => ( + + + + ), +} + +export const AlignmentDarkLongOptions: Story = { + render: () => ( + + + + ), +} + +export const OptimisticHighQuorumLight: Story = { + render: () => ( + + + + ), +} + +export const OptimisticDarkLowQuorumMixed: Story = { + render: () => ( + + + + ), +} + +export const FundingDistributionLight: Story = { + render: () => ( + + + + ), +} + +export const ImpactLightComponentOverride: Story = { + render: () => ( + + + + ), +} + +export const FundingDistributionDarkPopulated: Story = { + parameters: { viewport: { defaultViewport: 'mobile1' } }, + render: () => ( + + + + ), +} + +export const FundingDistributionDarkEmptyMobile: Story = { + parameters: { viewport: { defaultViewport: 'mobile1' } }, + render: () => ( + + + + ), +} diff --git a/packages/governance-widget/package.json b/packages/governance-widget/package.json new file mode 100644 index 0000000..dc91ce9 --- /dev/null +++ b/packages/governance-widget/package.json @@ -0,0 +1,47 @@ +{ + "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", + "react-native": ">=0.76.0" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + } + }, + "dependencies": { + "@goodwidget/core": "workspace:*", + "@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", + "react-native-web": "^0.19.13", + "tsup": "^8.4.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/governance-widget/src/AlignmentVotingProposalCard.tsx b/packages/governance-widget/src/AlignmentVotingProposalCard.tsx new file mode 100644 index 0000000..eb1bfbd --- /dev/null +++ b/packages/governance-widget/src/AlignmentVotingProposalCard.tsx @@ -0,0 +1,67 @@ +import { Heading, Icon, Text, YStack, XStack } from '@goodwidget/ui' +import type { AlignmentVotingProposalCardProps, RankedVotingOption } from './types' +import { clampPercentage } from './format' +import { GovernanceWrapper, ProgressBar, ProposalHeader } from './shared' + +function RankedOptionRow({ option }: { option: RankedVotingOption }) { + return ( + + + + {option.label} + + + {clampPercentage(option.percentage)}% + + + + + ) +} + +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} + + + ) +} diff --git a/packages/governance-widget/src/BalanceCard.tsx b/packages/governance-widget/src/BalanceCard.tsx new file mode 100644 index 0000000..bb0ea7b --- /dev/null +++ b/packages/governance-widget/src/BalanceCard.tsx @@ -0,0 +1,54 @@ +import { Icon, Text, XStack } from '@goodwidget/ui' +import type { BalanceCardProps } from './types' +import { isGovernanceAmount } from './format' +import { GovernanceWrapper, renderGovernanceAmount } from './shared' + +export function BalanceCard({ + icon, + title, + amount, + amountType = 'token', + metadataType, + metadata, + compact = false, + testID, +}: BalanceCardProps) { + const amountValue = isGovernanceAmount(amount) ? amount : { value: amount, token: amountType === 'token' ? 'G$' : undefined } + const metadataTone = + metadata.tone === 'positive' || metadataType === 'growth' + ? 'default' + : metadata.tone === 'muted' || metadataType === 'time-window' + ? 'secondary' + : 'soft' + const metadataIcon = metadata.icon ?? (metadataType === 'growth' ? 'chevron-up' : metadataType === 'time-window' ? 'info' : undefined) + const metadataIconColor = metadata.tone === 'positive' || metadataType === 'growth' ? 'success' : 'muted' + + return ( + + + + + {title} + + + {renderGovernanceAmount(amountValue, compact ? 'md' : 'lg')} + + {metadataIcon ? : null} + + {metadata.label} + + + + ) +} diff --git a/packages/governance-widget/src/FundingDistributionChart.tsx b/packages/governance-widget/src/FundingDistributionChart.tsx new file mode 100644 index 0000000..3957911 --- /dev/null +++ b/packages/governance-widget/src/FundingDistributionChart.tsx @@ -0,0 +1,167 @@ +import Svg, { Circle, G } from 'react-native-svg' +import { Stack, useTheme } from 'tamagui' +import { Heading, Text, XStack, YStack } from '@goodwidget/ui' +import type { FundingDistributionChartProps, FundingProjectAllocation, GovernanceAmount } from './types' +import { clampPercentage, fundingAmountLabel } from './format' +import { GovernanceWrapper, resolveThemeColor } from './shared' + +const DONUT_COLOR_KEYS = ['primary', 'success', 'warning', 'colorDim', 'error'] as const + +function FundingLegend({ + projects, + colors, + emptyStateLabel, + onProjectPress, +}: { + projects: FundingProjectAllocation[] + colors: string[] + emptyStateLabel: string + onProjectPress?: (id: string) => void +}) { + if (projects.length === 0) { + return ( + + {emptyStateLabel} + + ) + } + + return ( + + {projects.map((project, index) => ( + onProjectPress(project.id) : undefined} + role={onProjectPress ? 'button' : undefined} + aria-label={`Open ${project.name} allocation`} + > + + + + + + {project.name} + + + {clampPercentage(project.percentage)}% - {fundingAmountLabel(project.amount)} + + + + ))} + + ) +} + +function FundingDonut({ + projects, + totalAmount, + centerLabel, + colors, + onProjectPress, +}: { + projects: FundingProjectAllocation[] + totalAmount: GovernanceAmount + centerLabel: string + colors: string[] + onProjectPress?: (id: string) => void +}) { + const size = 188 + const strokeWidth = 20 + 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} + /> + ) + })} + + + + + {centerLabel} + + + {fundingAmountLabel(totalAmount)} + + + {totalAmount.streamLabel ?? 'Current allocation'} + + + + ) +} + +function FundingDistributionChartContent({ + title = 'Funding distribution', + centerLabel = 'Total active funding', + emptyStateLabel = 'No active funding distribution yet.', + totalAmount, + projects, + isStreaming = false, + onProjectPress, +}: FundingDistributionChartProps) { + const theme = useTheme() + const colors = DONUT_COLOR_KEYS.map((key) => resolveThemeColor(theme as unknown as Record, key)) + const total = { ...totalAmount, isStreaming: totalAmount.isStreaming ?? isStreaming } + + return ( + <> + + {title} + + + + + + + ) +} + +export function FundingDistributionChart(props: FundingDistributionChartProps) { + return ( + + + + ) +} diff --git a/packages/governance-widget/src/GovernanceWidgetProvider.tsx b/packages/governance-widget/src/GovernanceWidgetProvider.tsx new file mode 100644 index 0000000..4d4d2ac --- /dev/null +++ b/packages/governance-widget/src/GovernanceWidgetProvider.tsx @@ -0,0 +1,51 @@ +import { useMemo } from 'react' +import { GoodWidgetProvider } from '@goodwidget/core' +import { mergeOverrideMaps } from '@goodwidget/ui' +import type { + EIP1193Provider, + GoodWidgetConfig, + GoodWidgetThemeOverrides, +} from '@goodwidget/core' +import type { ReactNode } from 'react' +import { governanceWidgetConfig } from './config' + +export interface GovernanceWidgetProviderProps { + provider?: EIP1193Provider + config?: GoodWidgetConfig + themeOverrides?: GoodWidgetThemeOverrides + defaultTheme?: 'light' | 'dark' + children: ReactNode +} + +/** + * Builds the governance author configuration before host theme overrides are + * applied by GoodWidgetProvider. + */ +function createGovernanceWidgetConfig(config?: GoodWidgetConfig): GoodWidgetConfig { + return { + preset: config?.preset, + tokens: config?.tokens, + themes: mergeOverrideMaps(governanceWidgetConfig.themes, config?.themes), + } +} + +export function GovernanceWidgetProvider({ + provider, + config, + themeOverrides, + defaultTheme = 'light', + children, +}: GovernanceWidgetProviderProps) { + const governanceConfig = useMemo(() => createGovernanceWidgetConfig(config), [config]) + + return ( + + {children} + + ) +} diff --git a/packages/governance-widget/src/ImpactCard.tsx b/packages/governance-widget/src/ImpactCard.tsx new file mode 100644 index 0000000..d42ba4d --- /dev/null +++ b/packages/governance-widget/src/ImpactCard.tsx @@ -0,0 +1,161 @@ +import { Stack } from 'tamagui' +import { Heading, Icon, Text, XStack, YStack } from '@goodwidget/ui' +import type { ImpactCardMetric, ImpactCardProps } from './types' +import { formatCompactValue } from './format' +import { ImpactCardAction, ImpactCardFrame } from './shared' + +function HeroBackdrop() { + return ( + <> + + + + + + ) +} + +function renderHeroAmount(metric: ImpactCardMetric, emphasized = false) { + return ( + + + + {metric.label} + + + + {metric.amount.token ? ( + <> + + {metric.amount.token} + + + {formatCompactValue(metric.amount.value)} + + + ) : ( + + {formatCompactValue(metric.amount.value)} + + )} + + {metric.amount.isStreaming ? ( + + + + {metric.amount.streamLabel ?? 'Live stream active'} + + + ) : null} + + ) +} + +function MetricColumn({ + metric, + emphasized = false, + withDivider = false, +}: { + metric: ImpactCardMetric + emphasized?: boolean + withDivider?: boolean +}) { + return ( + + {renderHeroAmount(metric, emphasized)} + {metric.description ? ( + + {metric.description} + + ) : null} + + ) +} + +export function ImpactCard({ + title, + metrics, + description, + ctaLabel, + ctaDisabled = false, + onCtaPress, + testID, +}: ImpactCardProps) { + const [primaryMetric, secondaryMetric] = metrics + + return ( + + + + + {title} + + + + + + + + {description} + + + {ctaLabel ? ( + + + + {ctaLabel} + + + + + ) : null} + + ) +} diff --git a/packages/governance-widget/src/OptimisticVotingProposalCard.tsx b/packages/governance-widget/src/OptimisticVotingProposalCard.tsx new file mode 100644 index 0000000..4f8ebdc --- /dev/null +++ b/packages/governance-widget/src/OptimisticVotingProposalCard.tsx @@ -0,0 +1,107 @@ +import { Stack } from 'tamagui' +import { Heading, Text, XStack } from '@goodwidget/ui' +import type { OptimisticVotingProposalCardProps, VoteSegment } from './types' +import { clampPercentage } from './format' +import { + GovernanceWrapper, + ProposalHeader, + SEGMENT_TONES, +} from './shared' +import { VoterAvatarStack } from './VoterAvatarStack' + +function StackedProgressBar({ segments }: { segments: VoteSegment[] }) { + return ( + + {segments.map((segment) => ( + + ))} + + ) +} + +function VoteLegend({ segments }: { segments: VoteSegment[] }) { + return ( + + {segments.map((segment) => ( + + + + {segment.label} ({clampPercentage(segment.percentage)}%) + + + ))} + + ) +} + +export function OptimisticVotingProposalCard({ + id, + categoryLabel, + title, + quorumLabel = 'Current vote quorum', + quorumReachedPercent, + voteSegments, + voters, + remainingVoterCountLabel, + statusLabel, + statusTone = 'warning', + onPress, + testID, +}: OptimisticVotingProposalCardProps) { + const statusColor = + statusTone === 'positive' ? '$success' : statusTone === 'muted' ? '$placeholderColor' : '$warning' + + return ( + onPress(id) : undefined} + role={onPress ? 'button' : undefined} + aria-label={`Open proposal ${title}`} + > + + + {title} + + + + + {quorumLabel} + + + {clampPercentage(quorumReachedPercent)}% reached + + + + + + + + + + + + + + + {statusLabel ? ( + + {statusLabel} + + ) : null} + + + + ) +} diff --git a/packages/governance-widget/src/VoterAvatarStack.tsx b/packages/governance-widget/src/VoterAvatarStack.tsx new file mode 100644 index 0000000..1abd3fa --- /dev/null +++ b/packages/governance-widget/src/VoterAvatarStack.tsx @@ -0,0 +1,56 @@ +import { Image } from 'react-native' +import { Stack } from 'tamagui' +import { Badge, BadgeText, Text, XStack } from '@goodwidget/ui' +import type { VoterPreview } from './types' + +function VoterAvatar({ voter, index }: { voter: VoterPreview; index: number }) { + const initial = voter.label.trim().slice(0, 1).toUpperCase() || '?' + + return ( + + {voter.avatarUrl ? ( + + ) : ( + + {initial} + + )} + + ) +} + +export function VoterAvatarStack({ voters, remainingLabel }: { voters: VoterPreview[]; remainingLabel?: string }) { + return ( + + + {voters.slice(0, 4).map((voter, index) => ( + + ))} + + {remainingLabel ? ( + + {remainingLabel} + + ) : null} + + ) +} diff --git a/packages/governance-widget/src/config.ts b/packages/governance-widget/src/config.ts new file mode 100644 index 0000000..07821ba --- /dev/null +++ b/packages/governance-widget/src/config.ts @@ -0,0 +1,60 @@ +import type { GoodWidgetConfig } from '@goodwidget/core' +import { defaultTokenPreset } from '@goodwidget/ui' + +const color = defaultTokenPreset.tokens.color + +const governanceTokenPreset = { + impactOverlay: 'rgba(255, 255, 255, 0.12)', + impactOverlayPressed: 'rgba(255, 255, 255, 0.08)', + impactOverlayStrong: 'rgba(255, 255, 255, 0.18)', + impactTextSoft: 'rgba(255, 255, 255, 0.88)', + impactTextDim: 'rgba(255, 255, 255, 0.92)', + impactBorder: 'rgba(255, 255, 255, 0.12)', + impactBorderHover: 'rgba(255, 255, 255, 0.20)', + impactBorderFocus: 'rgba(255, 255, 255, 0.24)', +} as const + +export const governanceSurfaceTheme = { + backgroundColor: '$background', + borderColor: '$borderColor', + color: '$color', + shadowColor: '$shadowColor', +} as const + +const governanceImpactTheme = { + governanceImpactOverlay: governanceTokenPreset.impactOverlay, + governanceImpactOverlayPressed: governanceTokenPreset.impactOverlayPressed, + governanceImpactOverlayStrong: governanceTokenPreset.impactOverlayStrong, + governanceImpactTextSoft: governanceTokenPreset.impactTextSoft, + governanceImpactTextDim: governanceTokenPreset.impactTextDim, + governanceImpactBorder: governanceTokenPreset.impactBorder, + governanceImpactBorderHover: governanceTokenPreset.impactBorderHover, + governanceImpactBorderFocus: governanceTokenPreset.impactBorderFocus, +} as const + +/** + * Governance-local author defaults. + * + * Shared preset values stay in @goodwidget/ui. This config only adds widget + * semantics that the governance components consume directly. + */ +export const governanceWidgetConfig = { + themes: { + light: governanceImpactTheme, + dark: governanceImpactTheme, + light_GovernanceWrapper: { + background: color.governanceSurface, + }, + dark_GovernanceWrapper: { + background: color.surfaceDark, + }, + light_ImpactCard: { + background: color.governancePrimary, + shadowColor: color.governanceElevationShadow, + }, + dark_ImpactCard: { + background: color.primary, + shadowColor: 'rgba(3, 7, 18, 0.9)', + }, + }, +} satisfies GoodWidgetConfig diff --git a/packages/governance-widget/src/format.ts b/packages/governance-widget/src/format.ts new file mode 100644 index 0000000..490ceda --- /dev/null +++ b/packages/governance-widget/src/format.ts @@ -0,0 +1,37 @@ +import type { GovernanceAmount } from './types' + +export function clampPercentage(value: number): number { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.max(0, Math.min(100, value)) +} + +export function formatRawValue(value: string | number): string { + if (typeof value === 'string') { + return value + } + + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value) +} + +export function formatCompactValue(value: string | number, maximumFractionDigits = 1): string { + if (typeof value === 'string') { + return value + } + + return new Intl.NumberFormat('en-US', { + maximumFractionDigits, + notation: 'compact', + }).format(value) +} + +export function isGovernanceAmount(value: GovernanceAmount | string | number): value is GovernanceAmount { + return typeof value === 'object' && value !== null && 'value' in value +} + +export function fundingAmountLabel(amount: GovernanceAmount): string { + const formattedValue = formatCompactValue(amount.value) + return amount.token ? `${amount.token} ${formattedValue}` : formattedValue +} diff --git a/packages/governance-widget/src/index.ts b/packages/governance-widget/src/index.ts new file mode 100644 index 0000000..b461f73 --- /dev/null +++ b/packages/governance-widget/src/index.ts @@ -0,0 +1,33 @@ +export { + ImpactCard, +} from './ImpactCard' +export { + BalanceCard, +} from './BalanceCard' +export { + AlignmentVotingProposalCard, +} from './AlignmentVotingProposalCard' +export { + OptimisticVotingProposalCard, +} from './OptimisticVotingProposalCard' +export { + FundingDistributionChart, +} from './FundingDistributionChart' +export { + GovernanceWidgetProvider, +} from './GovernanceWidgetProvider' +export type { GovernanceWidgetProviderProps } from './GovernanceWidgetProvider' +export type { + GovernanceAmount, + ImpactCardMetric, + ImpactCardProps, + BalanceCardProps, + BalanceCardMetadata, + RankedVotingOption, + AlignmentVotingProposalCardProps, + VoteSegment, + VoterPreview, + OptimisticVotingProposalCardProps, + FundingProjectAllocation, + FundingDistributionChartProps, +} from './types' diff --git a/packages/governance-widget/src/shared.tsx b/packages/governance-widget/src/shared.tsx new file mode 100644 index 0000000..17b4215 --- /dev/null +++ b/packages/governance-widget/src/shared.tsx @@ -0,0 +1,169 @@ +import { Stack } from 'tamagui' +import { ButtonFrame, Card, Heading, Icon, Text, XStack, YStack, createComponent } from '@goodwidget/ui' +import type { GovernanceAmount, VoteSegment } from './types' +import { governanceSurfaceTheme } from './config' +import { clampPercentage, formatCompactValue, formatRawValue } from './format' + +export const SEGMENT_TONES: Record, string> = { + for: '$primary', + against: '$error', + abstain: '$placeholderColor', + neutral: '$success', +} + +export type GovernanceAmountSize = 'sm' | 'md' | 'lg' | 'xl' + +const GOVERNANCE_CARD_LAYOUT = { + width: '100%', + gap: '$4', + elevated: true, +} as const + +export const GovernanceWrapper = createComponent(Card, { + name: 'GovernanceWrapper', + extends: 'Card', + ...governanceSurfaceTheme, + ...GOVERNANCE_CARD_LAYOUT, +}) + +export const ImpactCardFrame = createComponent(Card, { + name: 'ImpactCard', + extends: 'Card', + ...GOVERNANCE_CARD_LAYOUT, + backgroundColor: '$background', + color: '$white', + shadowColor: '$shadowColor', + maxWidth: 390, + overflow: 'hidden', + borderWidth: 0, + padding: '$5', +}) + +export const ImpactCardAction = createComponent(ButtonFrame, { + name: 'ImpactCardAction', + extends: 'Button', + width: '100%', + maxWidth: 320, + minHeight: '$8', + alignSelf: 'center', + backgroundColor: '$white', + borderWidth: 0, + borderRadius: '$full', + shadowColor: '$elevationShadowColor', + shadowOffset: { width: 0, height: 10 }, + shadowRadius: 24, + hoverStyle: { backgroundColor: '$grey100' }, + pressStyle: { backgroundColor: '$grey300' }, + focusStyle: { backgroundColor: '$grey100' }, +}) + +const GovernanceAmountValue = createComponent(Text, { + name: 'GovernanceAmountValue', + extends: 'Text', + color: '$color', + fontWeight: '700', + variants: { + amountSize: { + sm: { fontSize: '$4', lineHeight: '$3' }, + md: { fontSize: '$6', lineHeight: '$4' }, + lg: { fontSize: '$8', lineHeight: '$6' }, + xl: { fontSize: '$10', lineHeight: '$8' }, + }, + } as const, +}) + +const GovernanceAmountToken = createComponent(Text, { + name: 'GovernanceAmountToken', + extends: 'Text', + color: '$color', + fontWeight: '700', + variants: { + amountSize: { + sm: { fontSize: '$2', lineHeight: '$1' }, + md: { fontSize: '$4', lineHeight: '$2' }, + lg: { fontSize: '$5', lineHeight: '$3' }, + xl: { fontSize: '$6', lineHeight: '$4' }, + }, + } as const, +}) + +const CAPTION_SIZE: Record = { + sm: 'caption', + md: 'caption', + lg: 'label', + xl: 'label', +} + +export function renderGovernanceAmount(amount: GovernanceAmount, size: GovernanceAmountSize = 'lg') { + return ( + + {amount.token ? ( + + + {amount.token} + + + {formatCompactValue(amount.value)} + + + ) : ( + {formatRawValue(amount.value)} + )} + {amount.isStreaming ? ( + + {amount.streamLabel ?? 'Live stream'} + + ) : null} + + ) +} + +export function ProposalHeader({ categoryLabel }: { categoryLabel: string }) { + return ( + + + + {categoryLabel} + + + + + ) +} + +export function resolveThemeColor( + theme: Record, + key: string, +): string { + const themeValue = theme[key] + + if (themeValue && typeof themeValue === 'object' && 'val' in themeValue) { + return String((themeValue as { val: unknown }).val) + } + + return typeof themeValue === 'string' ? themeValue : '' +} + +export function ProgressBar({ + percentage, + colorToken = '$primary', + height = 8, +}: { + percentage: number + colorToken?: string + height?: number +}) { + return ( + + + + ) +} diff --git a/packages/governance-widget/src/types.ts b/packages/governance-widget/src/types.ts new file mode 100644 index 0000000..712315d --- /dev/null +++ b/packages/governance-widget/src/types.ts @@ -0,0 +1,104 @@ +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' + metadataType?: 'growth' | 'time-window' + 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 + statusLabel?: string + statusTone?: 'warning' | 'muted' | 'positive' + onPress?: (id: string) => void + testID?: string +} + +export interface FundingProjectAllocation { + id: string + name: string + amount: GovernanceAmount + percentage: number +} + +export interface FundingDistributionChartProps { + title?: string + centerLabel?: string + emptyStateLabel?: string + totalAmount: GovernanceAmount + projects: FundingProjectAllocation[] + isStreaming?: boolean + onProjectPress?: (id: string) => void + testID?: string +} 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/packages/ui/src/config.ts b/packages/ui/src/config.ts index 726c565..6e617c0 100644 --- a/packages/ui/src/config.ts +++ b/packages/ui/src/config.ts @@ -243,7 +243,7 @@ export function createGoodWidgetConfig(overrides?: GoodWidgetConfig) { /** * Merges partial theme override maps by theme name. */ -function mergeOverrideMaps( +export function mergeOverrideMaps( base?: Record>, override?: Record>, ): Record> | undefined { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 27a33dc..4c87a09 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,6 +2,7 @@ export { createGoodWidgetConfig, createGoodWidgetThemes, + mergeOverrideMaps, mergeThemeOverrides, defaultConfig, defaultPreset, @@ -25,7 +26,7 @@ export type { // Theme export { defaultTokenValues, createGoodWidgetTokens, createThemeValues } from './theme' -export { goodWalletV2Preset } from './presets' +export { defaultTokenPreset, goodWalletV2Preset } from './presets' // createComponent + Manifest export { createComponent } from './createComponent' diff --git a/packages/ui/src/presets.ts b/packages/ui/src/presets.ts index 1284e8c..22c65f4 100644 --- a/packages/ui/src/presets.ts +++ b/packages/ui/src/presets.ts @@ -4,7 +4,7 @@ import type { WidgetDesignPreset } from './configTypes' // which are the source of truth for all design values used in the GoodWalletV2 app. // These should be used as the basis for all themes and component themes, // and only in rare cases should a new value be added that isn't derived from these tokens. -const tokenPreset = { +export const defaultTokenPreset = { tokens: { color: { white: '#FFFFFF', @@ -153,7 +153,8 @@ const tokenPreset = { }, } -const color = tokenPreset.tokens.color +const color = defaultTokenPreset.tokens.color + // GoodWalletV2 baseline preset wired directly into the native GoodWidget // tokens -> themes -> createTamagui pipeline. // Tokens: should be highest level of primitives for pallette, spacing, colors. @@ -180,7 +181,7 @@ const color = tokenPreset.tokens.color export const goodWalletV2Preset: WidgetDesignPreset = { id: 'goodwallet-v2', version: '1.1.0', - tokens: tokenPreset.tokens, + tokens: defaultTokenPreset.tokens, themes: { // Governance UI light-mode branch. // This mirrors the separate governance preset direction so that one preset can diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04afa8b..386e2e2 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,46 @@ importers: specifier: ^5.7.0 version: 5.9.3 + packages/governance-widget: + dependencies: + '@goodwidget/core': + specifier: workspace:* + version: link:../core + '@goodwidget/ui': + specifier: workspace:* + version: link:../ui + react-native: + specifier: '>=0.76.0' + 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) + 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) + react-native-web: + specifier: ^0.19.13 + version: 0.19.13(react-dom@18.3.1(react@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 +3635,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 +3972,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 +4124,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 +4136,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 +4197,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 +5478,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 +5798,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 +6238,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 +11902,8 @@ snapshots: big-integer@1.6.52: {} + boolbase@1.0.0: {} + bplist-creator@0.0.7: dependencies: stream-buffers: 2.2.0 @@ -12193,6 +12278,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 +12397,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 +12411,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 +12470,8 @@ snapshots: entities@2.2.0: {} + entities@4.5.0: {} + env-editor@0.4.2: {} error-ex@1.3.4: @@ -13963,6 +14081,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 +14591,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 +15055,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..c6ba0f7 --- /dev/null +++ b/tests/widgets/governance-widget/states.spec.ts @@ -0,0 +1,144 @@ +/** + * 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 + expectedBackgroundColor?: 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', + width: 390, + height: 844, + expectedText: 'View Impact Report Q3', + }, + { + 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: 'View Impact Report Q3', + }, + { + id: 'widgets-governancewidget--impact-light-component-override', + testId: 'ImpactCard-light-component-override', + screenshot: 'tests/widgets/governance-widget/test-results/gw-13-impact-light-component-override.png', + width: 390, + height: 844, + expectedText: 'View Impact Report Q3', + expectedBackgroundColor: 'rgb(15, 118, 110)', + }, + { + id: 'widgets-governancewidget--balance-variants-light', + testId: 'BalanceCard-light-variants', + screenshot: 'tests/widgets/governance-widget/test-results/gw-03-balance-variants-light.png', + expectedText: 'DAO Treasury 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: '2 days remaining', + }, + { + 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: 390, + height: 844, + expectedText: 'Education Hubs', + }, + { + id: 'widgets-governancewidget--funding-distribution-dark-populated', + testId: 'FundingDistributionChart-populated-dark', + screenshot: 'tests/widgets/governance-widget/test-results/gw-10-funding-distribution-dark-populated.png', + width: 390, + height: 844, + expectedText: 'Education Hubs', + }, + { + id: 'widgets-governancewidget--funding-distribution-dark-empty-mobile', + testId: 'FundingDistributionChart-empty-dark-mobile', + screenshot: 'tests/widgets/governance-widget/test-results/gw-11-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') + await page.locator('#storybook-root').waitFor({ state: 'attached' }) + await page.waitForLoadState('networkidle') +} + +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({ timeout: 15_000 }) + await expect(page.getByText(storyCase.expectedText).first()).toBeVisible() + if (storyCase.expectedBackgroundColor) { + await expect(component).toHaveCSS('background-color', storyCase.expectedBackgroundColor) + } + + await component.screenshot({ path: storyCase.screenshot }) + }) +} + +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.getByTestId('AlignmentVotingProposalCard-default').screenshot({ + path: 'tests/widgets/governance-widget/test-results/gw-12-interaction-alignment.png', + }) +}) 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..9cab52e 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..5509b48 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..8d6a6c0 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..2cd606c 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..82a8a18 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..73aaefc 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..76210b8 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..9b6ffaf 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..fae2f53 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-dark-populated.png b/tests/widgets/governance-widget/test-results/gw-10-funding-distribution-dark-populated.png new file mode 100644 index 0000000..2a9de0a Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-10-funding-distribution-dark-populated.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-11-funding-distribution-empty-dark-mobile.png b/tests/widgets/governance-widget/test-results/gw-11-funding-distribution-empty-dark-mobile.png new file mode 100644 index 0000000..1cd8ab6 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-11-funding-distribution-empty-dark-mobile.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-12-interaction-alignment.png b/tests/widgets/governance-widget/test-results/gw-12-interaction-alignment.png new file mode 100644 index 0000000..82a8a18 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-12-interaction-alignment.png differ diff --git a/tests/widgets/governance-widget/test-results/gw-13-impact-light-component-override.png b/tests/widgets/governance-widget/test-results/gw-13-impact-light-component-override.png new file mode 100644 index 0000000..e1c4916 Binary files /dev/null and b/tests/widgets/governance-widget/test-results/gw-13-impact-light-component-override.png differ