diff --git a/README.md b/README.md index bf432b46..590b1cf6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ Commonality is a system for decentralized crowdfunding of public goods: people can fund projects aligned with shared values without needing a central organization to coordinate them. See [What is Commonality?](./specs/README.md#what-is-commonality) for the product overview. + - [Current focus](./focus.md) — the one or two kinds of work we are concentrating on right now (not a to-do list) - [AI continuity notes](./CONTINUITY.md) - [Fake / seed data plan](./fake-data-generation/PLAN.md) — tiny local world vs real Conceptspace statements vs mass fake activity; **next step for a fresh LLM** - [To-do list](./TODO.md) — where LLMs file new one-shot tasks. Tag each with its [autonomy tier](./workflow/task-tiers.md) (Ask / Tell / Trust); untagged means Ask. diff --git a/causestarter/README.md b/causestarter/README.md index d12dac1e..24eb1a21 100644 --- a/causestarter/README.md +++ b/causestarter/README.md @@ -303,10 +303,10 @@ Then **restart Grok** so MCP tools load. | `wallet-account-menu` | Hardhat account picker (localhost only) | | `wallet-hardhat-0` … `wallet-hardhat-9` | Pick Hardhat account | | `wallet-disconnect` | Disconnect | -| `home-start-cause` | Home / `/welcome` CTA → create a draft and open the cause editor | -| `home-dashboard` | Occupied home (connected wallet and/or cause boards on this device) | -| `home-dashboard-board` | Occupied-home teaser of the personal fundable-projects board | -| `home-dashboard-see-all` | Occupied home → `/dashboard` (full personal list) | +| `home-landing` | Root landing (first-visit pitch plus role cards) | +| `home-dashboard` | Role-card grid on the home landing | +| `home-dashboard-board` | Preview layout of the personal fundable-projects board (unused on home now) | +| `nav-profile` | Header icon → `/profile` | | `personal-dashboard-page` | Full personal fundable-projects board at `/dashboard` | | `nav-start` | Desktop/mobile nav “Start” → same (creates a new draft) | | `cause-detail-page` | Cause page root (where all editing happens; brand-new drafts show “Start a cause board” coach copy here) | diff --git a/causestarter/e2e/connect-and-start.spec.ts b/causestarter/e2e/connect-and-start.spec.ts index b8667232..7b28c5b4 100644 --- a/causestarter/e2e/connect-and-start.spec.ts +++ b/causestarter/e2e/connect-and-start.spec.ts @@ -55,15 +55,16 @@ test.describe('CauseStarter agent smoke', () => { await page.goto(appPath('/')) }) - test('occupied home shows the personal fundable-projects board after connect', async ({ page }) => { + test('personal fundable-projects board lives under Fund after connect', async ({ page }) => { await connectHardhat0(page) - await expect(page.getByTestId('home-dashboard-board')).toBeVisible({ timeout: 15_000 }) - await expect(page.getByRole('heading', { name: /your fundable projects/i })).toBeVisible() + await page.getByTestId('nav-fund').click() + await expect(page.getByTestId('personal-dashboard-page')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('heading', { name: /fundable projects/i })).toBeVisible() }) test('starts a cause and lands on its editable page', async ({ page }) => { await expect(page.getByTestId('wallet-connect-button')).toBeVisible() - await expect(page.getByTestId('home-start-cause')).toBeVisible() + await expect(page.getByTestId('home-landing')).toBeVisible() await connectHardhat0(page) await startCause(page) diff --git a/focus.md b/focus.md new file mode 100644 index 00000000..4897c03a --- /dev/null +++ b/focus.md @@ -0,0 +1,17 @@ +# Current focus + +High-level work we are actually concentrating on right now. Not a to-do list — those live in [TODO.md](./TODO.md), [causestarter/TODO.md](./causestarter/TODO.md), and the [testnet working plan](./workflow/testnet-working-plan.md). At most three items. Keep this file current. + +## 1. Make testnet usable + +The MVP is implemented in code; the live testnet stack is not yet a credible shared lab. Goal: Adam and Sam can use the deployed testnet without fighting ops, config, or broken paths. + +Ordered work is in [`workflow/testnet-working-plan.md`](./workflow/testnet-working-plan.md). Do the next unchecked item there. Mass fake activity is **not** part of this focus. + +Related: [project status](./workflow/project-status.md), [MVP](./specs/product/mvp.md), verifier reports. + +## 2. Finish the CauseStarter UI + +CauseStarter is the founder-first surface and the intended primary entry. Goal: it is complete enough as a product UI that remaining items are known leftovers, not “the app isn’t there yet.” + +Backlog: [`causestarter/TODO.md`](./causestarter/TODO.md). Product/tech domain notes: [product UI domains](./specs/product/ui-domains.md), [technical UI domains](./specs/tech/ui-domains.md). diff --git a/ui/src/causestarter/hooks/useUserAlignments.ts b/ui/src/causestarter/hooks/useUserAlignments.ts new file mode 100644 index 00000000..07988d4e --- /dev/null +++ b/ui/src/causestarter/hooks/useUserAlignments.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAccount } from 'wagmi' +import { getAlignmentsByAttester, type AlignmentAttestation } from '@commonality/sdk/fundingportals' +import { useMachinery } from '../../shared' + +export function useUserAlignments(): { + attestations: AlignmentAttestation[] + loading: boolean + connected: boolean + error: string | null + refresh: () => void +} { + const machinery = useMachinery() + const { address } = useAccount() + const [attestations, setAttestations] = useState([]) + const [loading, setLoading] = useState(Boolean(address)) + const [error, setError] = useState(null) + const [tick, setTick] = useState(0) + + const refresh = useCallback(() => setTick((n) => n + 1), []) + + useEffect(() => { + let cancelled = false + + const run = async () => { + if (!address) { + if (!cancelled) { + setAttestations([]) + setError(null) + setLoading(false) + } + return + } + + if (!cancelled) setLoading(true) + try { + const next = await getAlignmentsByAttester(machinery, address) + if (!cancelled) { + setAttestations(next) + setError(null) + } + } catch (cause) { + if (!cancelled) { + setAttestations([]) + setError(cause instanceof Error ? cause.message : 'Could not load alignment attestations') + } + } finally { + if (!cancelled) setLoading(false) + } + } + + void run() + return () => { + cancelled = true + } + }, [machinery, address, tick]) + + return { attestations, loading, connected: Boolean(address), error, refresh } +} diff --git a/ui/src/causestarter/pages/HomePage.test.tsx b/ui/src/causestarter/pages/HomePage.test.tsx index efdf1c6c..04a09f1d 100644 --- a/ui/src/causestarter/pages/HomePage.test.tsx +++ b/ui/src/causestarter/pages/HomePage.test.tsx @@ -26,17 +26,23 @@ function renderHome({ connected = false, statements = 0, pledges = 0, notes = 0 render() } -describe('HomePage role launcher', () => { +describe('HomePage landing', () => { afterEach(cleanup) - it('gives every visitor the focused role cards', () => { + it('explains the product to a first-time visitor and still offers the role cards', () => { renderHome() + expect(screen.getByTestId('home-landing')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'There are enough of us. We just couldn’t work together.' })).toBeInTheDocument() + expect(screen.getByText('A cause board is a bulletin of crowdfundable projects', { exact: false })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Start a cause board' })).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Read the short version' })).toHaveAttribute('href', '/docs') expect(screen.getByRole('heading', { name: 'What would you like to do?' })).toBeInTheDocument() expect(screen.getByTestId('home-role-sign')).toHaveAttribute('href', '/statements') expect(screen.getByTestId('home-role-donate')).toHaveAttribute('href', '/donate') expect(screen.getByTestId('home-role-fund')).toHaveAttribute('href', '/dashboard') expect(screen.getByTestId('home-role-work')).toHaveAttribute('href', '/work') expect(screen.getByTestId('home-role-organize')).toHaveAttribute('href', '/causes') + expect(screen.getByTestId('home-profile-link')).toHaveAttribute('href', '/profile') expect(screen.getByText('Signing does not commit money.', { exact: false })).toBeInTheDocument() }) diff --git a/ui/src/causestarter/pages/HomePage.tsx b/ui/src/causestarter/pages/HomePage.tsx index df103cbe..cf8cafe7 100644 --- a/ui/src/causestarter/pages/HomePage.tsx +++ b/ui/src/causestarter/pages/HomePage.tsx @@ -1,5 +1,5 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward' -import { Box, Card, CardActionArea, CardContent, CircularProgress, Stack, Typography } from '@mui/material' +import { Box, Button, Card, CardActionArea, CardContent, CircularProgress, Paper, Stack, Typography } from '@mui/material' import type { ReactNode } from 'react' import { Link as RouterLink } from 'react-router-dom' import { useAccount } from 'wagmi' @@ -51,70 +51,131 @@ export function HomePage() { const fundingBoard = readPersonalFundingBoard(address) return ( - - - + + + theme.palette.mode === 'light' + ? 'linear-gradient(160deg, rgba(15,118,110,0.10) 0%, rgba(255,252,247,0.95) 55%, #fff 100%)' + : 'linear-gradient(160deg, rgba(45,212,191,0.14) 0%, rgba(15,23,42,0.9) 60%, #0b1220 100%)', + }} + > + + CauseStarter + + + There are enough of us. We just couldn’t work together. + + + A cause board is a bulletin of crowdfundable projects: raise a specific + amount by a date to do a specific piece of work. If the crowd doesn’t + show, contributors get their money back. That is a third way besides + “government does it” and “a big charity does it.” + + + You do not have to watch the board — pledge a monthly amount to someone + you already trust. You do not have to bet on pitches — reimburse work + that already delivered. Signing a statement does not spend money. + + + + + + + What would you like to do? - - Choose a job. Each workspace stays focused, and you can come back here whenever you want to switch roles. + + Money, judgment, skilled work, and organizing do not have to arrive in + one organization. Pick the job you would do anyway. Come back here to + switch. - - - 0 - ? {countLabel(statements.length, 'signed statement')} - : Find a statement worth standing behind.} - action={statements.length > 0 ? 'Continue signing' : 'Explore statements'} - /> - {countLabel(donation.activePledgeCount, 'monthly pledge')} · {countLabel(donation.activeNoteCount, 'active fund')} - : Set up giving that does not need your daily attention.} - action={hasDonateActivity ? 'Manage donations' : 'Set up a donation'} - /> - {countLabel(fundingBoard.statementCids.length, 'statement')} in your board{fundingBoard.geographicWithin?.length ? ` · ${fundingBoard.geographicWithin.join(', ')}` : ''} - : statements.length > 0 - ? {countLabel(statements.length, 'signed statement')} (default board) - : Set the scope of your personal funding board.} - action={fundingBoard || statements.length > 0 ? 'Review projects' : 'Set up your funding board'} - /> - 0 - ? {countLabel(createdProjects, 'created project')} - : Publish a piece of work people can fund.} - action={createdProjects > 0 ? 'Continue your work' : 'Start a project'} - /> - 0 - ? {countLabel(causes.length, 'cause board')} - : Turn a useful mix of statements into a board people can share.} - action={causes.length > 0 ? 'Continue organizing' : 'Start organizing'} - /> + + 0 + ? {countLabel(statements.length, 'signed statement')} + : Find a statement worth standing behind.} + action={statements.length > 0 ? 'Continue signing' : 'Explore statements'} + /> + {countLabel(donation.activePledgeCount, 'monthly pledge')} · {countLabel(donation.activeNoteCount, 'active fund')} + : Set up giving that does not need your daily attention.} + action={hasDonateActivity ? 'Manage donations' : 'Set up a donation'} + /> + {countLabel(fundingBoard.statementCids.length, 'statement')} in your board{fundingBoard.geographicWithin?.length ? ` · ${fundingBoard.geographicWithin.join(', ')}` : ''} + : statements.length > 0 + ? {countLabel(statements.length, 'signed statement')} (default board) + : Set the scope of your personal funding board.} + action={fundingBoard || statements.length > 0 ? 'Review projects' : 'Set up your funding board'} + /> + 0 + ? {countLabel(createdProjects, 'created project')} + : Publish a piece of work people can fund.} + action={createdProjects > 0 ? 'Continue your work' : 'Start a project'} + /> + 0 + ? {countLabel(causes.length, 'cause board')} + : Turn a useful mix of statements into a board people can share.} + action={causes.length > 0 ? 'Continue organizing' : 'Start organizing'} + /> + + + ) } diff --git a/ui/src/causestarter/pages/ProfilePage.test.tsx b/ui/src/causestarter/pages/ProfilePage.test.tsx new file mode 100644 index 00000000..b4973e77 --- /dev/null +++ b/ui/src/causestarter/pages/ProfilePage.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { ProfilePage } from './ProfilePage' + +vi.mock('wagmi', () => ({ + useAccount: () => ({ address: '0xabc', isConnected: true }), +})) + +vi.mock('../../shared', () => ({ + AddressDisplay: ({ address }: { address: string }) =>
{address}
, +})) + +vi.mock('../hooks/useDonationSummary', () => ({ + useDonationSummary: () => ({ + activePledgeCount: 1, + activeNoteCount: 2, + delegatedNoteCount: 0, + loading: false, + }), +})) + +vi.mock('../hooks/useUserProjects', () => ({ + useUserProjects: () => ({ projects: [], loading: false, connected: true }), +})) + +vi.mock('../hooks/useUserStatements', () => ({ + useUserStatements: () => ({ statements: [], loading: false, error: null }), +})) + +vi.mock('../hooks/useUserCauses', () => ({ + useUserCauses: () => ({ causes: [], loading: false }), +})) + +vi.mock('../hooks/useUserAlignments', () => ({ + useUserAlignments: () => ({ attestations: [], loading: false, error: null, refresh: () => undefined }), +})) + +vi.mock('../components/YourProjects', () => ({ + YourProjects: ({ testId, heading }: { testId: string; heading: string }) => ( +
{heading}
+ ), +})) + +vi.mock('../components/CauseCard', () => ({ + CauseCard: () => null, +})) + +vi.mock('../components/ConnectWalletHint', () => ({ + ConnectWalletHint: ({ children }: { children: string }) =>
{children}
, +})) + +describe('ProfilePage', () => { + it('is a record of past activity, not a workspace for next actions', () => { + render( + + + , + ) + expect(screen.getByTestId('profile-page')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Stuff you’ve done' })).toBeInTheDocument() + expect(screen.getByTestId('profile-giving')).toBeInTheDocument() + expect(screen.getByText('1 monthly pledge · 2 active funds')).toBeInTheDocument() + expect(screen.getByTestId('profile-contributed-projects')).toBeInTheDocument() + expect(screen.getByTestId('profile-created-projects')).toBeInTheDocument() + expect(screen.getByTestId('profile-causes')).toBeInTheDocument() + expect(screen.getByTestId('profile-statements')).toBeInTheDocument() + expect(screen.getByTestId('profile-alignments')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Open Donate' })).toHaveAttribute('href', '/donate') + expect(screen.getByRole('link', { name: 'Open Organize' })).toHaveAttribute('href', '/causes') + expect(screen.getByRole('link', { name: 'Open Sign' })).toHaveAttribute('href', '/statements') + }) +}) diff --git a/ui/src/causestarter/pages/ProfilePage.tsx b/ui/src/causestarter/pages/ProfilePage.tsx new file mode 100644 index 00000000..238030cb --- /dev/null +++ b/ui/src/causestarter/pages/ProfilePage.tsx @@ -0,0 +1,219 @@ +import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from '@mui/material' +import { Link as RouterLink } from 'react-router-dom' +import { useAccount } from 'wagmi' +import { AddressDisplay } from '../../shared' +import { CauseCard } from '../components/CauseCard' +import { ConnectWalletHint } from '../components/ConnectWalletHint' +import { YourProjects } from '../components/YourProjects' +import { useDonationSummary } from '../hooks/useDonationSummary' +import { useUserAlignments } from '../hooks/useUserAlignments' +import { useUserCauses } from '../hooks/useUserCauses' +import { useUserProjects } from '../hooks/useUserProjects' +import { useUserStatements } from '../hooks/useUserStatements' +import { isLive } from '../lib/causeStore' + +function countLabel(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}` +} + +function paddedAddressSubject(subjectId: string): string | null { + const hex = subjectId.toLowerCase().replace(/^0x/, '') + if (hex.length !== 64 || !hex.startsWith('0'.repeat(24))) return null + return `0x${hex.slice(24)}` +} + +export function ProfilePage() { + const { address, isConnected } = useAccount() + const donation = useDonationSummary() + const { projects, loading: projectsLoading, connected } = useUserProjects() + const { statements, loading: statementsLoading, error: statementsError } = useUserStatements() + const { causes, loading: causesLoading } = useUserCauses() + const { attestations, loading: alignmentsLoading, error: alignmentsError, refresh: refreshAlignments } = useUserAlignments() + + const contributed = projects.filter((project) => project.relations.includes('contributed')) + const created = projects.filter((project) => project.relations.includes('created')) + const addressLc = address?.toLowerCase() + const organized = causes.filter((cause) => isLive(cause) && cause.founderAddress?.toLowerCase() === addressLc) + + return ( + + + + Profile + + + Stuff you’ve done + + + This is a record for this wallet, not a workspace. Home is for what you + could do next. Signing, donating, funding, working, and organizing stay + in their own places. + + {address && ( + + + + )} + + + {!isConnected && ( + Connect a wallet to see pledges, receipts, causes, signatures, and attestations on this device. + )} + + + Giving + + Monthly pledges and funds you still have open. The Donate workspace is + where you change them. + + {donation.loading ? ( + + + Loading giving… + + ) : ( + + {countLabel(donation.activePledgeCount, 'monthly pledge')} · {countLabel(donation.activeNoteCount, 'active fund')} + + )} + + + + + + + + + + Cause boards you published + + {causesLoading && organized.length === 0 ? ( + + + Loading cause boards… + + ) : organized.length === 0 ? ( + + No published cause boards from this wallet on this device. + + ) : ( + + {organized.map((cause) => )} + + )} + + + + + + Statements you’ve signed + + {statementsLoading && statements.length === 0 ? ( + + + Loading signed statements… + + ) : statementsError ? ( + {statementsError} + ) : statements.length === 0 ? ( + + No signed statements on this wallet yet. + + ) : ( + + {statements.map((statement) => ( + + ))} + + )} + + + + + + Alignment attestations + + + Vouches this wallet made that a project advances a statement. + + {alignmentsLoading && attestations.length === 0 ? ( + + + Loading attestations… + + ) : alignmentsError ? ( + + {alignmentsError} + + + ) : attestations.length === 0 ? ( + + No alignment attestations from this wallet yet. + + ) : ( + + {attestations.map((attestation) => { + const projectAddress = paddedAddressSubject(attestation.subjectId) + return ( + + + {projectAddress ? ( + + ) : ( + `Subject ${attestation.subjectId.slice(0, 12)}…` + )} + + + + ) + })} + + )} + + + ) +} diff --git a/ui/src/causestarter/pages/WelcomePage.tsx b/ui/src/causestarter/pages/WelcomePage.tsx index 56894230..9e31d920 100644 --- a/ui/src/causestarter/pages/WelcomePage.tsx +++ b/ui/src/causestarter/pages/WelcomePage.tsx @@ -1,106 +1 @@ -import { Box, Button, Paper, Stack, Typography } from '@mui/material' -import { Link as RouterLink, useNavigate } from 'react-router-dom' -import { CrowdJobs } from '../components/CrowdJobs' -import { createCausePath } from '../lib/causeStore' -import { JOBS_DOC_PATH } from '../lib/jobs' - -/** Always the first-visit pitch, even if this device already has causes. */ -export function WelcomePage() { - const navigate = useNavigate() - - return ( - - - theme.palette.mode === 'light' - ? 'linear-gradient(160deg, rgba(15,118,110,0.10) 0%, rgba(255,252,247,0.95) 55%, #fff 100%)' - : 'linear-gradient(160deg, rgba(45,212,191,0.14) 0%, rgba(15,23,42,0.9) 60%, #0b1220 100%)', - }} - > - - CauseStarter - - - There are enough of us. We just couldn’t work together. - - - Give money without becoming a grant officer. Spot projects without bankrolling them. - Do the work without knowing a foundation. Sign what you actually mean. The rest is - optional. - - - - - - - - - - - Take the job you’d take anyway - - - Nobody has to agree on a leader, a manifesto, or a treasury. Overlap is enough. - - - - - - - Docs - - - How to start a cause board, the full ugh-catalog, walkthroughs, and the longer argument. - There is no directory of other people’s cause boards — you get there by their link. - - - - - ) -} +export { HomePage as WelcomePage } from './HomePage' diff --git a/ui/src/causestarter/shell/CauseShell.tsx b/ui/src/causestarter/shell/CauseShell.tsx index 34bf3c05..3600dbae 100644 --- a/ui/src/causestarter/shell/CauseShell.tsx +++ b/ui/src/causestarter/shell/CauseShell.tsx @@ -19,6 +19,7 @@ import VolunteerActivismOutlinedIcon from '@mui/icons-material/VolunteerActivism import SavingsOutlinedIcon from '@mui/icons-material/SavingsOutlined' import MenuBookOutlinedIcon from '@mui/icons-material/MenuBookOutlined' import GitHubIcon from '@mui/icons-material/GitHub' +import PersonOutlinedIcon from '@mui/icons-material/PersonOutlined' import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined' import { Link, useLocation, useNavigate } from 'react-router-dom' import { WalletButton } from '../../shared/components/WalletButton' @@ -155,6 +156,15 @@ export function CauseShell({ children }: CauseShellProps) { )} + + + import('../../causestarter/pages/HomePage'), 'HomePage')} /> import('../../causestarter/pages/PersonalDashboardPage'), 'PersonalDashboardPage')} /> + import('../../causestarter/pages/ProfilePage'), 'ProfilePage')} /> import('../../delegation/pages/MyNotesPage'), 'DonatePage')} /> - import('../../causestarter/pages/WelcomePage'), 'WelcomePage')} /> + } /> import('../../causestarter/pages/StartCauseRedirect'), 'StartCauseRedirect')} /> import('../../causestarter/pages/StartBridgeRedirect'), 'StartBridgeRedirect')} /> import('../../causestarter/pages/BridgeTriplePage'), 'BridgeTriplePage')} /> @@ -77,6 +78,7 @@ export const causestarterManifest: DomainManifest = { { label: 'Docs', path: '/docs' }, ], secondaryNavigation: [ + { label: 'Profile', path: '/profile' }, { label: 'Settings', path: '/settings' }, ], footerText: 'CauseStarter is a lens: it renders a cause you already have a link to. It does not rank or directory causes.', @@ -84,5 +86,5 @@ export const causestarterManifest: DomainManifest = { basePath: '/', routes, Shell: CauseShell, - LandingPage: WelcomePage, + LandingPage: HomePage, }