diff --git a/packages/governance-widget/package.json b/packages/governance-widget/package.json
index 007f847..cdfe4ac 100644
--- a/packages/governance-widget/package.json
+++ b/packages/governance-widget/package.json
@@ -21,7 +21,14 @@
},
"peerDependencies": {
"react": ">=18.0.0",
- "react-dom": ">=18.0.0"
+ "react-dom": ">=18.0.0",
+ "react-native": ">=0.76.0",
+ "react-native-web": ">=0.19.0"
+ },
+ "peerDependenciesMeta": {
+ "react-native-web": {
+ "optional": true
+ }
},
"dependencies": {
"@goodwidget/ui": "workspace:*",
@@ -33,6 +40,8 @@
"@types/react-dom": "^18.3.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
+ "react-native": "0.76.9",
+ "react-native-web": "^0.19.13",
"tsup": "^8.4.0",
"typescript": "^5.7.0"
}
diff --git a/packages/governance-widget/src/BalanceCard.tsx b/packages/governance-widget/src/BalanceCard.tsx
new file mode 100644
index 0000000..e1bb21a
--- /dev/null
+++ b/packages/governance-widget/src/BalanceCard.tsx
@@ -0,0 +1,35 @@
+import React from 'react'
+import { Card, Icon, Text, XStack } from '@goodwidget/ui'
+import type { BalanceCardProps } from './types'
+import { isGovernanceAmount, renderGovernanceAmount } from './utils'
+
+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}
+
+
+
+ )
+}
diff --git a/packages/governance-widget/src/FundingDistributionChart.tsx b/packages/governance-widget/src/FundingDistributionChart.tsx
new file mode 100644
index 0000000..3047d4f
--- /dev/null
+++ b/packages/governance-widget/src/FundingDistributionChart.tsx
@@ -0,0 +1,32 @@
+import React from 'react'
+import { Card, Heading, Text, XStack, YStack } from '@goodwidget/ui'
+import { useTheme } from 'tamagui'
+import { FundingDonut, FundingLegend } from './primitives'
+import type { FundingDistributionChartProps } from './types'
+import { DONUT_COLOR_KEYS, resolveThemeColor } from './utils'
+
+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/ImpactCard.tsx b/packages/governance-widget/src/ImpactCard.tsx
new file mode 100644
index 0000000..077526f
--- /dev/null
+++ b/packages/governance-widget/src/ImpactCard.tsx
@@ -0,0 +1,31 @@
+import React from 'react'
+import { Button, ButtonText, Card, Heading, Text, XStack } from '@goodwidget/ui'
+import { MetricBox } from './primitives'
+import type { ImpactCardProps } from './types'
+
+export function ImpactCard({
+ title,
+ metrics,
+ description,
+ ctaLabel,
+ ctaDisabled = false,
+ onCtaPress,
+ testID,
+}: ImpactCardProps) {
+ return (
+
+ {title}
+
+ {metrics.map((metric) => (
+
+ ))}
+
+ {description}
+ {ctaLabel ? (
+
+ ) : null}
+
+ )
+}
diff --git a/packages/governance-widget/src/ProposalCards.tsx b/packages/governance-widget/src/ProposalCards.tsx
new file mode 100644
index 0000000..eead95d
--- /dev/null
+++ b/packages/governance-widget/src/ProposalCards.tsx
@@ -0,0 +1,101 @@
+import React from 'react'
+import { Stack } from 'tamagui'
+import { Card, Heading, Icon, Text, XStack, YStack } from '@goodwidget/ui'
+import { ProposalHeader, RankedOptionRow, StackedProgressBar, VoteLegend, VoterPreviewGroup } from './primitives'
+import type { AlignmentVotingProposalCardProps, OptimisticVotingProposalCardProps } from './types'
+import { clampPercentage } from './utils'
+
+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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/governance-widget/src/governanceComponents.tsx b/packages/governance-widget/src/governanceComponents.tsx
deleted file mode 100644
index ccce74e..0000000
--- a/packages/governance-widget/src/governanceComponents.tsx
+++ /dev/null
@@ -1,606 +0,0 @@
-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 ? (
-
- ) : (
-
- {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 (
-
-
-
- {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
index 0ad8bec..bc01012 100644
--- a/packages/governance-widget/src/index.ts
+++ b/packages/governance-widget/src/index.ts
@@ -1,10 +1,16 @@
export {
ImpactCard,
+} from './ImpactCard'
+export {
BalanceCard,
+} from './BalanceCard'
+export {
AlignmentVotingProposalCard,
OptimisticVotingProposalCard,
+} from './ProposalCards'
+export {
FundingDistributionChart,
-} from './governanceComponents'
+} from './FundingDistributionChart'
export type {
GovernanceAmount,
ImpactCardMetric,
@@ -18,4 +24,4 @@ export type {
OptimisticVotingProposalCardProps,
FundingProjectAllocation,
FundingDistributionChartProps,
-} from './governanceComponents'
+} from './types'
diff --git a/packages/governance-widget/src/primitives.tsx b/packages/governance-widget/src/primitives.tsx
new file mode 100644
index 0000000..1c9e8a6
--- /dev/null
+++ b/packages/governance-widget/src/primitives.tsx
@@ -0,0 +1,269 @@
+import React from 'react'
+import { Image } from 'react-native'
+import Svg, { Circle, G } from 'react-native-svg'
+import { Stack } from 'tamagui'
+import { Badge, BadgeText, Button, Icon, Text, XStack, YStack } from '@goodwidget/ui'
+import type { FundingProjectAllocation, GovernanceAmount, ImpactCardMetric, RankedVotingOption, VoteSegment, VoterPreview } from './types'
+import { clampPercentage, formatRawValue, renderGovernanceAmount, SEGMENT_TONES } from './utils'
+
+export function MetricBox({ metric }: { metric: ImpactCardMetric }) {
+ return (
+
+
+ {metric.label}
+
+ {renderGovernanceAmount(metric.amount, 'lg')}
+ {metric.description ? (
+
+ {metric.description}
+
+ ) : null}
+
+ )
+}
+
+export function ProposalHeader({ categoryLabel }: { categoryLabel: string }) {
+ return (
+
+
+ {categoryLabel}
+
+
+
+ )
+}
+
+export function ProgressBar({ percentage, colorToken = '$primary' }: { percentage: number; colorToken?: string }) {
+ return (
+
+
+
+ )
+}
+
+export function RankedOptionRow({ option }: { option: RankedVotingOption }) {
+ return (
+
+
+
+ {option.label}
+
+
+ {clampPercentage(option.percentage)}%
+
+
+
+
+ )
+}
+
+export 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 ? (
+
+ ) : (
+
+ {initial}
+
+ )}
+
+ )
+}
+
+export function VoterPreviewGroup({ voters, remainingLabel }: { voters: VoterPreview[]; remainingLabel?: string }) {
+ return (
+
+
+ {voters.slice(0, 4).map((voter, index) => (
+
+ ))}
+
+ {remainingLabel ? (
+
+ {remainingLabel}
+
+ ) : null}
+
+ )
+}
+
+export 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
+}
+
+export 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) => (
+
+ ))}
+
+ )
+}
+
+export 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 (
+
+
+
+ {renderGovernanceAmount(totalAmount, 'md')}
+
+ Total active funding
+
+
+
+ )
+}
diff --git a/packages/governance-widget/src/types.ts b/packages/governance-widget/src/types.ts
new file mode 100644
index 0000000..c696ccb
--- /dev/null
+++ b/packages/governance-widget/src/types.ts
@@ -0,0 +1,98 @@
+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
+}
diff --git a/packages/governance-widget/src/utils.tsx b/packages/governance-widget/src/utils.tsx
new file mode 100644
index 0000000..912b8c3
--- /dev/null
+++ b/packages/governance-widget/src/utils.tsx
@@ -0,0 +1,60 @@
+import React from 'react'
+import { Heading, Text, TokenAmount, YStack } from '@goodwidget/ui'
+import type { useTheme } from 'tamagui'
+import type { GovernanceAmount } from './types'
+
+export const SEGMENT_TONES = {
+ for: '$primary',
+ against: '$error',
+ abstain: '$placeholderColor',
+ neutral: '$success',
+} as const
+
+export const DONUT_COLOR_KEYS = ['primary', 'success', 'warning', 'info', 'error'] as const
+
+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 isGovernanceAmount(value: GovernanceAmount | string | number): value is GovernanceAmount {
+ return typeof value === 'object' && value !== null && 'value' in value
+}
+
+export 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
+}
+
+export function renderGovernanceAmount(amount: GovernanceAmount, size: 'sm' | 'md' | 'lg' | 'xl' = 'lg') {
+ return (
+
+ {amount.token ? (
+
+ ) : (
+ {formatRawValue(amount.value)}
+ )}
+ {amount.isStreaming ? (
+
+ {amount.streamLabel ?? 'Live stream'}
+
+ ) : null}
+
+ )
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3c1446a..2f0e35b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -253,6 +253,12 @@ importers:
react-dom:
specifier: ^18.3.0
version: 18.3.1(react@18.3.1)
+ 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-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)