diff --git a/examples/storybook/.storybook/main.ts b/examples/storybook/.storybook/main.ts index 7118a297..a355900d 100644 --- a/examples/storybook/.storybook/main.ts +++ b/examples/storybook/.storybook/main.ts @@ -39,6 +39,15 @@ const config: StorybookConfig = { } config.optimizeDeps = { ...config.optimizeDeps, + // Storybook loads this preview annotation through a virtual module. When it is + // pre-bundled alongside the branch's larger widget dependency graph, Vite can + // discover a new shared chunk after the first browser request and invalidate the + // URL that Storybook already emitted. It is a small ESM module, so serving it + // directly avoids that dev-only optimizer race while preserving interactions. + exclude: [ + ...(config.optimizeDeps?.exclude ?? []), + '@storybook/addon-interactions/preview', + ], esbuildOptions: { ...config.optimizeDeps?.esbuildOptions, resolveExtensions: [ diff --git a/examples/storybook/src/fixtures/governanceInteractiveMock.ts b/examples/storybook/src/fixtures/governanceInteractiveMock.ts new file mode 100644 index 00000000..0084fb8b --- /dev/null +++ b/examples/storybook/src/fixtures/governanceInteractiveMock.ts @@ -0,0 +1,252 @@ +import { decodeAbiParameters, decodeFunctionData, parseAbi, type Address, type Hex } from 'viem' +import type { EIP1193Provider } from '@goodwidget/core' +import { + encodeMockGovernanceRead, + MOCK_ALIGNMENT, + MOCK_CITIZEN, + MOCK_G_TOKEN, + MOCK_GOOD_ID, + MOCK_HOUSES, +} from './governanceRuntimeMock' + +// Minimal write-side ABI fragments, mirroring packages/governance-widget/src/sdks/contracts.ts. +// Duplicated locally (same convention as the read ABI in governanceRuntimeMock.ts) so this +// browser fixture has no dependency on package internals. +const G_TOKEN_WRITE_ABI = parseAbi([ + 'function transferAndCall(address to, uint256 value, bytes data) returns (bool)', +]) +const HOUSES_WRITE_ABI = parseAbi([ + 'function castVote(address[] recipients, uint256[] allocations)', + 'function unstake()', +]) +const REGISTRATION_DATA_TYPES = [ + { type: 'uint8' }, + { type: 'string' }, + { type: 'string' }, + { type: 'string' }, + { type: 'string' }, + { type: 'string' }, +] as const + +const MOCK_GOVERNANCE_RPC_PATH = '/mock-governance-rpc' +const MOCK_SUPERFLUID_URL_FRAGMENT = 'celo-mainnet/protocol-v1' +const MOCK_ACCOUNT: Address = '0x1234123412341234123412341234123412341234' +const MOCK_NOW_SECONDS = 1_784_419_200 + +type PendingTransactionEffect = + | { kind: 'registration'; house: 0 | 1 } + | { kind: 'vote' } + | { kind: 'unstake' } + +interface InteractiveGovernanceSession { + memberStatus: 0 | 1 | 2 | 3 | 4 + memberHouse: 0 | 1 + hasVoted: boolean +} + +function buildMockReceipt(status: 'success' | 'reverted', hash: Hex) { + return { + blockHash: `0x${'b'.repeat(64)}`, + blockNumber: '0x10', + contractAddress: null, + cumulativeGasUsed: '0x5208', + effectiveGasPrice: '0x1', + from: MOCK_ACCOUNT, + gasUsed: '0x5208', + logs: [], + logsBloom: `0x${'0'.repeat(512)}`, + status: status === 'reverted' ? '0x0' : '0x1', + to: MOCK_HOUSES, + transactionHash: hash, + transactionIndex: '0x0', + type: '0x2', + } +} + +function buildMockBlock() { + return { + baseFeePerGas: '0x0', + difficulty: '0x0', + extraData: '0x', + gasLimit: '0x1c9c380', + gasUsed: '0x0', + hash: `0x${'b'.repeat(64)}`, + logsBloom: `0x${'0'.repeat(512)}`, + miner: MOCK_HOUSES, + mixHash: `0x${'c'.repeat(64)}`, + nonce: '0x0000000000000000', + number: '0x10', + parentHash: `0x${'d'.repeat(64)}`, + receiptsRoot: `0x${'e'.repeat(64)}`, + sha3Uncles: `0x${'f'.repeat(64)}`, + size: '0x0', + stateRoot: `0x${'1'.repeat(64)}`, + timestamp: `0x${MOCK_NOW_SECONDS.toString(16)}`, + totalDifficulty: '0x0', + transactions: [], + transactionsRoot: `0x${'2'.repeat(64)}`, + uncles: [], + } +} + +function buildMockFundingStreams() { + return { + data: { + streams: [ + { + sender: { id: MOCK_CITIZEN.toLowerCase() }, + currentFlowRate: '0', + streamedUntilUpdatedAt: '300000000000000000000', + updatedAtTimestamp: String(MOCK_NOW_SECONDS), + }, + { + sender: { id: MOCK_ALIGNMENT.toLowerCase() }, + currentFlowRate: '1', + streamedUntilUpdatedAt: '150000000000000000000', + updatedAtTimestamp: String(MOCK_NOW_SECONDS), + }, + ], + }, + } +} + +function requestUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') return input + if (input instanceof URL) return input.href + return input.url +} + +export interface InteractiveGovernanceEnvironment { + provider: EIP1193Provider + celoRpcUrl: string + addresses: { housesAddress: Address; goodIdAddress: Address; gTokenAddress: Address } + teardown: () => void +} + +/** + * Wires a self-contained, browser-native mocked Celo RPC and Superfluid + * subgraph behind a `window.fetch` override, paired with a matching mock + * EIP-1193 wallet. This lets a human open the story directly in Storybook + * and drive the real `useGovernanceAdapter` runtime end-to-end (onboarding -> + * vote -> unstake) without a live contract or Playwright's `page.route` + * network interception, which only runs under automation. + */ +export function createInteractiveGovernanceEnvironment(): InteractiveGovernanceEnvironment { + const session: InteractiveGovernanceSession = { memberStatus: 0, memberHouse: 0, hasVoted: false } + const pendingEffectsByHash = new Map() + const listeners: Record void>> = {} + const originalFetch = window.fetch.bind(window) + let transactionCounter = 0 + + const nextTransactionHash = (): Hex => { + transactionCounter += 1 + return `0x${transactionCounter.toString(16).padStart(64, '0')}` as Hex + } + + const applyReceiptEffect = (effect: PendingTransactionEffect) => { + if (effect.kind === 'registration') { + session.memberStatus = 2 + session.memberHouse = effect.house + } else if (effect.kind === 'vote') { + session.hasVoted = true + } else if (effect.kind === 'unstake') { + session.memberStatus = 4 + } + } + + const provider = { + async request({ method, params }: { method: string; params?: unknown }) { + switch (method) { + case 'eth_requestAccounts': + case 'eth_accounts': + return [MOCK_ACCOUNT] + case 'eth_chainId': + return '0xa4ec' + case 'wallet_switchEthereumChain': + return null + case 'eth_estimateGas': + return '0x5208' + case 'eth_sendTransaction': { + const tx = (params as Array>)?.[0] ?? {} + const to = String(tx.to ?? '').toLowerCase() + const data = tx.data as Hex + const hash = nextTransactionHash() + + if (to === MOCK_HOUSES.toLowerCase()) { + const decoded = decodeFunctionData({ abi: HOUSES_WRITE_ABI, data }) + if (decoded.functionName === 'castVote') pendingEffectsByHash.set(hash, { kind: 'vote' }) + if (decoded.functionName === 'unstake') pendingEffectsByHash.set(hash, { kind: 'unstake' }) + } else if (to === MOCK_G_TOKEN.toLowerCase()) { + const decoded = decodeFunctionData({ abi: G_TOKEN_WRITE_ABI, data }) + if (decoded.functionName === 'transferAndCall') { + const registrationData = decoded.args[2] + const [house] = decodeAbiParameters(REGISTRATION_DATA_TYPES, registrationData) + pendingEffectsByHash.set(hash, { kind: 'registration', house: Number(house) === 1 ? 1 : 0 }) + } + } + return hash + } + default: + throw new Error(`Interactive governance mock: unsupported wallet method "${method}"`) + } + }, + on(event: string, listener: (...args: unknown[]) => void) { + listeners[event] = [...(listeners[event] ?? []), listener] + }, + removeListener(event: string, listener: (...args: unknown[]) => void) { + listeners[event] = (listeners[event] ?? []).filter((entry) => entry !== listener) + }, + } as EIP1193Provider + + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input) + + if (url.includes(MOCK_GOVERNANCE_RPC_PATH)) { + const payload = JSON.parse(String(init?.body ?? '{}')) as { + id: number + method: string + params?: unknown[] + } + const respond = (result: unknown) => + new Response(JSON.stringify({ jsonrpc: '2.0', id: payload.id, result }), { + headers: { 'content-type': 'application/json' }, + }) + + if (payload.method === 'eth_getTransactionReceipt') { + const hash = payload.params?.[0] as Hex + const effect = pendingEffectsByHash.get(hash) + if (effect) applyReceiptEffect(effect) + return respond(buildMockReceipt('success', hash)) + } + if (payload.method === 'eth_getBlockByNumber') return respond(buildMockBlock()) + if (payload.method === 'eth_blockNumber') return respond('0x10') + if (payload.method !== 'eth_call') return respond('0x') + + const call = (payload.params?.[0] as { to?: Address; data?: Hex } | undefined) ?? {} + if (!call.to || !call.data) return respond('0x') + const result = encodeMockGovernanceRead(call.to, call.data, { + memberStatusByAccount: { [MOCK_ACCOUNT.toLowerCase()]: session.memberStatus }, + memberHouseByAccount: { [MOCK_ACCOUNT.toLowerCase()]: session.memberHouse }, + hasVotedByVoter: { [MOCK_ACCOUNT.toLowerCase()]: session.hasVoted }, + }) + return respond(result) + } + + if (url.includes(MOCK_SUPERFLUID_URL_FRAGMENT)) { + return new Response(JSON.stringify(buildMockFundingStreams()), { + headers: { 'content-type': 'application/json' }, + }) + } + + return originalFetch(input, init) + }) as typeof window.fetch + + return { + provider, + celoRpcUrl: MOCK_GOVERNANCE_RPC_PATH, + addresses: { housesAddress: MOCK_HOUSES, goodIdAddress: MOCK_GOOD_ID, gTokenAddress: MOCK_G_TOKEN }, + teardown: () => { + window.fetch = originalFetch + }, + } +} diff --git a/examples/storybook/src/fixtures/governanceRuntimeMock.ts b/examples/storybook/src/fixtures/governanceRuntimeMock.ts index 656703e8..049d58b2 100644 --- a/examples/storybook/src/fixtures/governanceRuntimeMock.ts +++ b/examples/storybook/src/fixtures/governanceRuntimeMock.ts @@ -31,11 +31,15 @@ export const MOCK_GOOD_ID = '0x5555555555555555555555555555555555555555' as Addr export const MOCK_CITIZEN = '0x6666666666666666666666666666666666666666' as Address export const MOCK_ALIGNMENT = '0x7777777777777777777777777777777777777777' as Address export const MOCK_POOL = '0x8888888888888888888888888888888888888888' as Address +export const MOCK_G_TOKEN = '0x9999999999999999999999999999999999999999' as Address export interface MockGovernanceReadOptions { memberStatus?: 0 | 1 | 2 | 3 | 4 memberStatusByAccount?: Record memberHouseByAccount?: Record + // Keyed by voter address, lowercased. Lets an interactive session reflect a + // just-submitted vote without needing a real per-voteId ledger. + hasVotedByVoter?: Record } export function encodeMockGovernanceRead( @@ -148,12 +152,14 @@ export function encodeMockGovernanceRead( functionName: 'getVoteRecipients', result: [MOCK_ALIGNMENT], }) - case 'getHasVoted': + case 'getHasVoted': { + const voter = String(decoded.args[1]).toLowerCase() return encodeFunctionResult({ abi: HOUSES_READ_ABI, functionName: 'getHasVoted', - result: false, + result: options.hasVotedByVoter?.[voter] ?? false, }) + } case 'getFinalizedUnits': return encodeFunctionResult({ abi: HOUSES_READ_ABI, @@ -167,6 +173,9 @@ export function encodeMockGovernanceRead( result: [MOCK_HOUSES, 1n, MOCK_POOL], }) default: - throw new Error(`Unexpected houses read: ${decoded.functionName}`) + // Every HOUSES_READ_ABI function is handled above, so this branch is unreachable at + // the type level (decoded narrows to `never`) but kept as a runtime guard against a + // future ABI addition that isn't wired into this mock yet. + throw new Error(`Unexpected houses read call data: ${data}`) } } diff --git a/examples/storybook/src/stories/governance-widget/GovernanceDashboard.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceDashboard.stories.tsx deleted file mode 100644 index 37b41f1e..00000000 --- a/examples/storybook/src/stories/governance-widget/GovernanceDashboard.stories.tsx +++ /dev/null @@ -1,359 +0,0 @@ -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: 'padded', - 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, - 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/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx index a73695ff..8d4e7875 100644 --- a/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx +++ b/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx @@ -277,8 +277,7 @@ export const CustodialAlignmentProfileError: Story = { }, initialFieldErrors: { projectWebpage: 'Project webpage is required', - missionStatement: 'Mission statement is required', - distributionStrategy: 'Distribution strategy is required', + missionStatement: 'Discourse link for mission statement and distribution strategy is required', }, dataTestId: 'GovernanceOnboardingWidget-alignment-profile-error', }} @@ -416,8 +415,7 @@ export const CustodialMobileDarkProfile: Story = { initialProfileDraft: { name: 'Solar Commons' }, initialFieldErrors: { projectWebpage: 'Project webpage is required', - missionStatement: 'Mission statement is required', - distributionStrategy: 'Distribution strategy is required', + missionStatement: 'Discourse link for mission statement and distribution strategy is required', }, }} /> diff --git a/examples/storybook/src/stories/governance-widget/GovernanceWidget.mdx b/examples/storybook/src/stories/governance-widget/GovernanceWidget.mdx new file mode 100644 index 00000000..f9ff004d --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceWidget.mdx @@ -0,0 +1,108 @@ +import { Canvas, Meta, Source } from '@storybook/blocks'; +import * as ShowcaseStories from './GovernanceWidgetShowcase.stories'; +import * as ThemeOverridesStories from './GovernanceWidgetThemeOverrides.stories'; +import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection } from '../docs/DocsLayout'; + + + + + + + + + + + + + + + + + + + + + ) +}`} + /> + + + + + Connects a mock wallet and mock RPC/subgraph directly to the real adapter — the only QA + story that exercises the runtime rather than a static state. + + + + + + Disconnected, onboarding, active membership, voting, unstaking, and error states all live in + `QA / GovernanceWidget / Runtime Fixtures`. + + + + + + + Real wallet, real GoodDaoHouses contract, no mocked reads or writes. + + + Static dashboard fixtures for screenshots and automation, plus one live-mocked-data story + for driving the real runtime by hand. + + + + + + + Use the showcase story for product-facing wallet checks against the real contract. Use the + QA fixtures for repeatable screenshots and state coverage, and the live mocked-data flow when + you need to manually exercise the real runtime without a live contract. + + + diff --git a/examples/storybook/src/stories/governance-widget/GovernanceRuntime.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceWidgetQA.stories.tsx similarity index 60% rename from examples/storybook/src/stories/governance-widget/GovernanceRuntime.stories.tsx rename to examples/storybook/src/stories/governance-widget/GovernanceWidgetQA.stories.tsx index ff1d89fb..c21dd967 100644 --- a/examples/storybook/src/stories/governance-widget/GovernanceRuntime.stories.tsx +++ b/examples/storybook/src/stories/governance-widget/GovernanceWidgetQA.stories.tsx @@ -1,21 +1,27 @@ -import React from 'react' +import React, { useEffect, useRef } from 'react' import type { Meta, StoryObj } from '@storybook/react' import { Card, Text, YStack } from '@goodwidget/ui' -import { - GovernanceWidget, - type GovernanceWidgetAdapterFactory, - type GovernanceWidgetAdapterState, - type GovernanceWidgetStatus, -} from '@goodwidget/governance-widget' +import { GovernanceWidget, type GovernanceWidgetAdapterState } from '@goodwidget/governance-widget' import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' import { getInjectedEip1193Provider, isInjectedProviderUsable, } from '../../fixtures/injectedEip1193' +import { + createInteractiveGovernanceEnvironment, + type InteractiveGovernanceEnvironment, +} from '../../fixtures/governanceInteractiveMock' +import { + alignmentRecipients, + createAdapterFactory, + createDashboard, + createState, +} from '../helpers/governanceWidgetStories' const meta: Meta = { - title: 'QA/GovernanceWidget Runtime Fixtures', + title: 'QA/GovernanceWidget/Runtime Fixtures', component: GovernanceWidget, + tags: ['autodocs', 'qa'], parameters: { layout: 'padded', goodWidgetProvider: { useShell: false, useProvider: false }, @@ -25,153 +31,6 @@ const meta: Meta = { export default meta type Story = StoryObj -const connectedAddress = '0x4E5B2D7a45C2e31a8F0d09b4bE1fA11aD3aC9F08' as const -const alignmentRecipients = [ - '0x1111111111111111111111111111111111111111', - '0x2222222222222222222222222222222222222222', - '0x3333333333333333333333333333333333333333', -] as const - -function createDashboard( - overrides: Partial = {}, -): GovernanceWidgetAdapterState['dashboard'] { - return { - impact: { - title: 'Distributed', - metrics: [ - { label: 'UBI Pool', amount: { value: 12400000, token: 'G$' } }, - { - label: 'Impact Pool', - amount: { value: 5234891, token: 'G$', isStreaming: true, streamLabel: 'Live stream active' }, - }, - ], - description: - 'Empowering 640k+ people worldwide through transparent, decentralized funding for public goods.', - ctaLabel: 'View Impact Report Q3', - }, - activeMembers: { - icon: 'check' as const, - title: 'Active Members', - amount: 12402, - amountType: 'raw' as const, - metadataType: 'time-window' as const, - metadata: { label: 'Active members only', tone: 'muted' as const, icon: 'info' as const }, - }, - alignmentVoting: { - voteId: 'alignment-current', - title: 'Q3 House Of Alignment Funding Allocation', - summaryLabel: 'Current top 3 voted', - options: [ - { id: alignmentRecipients[0], label: 'Local Food Chain', percentage: 42 }, - { id: alignmentRecipients[1], label: 'Web3 Literacy', percentage: 31 }, - { id: alignmentRecipients[2], label: 'Civic Onboarding', percentage: 27 }, - ], - recipients: [...alignmentRecipients], - allocationsBps: { - [alignmentRecipients[0]]: 4200, - [alignmentRecipients[1]]: 3100, - [alignmentRecipients[2]]: 2700, - }, - allocationTotalBps: 10000, - canVote: false, - hasVoted: false, - isVotingOpen: true, - executed: false, - finalizedUnits: {}, - disabledReason: 'Only active House of Alignment members can vote.', - }, - fundingDistribution: { - title: 'Funding distribution', - centerLabel: 'Mocked pool total', - totalAmount: { value: 450000, token: 'G$', isStreaming: true, streamLabel: 'Mock pool data' }, - projects: [ - { 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 }, - ], - isStreaming: true, - emptyStateLabel: 'No active funding distribution yet.', - }, - ...overrides, - } -} - -function createState( - status: GovernanceWidgetStatus, - overrides: Partial = {}, -): GovernanceWidgetAdapterState { - const isConnected = status !== 'disconnected' - const member: GovernanceWidgetAdapterState['member'] = - status === 'active_citizenship' || status === 'active_alignment' || status === 'revoked' - ? { - house: status === 'active_alignment' ? 'alignment' : 'citizenship', - status: status === 'revoked' ? 'revoked' : 'active', - stakedAmount: 250000000000000000000n, - joinedAt: Date.UTC(2026, 0, 10), - updatedAt: Date.UTC(2026, 2, 1), - unstakedAt: null, - memberIndex: 0n, - name: status === 'active_alignment' ? 'Solar Commons' : 'Maya Citizen', - socialLinks: 'https://twitter.com/gooddollar', - projectWebpage: 'https://solar.example', - missionStatement: 'Expand regenerative local access.', - distributionStrategy: 'Allocate quarterly grants through community review.', - } - : null - - return { - status, - address: isConnected ? connectedAddress : null, - chainId: status === 'unsupported_chain' ? 1 : 42220, - identityStatus: status === 'onboarding_required' ? 'unverified' : 'verified', - identityVerificationUrl: null, - member, - dashboard: createDashboard(), - selectedHouse: 'citizenship', - onboardingStepId: undefined, - profileDraft: {}, - stakeAmountLabel: '250 G$', - minimumStakeAmounts: { citizenship: 250000000000000000000n, alignment: 500000000000000000000n }, - transactionSteps: [ - { id: 'prepare', title: 'Prepare wallet balance', status: 'completed' }, - { id: 'approve', title: 'Approve governance stake', status: 'active' }, - { id: 'stake', title: 'Lock the membership stake', status: 'pending' }, - { id: 'finalize', title: 'Finalize governance access', status: 'pending' }, - ], - registrationHash: null, - transaction: { kind: null, status: 'idle', hash: null, error: null }, - unstakeAvailability: { - canUnstake: false, - unlockAt: Date.UTC(2026, 8, 1, 12), - disabledReason: 'Membership remains locked until the current governance term has passed.', - }, - lifecycleNotice: null, - error: null, - ...overrides, - } -} - -function createAdapterFactory(state: GovernanceWidgetAdapterState): GovernanceWidgetAdapterFactory { - return () => ({ - state, - actions: { - connect: async () => {}, - switchToCelo: async () => {}, - refresh: async () => {}, - retry: async () => {}, - selectHouse: () => {}, - register: async () => {}, - unstake: async () => {}, - openVote: () => {}, - closeVote: () => {}, - setVoteAllocation: () => {}, - submitVote: async () => {}, - startIdentityVerification: async () => {}, - }, - }) -} - function RuntimeStory({ state, defaultTheme = 'light', @@ -206,6 +65,30 @@ function RuntimeStory({ ) } +// Uses the real useGovernanceAdapter runtime (no adapterFactory override) against a +// browser-native mocked Celo RPC + Superfluid subgraph, so a human can drive the full +// onboarding -> vote -> unstake flow directly in Storybook, not just under Playwright. +function LiveMockedDataFlowStory() { + const environmentRef = useRef(null) + if (!environmentRef.current) environmentRef.current = createInteractiveGovernanceEnvironment() + + useEffect(() => { + const environment = environmentRef.current + return () => environment?.teardown() + }, []) + + const { provider, celoRpcUrl, addresses } = environmentRef.current + + return ( + + ) +} + export const DisconnectedDashboard: Story = { render: () => , } @@ -256,7 +139,7 @@ export const ActiveAlignmentInjected: Story = { render: () => ( { const injectedProvider = getInjectedEip1193Provider() @@ -489,3 +377,11 @@ export const RealAdapterMockedRuntime: Story = { ) }, } + +// The live testable flow with mocked data: separated from GovernanceWidgetShowcase (which +// always uses a real wallet against the real contract), and separated from the static +// fixtures above (which never touch useGovernanceAdapter). Self-contained mocked RPC + +// wallet, so a human can drive it directly in Storybook without Playwright. +export const LiveMockedDataFlow: Story = { + render: () => , +} diff --git a/examples/storybook/src/stories/governance-widget/GovernanceWidgetShowcase.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceWidgetShowcase.stories.tsx new file mode 100644 index 00000000..3720e109 --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceWidgetShowcase.stories.tsx @@ -0,0 +1,218 @@ +import { useCallback, useMemo, useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Card, Text, YStack } from '@goodwidget/ui' +import { + GovernanceWidget, + type GovernanceWidgetAdapterActions, + type GovernanceWidgetAdapterState, +} from '@goodwidget/governance-widget' +import { + getInjectedEip1193Provider, + isInjectedProviderUsable, +} from '../../fixtures/injectedEip1193' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' +import { + alignmentRecipients, + createDashboard, + createState, +} from '../helpers/governanceWidgetStories' + +// The GoodDaoHouses contract is not yet on the production Celo deployment — this is the +// `development-celo` address recorded in GoodProtocol PR #300 (GoodProtocol PR #299 has the +// final contract build this widget targets). FlowSplitter isn't wired to this deployment yet, +// so the funding-distribution chart is expected to render its empty state here. +const DEV_CELO_HOUSES_ADDRESS = '0x4Bc3Cdc036f21b68E034C0f1d90775fc3D725735' as const + +interface GovernanceWidgetStoryArgs { + defaultTheme: 'light' | 'dark' +} + +const meta: Meta = { + title: 'Widgets/GovernanceWidget/Showcase', + component: GovernanceWidget, + tags: ['integrator', 'manual', 'showcase'], + parameters: { layout: 'padded' }, + argTypes: { + defaultTheme: { + control: 'radio', + options: ['dark', 'light'], + description: 'Base theme applied via the widget’s own defaultTheme prop.', + }, + }, + args: { + defaultTheme: 'light', + }, +} + +export default meta +type Story = StoryObj + +function InjectedWalletStory({ defaultTheme }: GovernanceWidgetStoryArgs) { + const injectedProvider = getInjectedEip1193Provider() + + if (!isInjectedProviderUsable(injectedProvider)) { + return ( + + + No injected wallet found + + Install or enable an injected EIP-1193 wallet on Celo, then refresh Storybook. + + + + ) + } + + return ( + + ) +} + +function CustodialWalletStory({ defaultTheme }: GovernanceWidgetStoryArgs) { + try { + const provider = createCustodialEip1193Provider() + + return ( + + ) + } catch (error: unknown) { + return ( + + + Custodial fixture not configured + + {error instanceof Error ? error.message : 'Set a local private key in custodialEip1193.ts'} + + + + ) + } +} + +const activeDemoVote: GovernanceWidgetAdapterState['dashboard']['alignmentVoting'] = { + ...createDashboard().alignmentVoting, + voteId: 'alignment-active-demo', + title: 'House of Alignment Community Grants', + summaryLabel: 'Voting open · 2 days remaining', + options: [ + { id: alignmentRecipients[0], label: 'Local Food Chain', percentage: 42 }, + { id: alignmentRecipients[1], label: 'Web3 Literacy', percentage: 31 }, + { id: alignmentRecipients[2], label: 'Civic Onboarding', percentage: 27 }, + ], + recipients: [...alignmentRecipients], + allocationsBps: { + [alignmentRecipients[0]]: 4200, + [alignmentRecipients[1]]: 3000, + [alignmentRecipients[2]]: 2000, + }, + allocationTotalBps: 9200, + canVote: true, + hasVoted: false, + isVotingOpen: true, + executed: false, + finalizedUnits: {}, + disabledReason: undefined, +} + +const previousDemoVote: GovernanceWidgetAdapterState['dashboard']['alignmentVoting'] = { + ...activeDemoVote, + voteId: 'alignment-previous-demo', + title: 'Previous Round: Regional Access Grants', + summaryLabel: 'Final units executed', + options: [ + { id: alignmentRecipients[0], label: 'Local Food Chain', percentage: 50 }, + { id: alignmentRecipients[1], label: 'Web3 Literacy', percentage: 30 }, + { id: alignmentRecipients[2], label: 'Civic Onboarding', percentage: 20 }, + ], + allocationsBps: {}, + allocationTotalBps: 0, + canVote: false, + hasVoted: true, + isVotingOpen: false, + executed: true, + finalizedUnits: { + [alignmentRecipients[0]]: '500000', + [alignmentRecipients[1]]: '300000', + [alignmentRecipients[2]]: '200000', + }, + disabledReason: 'This vote has already been executed.', +} + +function DemoGovernanceWidget({ defaultTheme }: GovernanceWidgetStoryArgs) { + const initialState = useMemo( + () => createState('active_alignment', { + dashboard: createDashboard({ + alignmentVoting: activeDemoVote, + alignmentVotingHistory: [previousDemoVote], + }), + }), + [], + ) + const [state, setState] = useState(initialState) + + const setVoteAllocation = useCallback((recipientId: string, basisPoints: number) => { + setState((previous) => { + const voting = previous.dashboard.alignmentVoting + if (!(recipientId in voting.allocationsBps)) return previous + const allocationsBps = { + ...voting.allocationsBps, + [recipientId]: Math.max(0, Math.min(10_000, Math.trunc(basisPoints))), + } + return { + ...previous, + dashboard: { + ...previous.dashboard, + alignmentVoting: { + ...voting, + allocationsBps, + allocationTotalBps: Object.values(allocationsBps).reduce((total, amount) => total + amount, 0), + }, + }, + } + }) + }, []) + + const actions = useMemo(() => ({ + connect: async () => {}, + switchToCelo: async () => {}, + refresh: async () => {}, + retry: async () => {}, + selectHouse: () => {}, + register: async () => {}, + unstake: async () => {}, + openVote: () => setState((previous) => ({ ...previous, status: 'vote_detail' })), + closeVote: () => setState((previous) => ({ ...previous, status: 'active_alignment' })), + setVoteAllocation, + submitVote: async () => {}, + startIdentityVerification: async () => {}, + }), [setVoteAllocation]) + + const adapterFactory = useCallback(() => ({ state, actions }), [actions, state]) + + return +} + +// Real wallet, real dev-celo GoodDaoHouses contract, no mocked reads or writes — this is the +// live integrator-facing surface, deliberately kept separate from the QA fixtures/mocked flow. +export const InjectedWallet: Story = { + render: ({ defaultTheme }) => , +} + +export const CustodialWallet: Story = { + tags: ['!dev'], + render: ({ defaultTheme }) => , +} + +export const Demo: Story = { + render: ({ defaultTheme }) => , +} diff --git a/examples/storybook/src/stories/governance-widget/GovernanceWidgetThemeOverrides.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceWidgetThemeOverrides.stories.tsx new file mode 100644 index 00000000..4ab69bab --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceWidgetThemeOverrides.stories.tsx @@ -0,0 +1,154 @@ +/** + * GovernanceWidget — Theme Overrides — demonstrates the widget's public theming + * surface as live color-picker controls. The code snippet is generated from the + * live arg values, so it can never drift from what's rendered. + * + * GovernanceWidget's own named theme components (packages/governance-widget/src/shared.tsx) + * are `GovernanceWrapper` (the card shell every section — impact, alignment voting, + * optimistic voting, funding distribution — renders inside of) and `ImpactCard` / + * `ImpactCardAction` (the impact summary card and its call-to-action button). All other + * governance surfaces reuse shared @goodwidget/ui components (`BalanceCard`, `Button`) + * that already have theme keys wired for other widgets — those are documented as + * reference-only below since they aren't governance-specific. + * + * Controls are wired for `dark_GovernanceWrapper` and `dark_ImpactCard` / + * `dark_ImpactCardAction` — the handful of high-impact targets that visibly shift the + * default brand, not exhaustive coverage of every value. + */ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import type { GoodWidgetThemeOverrides } from '@goodwidget/core' +import { ThemedDashboardStory } from '../helpers/governanceWidgetStories' +import { DocsCallout, DocsList } from '../docs/DocsLayout' + +const REFERENCE_ONLY_TARGETS: Array<{ name: string; fields: string[] }> = [ + { name: 'BalanceCard', fields: ['background', 'borderColor', 'shadowColor'] }, + { name: 'Button', fields: ['background', 'color', 'borderColor'] }, +] + +function CodeBlock({ children }: { children: string }) { + return ( +
+      {children}
+    
+ ) +} + +interface OverridesArgs { + defaultTheme: 'light' | 'dark' + wrapperBorderColor: string + wrapperShadowColor: string + impactCardBackground: string + impactCardActionBackground: string +} + +function buildThemeOverrides(args: OverridesArgs): GoodWidgetThemeOverrides { + const componentThemes = { + GovernanceWrapper: { + borderColor: args.wrapperBorderColor, + shadowColor: args.wrapperShadowColor, + }, + ImpactCard: { + background: args.impactCardBackground, + }, + ImpactCardAction: { + white: args.impactCardActionBackground, + }, + } + + return { + themes: { + dark_GovernanceWrapper: componentThemes.GovernanceWrapper, + light_GovernanceWrapper: componentThemes.GovernanceWrapper, + dark_ImpactCard: componentThemes.ImpactCard, + light_ImpactCard: componentThemes.ImpactCard, + dark_ImpactCardAction: componentThemes.ImpactCardAction, + light_ImpactCardAction: componentThemes.ImpactCardAction, + }, + } +} + +const meta: Meta = { + title: 'Widgets/GovernanceWidget/Theme overrides', + tags: ['integrator', 'showcase'], + parameters: { layout: 'padded' }, + argTypes: { + defaultTheme: { + control: 'radio', + options: ['light', 'dark'], + description: 'Base theme applied via the widget’s own defaultTheme prop.', + }, + wrapperBorderColor: { control: 'color', description: 'themes.dark_GovernanceWrapper.borderColor' }, + wrapperShadowColor: { control: 'color', description: 'themes.dark_GovernanceWrapper.shadowColor' }, + impactCardBackground: { control: 'color', description: 'themes.dark_ImpactCard.backgroundColor' }, + impactCardActionBackground: { + control: 'color', + description: 'themes.dark_ImpactCardAction.backgroundColor', + }, + }, + args: { + defaultTheme: 'light', + wrapperBorderColor: '#7C3AED', + wrapperShadowColor: '#7C3AED', + impactCardBackground: '#1E1B4B', + impactCardActionBackground: '#7C3AED', + }, +} +export default meta +type Story = StoryObj + +export const Playground: Story = { + render: (args) => { + const themeOverrides = buildThemeOverrides(args) + return ( +
+ + {``} + + + + +
  • + dark_GovernanceWrapper / light_GovernanceWrapper: backgroundColor, + borderColor, color, shadowColor — wired to the controls above (borderColor and + shadowColor only) +
  • +
  • + dark_ImpactCard / light_ImpactCard: backgroundColor, borderColor, + color — wired to the controls above (backgroundColor only) +
  • +
  • + dark_ImpactCardAction / light_ImpactCardAction: backgroundColor, + color — wired to the controls above (backgroundColor only) +
  • + {REFERENCE_ONLY_TARGETS.map((target) => ( +
  • + + dark_{target.name} / light_{target.name} + + : {target.fields.join(', ')} — shared with other widgets, not governance-specific +
  • + ))} +
    +
    + + +
    + ) + }, +} diff --git a/examples/storybook/src/stories/helpers/governanceWidgetStories.tsx b/examples/storybook/src/stories/helpers/governanceWidgetStories.tsx new file mode 100644 index 00000000..9506ae54 --- /dev/null +++ b/examples/storybook/src/stories/helpers/governanceWidgetStories.tsx @@ -0,0 +1,185 @@ +import type { GoodWidgetThemeOverrides } from '@goodwidget/core' +import { + GovernanceWidget, + type GovernanceWidgetAdapterFactory, + type GovernanceWidgetAdapterState, + type GovernanceWidgetStatus, +} from '@goodwidget/governance-widget' + +const connectedAddress = '0x4E5B2D7a45C2e31a8F0d09b4bE1fA11aD3aC9F08' as const +export const alignmentRecipients = [ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + '0x3333333333333333333333333333333333333333', +] as const + +export function createDashboard( + overrides: Partial = {}, +): GovernanceWidgetAdapterState['dashboard'] { + return { + impact: { + title: 'Distributed', + metrics: [ + { label: 'UBI Pool', amount: { value: 12400000, token: 'G$' } }, + { + label: 'Impact Pool', + amount: { value: 5234891, token: 'G$', isStreaming: true, streamLabel: 'Live stream active' }, + }, + ], + description: + 'Empowering 640k+ people worldwide through transparent, decentralized funding for public goods.', + ctaLabel: 'View Impact Report Q3', + }, + activeMembers: { + icon: 'check' as const, + title: 'Active Members', + amount: 12402, + amountType: 'raw' as const, + metadataType: 'time-window' as const, + metadata: { label: 'Active members only', tone: 'muted' as const, icon: 'info' as const }, + }, + alignmentVoting: { + voteId: 'alignment-current', + title: 'Q3 House Of Alignment Funding Allocation', + summaryLabel: 'Current top 3 voted', + options: [ + { id: alignmentRecipients[0], label: 'Local Food Chain', percentage: 42 }, + { id: alignmentRecipients[1], label: 'Web3 Literacy', percentage: 31 }, + { id: alignmentRecipients[2], label: 'Civic Onboarding', percentage: 27 }, + ], + recipients: [...alignmentRecipients], + allocationsBps: { + [alignmentRecipients[0]]: 4200, + [alignmentRecipients[1]]: 3100, + [alignmentRecipients[2]]: 2700, + }, + allocationTotalBps: 10000, + canVote: false, + hasVoted: false, + isVotingOpen: true, + executed: false, + finalizedUnits: {}, + disabledReason: 'Only active House of Alignment members can vote.', + }, + fundingDistribution: { + title: 'Funding distribution', + centerLabel: 'Mocked pool total', + totalAmount: { value: 450000, token: 'G$', isStreaming: true, streamLabel: 'Mock pool data' }, + projects: [ + { 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 }, + ], + isStreaming: true, + emptyStateLabel: 'No active funding distribution yet.', + }, + ...overrides, + } +} + +export function createState( + status: GovernanceWidgetStatus, + overrides: Partial = {}, +): GovernanceWidgetAdapterState { + const isConnected = status !== 'disconnected' + const member: GovernanceWidgetAdapterState['member'] = + status === 'active_citizenship' || status === 'active_alignment' || status === 'revoked' + ? { + house: status === 'active_alignment' ? 'alignment' : 'citizenship', + status: status === 'revoked' ? 'revoked' : 'active', + stakedAmount: 250000000000000000000n, + joinedAt: Date.UTC(2026, 0, 10), + updatedAt: Date.UTC(2026, 2, 1), + unstakedAt: null, + memberIndex: 0n, + name: status === 'active_alignment' ? 'Solar Commons' : 'Maya Citizen', + socialLinks: 'https://twitter.com/gooddollar', + projectWebpage: 'https://solar.example', + missionStatement: 'Expand regenerative local access.', + distributionStrategy: 'Allocate quarterly grants through community review.', + } + : null + + return { + status, + address: isConnected ? connectedAddress : null, + chainId: status === 'unsupported_chain' ? 1 : 42220, + identityStatus: status === 'onboarding_required' ? 'unverified' : 'verified', + identityVerificationUrl: null, + member, + dashboard: createDashboard(), + selectedHouse: 'citizenship', + onboardingStepId: undefined, + profileDraft: {}, + stakeAmountLabel: '250 G$', + minimumStakeAmounts: { citizenship: 250000000000000000000n, alignment: 500000000000000000000n }, + transactionSteps: [ + { id: 'prepare', title: 'Prepare wallet balance', status: 'completed' }, + { id: 'approve', title: 'Approve governance stake', status: 'active' }, + { id: 'stake', title: 'Lock the membership stake', status: 'pending' }, + { id: 'finalize', title: 'Finalize governance access', status: 'pending' }, + ], + registrationHash: null, + transaction: { kind: null, status: 'idle', hash: null, error: null }, + unstakeAvailability: { + canUnstake: false, + unlockAt: Date.UTC(2026, 8, 1, 12), + disabledReason: 'Membership remains locked until the current governance term has passed.', + }, + lifecycleNotice: null, + error: null, + ...overrides, + } +} + +export function createAdapterFactory(state: GovernanceWidgetAdapterState): GovernanceWidgetAdapterFactory { + return () => ({ + state, + actions: { + connect: async () => {}, + switchToCelo: async () => {}, + refresh: async () => {}, + retry: async () => {}, + selectHouse: () => {}, + register: async () => {}, + unstake: async () => {}, + openVote: () => {}, + closeVote: () => {}, + setVoteAllocation: () => {}, + submitVote: async () => {}, + startIdentityVerification: async () => {}, + }, + }) +} + +// A single fully-populated dashboard state (active alignment member, open vote, live funding +// distribution) used by the theme-overrides Playground so every themeable governance surface +// (GovernanceWrapper, ImpactCard, ImpactCardAction, the shared BalanceCard/Button) renders at +// once behind a mocked adapterFactory — no wallet or network required. +export function ThemedDashboardStory({ + themeOverrides, + defaultTheme = 'dark', +}: { + themeOverrides?: GoodWidgetThemeOverrides + defaultTheme?: 'light' | 'dark' +}) { + const state = createState('active_alignment', { + dashboard: createDashboard({ + alignmentVoting: { + ...createDashboard().alignmentVoting, + canVote: true, + disabledReason: undefined, + }, + }), + }) + + return ( + + ) +} diff --git a/packages/governance-widget/src/AlignmentVotingProposalCard.tsx b/packages/governance-widget/src/AlignmentVotingProposalCard.tsx index eb1bfbdb..288f1be9 100644 --- a/packages/governance-widget/src/AlignmentVotingProposalCard.tsx +++ b/packages/governance-widget/src/AlignmentVotingProposalCard.tsx @@ -35,7 +35,6 @@ export function AlignmentVotingProposalCard({ return ( onPress(id) : undefined} diff --git a/packages/governance-widget/src/BalanceCard.tsx b/packages/governance-widget/src/BalanceCard.tsx index bb0ea7ba..615c03f1 100644 --- a/packages/governance-widget/src/BalanceCard.tsx +++ b/packages/governance-widget/src/BalanceCard.tsx @@ -26,7 +26,6 @@ export function BalanceCard({ return ( diff --git a/packages/governance-widget/src/FundingDistributionChart.tsx b/packages/governance-widget/src/FundingDistributionChart.tsx index 5f1902eb..bc1f13e3 100644 --- a/packages/governance-widget/src/FundingDistributionChart.tsx +++ b/packages/governance-widget/src/FundingDistributionChart.tsx @@ -166,7 +166,7 @@ function FundingDistributionChartContent({ export function FundingDistributionChart(props: FundingDistributionChartProps) { return ( - + ) diff --git a/packages/governance-widget/src/GovernanceWidget.tsx b/packages/governance-widget/src/GovernanceWidget.tsx index e1ecc7b3..77318584 100644 --- a/packages/governance-widget/src/GovernanceWidget.tsx +++ b/packages/governance-widget/src/GovernanceWidget.tsx @@ -1,5 +1,18 @@ -import { useMemo } from 'react' -import { Button, ButtonText, Card, Heading, Icon, Input, Spinner, Text, XStack, YStack } from '@goodwidget/ui' +import { createElement, useEffect, useMemo, useRef, useState } from 'react' +import { useWallet, WalletControls } from '@goodwidget/core' +import { + Button, + ButtonText, + Card, + Heading, + Icon, + resolveThemeColor, + Spinner, + Text, + XStack, + YStack, +} from '@goodwidget/ui' +import { useTheme } from 'tamagui' import { AlignmentVotingProposalCard } from './AlignmentVotingProposalCard' import { BalanceCard } from './BalanceCard' import { FundingDistributionChart } from './FundingDistributionChart' @@ -18,6 +31,8 @@ import { import { isActiveStatus } from './adapter' import { formatStakeAmount } from './sdks/contracts' +const GOVERNANCE_WIDGET_MAX_WIDTH = 480 + function formatMemberDate(timestamp: number | null): string { if (!timestamp) return 'Not available' return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format( @@ -43,7 +58,7 @@ function GovernanceHeader({ state: GovernanceWidgetAdapterState actions: GovernanceWidgetAdapterActions }) { - const addressLabel = state.address ? `${state.address.slice(0, 6)}…${state.address.slice(-4)}` : null + const { address: walletContextAddress } = useWallet() return ( GoodDAO - {state.address ? ( + {state.address && walletContextAddress ? ( + + ) : state.address ? ( - - Connected wallet - - {addressLabel} + Connected wallet + {`${state.address.slice(0, 6)}…${state.address.slice(-4)}`} ) : ( + + + + ) : null} {state.dashboard.alignmentVoting.options.length === 0 ? ( @@ -290,20 +373,78 @@ function RevokedState({ state }: { state: GovernanceWidgetAdapterState }) { ) } -function MemberFooter({ state }: { state: GovernanceWidgetAdapterState }) { - if (!state.member || !isActiveStatus(state.status)) return null +function GovernanceSignupBanner({ onResume }: { onResume: () => void }) { + return ( + + + Sign up and stake to participate in GoodDAO + + + + ) +} + +interface WidgetBounds { + left: number + width: number +} + +function MemberFooter({ + state, + bounds, +}: { + state: GovernanceWidgetAdapterState + bounds?: WidgetBounds +}) { + const memberStatus = state.member?.status ?? ( + state.status === 'disconnected' ? 'not connected' : + state.status === 'onboarding_required' ? 'onboarding' : + state.status === 'pending_alignment' ? 'pending approval' : + state.status === 'revoked' ? 'revoked' : 'not available' + ) + const house = state.member + ? state.member.house === 'alignment' ? 'House of Alignment' : 'House of Citizenship' + : 'No house selected' return ( - + - House: {state.member.house === 'alignment' ? 'House of Alignment' : 'House of Citizenship'} + House: {house} - Joined: {formatMemberDate(state.member.joinedAt)} + Joined: {state.member ? formatMemberDate(state.member.joinedAt) : 'Not yet'} - Status: {state.member.status} + Status: {memberStatus} @@ -317,6 +458,9 @@ function GovernanceVoteDetail({ state: GovernanceWidgetAdapterState actions: GovernanceWidgetAdapterActions }) { + const theme = useTheme() + const sliderAccentColor = resolveThemeColor(theme, '$primary') + const sliderTrackColor = resolveThemeColor(theme, '$backgroundHover') const vote = state.dashboard.alignmentVoting const disabledReason = getGovernanceVotingDisabledReason(vote) const voteTransactionPending = @@ -339,8 +483,15 @@ function GovernanceVoteDetail({ {vote.title} - @@ -348,25 +499,55 @@ function GovernanceVoteDetail({ Your allocation must total exactly 10,000 basis points. - {vote.options.map((option) => - isReadOnly ? ( + {vote.options.map((option) => { + const currentValue = vote.allocationsBps[option.id] ?? 0 + const availablePoints = Math.max(0, 10_000 - vote.allocationTotalBps) + const sliderMax = Math.max(currentValue, currentValue + availablePoints) + + return isReadOnly ? ( - {option.label}: {vote.executed ? `${vote.finalizedUnits[option.id] ?? '0'} finalized units` : `${vote.allocationsBps[option.id] ?? 0} bps`} + {option.label}: {vote.executed ? `${vote.finalizedUnits[option.id] ?? '0'} finalized units` : `${currentValue} bps`} ) : ( - actions.setVoteAllocation(option.id, Number.parseInt(value || '0', 10))} - /> - ), - )} + + + {option.label} + {currentValue} bps + + {createElement('input', { + type: 'range', + min: 0, + max: sliderMax, + step: 100, + value: currentValue, + 'aria-label': `${option.label} allocation`, + onChange: (event: { currentTarget?: { value?: string } }) => { + actions.setVoteAllocation(option.id, Number.parseInt(event.currentTarget?.value ?? '0', 10)) + }, + style: { + width: '100%', + accentColor: sliderAccentColor, + backgroundColor: sliderTrackColor, + color: sliderAccentColor, + cursor: 'pointer', + }, + })} + + 0 bps + Up to {sliderMax} bps + + + ) + })} Allocation total: {vote.allocationTotalBps} / 10,000 bps + {!isReadOnly ? ( + + Available points: {Math.max(0, 10_000 - vote.allocationTotalBps)} bps + + ) : null} {vote.hasVoted ? ( Already voted — this contract does not support ballot replacement. @@ -408,15 +589,47 @@ function GovernanceWidgetView({ testId?: string }) { const { state, actions } = adapter + const widgetRef = useRef(null) + const [widgetBounds, setWidgetBounds] = useState() + useEffect(() => { + const element = widgetRef.current + if (!element || typeof ResizeObserver === 'undefined') return + + const updateBounds = () => { + const { left, width } = element.getBoundingClientRect() + setWidgetBounds({ left, width }) + } + + updateBounds() + const observer = new ResizeObserver(updateBounds) + observer.observe(element) + return () => observer.disconnect() + }, []) + + // Skip is a view-only choice, not membership state: it never touches the + // contract, so a reload or a wallet reconnect (address change) drops back + // to onboarding rather than silently remembering the skip. + const [isOnboardingSkipped, setIsOnboardingSkipped] = useState(false) + useEffect(() => { + setIsOnboardingSkipped(false) + }, [state.address]) + const shouldShowDashboard = state.status === 'disconnected' || state.status === 'loading' || state.status === 'unsupported_chain' || state.status === 'friendly_error' || - isActiveStatus(state.status) + isActiveStatus(state.status) || + (state.status === 'onboarding_required' && isOnboardingSkipped) return ( - + {state.error && state.status !== 'friendly_error' && state.transaction.status === 'idle' ? ( @@ -428,7 +641,10 @@ function GovernanceWidgetView({ ) : null} {state.status === 'vote_detail' ? : null} - {state.status === 'onboarding_required' ? ( + {state.status === 'onboarding_required' && isOnboardingSkipped ? ( + setIsOnboardingSkipped(false)} /> + ) : null} + {state.status === 'onboarding_required' && !isOnboardingSkipped ? ( {state.lifecycleNotice ? ( @@ -456,13 +672,33 @@ function GovernanceWidgetView({ void actions.register(profileDraft) }} /> + ) : null} {state.status === 'pending_alignment' ? : null} {state.status === 'revoked' ? : null} {shouldShowDashboard ? : null} - {isActiveStatus(state.status) ? : null} + ) } diff --git a/packages/governance-widget/src/adapter.ts b/packages/governance-widget/src/adapter.ts index 0e89e241..c8494c2f 100644 --- a/packages/governance-widget/src/adapter.ts +++ b/packages/governance-widget/src/adapter.ts @@ -132,9 +132,7 @@ export function useGovernanceAdapter({ [address], ) const resolvedChainId = chainId ?? null - const runtimeEnabled = Boolean( - account && resolvedChainId === CELO_CHAIN_ID && addresses.houses, - ) + const runtimeEnabled = Boolean(addresses.houses) const membership = useGovernanceMembership({ account, diff --git a/packages/governance-widget/src/hooks/useGovernanceMembership.ts b/packages/governance-widget/src/hooks/useGovernanceMembership.ts index a9a73dc2..badc3889 100644 --- a/packages/governance-widget/src/hooks/useGovernanceMembership.ts +++ b/packages/governance-widget/src/hooks/useGovernanceMembership.ts @@ -19,9 +19,11 @@ import { type GovernanceMemberRecord, } from '../sdks/contracts' import { - readGovernanceMembership, + readGovernanceAccountState, + readGovernancePublicState, readGovernanceSchedule, type GovernanceMembershipReads, + type GovernancePublicReads, type GovernanceSchedule, type GovernanceStakeRequirements, } from '../sdks/contractReads' @@ -185,6 +187,7 @@ export function getUnstakeAvailability( interface MembershipHookState { loadedAccount: Address | null membership: GovernanceMembershipReads | null + publicState: GovernancePublicReads | null schedule: GovernanceSchedule | null selectedHouse: GovernanceHouse onboardingStepId?: GovernanceOnboardingStepId @@ -215,6 +218,7 @@ function createInitialMembershipState(): MembershipHookState { return { loadedAccount: null, membership: null, + publicState: null, schedule: null, selectedHouse: 'citizenship', onboardingStepId: undefined, @@ -253,38 +257,49 @@ export function useGovernanceMembership(params: { chainId ?? 'no-chain', addresses.houses?.toLowerCase() ?? 'no-contract', ].join(':')) - const enabled = Boolean(account && chainId === CELO_CHAIN_ID && addresses.houses) + const enabled = Boolean(addresses.houses) const hasCurrentAccountState = Boolean( account && state.loadedAccount?.toLowerCase() === account.toLowerCase(), ) const membership = hasCurrentAccountState ? state.membership : null - const schedule = hasCurrentAccountState ? state.schedule : null + const schedule = state.publicState ? state.schedule : null const refresh = useCallback(async () => { - if (!account || !addresses.houses || chainId !== CELO_CHAIN_ID) return + if (!addresses.houses) return const requestId = ++refreshRequestId.current setState((previous) => ({ ...previous, isLoading: true, loadError: null })) try { - const [membership, schedule] = await Promise.all([ - readGovernanceMembership({ + const [publicState, schedule, accountState] = await Promise.all([ + readGovernancePublicState({ publicClient, housesAddress: addresses.houses, - goodIdAddress: addresses.goodId, - account, }), readGovernanceSchedule({ publicClient, housesAddress: addresses.houses }), + account && chainId === CELO_CHAIN_ID + ? readGovernanceAccountState({ + publicClient, + housesAddress: addresses.houses, + goodIdAddress: addresses.goodId, + account, + }) + : Promise.resolve(null), ]) setState((previous) => requestId === refreshRequestId.current ? { ...previous, loadedAccount: account, - membership, + publicState, + membership: accountState + ? { ...publicState, ...accountState } + : null, schedule, selectedHouse: - membership.member.status === 'none' || membership.member.status === 'unstaked' + !accountState || + accountState.member.status === 'none' || + accountState.member.status === 'unstaked' ? previous.selectedHouse - : membership.member.house, + : accountState.member.house, isLoading: false, loadError: null, } @@ -505,7 +520,7 @@ export function useGovernanceMembership(params: { } }, [account, chainId, environment, provider, publicClient]) - const minimumStakes = membership?.minimumStakes ?? EMPTY_STAKES + const minimumStakes = membership?.minimumStakes ?? state.publicState?.minimumStakes ?? EMPTY_STAKES const member = membership?.member ?? null const status = statusFromMember(member) const unstakeAvailability = useMemo( @@ -521,7 +536,9 @@ export function useGovernanceMembership(params: { ...state, membership, schedule, - isLoading: enabled && !hasCurrentAccountState ? true : state.isLoading, + isLoading: enabled && (!state.publicState || (account !== null && !hasCurrentAccountState)) + ? true + : state.isLoading, status, member, minimumStakes, @@ -530,8 +547,8 @@ export function useGovernanceMembership(params: { identityStatus: membership?.identityRoot && membership.identityRoot !== '0x0000000000000000000000000000000000000000' ? 'verified' as const : 'unverified' as const, - activeCitizens: membership?.activeCitizens ?? EMPTY_ADDRESSES, - activeAlignment: membership?.activeAlignment ?? EMPTY_ADDRESSES, + activeCitizens: membership?.activeCitizens ?? state.publicState?.activeCitizens ?? EMPTY_ADDRESSES, + activeAlignment: membership?.activeAlignment ?? state.publicState?.activeAlignment ?? EMPTY_ADDRESSES, unstakeAvailability, refresh, selectHouse, diff --git a/packages/governance-widget/src/hooks/useGovernanceVoting.ts b/packages/governance-widget/src/hooks/useGovernanceVoting.ts index fdebfc63..a43ad2f8 100644 --- a/packages/governance-widget/src/hooks/useGovernanceVoting.ts +++ b/packages/governance-widget/src/hooks/useGovernanceVoting.ts @@ -63,6 +63,7 @@ export function resolveGovernanceVoterKey( } export function createVotingState(params: { + account?: Address | null member: GovernanceMemberRecord | null identityRoot: Address | null voteId: bigint @@ -120,6 +121,8 @@ export function createVotingState(params: { disabledReason = 'No House of Alignment members were eligible when this vote opened.' } else if (params.hasVoted) { disabledReason = 'You already voted in this allocation cycle.' + } else if (params.account === null) { + disabledReason = 'Connect a wallet to participate in voting.' } else if (!isActiveMember) { disabledReason = 'Only active members can vote.' } else if (!hasRequiredStake) { @@ -197,7 +200,7 @@ export function createEmptyVotingState(): GovernanceVotingState { isVotingOpen: false, executed: false, finalizedUnits: {}, - disabledReason: 'Connect a wallet to load governance voting state.', + disabledReason: 'Connect a wallet to participate in voting.', } } @@ -237,10 +240,10 @@ export function useGovernanceVoting(params: { ].join(':')) const refresh = useCallback(async () => { - if (!enabled || !account || !addresses.houses || !schedule) return + if (!enabled || !addresses.houses || !schedule) return const requestId = ++refreshRequestId.current try { - const voterKey = resolveGovernanceVoterKey(member, identityRoot, account) + const voterKey = account ? resolveGovernanceVoterKey(member, identityRoot, account) : undefined const vote = await readGovernanceVote({ publicClient, housesAddress: addresses.houses, @@ -250,6 +253,7 @@ export function useGovernanceVoting(params: { }) if (requestId === refreshRequestId.current) { setVoting(createVotingState({ + account, member, identityRoot, voteId: vote.voteId, diff --git a/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx b/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx index 25e3cbef..8cb59941 100644 --- a/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx +++ b/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx @@ -3,7 +3,12 @@ import type { ReactNode } from 'react' import { Button, ButtonText, PageWizardShell, XStack, usePageWizard } from '@goodwidget/ui' import { HOUSE_COPY } from './copy' import { DEFAULT_TRANSACTION_STEPS, DEFAULT_FINAL_ACTIONS } from './constants' -import { validateProfileDraft, isProfileDraftComplete, validateField } from './validation' +import { + isProfileDraftComplete, + isProfileFieldRequired, + validateField, + validateProfileDraft, +} from './validation' import { WelcomeStepContent } from './steps/WelcomeStepContent' import { HouseStepContent } from './steps/HouseStepContent' import { ProfileStepContent } from './steps/ProfileStepContent' @@ -51,7 +56,7 @@ export function GovernanceOnboardingFlow({ onFinalActionPress, dataTestId, }: GovernanceOnboardingFlowProps) { - const { currentStep, steps, data, setData, next } = usePageWizard() + const { currentStep, steps, data, setData, next, back, isFirst } = usePageWizard() const stepperDisplaySteps = steps.filter((s) => s.id !== 'success') const [fieldErrors, setFieldErrors] = useState(initialFieldErrors) @@ -83,7 +88,11 @@ export function GovernanceOnboardingFlow({ } const handleFieldBlur = (fieldKey: GovernanceProfileFieldKey, fieldValue: string) => { - const error = validateField(fieldKey, fieldValue) + const error = validateField( + fieldKey, + fieldValue, + isProfileFieldRequired(resolvedHouse, fieldKey), + ) setFieldErrors((prev) => { if (!error) { const next = { ...prev } @@ -114,6 +123,7 @@ export function GovernanceOnboardingFlow({ let shellContent: ReactNode = null let shellFooter: ReactNode = null let hideStepper = false + const showBackButton = !isFirst && currentStep?.id !== 'stake' && currentStep?.id !== 'success' switch (currentStep?.id as GovernanceOnboardingStepId | undefined) { case 'welcome': @@ -210,6 +220,21 @@ export function GovernanceOnboardingFlow({ + Back + + ) : undefined} footer={shellFooter} dataTestId={dataTestId} showStepper={!hideStepper} diff --git a/packages/governance-widget/src/onboarding/constants.ts b/packages/governance-widget/src/onboarding/constants.ts index 02d73a60..6b8dfc1b 100644 --- a/packages/governance-widget/src/onboarding/constants.ts +++ b/packages/governance-widget/src/onboarding/constants.ts @@ -11,8 +11,8 @@ export const ONBOARDING_STEPS: PageWizardStep[] = [ ] export const REQUIRED_PROFILE_FIELDS: Record = { - citizenship: ['name', 'socialLinks'], - alignment: ['name', 'projectWebpage', 'missionStatement', 'distributionStrategy'], + citizenship: ['name'], + alignment: ['name', 'socialLinks', 'projectWebpage', 'missionStatement'], } export const DEFAULT_TRANSACTION_STEPS: StepperStepItem[] = [ diff --git a/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx b/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx index ef3df1ee..93d50aa6 100644 --- a/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx +++ b/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx @@ -65,7 +65,9 @@ function FormField({ /> {/* Show the requirement hint only when there is no error showing */} {helperText && !errorMessage ? ( - {helperText} + + {helperText} + ) : null} {errorMessage ? {errorMessage} : null} @@ -82,7 +84,6 @@ export function ProfileStepContent({ onProfileFieldBlur, onContinuePress, }: ProfileStepContentProps) { - return ( @@ -140,20 +141,28 @@ export function ProfileStepContent({ onBlur={(v) => onProfileFieldBlur('name', v)} /> - {selectedHouse === 'citizenship' ? ( - onProfileFieldChange('socialLinks', v)} - onBlur={(v) => onProfileFieldBlur('socialLinks', v)} - /> - ) : ( + onProfileFieldChange('socialLinks', v)} + onBlur={(v) => onProfileFieldBlur('socialLinks', v)} + /> + + {selectedHouse !== 'citizenship' && ( <> onProfileFieldChange('projectWebpage', v)} onBlur={(v) => onProfileFieldBlur('projectWebpage', v)} /> - onProfileFieldChange('missionStatement', v)} onBlur={(v) => onProfileFieldBlur('missionStatement', v)} /> - onProfileFieldChange('distributionStrategy', v)} - onBlur={(v) => onProfileFieldBlur('distributionStrategy', v)} - /> )} diff --git a/packages/governance-widget/src/onboarding/validation.ts b/packages/governance-widget/src/onboarding/validation.ts index c5b0e395..7a7db62f 100644 --- a/packages/governance-widget/src/onboarding/validation.ts +++ b/packages/governance-widget/src/onboarding/validation.ts @@ -9,11 +9,17 @@ import type { /** Minimum character counts (after trimming) for each field. */ const FIELD_MIN_LENGTH: Partial> = { name: 3, - missionStatement: 20, distributionStrategy: 20, } -const URL_FIELDS: GovernanceProfileFieldKey[] = ['socialLinks', 'projectWebpage'] +const URL_FIELDS: GovernanceProfileFieldKey[] = ['socialLinks', 'projectWebpage', 'missionStatement'] + +export function isProfileFieldRequired( + selectedHouse: GovernanceHouse, + fieldKey: GovernanceProfileFieldKey, +): boolean { + return REQUIRED_PROFILE_FIELDS[selectedHouse].includes(fieldKey) +} function isValidUrl(val: string): boolean { if (!/^https:\/\//i.test(val)) return false @@ -29,15 +35,17 @@ function isValidUrl(val: string): boolean { export function validateField( fieldKey: GovernanceProfileFieldKey, fieldValue: string | undefined, + required = true, ): string | undefined { const trimmed = fieldValue?.trim() ?? '' if (!trimmed) { + if (!required) return undefined switch (fieldKey) { case 'name': return 'Name is required' case 'socialLinks': return 'Social links are required' case 'projectWebpage': return 'Project webpage is required' - case 'missionStatement': return 'Mission statement is required' + case 'missionStatement': return 'Discourse link for mission statement and distribution strategy is required' case 'distributionStrategy': return 'Distribution strategy is required' default: return 'This field is required' } @@ -61,7 +69,7 @@ export function validateProfileDraft( ): GovernanceProfileFieldErrors { return REQUIRED_PROFILE_FIELDS[selectedHouse].reduce( (errors, fieldKey) => { - const error = validateField(fieldKey, profileDraft[fieldKey]) + const error = validateField(fieldKey, profileDraft[fieldKey], true) if (error) errors[fieldKey] = error return errors }, diff --git a/packages/governance-widget/src/sdks/contractReads.ts b/packages/governance-widget/src/sdks/contractReads.ts index 4426cdf4..b1c5e626 100644 --- a/packages/governance-widget/src/sdks/contractReads.ts +++ b/packages/governance-widget/src/sdks/contractReads.ts @@ -51,60 +51,95 @@ export interface GovernanceMembershipReads { activeAlignment: Address[] } -export async function readGovernanceMembership(params: { +export interface GovernancePublicReads { + minimumStakes: GovernanceStakeRequirements + activeCitizens: Address[] + activeAlignment: Address[] +} + +export async function readGovernancePublicState(params: { publicClient: PublicClient housesAddress: Address - goodIdAddress: Address - account: Address -}): Promise { - const { publicClient, housesAddress, goodIdAddress, account } = params - const [member, citizenshipStake, alignmentStake, identityRoot, activeCitizens, activeAlignment] = - await Promise.all([ - publicClient.readContract({ - address: housesAddress, - abi: GOODDAO_HOUSES_ABI, - functionName: 'getMember', - args: [account], - }), - publicClient.readContract({ - address: housesAddress, - abi: GOODDAO_HOUSES_ABI, - functionName: 'minimumStake', - args: [houseToContractValue('citizenship')], - }), - publicClient.readContract({ - address: housesAddress, - abi: GOODDAO_HOUSES_ABI, - functionName: 'minimumStake', - args: [houseToContractValue('alignment')], - }), - readGoodIdRoot(publicClient, goodIdAddress, account), - publicClient.readContract({ - address: housesAddress, - abi: GOODDAO_HOUSES_ABI, - functionName: 'getActiveMembers', - args: [houseToContractValue('citizenship')], - }), - publicClient.readContract({ - address: housesAddress, - abi: GOODDAO_HOUSES_ABI, - functionName: 'getActiveMembers', - args: [houseToContractValue('alignment')], - }), - ]) +}): Promise { + const { publicClient, housesAddress } = params + const [citizenshipStake, alignmentStake, activeCitizens, activeAlignment] = await Promise.all([ + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'minimumStake', + args: [houseToContractValue('citizenship')], + }), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'minimumStake', + args: [houseToContractValue('alignment')], + }), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getActiveMembers', + args: [houseToContractValue('citizenship')], + }), + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getActiveMembers', + args: [houseToContractValue('alignment')], + }), + ]) return { - member: mapMemberRecord(member), minimumStakes: { citizenship: citizenshipStake, alignment: alignmentStake, }, - identityRoot, activeCitizens: [...activeCitizens], activeAlignment: [...activeAlignment], } } +export async function readGovernanceAccountState(params: { + publicClient: PublicClient + housesAddress: Address + goodIdAddress: Address + account: Address +}): Promise> { + const { publicClient, housesAddress, goodIdAddress, account } = params + const [member, identityRoot] = await Promise.all([ + publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getMember', + args: [account], + }), + readGoodIdRoot(publicClient, goodIdAddress, account), + ]) + + return { + member: mapMemberRecord(member), + identityRoot, + } +} + +export async function readGovernanceMembership(params: { + publicClient: PublicClient + housesAddress: Address + goodIdAddress: Address + account: Address +}): Promise { + const { publicClient, housesAddress, goodIdAddress, account } = params + const [publicState, accountState] = await Promise.all([ + readGovernancePublicState({ publicClient, housesAddress }), + readGovernanceAccountState({ publicClient, housesAddress, goodIdAddress, account }), + ]) + + return { + ...publicState, + ...accountState, + } +} + export async function readGovernanceSchedule(params: { publicClient: PublicClient housesAddress: Address @@ -142,7 +177,7 @@ export async function readGovernanceSchedule(params: { export async function readGovernanceVote(params: { publicClient: PublicClient housesAddress: Address - voterKey: Address + voterKey?: Address activeAlignment: Address[] schedule: GovernanceSchedule }): Promise<{ @@ -173,12 +208,14 @@ export async function readGovernanceVote(params: { functionName: 'getVoteRecipients', args: [voteId], }), - publicClient.readContract({ - address: housesAddress, - abi: GOODDAO_HOUSES_ABI, - functionName: 'getHasVoted', - args: [voteId, voterKey], - }), + voterKey + ? publicClient.readContract({ + address: housesAddress, + abi: GOODDAO_HOUSES_ABI, + functionName: 'getHasVoted', + args: [voteId, voterKey], + }) + : Promise.resolve(false), ]) const voteConfig = mapVoteConfig(rawVoteConfig) diff --git a/packages/governance-widget/src/shared.tsx b/packages/governance-widget/src/shared.tsx index 17b42159..054f8c45 100644 --- a/packages/governance-widget/src/shared.tsx +++ b/packages/governance-widget/src/shared.tsx @@ -33,7 +33,6 @@ export const ImpactCardFrame = createComponent(Card, { backgroundColor: '$background', color: '$white', shadowColor: '$shadowColor', - maxWidth: 390, overflow: 'hidden', borderWidth: 0, padding: '$5', diff --git a/packages/governance-widget/src/widgetRuntimeContract.ts b/packages/governance-widget/src/widgetRuntimeContract.ts index bc720f20..a752d5e4 100644 --- a/packages/governance-widget/src/widgetRuntimeContract.ts +++ b/packages/governance-widget/src/widgetRuntimeContract.ts @@ -82,6 +82,8 @@ export interface GovernanceDashboardState { impact: ImpactCardProps activeMembers: BalanceCardProps alignmentVoting: GovernanceVotingState + /** Optional previous rounds shown alongside the current vote on the homepage. */ + alignmentVotingHistory?: GovernanceVotingState[] fundingDistribution: FundingDistributionChartProps } diff --git a/packages/ui/src/components/PageWizard.tsx b/packages/ui/src/components/PageWizard.tsx index 12e853e3..88be9b49 100644 --- a/packages/ui/src/components/PageWizard.tsx +++ b/packages/ui/src/components/PageWizard.tsx @@ -49,6 +49,7 @@ interface PageWizardProviderProps { interface PageWizardShellProps { title: string description?: string + headerAction?: ReactNode footer?: ReactNode children: ReactNode dataTestId?: string @@ -209,6 +210,7 @@ export function usePageWizard(): PageWizardContextValue { export function PageWizardShell({ title, description, + headerAction, footer, children, dataTestId, @@ -326,10 +328,20 @@ export function PageWizardShell({ })} - - {title} - {description ? {description} : null} - + {headerAction ? ( + + + {title} + {description ? {description} : null} + + {headerAction} + + ) : ( + + {title} + {description ? {description} : null} + + )} ) : null} diff --git a/tests/widgets/governance-widget/adapter-logic.spec.ts b/tests/widgets/governance-widget/adapter-logic.spec.ts index e04e10c7..84e4154a 100644 --- a/tests/widgets/governance-widget/adapter-logic.spec.ts +++ b/tests/widgets/governance-widget/adapter-logic.spec.ts @@ -15,6 +15,7 @@ import { unstakeGovernanceMembership, } from '../../../packages/governance-widget/src/sdks/transactions' import { + readGovernancePublicState, readGovernanceSchedule, readGovernanceVote, voteStartTimeFromSchedule, @@ -453,6 +454,47 @@ test('derives vote start and excludes late provisional Alignment recipients', as expect(result.recipients).toEqual([earlyRecipient]) }) +test('loads public governance state and voting data without a wallet account', async () => { + const recipient = '0x1111111111111111111111111111111111111111' as Address + const calls: string[] = [] + const publicClient = { + readContract: async ({ functionName }: { functionName: string }) => { + calls.push(functionName) + switch (functionName) { + case 'minimumStake': return 1_000n + case 'getActiveMembers': return [recipient] + case 'isVotingPeriod': return true + case 'getCurrentVoteId': return 2n + case 'getVoteConfig': return [1_000n, 2_000n, 0n, false] + case 'getVoteRecipients': return [recipient] + case 'getFinalizedUnits': return 0n + default: throw new Error(`Unexpected contract read: ${functionName}`) + } + }, + } as unknown as PublicClient + + await expect(readGovernancePublicState({ publicClient, housesAddress: houses })).resolves.toEqual({ + minimumStakes: { citizenship: 1_000n, alignment: 1_000n }, + activeCitizens: [recipient], + activeAlignment: [recipient], + }) + + const vote = await readGovernanceVote({ + publicClient, + housesAddress: houses, + activeAlignment: [recipient], + schedule: { + cycleStartTime: 1_000_000, + termDurationSeconds: 100n, + votingTermLengthSeconds: 50n, + currentBlockTime: 1_200_000, + }, + }) + + expect(vote.recipients).toEqual([recipient]) + expect(calls).not.toContain('getHasVoted') +}) + test('requires voters to have joined before vote start and validates exact ballots', () => { const recipient = '0x1111111111111111111111111111111111111111' as Address const member = mapMemberRecord([ diff --git a/tests/widgets/governance-widget/dashboard.spec.ts b/tests/widgets/governance-widget/dashboard.spec.ts deleted file mode 100644 index e41195c3..00000000 --- a/tests/widgets/governance-widget/dashboard.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * 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/onboarding.spec.ts b/tests/widgets/governance-widget/onboarding.spec.ts index 27e60f93..47e0b425 100644 --- a/tests/widgets/governance-widget/onboarding.spec.ts +++ b/tests/widgets/governance-widget/onboarding.spec.ts @@ -114,7 +114,7 @@ test('Governance onboarding shows the citizenship profile ready state', async ({ test('Governance onboarding shows the alignment profile validation state', async ({ page }) => { await gotoStory(page, STORY_IDS.custodialAlignmentProfileError) await expect(page.getByText('Project webpage is required')).toBeVisible() - await expect(page.getByText('Distribution strategy is required')).toBeVisible() + await expect(page.getByText('Discourse link for mission statement and distribution strategy is required')).toBeVisible() await captureEvidence(page, 'tests/widgets/governance-widget/test-results/gwo-09-profile-alignment-error.png') }) diff --git a/tests/widgets/governance-widget/runtime.spec.ts b/tests/widgets/governance-widget/runtime.spec.ts index e1d8bca5..4c4ab4f2 100644 --- a/tests/widgets/governance-widget/runtime.spec.ts +++ b/tests/widgets/governance-widget/runtime.spec.ts @@ -688,13 +688,14 @@ test('vote submission is single-flight and ignores a stale receipt after account logRuntimeDiagnostics(page) await page.clock.install({ time: MOCK_NOW_SECONDS * 1000 }) await installInjectedProvider(page) - const runtimeMocks = await installGovernanceRuntimeMocks(page) + const runtimeMocks = await installGovernanceRuntimeMocks(page, { memberHouse: 1 }) runtimeMocks.pauseReceipts() await gotoStory(page, 'qa-governancewidget-runtime-fixtures--real-adapter-mocked-runtime') await page.getByTestId('GovernanceWidget-active-governance').click() await expect(page.getByTestId('GovernanceWidget-vote-detail')).toBeVisible() - await page.getByRole('textbox').fill('10000') + await expect(page.locator('input[type="range"]')).toHaveCount(1) + await page.locator('input[type="range"]').fill('10000') const submitButton = page.getByRole('button', { name: 'Submit Allocation Vote' }) await expect(submitButton).toBeEnabled() await submitButton.evaluate((button) => { @@ -722,6 +723,19 @@ test('vote submission is single-flight and ignores a stale receipt after account await expect(page.getByText('Vote confirmed on Celo.')).toHaveCount(0) }) +test('live mocked-data flow completes citizenship registration end-to-end', async ({ page }) => { + // Exercises the self-contained fixture (createInteractiveGovernanceEnvironment) used by the + // qa-governancewidget-runtime-fixtures--live-mocked-data-flow story: its window.fetch and + // EIP-1193 provider mocks are installed by the story component itself, not by this test, so + // this only needs to navigate and drive the UI like a human would. + await gotoStory(page, 'qa-governancewidget-runtime-fixtures--live-mocked-data-flow') + + await submitCitizenshipRegistration(page) + + await expect(page.getByTestId('GovernanceWidget-dashboard')).toBeVisible() + await expect(page.getByTestId('GovernanceWidget-member-footer')).toContainText('House of Citizenship') +}) + test('real adapter clears account-scoped governance state while a new wallet loads', async ({ page }) => { logRuntimeDiagnostics(page) await installInjectedProvider(page) @@ -740,7 +754,7 @@ test('real adapter clears account-scoped governance state while a new wallet loa await expect(page.getByTestId('GovernanceWidget-header')).toContainText('0x9999') await expect(page.getByTestId('GovernanceWidget-loading')).toBeVisible() - await expect(page.getByTestId('GovernanceWidget-member-footer')).toHaveCount(0) + await expect(page.getByTestId('GovernanceWidget-member-footer')).toContainText('Status: not available') await expect(page.getByTestId('GovernanceWidget-unstake')).toHaveCount(0) } finally { runtimeMocks.resumeReads() diff --git a/tests/widgets/governance-widget/showcase.spec.ts b/tests/widgets/governance-widget/showcase.spec.ts new file mode 100644 index 00000000..397a0b5b --- /dev/null +++ b/tests/widgets/governance-widget/showcase.spec.ts @@ -0,0 +1,69 @@ +import { expect, test, type Page } from '@playwright/test' + +async function gotoStory(page: Page, storyId: string) { + 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') +} + +test('showcase demo exposes active and previous governance rounds with live allocation controls', async ({ page }) => { + await page.setViewportSize({ width: 900, height: 1100 }) + await gotoStory(page, 'widgets-governancewidget-showcase--demo') + + const activeVote = page.getByTestId('GovernanceWidget-active-governance') + await expect(activeVote).toBeVisible() + await expect(page.getByTestId('GovernanceWidget-past-governance-1')).toBeVisible() + await expect(page.getByTestId('GovernanceWidget-funding-distribution')).toContainText('450') + await expect(page.getByTestId('GovernanceWidget-member-footer')).toContainText('Status: active') + await expect(page.getByTestId('GovernanceWidget-member-footer')).toHaveCSS('position', 'fixed') + const widgetBox = await page.getByTestId('GovernanceWidget-showcase-demo').boundingBox() + const footerBox = await page.getByTestId('GovernanceWidget-member-footer').boundingBox() + expect(widgetBox).not.toBeNull() + expect(footerBox).not.toBeNull() + expect(footerBox?.width).toBeLessThan(page.viewportSize()?.width ?? 0) + expect(Math.abs((footerBox?.width ?? 0) - (widgetBox?.width ?? 0))).toBeLessThanOrEqual(2) + + const carouselMetrics = await page.getByTestId('GovernanceWidget-voting-carousel').evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })) + expect(carouselMetrics.scrollWidth).toBeGreaterThan(carouselMetrics.clientWidth) + await expect(page.getByText('Voting round 1 of 2')).toBeVisible() + await page.getByRole('button', { name: 'Next voting round' }).click() + await expect(page.getByText('Voting round 2 of 2')).toBeVisible() + await expect.poll(async () => page.getByTestId('GovernanceWidget-voting-carousel').evaluate((element) => element.scrollLeft)).toBeGreaterThan(0) + await page.getByRole('button', { name: 'Previous voting round' }).click() + await expect(page.getByText('Voting round 1 of 2')).toBeVisible() + + await activeVote.click() + await expect(page.getByTestId('GovernanceWidget-vote-detail')).toBeVisible() + await expect(page.getByTestId('GovernanceWidget-vote-available-points')).toHaveText('Available points: 800 bps') + + const sliders = page.locator('input[type="range"]') + await expect(sliders).toHaveCount(3) + await sliders.nth(0).fill('5000') + await expect(page.getByText('Allocation total: 10000 / 10,000 bps')).toBeVisible() + await expect(page.getByTestId('GovernanceWidget-vote-available-points')).toHaveText('Available points: 0 bps') + + const backButton = page.getByRole('button', { name: 'Back' }) + await expect(backButton).toHaveCSS('background-color', /rgb\(/) + await backButton.click() + await expect(activeVote).toBeVisible() +}) + +test('onboarding Back returns to the welcome step and Skip stays visually secondary', async ({ page }) => { + await gotoStory(page, 'qa-governancewidget-runtime-fixtures--unstaked-returns-to-onboarding') + + const skipButton = page.getByTestId('GovernanceWidget-skip-onboarding') + await expect(skipButton).toHaveCSS('border-width', '1px') + await expect(skipButton).toHaveCSS('height', '32px') + await skipButton.click() + await expect(page.getByTestId('GovernanceWidget-signup-banner')).toBeVisible() + + await gotoStory(page, 'qa-governancewidget-runtime-fixtures--unstaked-returns-to-onboarding') + await page.getByRole('button', { name: 'Proceed to Membership' }).click() + await expect(page.getByTestId('GovernanceOnboardingWidget-back')).toBeVisible() + await page.getByTestId('GovernanceOnboardingWidget-back').click() + await expect(page.getByRole('button', { name: 'Proceed to Membership' })).toBeVisible() +})