diff --git a/CONTINUITY.md b/CONTINUITY.md index 950e42d14..748481c31 100644 --- a/CONTINUITY.md +++ b/CONTINUITY.md @@ -1779,6 +1779,14 @@ Temporary, reversible: `./scripts/services.sh --start` and `./scripts/deploy-cau `./scripts/data.sh --seed` now defaults to **tiny** (was small). `gen:small` and `gen:tiny` both pass `--skip-invariants`. Statement publish reuses one document store (or parallel PublishedData writes across Hardhat wallets, receipts awaited in a batch). Seed RPC clients poll every 50ms. See `fake-data-generation/generateStatements.ts`, `fake-data-generation/seedRpc.ts`, `scripts/data.sh`. +## 2026-08-24 — Cause board is the organizer publication, not the project list + +Copy sweep of [cause-page-not-a-club.md](specs/product/cause-page-not-a-club.md): +the Aligning/project list is now **fundable-projects board** in UI copy and +end-user docs; **cause board** is the CauseStarter organizer publication +(leftover “cause page” left in comments). Routes still `/portal/:cid` and +`/cause/:owner/:slug`; identifiers `fundingportal*` lag. + ## 2026-08-20 — Anvil `--state` restart dump Local `hardhat-node` was coming back empty after `stack.restart-consistency` because Docker SIGTERM did not make Anvil dump `/data/state.json` (then a fresh deploy + trust wiring filled ~41 blocks with no seed). Fix: `scripts/anvil-docker-entrypoint.sh` maps SIGTERM→SIGINT, compose `--state-interval 15` and 60s grace, restart check records block height and SIGINTs Anvil before stop. Recreate `hardhat-node` to mount the wrapper. diff --git a/README.md b/README.md index 9442cb773..6345d9f28 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Commonality is a system for decentralized crowdfunding of public goods: people c - [High-level project status](./workflow/project-status.md) AKA what milestone are we currently heading for: never deployed to mainnet yet, just did first testnet deployment, see also [MVP](./specs/product/mvp.md) - Product boundaries between the eight sites: [product UI domains](./specs/product/ui-domains.md). - Technical domain composition and live route ownership: [technical UI domains](./specs/tech/ui-domains.md) and the actual domain manifests under [`ui/src/domains/`](./ui/src/domains/). - - **CauseStarter** ([`causestarter/`](./causestarter/), backlog [`causestarter/TODO.md`](./causestarter/TODO.md)): founder-first core domain (eventual primary entry; Tally / LazyGiving / etc. de-emphasized as tools). Included in `./scripts/services.sh --start` and `./scripts/deploy-causestarter.sh`. Local start currently publishes **only** the CauseStarter IPFS bundle (`LOCAL_UI_DOMAINS`; restore all UIs with `LOCAL_UI_DOMAINS=all` — see [local development](./workflow/local-development.md)). LLM helpers: [`cause-assist/`](./cause-assist/). + - **CauseStarter** (SPA in [`ui/src/causestarter/`](./ui/src/causestarter/), `VITE_DOMAIN=causestarter`; glue/backlog [`causestarter/`](./causestarter/), [`causestarter/TODO.md`](./causestarter/TODO.md)): founder-first core domain (eventual primary entry; Tally / LazyGiving / etc. de-emphasized as tools). Included in `./scripts/services.sh --start` and `./scripts/deploy-causestarter.sh`. Local start currently publishes **only** the CauseStarter IPFS bundle (`LOCAL_UI_DOMAINS`; restore all UIs with `LOCAL_UI_DOMAINS=all` — see [local development](./workflow/local-development.md)). LLM helpers: [`cause-assist/`](./cause-assist/). - [Glossary](./specs/glossary.md) — the project's ubiquitous language. Read before naming anything; it also lists the known terminology drift. - [Architecture Decision Records](./specs/decisions/README.md) — immutable log of *why* consequential decisions were made (and what was rejected). Grep before reversing something that looks wrong. - [Role-based guidance](./workflow/roles/README.md) on what docs to read: diff --git a/TODO.md b/TODO.md index bff04719d..753719b01 100644 --- a/TODO.md +++ b/TODO.md @@ -10,6 +10,11 @@ When an item from this page is done and no longer needs an LLM implementor's att ---- +- **(Tell)** After folding CauseStarter into `ui/src/causestarter/`, leftover package + glue still talks as if `causestarter/src` is the SPA: `causestarter/vite.config.ts` + is unused, Compose/Docker docs mix `:8090` and `:5174`, and some verifier prompts + may still cite deleted paths. Sweep when touching local-dev docs. + - Add a fresh-stack integration test for the alignment-trust bootstrap: publish an alignment vouch from a previously unknown wallet, observe the service's `TrustSet(..., 100)`, confirm a wallet with no personal graph sees that vouch diff --git a/causestarter/Dockerfile b/causestarter/Dockerfile index 6669256f6..318b1c62e 100644 --- a/causestarter/Dockerfile +++ b/causestarter/Dockerfile @@ -43,6 +43,8 @@ COPY turbo.json ./turbo.json RUN --mount=type=cache,target=/root/.npm HUSKY=0 npm ci --legacy-peer-deps COPY sdk ./sdk +# In-app /docs bundles docs/end-user (endUserDocsPlugin). +COPY docs ./docs # Cause board reuses ui/fundingportals (+ shared, lazy-giving, content-funding, delegation). COPY ui ./ui COPY causestarter ./causestarter @@ -56,13 +58,13 @@ ENV VITE_WALLETCONNECT_PROJECT_ID=${VITE_WALLETCONNECT_PROJECT_ID} ENV HUSKY=0 RUN npm run build --workspace=@commonality/sdk \ - && npm run build --workspace=causestarter + && VITE_DOMAIN=causestarter npm run build --workspace=ui # --------------------------------------------------------------------------- FROM nginx:1.27-alpine AS runtime COPY causestarter/nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=builder /workspace/causestarter/dist /usr/share/nginx/html +COPY --from=builder /workspace/ui/dist/causestarter /usr/share/nginx/html COPY causestarter/docker-entrypoint.d/30-indexer-upstream.sh /docker-entrypoint.d/30-indexer-upstream.sh COPY causestarter/docker-entrypoint.d/40-causestarter-config.sh /docker-entrypoint.d/40-causestarter-config.sh RUN chmod +x /docker-entrypoint.d/30-indexer-upstream.sh \ diff --git a/causestarter/README.md b/causestarter/README.md index 339a6d110..51ae07547 100644 --- a/causestarter/README.md +++ b/causestarter/README.md @@ -1,9 +1,12 @@ # CauseStarter -Organizer-first reference lens for the Commonality substrate. Where the main -[`ui/`](../ui/) package is organized as multi-domain product sites (Commonality, -Civility, CSM, LazyGiving, …), **CauseStarter** is a single app organized around -the cause-starter job: +Organizer-first reference lens for the Commonality substrate. The SPA source +lives in [`ui/src/causestarter/`](../ui/src/causestarter/) and is built as +`VITE_DOMAIN=causestarter` (`npm run causestarter:dev` → Vite on **:5174**). +This directory keeps Docker/nginx, Playwright, and the product backlog. + +Where the other `ui/` domains are focused tool sites (Commonality, Civility, +CSM, LazyGiving, …), **CauseStarter** is organized around the cause-starter job: 1. **Organize a cause** — retrieve, review, and select the independent, signable statements it is made of 2. **Enroll people** — supporters (signers), volunteers, and collaborators @@ -41,7 +44,7 @@ as main `CreateStatementForm`), not browser → Kubo API upload. 1. Start (or leave running) the local stack: `./scripts/services.sh --start` (or at least hardhat + indexer + **cause-assist** + gateway). `cause-assist` is a Compose service (loopback **:3002**). You do **not** need a separate `npm run cause-assist:*` process for normal UI work. -2. Seed `causestarter/.env` from the running Docker SPA config (contract addresses + tool domain URLs): +2. Seed `ui/.env` (and optionally overlay `causestarter/.env`) from the running Docker SPA config (contract addresses + tool domain URLs). Vite HMR on :5174 reads `ui/.env`. IPFS/local publish merges `ui/.env` then `causestarter/.env`, then root contract-address mappings: ```bash python3 scripts/seed-causestarter-vite-env.py @@ -54,6 +57,7 @@ as main `CreateStatementForm`), not browser → Kubo API upload. ```bash npm run causestarter:dev + # VITE_DOMAIN=causestarter in the ui package, port 5174 ``` Or from this package: `npm run dev`. @@ -207,6 +211,12 @@ See [`cause-assist/README.md`](../cause-assist/README.md). Bridge-cluster wordin ## Design notes +- **Landing pitch is jobs, not a movement lifecycle.** Hero and `/docs/the-jobs` + (`docs/end-user/causestarter/the-jobs.md`) are the “do the part you’d do anyway” + catalog. Do not restore Start → Grow → Deliver or “build a Movement.” +- **In-app docs** (`/docs/*`) bundle `docs/end-user/causestarter/`, `shared/`, + and `commonality/` via `endUserDocsPlugin`. Keep markdown links relative so + they resolve in that viewer. - **CauseStarter is a lens, not a directory** ([ADR 0008](../specs/decisions/0008-operated-surfaces-are-lenses.md)). It authors no discovery: no search, browse, ranking, featuring, or leaderboards. A cause is reached at `/cause/:causeId` through a link its organizer circulates. @@ -290,9 +300,13 @@ 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 CTA → create a draft and open the cause editor | +| `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) | +| `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” coach copy here) | +| `cause-detail-page` | Cause page root (where all editing happens; brand-new drafts show “Start a cause board” coach copy here) | | `issue-guidance` | Static coach copy for what an issue is | | `cause-add-plank` | Add an issue | | `plank-text-N` | Nth issue's editable text (drafts only) | @@ -329,7 +343,7 @@ CAUSESTARTER_BASE_URL=http://localhost:5174 CAUSESTARTER_HASH_ROUTING=0 \ ### Example agent prompt > Use the browser. Open http://localhost:8090/, click Connect, choose Hardhat #0, -> click “Start a cause”, describe the cause, click Continue, then add and publish +> click “Start a cause board”, describe the cause, click Continue, then add and publish > issues on the cause page. This package stays thinner than `ui/` (no multi-domain matrix, no Privy). Product posture: diff --git a/causestarter/TODO.md b/causestarter/TODO.md index 141024945..c33f85934 100644 --- a/causestarter/TODO.md +++ b/causestarter/TODO.md @@ -42,10 +42,12 @@ open **if they stay listed here**. - [ ] Statement `bookmarks` ref is still reserved infrastructure only — no CauseStarter (or main `ui`) surface for remembering a statement without signing it. - [ ] No Privy path / full parity with main `ui` wallet story yet. - [ ] Product: how CauseStarter ranks vs other domains in nav/marketing once it’s “the main thing.” +- [x] **Copy:** two-step rename in [cause-page-not-a-club.md](/specs/product/cause-page-not-a-club.md) — fundable-projects board first, then organizer **cause board**. Identifiers and leftover “cause page” still lag. +- [ ] **Personal dashboard** ([personal-dashboard.md](/specs/product/personal-dashboard.md)): home hero = fundable-projects union over signed statements (first slice shipped). Do **not** reuse unpublished cause-board drafts. Starring / named subsets / MutableRef filters stay deferred. ## Architecture -- [ ] Long-term: fold CauseStarter into the real domain/shell model (shared machinery, runtime config, domain manifests) as we make it the primary surface; avoid unbounded parallel app growth under a second package forever. +- [x] Fold CauseStarter into the real domain/shell model (`VITE_DOMAIN=causestarter` in `ui/`). Docker/e2e glue still lives in this directory. - [ ] Optional: extract Hardhat local connectors for reuse by main `ui` local DX. ## Local stack notes diff --git a/causestarter/e2e/bookmarks.spec.ts b/causestarter/e2e/bookmarks.spec.ts index b73f7b52e..6e7214d54 100644 --- a/causestarter/e2e/bookmarks.spec.ts +++ b/causestarter/e2e/bookmarks.spec.ts @@ -130,7 +130,7 @@ test.describe('Cause bookmarks', () => { await clearBrowserStorage(page) await page.goto(appPath('/causes')) await connectHardhat(page, 1) - await expect(page.getByRole('heading', { name: 'Causes', exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Cause boards', exact: true })).toBeVisible() await expect(page.getByRole('heading', { name: title, exact: true })).toHaveCount(0, { timeout: 45_000, }) diff --git a/causestarter/e2e/connect-and-start.spec.ts b/causestarter/e2e/connect-and-start.spec.ts index bdc998cef..b86672321 100644 --- a/causestarter/e2e/connect-and-start.spec.ts +++ b/causestarter/e2e/connect-and-start.spec.ts @@ -55,6 +55,12 @@ test.describe('CauseStarter agent smoke', () => { await page.goto(appPath('/')) }) + test('occupied home shows the personal fundable-projects board 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() + }) + 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() @@ -67,7 +73,7 @@ test.describe('CauseStarter agent smoke', () => { // A brand-new cause has no planks, so no counts and nothing to select. await expect(page.getByTestId('cause-view-strip')).toBeHidden() await expect(page.getByTestId('cause-add-plank')).toBeVisible() - await expect(page.getByRole('heading', { name: /start a cause/i })).toBeVisible() + await expect(page.getByRole('heading', { name: /start a cause board/i })).toBeVisible() }) test('edits issues in place on the cause page', async ({ page }) => { @@ -105,11 +111,11 @@ test.describe('CauseStarter agent smoke', () => { await expect(page.getByTestId('view-count-all')).toBeVisible() }) - test('Causes page starts a cause and opens the editor while connected', async ({ page }) => { + test('Cause boards page starts a cause board and opens the editor while connected', async ({ page }) => { await connectHardhat0(page) await page.getByTestId('nav-causes').click() - await expect(page.getByRole('heading', { name: 'Causes' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Cause boards' })).toBeVisible() await page.getByTestId('causes-start-cause').click() await expect(page.getByTestId('cause-detail-page')).toBeVisible({ timeout: 10_000 }) diff --git a/causestarter/index.html b/causestarter/index.html index 06753020a..cd1d754da 100644 --- a/causestarter/index.html +++ b/causestarter/index.html @@ -4,7 +4,7 @@ - + CauseStarter diff --git a/causestarter/package.json b/causestarter/package.json index 4955ee21b..819d458b6 100644 --- a/causestarter/package.json +++ b/causestarter/package.json @@ -5,11 +5,11 @@ "type": "module", "scripts": { "predev": "npm run build --workspace=@commonality/sdk", - "dev": "../node_modules/.bin/vite", - "typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.node.json --noEmit", - "build": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.node.json --noEmit && ../node_modules/.bin/vite build", + "dev": "npm run dev:causestarter --workspace=ui", + "typecheck": "npm run typecheck --workspace=ui", + "build": "VITE_DOMAIN=causestarter npm run build --workspace=ui", "lint": "eslint .", - "test": "vitest run", + "test": "npm run test:vitest --workspace=ui -- src/causestarter", "test:watch": "vitest", "test:e2e": "playwright test --config=playwright.config.ts", "test:e2e:headed": "playwright test --config=playwright.config.ts --headed", @@ -25,7 +25,9 @@ "connectkit": "^1.9.1", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.18.2", + "rehype-sanitize": "^6.0.0", "viem": "2.54.3", "wagmi": "^3.6.21" }, diff --git a/causestarter/src/App.tsx b/causestarter/src/App.tsx deleted file mode 100644 index e73394cd2..000000000 --- a/causestarter/src/App.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { BrowserRouter, HashRouter, Navigate, Route, Routes } from 'react-router-dom' -import { CauseShell } from './shell/CauseShell' -import { HomePage } from './pages/HomePage' -import { StartCauseRedirect } from './pages/StartCauseRedirect' -import { StartBridgeRedirect } from './pages/StartBridgeRedirect' -import { BridgeClusterPage } from './pages/BridgeClusterPage' -import { BridgeTriplePage } from './pages/BridgeTriplePage' -import { CausesPage } from './pages/CausesPage' -import { CauseDetailPage } from './pages/CauseDetailPage' -import { CauseMediatorPage } from './pages/CauseMediatorPage' -import { CauseBoardLeaderboardPage } from './pages/CauseBoardLeaderboardPage' -import { StatementBoardLeaderboardPage } from './pages/StatementBoardLeaderboardPage' -import { StatementBoardRedirect } from './pages/StatementBoardRedirect' -import { StatementPage } from './pages/StatementPage' -import { DocsPage } from './pages/DocsPage' -import { StatementsPage } from './pages/StatementsPage' -import { SettingsPage } from './pages/SettingsPage' -import { ProjectDetailPage, ProjectLeaderboardPage } from './pages/ProjectDetailPage' -import { CreateProjectPage } from './pages/CreateProjectPage' -import { CauseContentBoardPage } from './pages/CauseContentBoardPage' -import { CauseFundingPage } from './pages/CauseFundingPage' -import { - ContentFundingAboutPage, - ContentFundingBrowsePage, - ContentFundingChannelPage, - ContentFundingContractPage, - ContentFundingCreateContractPage, - ContentFundingCreatorDashboardPage, - ContentFundingCreatorsPage, - ContentFundingExploreKindsPage, - ContentFundingLandingPage, - ContentFundingMaterializeFutureContentPage, - ContentFundingStartContractPage, -} from './pages/ContentFundingPages' -import { - DelegateProfilePage, - DepositPage, - MyNotesPage, - NoteDetailPage, -} from './pages/DelegationPages' -import { NotFoundPage } from './pages/NotFoundPage' - -function isHashRouting(): boolean { - return import.meta.env.MODE === 'ipfs' || import.meta.env.VITE_HASH_ROUTING === 'true' -} - -export default function App() { - const Router = isHashRouting() ? HashRouter : BrowserRouter - - return ( - - - - } /> - {/* No intermediate form — creates a draft and opens the editor. */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* Local drafts use a UUID. Published causes use - /cause/:owner/:slug[@versionCid] — stable id + optional pin. - See docs/founder/shaping-your-cause-statements.md § roster. */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* No browse or search route by design: a cause is reached by its own - link, never by a directory we rank. See ADR 0005 and - specs/product/ui-operator-posture.md. */} - } /> - {/* Old standalone board URL: the dashboard now lives on the statement. */} - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - ) -} diff --git a/causestarter/src/components/HowItWorksSteps.tsx b/causestarter/src/components/HowItWorksSteps.tsx deleted file mode 100644 index 3b356540b..000000000 --- a/causestarter/src/components/HowItWorksSteps.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Box, Stack, Typography } from '@mui/material' -import FlagIcon from '@mui/icons-material/Flag' -import GroupsIcon from '@mui/icons-material/Groups' -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch' -import type { ReactNode } from 'react' - -const steps: Array<{ icon: ReactNode; title: string; body: string }> = [ - { - icon: , - title: 'Start', - body: 'Shape the cause into clear, independent statements people can sincerely support.', - }, - { - icon: , - title: 'Grow', - body: 'Bring in supporters, volunteers, and collaborators with clear asks.', - }, - { - icon: , - title: 'Deliver', - body: 'Fund projects, back creators, and keep score in public.', - }, -] - -export function HowItWorksSteps() { - return ( - - {steps.map((step, index) => ( - - - {index + 1} - - - - {step.icon} - - {step.title} - - - - {step.body} - - - - ))} - - ) -} diff --git a/causestarter/src/main.tsx b/causestarter/src/main.tsx deleted file mode 100644 index 5e7b53c29..000000000 --- a/causestarter/src/main.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react' -import { createRoot } from 'react-dom/client' -import { Box, CssBaseline, ThemeProvider, createTheme } from '@mui/material' -import type { PaletteMode, Theme } from '@mui/material' -import { WagmiProvider } from 'wagmi' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { ConnectKitProvider } from 'connectkit' -import { config, createMockConfig } from './wagmi' -import { - getRuntimeConfigValue as getUiSharedRuntimeConfigValue, - loadRuntimeConfig as loadUiSharedRuntimeConfig, -} from '@ui/shared' -import { getRuntimeConfig, getRuntimeConfigValue, loadRuntimeConfig } from './lib/runtimeConfig' -import App from './App' -import './index.css' - -const queryClient = new QueryClient() -const TOUCH_TARGET_MIN = 44 - -function getSystemColorMode(): PaletteMode { - if (typeof window === 'undefined') return 'light' - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' -} - -function createAppTheme(mode: PaletteMode): Theme { - return createTheme({ - palette: { - mode, - primary: { - main: mode === 'light' ? '#0f766e' : '#2dd4bf', - light: mode === 'light' ? '#14b8a6' : '#5eead4', - dark: mode === 'light' ? '#115e59' : '#0f766e', - contrastText: mode === 'light' ? '#ffffff' : '#042f2e', - }, - secondary: { - main: mode === 'light' ? '#c2410c' : '#fb923c', - }, - background: { - default: mode === 'light' ? '#fffaf4' : '#0a1018', - paper: mode === 'light' ? '#ffffff' : '#121a24', - }, - }, - typography: { - fontFamily: "'Avenir Next', 'Segoe UI', system-ui, sans-serif", - }, - shape: { - borderRadius: 12, - }, - components: { - MuiButton: { - styleOverrides: { - sizeSmall: { - '@media (pointer: coarse)': { - minHeight: TOUCH_TARGET_MIN, - }, - }, - }, - }, - MuiIconButton: { - styleOverrides: { - sizeSmall: { - '@media (pointer: coarse)': { - minWidth: TOUCH_TARGET_MIN, - minHeight: TOUCH_TARGET_MIN, - }, - }, - }, - }, - MuiCssBaseline: { - styleOverrides: (themeParam) => ({ - body: { - color: themeParam.palette.text.primary, - background: themeParam.palette.mode === 'light' - ? 'radial-gradient(circle at top, rgba(20,184,166,0.14), transparent 40%), linear-gradient(180deg, #fff7ed 0%, #fffaf4 45%, #f0fdfa 100%)' - : 'radial-gradient(circle at top, rgba(45,212,191,0.12), transparent 40%), linear-gradient(180deg, #071018 0%, #0a1018 50%, #111827 100%)', - }, - }), - }, - }, - }) -} - -declare global { - interface Window { - _setupTestWallet: typeof createMockConfig - } -} - -export function Root() { - const [mode, setMode] = useState(getSystemColorMode) - const [wagmiConfig, setWagmiConfig] = useState(config) - const theme = useMemo(() => createAppTheme(mode), [mode]) - - useEffect(() => { - document.documentElement.dataset.colorMode = mode - }, [mode]) - - useEffect(() => { - const media = window.matchMedia('(prefers-color-scheme: dark)') - const applySystemMode = () => setMode(media.matches ? 'dark' : 'light') - applySystemMode() - media.addEventListener('change', applySystemMode) - return () => media.removeEventListener('change', applySystemMode) - }, []) - - const setupTestWallet = useCallback( - (...args: Parameters) => { - const next = createMockConfig(...args) - setWagmiConfig(next) - return next - }, - [], - ) - - if (typeof window !== 'undefined') { - window._setupTestWallet = setupTestWallet - } - - return ( - - - - - - - - - - - - - ) -} - -// Cause board reuses ui/fundingportals, which reads contracts/URLs via -// ui/shared runtime config (separate module store from CauseStarter's own -// lib/runtimeConfig). Both must load the same config.json and stay aligned on -// shared keys until those stores are unified. Failure of either load fails boot. -// Keys cover useMachinery contract/RPC/IPFS surface, payment currency, and -// domain URLs used by embedded @ui/* board/project pages. -const SHARED_RUNTIME_KEYS = [ - 'VITE_EVENT_CACHE_URL', - 'VITE_IPFS_GATEWAY', - 'VITE_PLATFORM_API_URL', - 'VITE_MAINNET_RPC_URL', - 'VITE_ETH_RPC_URL', - 'VITE_BELIEFS_CONTRACT_ADDRESS', - 'VITE_IMPLICATIONS_CONTRACT_ADDRESS', - 'VITE_ASSURANCE_CONTRACT_FACTORY_ADDRESS', - 'VITE_ERC1155_FACTORY_ADDRESS', - 'VITE_DELEGATABLE_NOTES_CONTRACT_ADDRESS', - 'VITE_RECURRING_PLEDGES_CONTRACT_ADDRESS', - 'VITE_NOTE_INTENT_CONTRACT_ADDRESS', - 'VITE_ALIGNMENT_ATTESTATIONS_CONTRACT_ADDRESS', - 'VITE_MUTABLE_REF_UPDATER_CONTRACT_ADDRESS', - 'VITE_TRUST_REGISTRY_CONTRACT_ADDRESS', - 'VITE_DEFAULT_ALIGNMENT_TRUST_ROOT', - 'VITE_NUDGE_PUBLICATIONS_CONTRACT_ADDRESS', - 'VITE_PUBLISHED_DATA_CONTRACT_ADDRESS', - 'VITE_CONTENT_REGISTRY_ADDRESS', - 'VITE_CHANNEL_REGISTRY_ADDRESS', - 'VITE_CHANNEL_ESCROW_ADDRESS', - 'VITE_CREATOR_CONTRACT_FACTORY_ADDRESS', - 'VITE_PROJECT_FACTORY_CONTRACT_ADDRESS', - 'VITE_PAYMENT_TOKEN_ADDRESS', - 'VITE_PAYMENT_TOKEN_SYMBOL', - 'VITE_PAYMENT_TOKEN_DECIMALS', - 'VITE_CHAIN_ID', - 'VITE_COMMONALITY_URL', - 'VITE_LAZYGIVING_URL', - 'VITE_ALIGNMENT_URL', - 'VITE_TALLY_URL', - 'VITE_CONTENT_FUNDING_URL', - 'VITE_CIVILITY_URL', - 'VITE_COMMON_SENSE_MAJORITY_URL', - 'VITE_CONCEPTSPACE_URL', -] as const - -/** Fail boot on dual-store drift in local/dev; warn-only elsewhere. */ -function isStrictRuntimeConfigEnv(): boolean { - if (import.meta.env.DEV) return true - const environment = - getRuntimeConfig().COMMONALITY_ENVIRONMENT - ?? (import.meta.env.COMMONALITY_ENVIRONMENT as string | undefined) - return environment === 'local' -} - -function assertRuntimeConfigStoresAligned(): void { - const mismatches: string[] = [] - for (const key of SHARED_RUNTIME_KEYS) { - const host = getRuntimeConfigValue(key) - const shared = getUiSharedRuntimeConfigValue(key) - if ((host ?? '') !== (shared ?? '')) { - mismatches.push(`${key}: host=${host ?? '(unset)'} ui/shared=${shared ?? '(unset)'}`) - } - } - if (mismatches.length > 0) { - const detail = - '[CauseStarter] dual runtime-config stores disagree after loadRuntimeConfig; ' - + 'board/project pages may use different addresses than native pages:\n' - + mismatches.join('\n') - // Local/dev: fail boot so silent wrong contracts cannot ship a broken board. - // Production dual-store drift still warns until stores are unified. - if (isStrictRuntimeConfigEnv()) { - throw new Error(detail) - } - console.warn(detail) - } else if (import.meta.env.DEV) { - console.info( - '[CauseStarter] dual runtime-config stores aligned on shared keys', - Object.fromEntries( - SHARED_RUNTIME_KEYS.map((key) => [key, getRuntimeConfigValue(key) ?? '(unset)']), - ), - ) - } -} - -Promise.all([loadRuntimeConfig(), loadUiSharedRuntimeConfig()]) - .then(() => { - assertRuntimeConfigStoresAligned() - createRoot(document.getElementById('root')!).render( - - - , - ) - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - document.getElementById('root')!.textContent = message - }) diff --git a/causestarter/src/pages/DocsPage.tsx b/causestarter/src/pages/DocsPage.tsx deleted file mode 100644 index 859ceb08b..000000000 --- a/causestarter/src/pages/DocsPage.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Box, Stack, Typography } from '@mui/material' -import { ToolCard } from '../components/ToolCard' -import { SUPPORTING_TOOLS } from '../lib/tools' - -const sections = [ - { - key: 'reference' as const, - title: 'Example causes', - description: 'Worked examples of focused causes you can learn from.', - }, - { - key: 'thesis' as const, - title: 'Background', - description: 'Why this approach to public goods exists — optional reading.', - }, -] - -export function DocsPage() { - return ( - - - - Docs - - - Worked examples and background reading. Funding and growth tools live on each - cause page. - - - - {sections.map((section) => { - const tools = SUPPORTING_TOOLS.filter((tool) => tool.kind === section.key) - if (tools.length === 0) return null - return ( - - - {section.title} - - - {section.description} - - - {tools.map((tool) => ( - - ))} - - - ) - })} - - ) -} diff --git a/causestarter/src/wagmi.ts b/causestarter/src/wagmi.ts deleted file mode 100644 index 62ebb0955..000000000 --- a/causestarter/src/wagmi.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { http, createConfig } from 'wagmi' -import { mainnet, base, baseSepolia, hardhat } from 'wagmi/chains' -import { getDefaultConfig, getDefaultConnectors } from 'connectkit' -import { injected, mock } from 'wagmi/connectors' -import { isAddress } from 'viem' -import { privateKeyToAccount } from 'viem/accounts' -import type { MockParameters } from 'wagmi/connectors' -import { HARDHAT_DEV_ACCOUNTS, isLocalDevHost } from './lib/hardhatAccounts' -import { hardhatLocalConnector } from './lib/hardhatLocalConnector' - -export const walletConnectProjectId = (import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || '').trim() -export const isE2E = import.meta.env.VITE_E2E === 'true' -export const useLocalHardhatWallets = !isE2E && isLocalDevHost() - -const mainnetRpcUrl = import.meta.env.VITE_MAINNET_RPC_URL || 'https://ethereum-rpc.publicnode.com' -const baseRpcUrl = import.meta.env.VITE_BASE_RPC_URL || 'https://mainnet.base.org' -const baseSepoliaRpcUrl = import.meta.env.VITE_BASE_SEPOLIA_RPC_URL || 'https://baseSepolia.base.org' -const hardhatRpcUrl = import.meta.env.VITE_ETH_RPC_URL || 'http://127.0.0.1:8545' - -export const wagmiChains = [mainnet, base, baseSepolia, hardhat] as const - -export const wagmiTransports = { - [mainnet.id]: http(mainnetRpcUrl), - [base.id]: http(baseRpcUrl), - [baseSepolia.id]: http(baseSepoliaRpcUrl), - [hardhat.id]: http(hardhatRpcUrl), -} - -export function createMockConfig( - addressOrPkey: `0x${string}` = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', - features?: MockParameters['features'], -) { - const account = isAddress(addressOrPkey) - ? addressOrPkey - : privateKeyToAccount(addressOrPkey) - - const address = typeof account === 'string' ? account : account.address - - return createConfig({ - chains: [hardhat, mainnet, baseSepolia], - transports: wagmiTransports, - connectors: [mock({ accounts: [address], features })], - }) -} - -/** - * Localhost: only Hardhat #0–#9 connectors (no MetaMask / WalletConnect). - * Matches the local Docker stack on chain 31337. - */ -function buildLocalHardhatConfig() { - return createConfig({ - chains: [hardhat], - transports: { - [hardhat.id]: http(hardhatRpcUrl), - }, - connectors: HARDHAT_DEV_ACCOUNTS.map((account) => hardhatLocalConnector(account)), - multiInjectedProviderDiscovery: false, - ssr: false, - }) -} - -/** - * Build ConnectKit/wagmi config for non-local browser use. - * - * Recent ConnectKit defaults enable an Aave Account connector (`enableAaveAccount: true`) - * and only MetaMask as a *named* injected target. That combination has caused Connect - * modal failures and missing browser wallets in local CauseStarter deploys. - * - * We: - * - disable Aave Account - * - use ConnectKit defaults for Coinbase / WalletConnect (when project id present) - * - prepend a generic `injected()` connector so any browser extension wallet works - */ -function buildWagmiConfig() { - if (!walletConnectProjectId && typeof console !== 'undefined') { - console.warn( - '[CauseStarter] VITE_WALLETCONNECT_PROJECT_ID is not set. ' - + 'Browser-injected wallets still work; WalletConnect QR / mobile wallets will not. ' - + 'Get a free id at https://cloud.reown.com and rebuild with it set.', - ) - } - - const defaultConnectors = getDefaultConnectors({ - app: { - name: 'CauseStarter', - description: 'Start a cause. Build a Movement. Change the world.', - url: typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8090', - }, - // Empty string → ConnectKit skips the WalletConnect connector (no broken project id). - walletConnectProjectId: walletConnectProjectId || '', - enableAaveAccount: false, - }) - - // Keep a generic injected connector first so any browser extension wallet works - // (ConnectKit defaults only target MetaMask by name). - const connectors = [ - injected({ shimDisconnect: true }), - ...defaultConnectors, - ] - - return createConfig( - getDefaultConfig({ - chains: wagmiChains, - transports: wagmiTransports, - connectors: connectors as never, - walletConnectProjectId: walletConnectProjectId || '', - appName: 'CauseStarter', - appDescription: 'Start a cause. Build a Movement. Change the world.', - appUrl: typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8090', - enableAaveAccount: false, - }), - ) -} - -export const config = isE2E - ? createMockConfig() - : useLocalHardhatWallets - ? buildLocalHardhatConfig() - : buildWagmiConfig() diff --git a/causestarter/vite.config.ts b/causestarter/vite.config.ts index aa526361d..9c22508d2 100644 --- a/causestarter/vite.config.ts +++ b/causestarter/vite.config.ts @@ -2,6 +2,7 @@ import { mkdirSync, writeFileSync } from 'node:fs' import path from 'node:path' import { defineConfig, loadEnv, type Plugin } from 'vite' import react from '@vitejs/plugin-react' +import { endUserDocsPlugin } from '../ui/endUserDocsPlugin.ts' const indexerUrl = process.env.INDEXER_URL ?? 'http://localhost:42069' @@ -13,7 +14,11 @@ export default defineConfig(({ mode }) => { build: { outDir: 'dist', }, - plugins: [react(), runtimeConfigPlugin(env)], + plugins: [ + react(), + runtimeConfigPlugin(env), + endUserDocsPlugin({ domain: 'causestarter' }), + ], resolve: { preserveSymlinks: true, // Single React/MUI/wagmi graph when bundling ui feature modules into CauseStarter. diff --git a/causestarter/vitest.config.ts b/causestarter/vitest.config.ts index 9666877ef..d2250ea21 100644 --- a/causestarter/vitest.config.ts +++ b/causestarter/vitest.config.ts @@ -1,9 +1,10 @@ import path from 'node:path' import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' +import { endUserDocsPlugin } from '../ui/endUserDocsPlugin.ts' export default defineConfig({ - plugins: [react()], + plugins: [react(), endUserDocsPlugin({ domain: 'causestarter' })], resolve: { alias: { '@ui': path.resolve(__dirname, '../ui/src'), diff --git a/docker-compose.yml b/docker-compose.yml index 07326aa0a..0983d76cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -393,10 +393,10 @@ services: # CauseStarter SPA hosting on local IPFS (core domain). ui-ipfs-publisher-causestarter: - image: commonality-causestarter-ipfs-publisher:dev + image: commonality-ui-ipfs-publisher:dev build: context: . - dockerfile: causestarter/Dockerfile.ipfs + dockerfile: ui/Dockerfile container_name: commonality-ui-ipfs-publisher-causestarter user: "${UID:-1000}:${GID:-1000}" command: ["node", "scripts/publish-ui-to-ipfs.mjs"] @@ -404,7 +404,6 @@ services: volumes: - ./.env:/workspace/.env:ro - ./ui/.env:/workspace/ui/.env:ro - - ./causestarter/.env:/workspace/causestarter/.env:ro - ./data/ui-ipfs/causestarter:/artifacts depends_on: ipfs: @@ -412,9 +411,10 @@ services: hardhat-deploy: condition: service_completed_successfully environment: - - UI_PACKAGE=causestarter + - VITE_DOMAIN=causestarter - VITE_DEFAULT_ALIGNMENT_TRUST_ROOT=${VITE_DEFAULT_ALIGNMENT_TRUST_ROOT:-0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f} - VITE_HASH_ROUTING=true + - VITE_ROUTER_MODE=hash - UI_IPFS_API_URL=http://ipfs:5001 - UI_IPFS_GATEWAY_URL=http://localhost:8080/ipfs - VITE_IPFS_GATEWAY=http://localhost:8080/ipfs diff --git a/docs/end-user/alignment/help-connect-things.md b/docs/end-user/alignment/help-connect-things.md index 0aeba15c1..351477a44 100644 --- a/docs/end-user/alignment/help-connect-things.md +++ b/docs/end-user/alignment/help-connect-things.md @@ -16,7 +16,7 @@ Your influence grows with trust. As people follow you and see that your attestat You browse cause portals on Aligning, projects on LazyGiving, or content on Content Funding. When you encounter something you believe genuinely aligns with a cause, you submit an attestation: "This project / this piece of content is aligned with [cause]." -Anyone who has marked you as trusted in their settings sees your attestation. That makes the project visible in their cause board — where it wasn't before. +Anyone who has marked you as trusted in their settings sees your attestation. That makes the project visible in their fundable-projects board — where it wasn't before. You can also vouch for other attesters. If you trust someone's judgment in a particular domain, pointing to them amplifies their reach. The trust network is composable: the people you trust, and the people they trust, all propagate your attestations through the network. diff --git a/docs/end-user/alignment/index.md b/docs/end-user/alignment/index.md index 8141518d3..9464d39f1 100644 --- a/docs/end-user/alignment/index.md +++ b/docs/end-user/alignment/index.md @@ -31,5 +31,5 @@ There's no committee deciding what counts. The portal you see is a function of w - **[Statements and the implication graph](../tally/statements-and-implication-graph.md)** — Causes are statements. Statements that mean similar things get connected automatically, so portals span natural coalitions even when nobody coordinated wording. - **[Trust networks](../shared/key-ideas/trust-networks.md)** — How "people I trust, plus people they trust" gets computed into the filter that shapes your portal. -- **[Cause boards](../lazyGiving/assurance-contracts.md)** — The crowdfunding mechanism the projects themselves use. +- **[Assurance contracts](../lazyGiving/assurance-contracts.md)** — The crowdfunding mechanism the projects themselves use. - **[AI evaluators](ai-evaluators.md)** — Attesters can be AI services, not just people. This is how a cause pool funds a whole *kind* of content automatically. diff --git a/docs/end-user/alignment/pledge-to-a-cause.md b/docs/end-user/alignment/pledge-to-a-cause.md index a0ebc7444..4cef7b1a1 100644 --- a/docs/end-user/alignment/pledge-to-a-cause.md +++ b/docs/end-user/alignment/pledge-to-a-cause.md @@ -23,7 +23,7 @@ You can: ## Getting started -Find the cause or cause board you care about in Aligning, then create a delegated fund for that cause. Set an amount and choose a delegate. Delegate profiles and track-record views show their past decisions, what they've funded, and what others have said about them. +Find the cause or fundable-projects board you care about in Aligning, then create a delegated fund for that cause. Set an amount and choose a delegate. Delegate profiles and track-record views show their past decisions, what they've funded, and what others have said about them. If you don't know any delegates personally, look for delegates whose public track records align with your values. A few funded projects tells you more than any biography. diff --git a/docs/end-user/alignment/successful-projects.md b/docs/end-user/alignment/successful-projects.md index b9450973b..3c4dacbcd 100644 --- a/docs/end-user/alignment/successful-projects.md +++ b/docs/end-user/alignment/successful-projects.md @@ -9,7 +9,7 @@ A project can be aligned long before anyone is willing to say it succeeded. That ## The Successful projects page -Every cause board has a **Successful** view. It shows projects that +Every fundable-projects board has a **Successful** view. It shows projects that 1. people in your trust network have **vouched as successful** at this cause, and 2. still have **reimbursement outstanding** for early contributors. diff --git a/docs/end-user/causestarter/index.md b/docs/end-user/causestarter/index.md new file mode 100644 index 000000000..fd497c51a --- /dev/null +++ b/docs/end-user/causestarter/index.md @@ -0,0 +1,27 @@ +# CauseStarter + +You followed a link to a **cause board**. That is how almost everyone gets here. There is no directory of causes to browse. + +This page is a place where **projects that advance these claims can get funded** — without a foundation, a club, or everyone agreeing on every sentence. + +Look at **Fundable Projects**. That fundable-projects board is the centerpiece. Alignment is to a statement, never to “the cause as a club,” so work can show up here if it advances *some* of these claims. You do not have to like the whole mix. + +**Pledge** if you would put money in anyway. The pledge [refunds if the goal isn’t met](../lazyGiving/assurance-contracts.md). You can [hand the picking](../shared/key-ideas/delegation.md) to a person you already trust instead of reading every project. + +**Publish work** if that’s your job. A friend one hop better-connected can vouch that it advances a statement people already watch. Then it can appear on every cause board that includes that statement — including this one. + +Signing a statement is free and optional. It marks which claims you personally mean, and the counts can show that more than one person cares. It does not enroll you. You are not a member by being here. After you sign, CauseStarter **home** lists fundable projects on those statements — that is the returning-user loop, not this organizer’s page. + +If this mix of claims is not the overlap you want to fund, you can reuse the ones you like on a page of your own. That is later, and it is success, not a split to police. + +The jobs — and the extra work each one used to demand — are in **[Do the part you’d do anyway](./the-jobs.md)**. Organizers who want to publish a new board: **[Start a cause board](./start-a-cause.md)**. + +## Where the money mechanics came from + +CauseStarter points those tools at a cause. Concrete stories: + +- [A neighborhood throws a block party](../shared/use-case-walkthroughs/block-party.md) +- [Getting a research project funded](../shared/use-case-walkthroughs/research-funding.md) +- [A town transitions away from government funding](../shared/use-case-walkthroughs/defunding.md) + +The longer case for this approach (why it is better than government and charity, why switching is easy, why it is hard to shut down) lives in the [vision and strategy](../commonality/vision-and-strategy/README.md) notes. You do not need those to use a cause board. diff --git a/docs/end-user/causestarter/start-a-cause.md b/docs/end-user/causestarter/start-a-cause.md new file mode 100644 index 000000000..43d5c5cf1 --- /dev/null +++ b/docs/end-user/causestarter/start-a-cause.md @@ -0,0 +1,33 @@ +# Start a cause + +A cause board in CauseStarter is **a published mix of independent statements**, not a manifesto with members. People sign statements one at a time. Projects attach to statements, not to the board as a blob. + +## Shape it + +1. **Start a cause board** from Home or Cause boards. That opens an editor on this device; nothing is on-chain yet. +2. Describe the intent in the picker. It searches **published** statements first. Reject anything that is not what you mean. Write one manually if you need to. +3. Publish each statement when the exact text is right. You will see its CID before you confirm. +4. Publish the **cause board** (title, optional description, slug) so it has a stable link: `/cause//`. Circulate that link. There is no directory. + +You can add or drop statements later. A new board version does not rewrite old pledges; earmarks stay on the statement CIDs they were made against. + +## Invite jobs, not members + +You are not recruiting people into an organization. You are publishing a place where [jobs people will actually take](./the-jobs.md) can attach: + +- Signers who like *some* of the statements +- Donors who will pledge and maybe delegate +- Doers who publish projects and get an alignment vouch +- Mediators who write bridges when wording does not line up + +If someone hates your combination of statements, they should start their own cause and reuse the overlapping planks. That is success, not a fork to suppress. + +## Bridges + +If another camp’s published cause is close but not implying yours, create a bridge from the cause board. You publish the cluster under *your* key. CauseStarter does not message the other organizer; the citation is public on their board if they look. + +## What not to expect + +- No search or “popular causes.” +- No platform verdict that a cause is good — only that counts are recomputable. +- No requirement that supporters agree with every statement on the board. diff --git a/docs/end-user/causestarter/the-jobs.md b/docs/end-user/causestarter/the-jobs.md new file mode 100644 index 000000000..9845c7a94 --- /dev/null +++ b/docs/end-user/causestarter/the-jobs.md @@ -0,0 +1,61 @@ +# Do the part you’d do anyway + +The core vision is simple: **people who agree should be able to cooperate on that agreement.** Money, work, and attention meet on the overlap. Nobody has to elect a board, swallow a manifesto, or wait until a movement is “big enough.” + +What used to block that was never a shortage of people who *cared*. It was a pile of extra jobs. “I’d be happy to do X, but ugh, Y.” CauseStarter and the rest of this substrate are mostly that ugh-removal kit. If you remember one thing, remember: **you only contribute the part you would contribute anyway.** + +## Money + +**I’d be happy to contribute $X/month (as long as enough others do too), but…** + +- **…I don’t have time to follow every project.** Pledge the $X/month and [delegate](../shared/key-ideas/delegation.md) the choices to someone you trust. Revoke anytime. +- **…I don’t want to donate to the big charity; I don’t trust them not to waste it.** Don’t. Delegate to a friend, not an institution. Your earmark is public guidance; if they send the money elsewhere, that is public too. +- **…I don’t want to be the sucker if nobody else shows up.** Use a pledge that [refunds if the goal isn’t met](../lazyGiving/assurance-contracts.md). You risk nothing. + +In CauseStarter this lives on a cause’s **Pledges** page and on **Delegation**. + +## Attention and judgment + +**I’d be happy to watch for worthwhile projects, but…** + +- **…I don’t even know what projects are out there.** Sign the statements you mean. Your CauseStarter home is then a **fundable-projects board** of work vouched as advancing those claims — not a club you joined. You can also follow (or start) a cause board whose mix you want to watch. You do not need a special introduction. +- **…I can’t tell whether this creator is a scammer or incompetent.** Be a delegate who mostly [funds proven work](../lazyGiving/retroactive-funding.md). You reimburse early contributors at cost after results, instead of betting on a pitch. +- **…I can’t put that much of my *own* money on it.** Be an early funder who asks for reimbursement. If later donors close the loop, that giving budget can go to the next attempt. Your receipt is a track record, not a payout. + +The cause board’s **Fundable Projects** list (the fundable-projects board) is the watch surface on a circulated mix. After you sign, the same kind of list on **home** is your everyday watch surface. Trust settings (the gear) control whose vouches you see. + +## Work + +**I’d be happy to do this work, but…** + +- **…I can’t afford to pay for it myself.** Publish the project and let others pledge. Funds release if the threshold is met; otherwise everyone is refunded. +- **…I don’t even know who to go to for funding.** You do not need a grant officer. Post the project, talk to a friend who is a bit better-connected in the trust graph, and get an **alignment** vouch onto a statement people already watch. The project then shows up on those cause boards. + +Start from **Start project** on a cause board, or from `/projects/new`. Occupied home is a watch list, not the create form. + +## Wording + +**I’d be happy to sign a statement sorta like that, but…** + +- **…I don’t love the way that one is phrased.** Write your own. Signing is per statement, not per cause. +- **…if I write my own it’ll have zero signers.** Write it so an implication attester can connect it to similar claims. Indirect support can count; you are not starting from a blank petition. +- **…the statement others signed doesn’t actually imply the better one I have in mind.** Write a **bridge** and, if you want distribution, submit it as a suggestion to a mediator service. If it agrees, it can nudge its subscribers. + +Cause boards have a **Bridges** section. Suggesters you opt into appear on Home. + +## Your own cause board + +**I’d be happy to follow a cause board sorta like that one, but…** + +- **…I don’t love the exact combination of statements they chose.** Publish your own board. A cause board is a mix of statements, not a club you join. +- **…if I write my own it’ll have zero signers or projects.** Reuse *some* of the same statements and you inherit their signers and aligned projects. Even new wording can pick up overlap through implication and bridges. + +**Start a cause board** is the organizer action. There is no browse list: circulate the link. + +## What this adds up to + +Project-doers can get money for worthwhile work. Donors can help financially without becoming grant officers or trusting a black-box charity. People with time or expertise can contribute those without writing a check. All of that information and money can move **without everyone agreeing on every idea or electing leaders.** + +That is why “there are *so* many of us — why can’t we get anything done?” was never a law of nature. It was coordination tax. This ecosystem is the claim that the tax was optional. + +For the longer argument (late aggregation, organic coalitions, why it is hard to stop), see [Why Commonality?](../commonality/vision-and-strategy/README.md). Role-by-role one-liners also live in [pitches](../commonality/vision-and-strategy/pitches.md). diff --git a/docs/end-user/civility/index.md b/docs/end-user/civility/index.md index 5a25e0d9f..dd92060c0 100644 --- a/docs/end-user/civility/index.md +++ b/docs/end-user/civility/index.md @@ -51,7 +51,7 @@ Every funding decision ultimately belongs to a human donor. But two things keep > "Sure — I'll put $10 a month toward making more noninflammatory content exist. I'll let my friend Andrew, who follows this stuff more closely than I do, make the actual picks." *…and then never think about it again.* -That's the experience Civility is built around. And from the other side, a creator looks at the cause board and sees real money — pledged and waiting — specifically earmarked for noninflammatory content, and thinks, *"Huh. I could write some of that."* Visible demand pulls supply into existence. +That's the experience Civility is built around. And from the other side, a creator looks at the fundable-projects board and sees real money — pledged and waiting — specifically earmarked for noninflammatory content, and thinks, *"Huh. I could write some of that."* Visible demand pulls supply into existence. ## Part of a bigger bridge-building effort diff --git a/docs/end-user/common-sense-majority/why-does-tally-help.md b/docs/end-user/common-sense-majority/why-does-tally-help.md index ae405b9db..bc93bea7b 100644 --- a/docs/end-user/common-sense-majority/why-does-tally-help.md +++ b/docs/end-user/common-sense-majority/why-does-tally-help.md @@ -2,7 +2,7 @@ Suppose the system reports that on topic T, 1.8 million people support a moderate-left statement, 2.1 million support a moderate-right one, and both imply the same common-ground statement. So what? Why does counting heads change anything? -The short answer is that the tally is doing several jobs at once — and each of them depends on a piece of infrastructure that didn't exist before: an AI mediator that watches the numbers, [bridging patterns](./hidden-majority-patterns.md) that make the common ground legible, and cause boards that turn counts into money. +The short answer is that the tally is doing several jobs at once — and each of them depends on a piece of infrastructure that didn't exist before: an AI mediator that watches the numbers, [bridging patterns](./hidden-majority-patterns.md) that make the common ground legible, and fundable-projects boards that turn counts into money. ## The tally and the mediator feed each other @@ -24,7 +24,7 @@ Once that pattern is visible, moderates on each side have a concrete way to see ## The numbers connect to funding -The [Aligning cause boards](/docs/end-user/alignment/index.md) turn visible support into actual money flowing toward content and projects that serve the moderate majority. The head-count doesn't directly cause any money to flow, but a big verified supporter count is a signal to potential project-creators and donors: "there's a lot of people over there, so if you're inclined to bring your energy or money there, it's likely to be fruitful." Tally numbers — especially verified ones — are exactly the kind of demand signal that should (and will, once we finish implementing that feature in the Aligning system's Fundable Project Explorer service) steer where funding goes. +The [Aligning fundable-projects boards](/docs/end-user/alignment/index.md) turn visible support into actual money flowing toward content and projects that serve the moderate majority. The head-count doesn't directly cause any money to flow, but a big verified supporter count is a signal to potential project-creators and donors: "there's a lot of people over there, so if you're inclined to bring your energy or money there, it's likely to be fruitful." Tally numbers — especially verified ones — are exactly the kind of demand signal that should (and will, once we finish implementing that feature in the Aligning system's Fundable Project Explorer service) steer where funding goes. ## It shifts the broader discourse diff --git a/docs/end-user/common-sense-majority/why-now.md b/docs/end-user/common-sense-majority/why-now.md index e0aefd3a0..0d385a0a8 100644 --- a/docs/end-user/common-sense-majority/why-now.md +++ b/docs/end-user/common-sense-majority/why-now.md @@ -46,4 +46,4 @@ The combination is what matters. Trustless infrastructure alone doesn't solve th - Subjective judgments are made by inspectable AI, with full configurability for those who want to verify. - The whole thing produces value at every step — no threshold to cross before it starts working, no single point of failure to attack. -The political tools to reveal the hidden majority — head counts, cause boards, bridge statements — existed conceptually for a long time. The infrastructure to run them without a trusted intermediary didn't. Now it does. +The political tools to reveal the hidden majority — head counts, fundable-projects boards, bridge statements — existed conceptually for a long time. The infrastructure to run them without a trusted intermediary didn't. Now it does. diff --git a/docs/end-user/commonality/index.md b/docs/end-user/commonality/index.md index 92490cd7a..393281430 100644 --- a/docs/end-user/commonality/index.md +++ b/docs/end-user/commonality/index.md @@ -2,7 +2,7 @@ Commonality is a movement — and a set of tools — for **internet-age coordination on public goods**. We're remarkably bad at producing the things we collectively need: shared infrastructure, independent journalism, scientific research, neighborhood projects, political organizing. New tech (assurance contracts, delegation, blockchains, AI) makes a much better approach viable. Commonality is what that better approach looks like. -The funding infrastructure here is the concrete instrument of the movement: assurance contracts that let you pledge without risk, delegation that lets you contribute without having to pick projects yourself, retroactive funding that reimburses early contributors at cost, and cause boards that route money to whatever serves a cause you care about. +The funding infrastructure here is the concrete instrument of the movement: assurance contracts that let you pledge without risk, delegation that lets you contribute without having to pick projects yourself, retroactive funding that reimburses early contributors at cost, and fundable-projects boards that route money to whatever serves a cause you care about. Commonality is one of several connected sites: @@ -13,6 +13,8 @@ Commonality is one of several connected sites: This site is for the funding side: pledging, delegating, getting projects funded, and the broader case for why a new approach is needed. +**CauseStarter** is the organizer-facing front door: publish a cause (a set of signable statements) and let people attach only the jobs they would actually take. Start with [Do the part you’d do anyway](../causestarter/the-jobs.md). + ## See it in action @@ -70,7 +72,7 @@ The ideas the funding tools rest on: Ideas that live mainly on neighboring sites but are load-bearing here too: -- **[Statements and the implication graph](../tally/statements-and-implication-graph.md)** — what cause boards point at; the consumer surface is [Tally](../tally/index.md). +- **[Statements and the implication graph](../tally/statements-and-implication-graph.md)** — what fundable-projects boards point at; the consumer surface is [Tally](../tally/index.md). - **[Content funding](../content-funding/content-funding.md)** — the specialized contracts the Content Funding site is built on. - **[Trust networks](../shared/key-ideas/trust-networks.md)** — the attester / nudger graph; the underlying infrastructure is Conceptspace. diff --git a/docs/end-user/commonality/vision-and-strategy/README.md b/docs/end-user/commonality/vision-and-strategy/README.md index c9d026cbc..414d2b054 100644 --- a/docs/end-user/commonality/vision-and-strategy/README.md +++ b/docs/end-user/commonality/vision-and-strategy/README.md @@ -54,4 +54,6 @@ It's not just a slightly better mousetrap. We are [remarkably bad at producing p - Something [much easier than politics](./so-what/easier-than-politics.md) for people who want to fund public goods but are tired of fighting over government. The response to "the government is hostile, how do we fund our priorities?" isn't "organize a massive political movement" — it's "just start using this." - And potentially a path toward [more-local government](./so-what/local-government.md): Commonality's mechanisms work structurally better at smaller scales, so voluntary public-goods funding has a natural gravity toward localism — which, over time, may shift power from higher-level government to communities, not through confrontation but by routing around it. +The everyday pitch is not this whole argument. It is: **[do the part you’d do anyway](./the-jobs.md)** — cooperate on agreement without the extra jobs that used to make that impossible. CauseStarter’s catalog of those jobs is [here](../../causestarter/the-jobs.md). + For a concrete walkthrough of how this plays out, see the [walkthrough](/docs/end-user/shared/use-case-walkthroughs/defunding.md). For tailored pitches to different types of users, see [pitches](./pitches.md). For the "won't this be used for evil?" question, see [ethics](./ethics.md). diff --git a/docs/end-user/commonality/vision-and-strategy/credible-solution/discovery.md b/docs/end-user/commonality/vision-and-strategy/credible-solution/discovery.md index c9f92697b..431d797c8 100644 --- a/docs/end-user/commonality/vision-and-strategy/credible-solution/discovery.md +++ b/docs/end-user/commonality/vision-and-strategy/credible-solution/discovery.md @@ -4,6 +4,6 @@ Problem: With far more projects than anyone can evaluate, how do you find the go Government's solution: grant review panels and planning committees. A small number of people evaluate a large number of proposals. Bottlenecked, slow, subject to political influence. -Commonality's solution: cause boards with a trust network feeding delegates a stream of aligned projects. Discovery is distributed across many individuals (alignment attesters, delegates, the community), each contributing their own knowledge. The system aggregates their judgments per-user rather than requiring everyone to agree on one committee's picks. +Commonality's solution: fundable-projects boards with a trust network feeding delegates a stream of aligned projects. Discovery is distributed across many individuals (alignment attesters, delegates, the community), each contributing their own knowledge. The system aggregates their judgments per-user rather than requiring everyone to agree on one committee's picks. This is an instance of the broader [openness](../why-its-better/openness.md) principle: anyone can publish, filtering is crowdsourced through trust networks rather than centralized gatekeepers. diff --git a/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/donor-project-tension.md b/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/donor-project-tension.md index 0a86c06b2..ddaead101 100644 --- a/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/donor-project-tension.md +++ b/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/donor-project-tension.md @@ -24,7 +24,7 @@ Even at Level 1, the sole donor gets real benefits from using Commonality rather - **Standardized infrastructure.** No need to build custom grant-management systems, reporting frameworks, or accountability mechanisms. The blockchain handles all of that. - **Verifiable track record.** The funder builds a public, auditable history of what they've funded and how those projects turned out. This is valuable for the funder's own reputation — especially for foundations that want to demonstrate impact. - - **Project ecosystem visibility.** The project shows up in cause boards, can receive alignment attestations, and is legible to the broader Commonality ecosystem. Even if the funder is currently the only one paying, the project is *discoverable* by others in a way that a private grant isn't. + - **Project ecosystem visibility.** The project shows up in fundable-projects boards, can receive alignment attestations, and is legible to the broader Commonality ecosystem. Even if the funder is currently the only one paying, the project is *discoverable* by others in a way that a private grant isn't. - **Upgrade path preserved.** If the funder later decides they're fine with co-funders, or the project creator wants to diversify their funding base, the infrastructure is already there. No migration needed. And from the project creator's side, using Commonality even with a sole donor means: diff --git a/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/for-established-orgs.md b/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/for-established-orgs.md index 1694e4222..af8bef17c 100644 --- a/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/for-established-orgs.md +++ b/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/for-established-orgs.md @@ -20,7 +20,7 @@ A new charity faces a brutal chicken-and-egg: you need donors to trust you, but The alignment-attestation system — where attesters vouch that a project aligns with a cause — serves as a straightforward drop-in for how orgs already evaluate proposals. -**Start closed:** Hardcode your org as the single trusted attester for your cause board. Nothing changes about your decision-making process. You're just recording "this project fits our mission" decisions onchain instead of in an internal database. Minimal effort, immediate benefits. +**Start closed:** Hardcode your org as the single trusted attester for your fundable-projects board. Nothing changes about your decision-making process. You're just recording "this project fits our mission" decisions onchain instead of in an internal database. Minimal effort, immediate benefits. **Open gradually:** Once you're using the system with yourself as sole attester, it's easy to start accepting others: - Accept attestations from specific trusted partners. @@ -29,7 +29,7 @@ The alignment-attestation system — where attesters vouch that a project aligns This is a [dial, not a switch](./dial-not-switch.md). The org controls the pace. -**The common-ground angle:** Here's where it gets interesting. A progressive environmental org and a conservative rural-community org might never collaborate directly. But they might both care about "clean drinking water for rural communities." If they each write alignment statements about their priorities, the implication-attestation system connects them automatically. A water filtration project shows up in *both* cause boards — funded from both sides, without either side needing to acknowledge the other or compromise on broader ideology. +**The common-ground angle:** Here's where it gets interesting. A progressive environmental org and a conservative rural-community org might never collaborate directly. But they might both care about "clean drinking water for rural communities." If they each write alignment statements about their priorities, the implication-attestation system connects them automatically. A water filtration project shows up in *both* fundable-projects boards — funded from both sides, without either side needing to acknowledge the other or compromise on broader ideology. Nobody has to agree on *why* clean water matters. The system just surfaces that they agree on *what* should be done. diff --git a/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/tip-jar-upgrade-path.md b/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/tip-jar-upgrade-path.md index 66ad8046c..bba1cba4a 100644 --- a/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/tip-jar-upgrade-path.md +++ b/docs/end-user/commonality/vision-and-strategy/ease-of-adoption/tip-jar-upgrade-path.md @@ -10,7 +10,7 @@ An unconditional donation can use Commonality without becoming a financial produ - a durable record of who contributed, how much, and when; - meaningful leaderboard recognition for early support; -- a portable contribution history that can participate in Commonality's cause boards and trust system. +- a portable contribution history that can participate in Commonality's fundable-projects boards and trust system. A donor who wants no possible reimbursement can choose **Donate normally**. They keep the recognition receipt but permanently forgo the reimbursement claim. @@ -34,7 +34,7 @@ Compared with a plain tip jar, receipt-backed donations provide: - **No financial design required.** Start with ordinary donations and recognition receipts. - **A gradual project path.** Add assurance thresholds only when a defined piece of future work needs enough support to proceed. - **Access to skilled scouts.** Delegates and early contributors can identify promising work, build public track records, and recycle reimbursed giving budgets into new projects. -- **Connection to Commonality.** Work can appear on cause boards, receive delegated funding, and benefit from alignment and success attestations. +- **Connection to Commonality.** Work can appear on fundable-projects boards, receive delegated funding, and benefit from alignment and success attestations. ## Subscriptions diff --git a/docs/end-user/commonality/vision-and-strategy/pitches.md b/docs/end-user/commonality/vision-and-strategy/pitches.md index 4df162318..b8aef5e72 100644 --- a/docs/end-user/commonality/vision-and-strategy/pitches.md +++ b/docs/end-user/commonality/vision-and-strategy/pitches.md @@ -33,3 +33,5 @@ Of course a cause still does need to actually gather users — get donors to ple ## The common thread Every pitch is about something individually useful. Nobody needs to be told "join our movement" or "here's our plan to persuade half the country." It's "here's this one small thing you can do, it's useful to you right now, and it doesn't depend on what anybody else does." + +The same idea, written as the “I’d be happy to X, but ugh Y” obstacles the substrate removes: **[Do the part you’d do anyway](../../causestarter/the-jobs.md)** ([strategy note](./the-jobs.md)). diff --git a/docs/end-user/commonality/vision-and-strategy/the-jobs.md b/docs/end-user/commonality/vision-and-strategy/the-jobs.md new file mode 100644 index 000000000..7ecf6d203 --- /dev/null +++ b/docs/end-user/commonality/vision-and-strategy/the-jobs.md @@ -0,0 +1,9 @@ +# Cooperate on agreement; skip the extra jobs + +The simple vision: people who already agree should be able to pool money and skill on that agreement. + +The invention is not a new kind of organization. It is **splitting the work into jobs people would already take**, and removing the extra job each role used to demand (pick every project, trust a big org, elect a board, know a grant officer, swallow someone else’s wording, wait for critical mass). + +Late aggregation is *how* that is allowed: you do not have to agree on a leader or a manifesto first. Organic coalitions are a *bonus* of not forcing a bundle. Neither is the headline. The headline is: **do the part you’d do anyway.** + +The user-facing catalog — money, attention, work, wording, “my own cause page” — lives in CauseStarter’s **[Do the part you’d do anyway](../../causestarter/the-jobs.md)**. Role-by-role one-liners: [pitches](./pitches.md). diff --git a/docs/end-user/commonality/vision-and-strategy/why-its-better/openness.md b/docs/end-user/commonality/vision-and-strategy/why-its-better/openness.md index eec25b609..af0efa73b 100644 --- a/docs/end-user/commonality/vision-and-strategy/why-its-better/openness.md +++ b/docs/end-user/commonality/vision-and-strategy/why-its-better/openness.md @@ -11,7 +11,7 @@ In Commonality, this applies throughout: Anyone can publish whatever they want, which will inevitably produce a huge mass of mostly-garbage. The filtering happens afterward, through the same mechanisms that work on the rest of the internet: - **Social signals.** Sorting by trending (velocity of new signatures), number of supporters, or amount of funding pledged naturally surfaces the things that real people care about. - - **Delegation and trust networks.** You choose which attesters to trust. If you trust Alice and Alice says a project is aligned, it shows up in your cause board. If you don't trust Bob, his attestations are invisible to you. The filtering is per-user, not imposed by a central authority. + - **Delegation and trust networks.** You choose which attesters to trust. If you trust Alice and Alice says a project is aligned, it shows up in your fundable-projects board. If you don't trust Bob, his attestations are invisible to you. The filtering is per-user, not imposed by a central authority. - **Implication attestations.** You don't need to find the "right" statement — say what you want, and the system connects you with similar statements that have more traction. The popular ones float up without anyone deciding they should. - **Skin in the game.** Assurance contracts mean money is at stake. A project with $50K in pledges from 200 people has been filtered by something much more meaningful than upvotes — people put their actual money behind it (conditionally, but still). diff --git a/docs/end-user/commonality/vision-and-strategy/why-its-better/organic-coalitions.md b/docs/end-user/commonality/vision-and-strategy/why-its-better/organic-coalitions.md index 7a7f54bc2..7101d7e87 100644 --- a/docs/end-user/commonality/vision-and-strategy/why-its-better/organic-coalitions.md +++ b/docs/end-user/commonality/vision-and-strategy/why-its-better/organic-coalitions.md @@ -17,7 +17,7 @@ What this looks like in practice: - A progressive environmental org publishes: "Rural communities deserve access to clean drinking water regardless of economic status." - A conservative community group publishes: "Local communities should control their own water infrastructure without federal interference." - An implication attester notices that both statements support a concrete project: "Build a water filtration system for Millbrook County." - - The project shows up in *both* groups' cause boards. It gets funded from both sides. Neither group had to acknowledge the other, compromise on language, or join a coalition. + - The project shows up in *both* groups' fundable-projects boards. It gets funded from both sides. Neither group had to acknowledge the other, compromise on language, or join a coalition. Nobody had to agree on *why* clean water matters. The system just surfaced that they agree on *what* should be done. diff --git a/docs/end-user/commonality/vision-and-strategy/why-its-better/what-its-better-for.md b/docs/end-user/commonality/vision-and-strategy/why-its-better/what-its-better-for.md index 028e2713d..c42748087 100644 --- a/docs/end-user/commonality/vision-and-strategy/why-its-better/what-its-better-for.md +++ b/docs/end-user/commonality/vision-and-strategy/why-its-better/what-its-better-for.md @@ -4,7 +4,7 @@ We already have governments and charities funding public goods. The world is not Building a vertical and need concrete examples rather than categories? These seven are the *gate*; `specs/product/cause-taxonomy.md` is the -generator that turns them into a populated cause board. +generator that turns them into a populated fundable-projects board. ## The kinds it's better for diff --git a/docs/end-user/lazyGiving/get-your-project-funded.md b/docs/end-user/lazyGiving/get-your-project-funded.md index d038b6749..1e9d46299 100644 --- a/docs/end-user/lazyGiving/get-your-project-funded.md +++ b/docs/end-user/lazyGiving/get-your-project-funded.md @@ -22,13 +22,13 @@ You don't have to convince every individual donor. You have to convince the scou You write up your project: what it is, what it will accomplish, how much you need. You set a funding goal and optionally a deadline. -Your project is published on-chain. Donors find it by browsing, through cause boards connected to relevant causes, or because a delegate or attester they trust has pointed them toward it. +Your project is published on-chain. Donors find it by browsing, through fundable-projects boards connected to relevant causes, or because a delegate or attester they trust has pointed them toward it. When pledges reach your goal, funds are released. If the deadline passes without reaching the goal, all pledges are automatically refunded. After you've delivered, your project remains open for **retroactive funding**. Donors who wanted to see results first can donate to its reimbursement waterfall. That money becomes available to early contributors pro-rata, capped at exactly what each person put in; it does not pay the project a second time and never includes interest or a premium. Reimbursement lets scouts use the same giving budget on another early project, while their public record helps them attract delegated funds. -**Alignment attestations** help a lot. If someone trusted by a community attests that your project aligns with a cause they care about, you become visible in that community's cause board — and visible to delegates who fund on that community's behalf. Reach out to people you know and ask them to vouch for your project's alignment. No money involved; just a statement of connection. +**Alignment attestations** help a lot. If someone trusted by a community attests that your project aligns with a cause they care about, you become visible in that community's fundable-projects board — and visible to delegates who fund on that community's behalf. Reach out to people you know and ask them to vouch for your project's alignment. No money involved; just a statement of connection. ## Getting started @@ -42,6 +42,6 @@ If you know anyone in the relevant community, ask them to attest that your proje A LazyGiving campaign is a one-off ask. Most projects benefit from a presence on neighboring sites too: -- **[Set up a cause board](../alignment/index.md)** on Aligning — if your project has ongoing operating costs, a portal lets delegates route monthly cause pledges to you. LazyGiving handles the launch ask; Aligning handles the sustaining drip. +- **[Set up a fundable-projects board](../alignment/index.md)** on Aligning — if your project has ongoing operating costs, a portal lets delegates route monthly cause pledges to you. LazyGiving handles the launch ask; Aligning handles the sustaining drip. - **[Get content pieces funded](../content-funding/get-your-content-funded.md)** on Content Funding — if your project produces individual articles, videos, or posts, each one can be funded separately. - **[Ask for attestations](../alignment/help-connect-things.md)** — find people in the community whose attestations carry weight, and ask them to vouch that your project aligns with relevant causes. diff --git a/docs/end-user/shared/key-ideas/trust-networks.md b/docs/end-user/shared/key-ideas/trust-networks.md index 6992761ba..ad1a3ca79 100644 --- a/docs/end-user/shared/key-ideas/trust-networks.md +++ b/docs/end-user/shared/key-ideas/trust-networks.md @@ -8,7 +8,7 @@ You might trust a few friends, a few public figures in fields you care about, an The system has a lot of information flowing through it — project endorsements ("project P serves cause C"), statement connections ("statement S1 implies statement S2"), content quality evaluations. Without filtering, it would be noise. Trust networks are the filter. -You only see endorsements from people in your trust network. That means the projects in your cause board, the statement connections in your implication graph, and the content evaluations you see are all filtered through the judgment of people you (directly or transitively) trust. +You only see endorsements from people in your trust network. That means the projects in your fundable-projects board, the statement connections in your implication graph, and the content evaluations you see are all filtered through the judgment of people you (directly or transitively) trust. ## No central gatekeepers @@ -17,5 +17,5 @@ This is the alternative to having a platform decide what's legitimate. Different ## How this shows up in practice - You set up your trust network by marking a few people or organizations as trusted. The system computes the transitive closure automatically. -- Everything you see — cause boards, statement pages, project recommendations — is filtered through your trust network. +- Everything you see — fundable-projects boards, statement pages, project recommendations — is filtered through your trust network. - You can adjust your trust network at any time. Add or remove someone, and your view of the system updates accordingly. diff --git a/docs/end-user/shared/use-case-walkthroughs/common-sense-majority.md b/docs/end-user/shared/use-case-walkthroughs/common-sense-majority.md index 0ab965036..e1cbf9435 100644 --- a/docs/end-user/shared/use-case-walkthroughs/common-sense-majority.md +++ b/docs/end-user/shared/use-case-walkthroughs/common-sense-majority.md @@ -41,7 +41,7 @@ The common-sense majority has always been there. It just couldn't see itself. Now that the demand is visible, things start to happen: -- **A cause board** for this cause now has real demand signal. There's money behind it — pledges from people who said they'd support this kind of thing. +- **A fundable-projects board** for this cause now has real demand signal. There's money behind it — pledges from people who said they'd support this kind of thing. - **Delegates** start directing funds toward specific projects and content that serve this cause. - **Content creators** see the portal and start targeting it — writing the kind of thoughtful, noninflammatory content that people said they wanted. - **Projects emerge** — maybe a media outlet that commits to the standard, maybe a set of AI evaluation tools, maybe a directory of writers who consistently produce good work. diff --git a/docs/end-user/shared/use-case-walkthroughs/defunding.md b/docs/end-user/shared/use-case-walkthroughs/defunding.md index a4ca14f44..81a7535cd 100644 --- a/docs/end-user/shared/use-case-walkthroughs/defunding.md +++ b/docs/end-user/shared/use-case-walkthroughs/defunding.md @@ -34,7 +34,7 @@ The province announces the funding cut. Now things get serious. - Maria sets up a **standby assurance contract**: "If provincial funding is cut, fund the mentorship program at $120K/year." Monthly pledges, $10K/month threshold. - 180 families pledge an average of $60/month. That's $10,800/month in publicly verifiable, locked pledges — past the threshold, but the community keeps going to build a buffer. - A local business owner pledges $2K/month through a bridge operator (tax-deductible via the program's existing charity). - - The wider network kicks in: three families from neighboring towns who signed the broader "rural communities" statement see Millbrook's project in their cause board (via the implication attestation) and pledge $50/month each. + - The wider network kicks in: three families from neighboring towns who signed the broader "rural communities" statement see Millbrook's project in their fundable-projects board (via the implication attestation) and pledge $50/month each. The pledges are onchain. The province can see exactly how much is committed and by whom. This isn't a petition with signatures — it's $13K/month in conditionally locked money. The community is demonstrating that the program survives without provincial funding. This is the [credible threat](../../commonality/vision-and-strategy/hard-to-stop/credible-threat.md). @@ -50,7 +50,7 @@ Alternatively: the province follows through on the cut. The assurance contract a - The $10K/month threshold is met (it grew to $11.5K during the standoff as more people pledged). Monthly funding flows to the program. - Maria, as delegate, directs the supplementary delegated funds ($2K, now grown to $5K as the crisis attracted more delegators) toward specific needs — new mentoring materials, a part-time coordinator. - - James attests alignment for a neighboring town's similar program, which now shows up in Millbrook's cause board. A donor who pledged for Millbrook sees the neighboring program and pledges there too. + - James attests alignment for a neighboring town's similar program, which now shows up in Millbrook's fundable-projects board. A donor who pledged for Millbrook sees the neighboring program and pledges there too. - The program's track record — months of successful delivery, transparent spending, growing community support — is all onchain. When the program applies for a private foundation grant six months later, the application includes verifiable proof of community backing. The foundation, impressed by the evidence, provides matching funds. ## What this illustrates diff --git a/docs/end-user/shared/use-case-walkthroughs/local-funding-shift.md b/docs/end-user/shared/use-case-walkthroughs/local-funding-shift.md index b9349dd91..724d8e821 100644 --- a/docs/end-user/shared/use-case-walkthroughs/local-funding-shift.md +++ b/docs/end-user/shared/use-case-walkthroughs/local-funding-shift.md @@ -24,7 +24,7 @@ None of this is about water infrastructure yet. Each town is just building the m - A resident of Millbrook creates a statement: "Our region's water infrastructure needs upgrading and we shouldn't have to wait for the province." - An implication attester (AI service) notices that people in Cedarville and Harton have signed similar statements — "Cedarville needs water treatment upgrades," "rural communities deserve reliable water infrastructure." It connects these through the implication graph. - - Now supporters in all three towns can see each other's statements and pledges in their cause boards. Nobody organized this. The system discovered the shared interest. + - Now supporters in all three towns can see each other's statements and pledges in their fundable-projects boards. Nobody organized this. The system discovered the shared interest. Ravi in Cedarville sees Millbrook's water-related pledges. Diane in Harton sees both. They start talking — not because an institution connected them, but because the implication graph made the overlap visible. diff --git a/docs/end-user/shared/use-case-walkthroughs/noninflammatory-content.md b/docs/end-user/shared/use-case-walkthroughs/noninflammatory-content.md index a2e2cfabb..1df0572ed 100644 --- a/docs/end-user/shared/use-case-walkthroughs/noninflammatory-content.md +++ b/docs/end-user/shared/use-case-walkthroughs/noninflammatory-content.md @@ -54,9 +54,9 @@ You find a delegate you trust — someone who's good at curating thoughtful poli ### The content creator -You're a writer. You look at the cause board for this cause and see real money — pledged and available — from people who want exactly the kind of writing you want to do. You write a piece about immigration that makes the conservative case without painting progressives as naive or unpatriotic. You submit it to an AI evaluation service that assesses whether it meets the standard: does it steelman the opposing view? Does it avoid contempt? Could a reasonable person who disagrees engage with it without feeling attacked? +You're a writer. You look at the fundable-projects board for this cause and see real money — pledged and available — from people who want exactly the kind of writing you want to do. You write a piece about immigration that makes the conservative case without painting progressives as naive or unpatriotic. You submit it to an AI evaluation service that assesses whether it meets the standard: does it steelman the opposing view? Does it avoid contempt? Could a reasonable person who disagrees engage with it without feeling attacked? -It passes. The AI issues an attestation: this piece meets the standard. Donors whose cause boards are configured to trust this evaluator now see your piece. Some fund it directly; others have delegated their decisions to taste-makers who fund it on their behalf. Either way, money flows to you for doing exactly what you wanted to do. +It passes. The AI issues an attestation: this piece meets the standard. Donors whose fundable-projects boards are configured to trust this evaluator now see your piece. Some fund it directly; others have delegated their decisions to taste-makers who fund it on their behalf. Either way, money flows to you for doing exactly what you wanted to do. ### The active reader / taste-maker @@ -71,7 +71,7 @@ The writer submits it to an AI evaluator that's designed to assess content from Now here's what happens: -- Left-leaning donors who pledged to "constructive discourse" see it in their cause board — because the implication graph connected their statement to this content's cause. +- Left-leaning donors who pledged to "constructive discourse" see it in their fundable-projects board — because the implication graph connected their statement to this content's cause. - Right-leaning donors who pledged to "content that doesn't paint us as villains" also see it — through a completely different path in the implication graph. - Both groups end up funding the same piece, without coordinating or even knowing about each other. diff --git a/docs/end-user/tally/index.md b/docs/end-user/tally/index.md index 06e5afe7c..59b41b67d 100644 --- a/docs/end-user/tally/index.md +++ b/docs/end-user/tally/index.md @@ -35,7 +35,7 @@ Petitions usually feel like shouting into a void. You sign one, it goes nowhere, Tally exists to fix two specific problems with that: - **Fragmented support.** A thousand people who all believe roughly the same thing end up scattered across a hundred different petitions, and nobody — including them — can see that they're a coalition. The implication graph aggregates that scattered support into something visible. -- **No follow-through.** Signing a petition is the end of the interaction. Tally is designed so a signature can become a signal that other tools — cause boards, organizing sites, content-funding contracts — can act on. Your signature on a statement about clean water can route real money toward projects that serve it. +- **No follow-through.** Signing a petition is the end of the interaction. Tally is designed so a signature can become a signal that other tools — fundable-projects boards, organizing sites, content-funding contracts — can act on. Your signature on a statement about clean water can route real money toward projects that serve it. Tally is the consumer-facing front door. The signing primitives, trust network, and implication graph it sits on top of are shared infrastructure used by sibling sites in the [Commonality](/docs) ecosystem. diff --git a/docs/end-user/tally/statements-and-implication-graph.md b/docs/end-user/tally/statements-and-implication-graph.md index 6def63e6f..30095c1c3 100644 --- a/docs/end-user/tally/statements-and-implication-graph.md +++ b/docs/end-user/tally/statements-and-implication-graph.md @@ -16,7 +16,7 @@ This is how the system builds coalitions without anyone having to build them. Yo ## How this shows up in practice -- **Cause boards** show you projects aligned with statements you've signed — including projects aligned with statements *implied by* your statements. You don't have to go looking; the system surfaces what's relevant. +- **Fundable-projects boards** show you projects aligned with statements you've signed — including projects aligned with statements *implied by* your statements. You don't have to go looking; the system surfaces what's relevant. - **Supporter counts** include both direct signers and indirect supporters (people who signed statements that imply yours). That number reflects the real, latent demand for a cause — often much larger than anyone expected. - **Projects** connect to causes through endorsements: someone (or some AI) vouches that a project serves a particular cause. If that cause is connected to your statements through the implication graph, the project appears in your portal. @@ -36,6 +36,6 @@ For a detailed look at the different shapes these patterns take, see the [common ## Cross-partisan discovery -The system surfaces common ground even between groups that would never coordinate directly. A progressive environmental organization and a conservative community group might each sign statements in their own language — "rural communities deserve access to clean drinking water regardless of economic status" vs. "local communities should control their own water infrastructure without federal interference." Different words, different politics. But an implication attester can notice that both statements support the same concrete project: a water filtration system for a specific county. The project appears in both groups' cause boards. Neither group needs to acknowledge the other, agree on framing, or join a coalition. They just both end up funding the same thing. +The system surfaces common ground even between groups that would never coordinate directly. A progressive environmental organization and a conservative community group might each sign statements in their own language — "rural communities deserve access to clean drinking water regardless of economic status" vs. "local communities should control their own water infrastructure without federal interference." Different words, different politics. But an implication attester can notice that both statements support the same concrete project: a water filtration system for a specific county. The project appears in both groups' fundable-projects boards. Neither group needs to acknowledge the other, agree on framing, or join a coalition. They just both end up funding the same thing. Nobody had to agree on *why* clean water matters. The system found that they agreed on *what should happen*. diff --git a/docs/end-user/tldr-for-llms.md b/docs/end-user/tldr-for-llms.md index a429f2051..3bcb09e5b 100644 --- a/docs/end-user/tldr-for-llms.md +++ b/docs/end-user/tldr-for-llms.md @@ -7,13 +7,29 @@ Each entry follows the same shape: - **When a user encounters it** — UI surfaces or moments in the user's flow where this concept becomes load-bearing. - **What they might want help with** — typical assistant tasks around this concept. +## CauseStarter (you landed on a cause page) + +[Full page](/docs/end-user/causestarter/index.md) + +- **What it is:** The newcomer briefing. Almost everyone arrives via a circulated cause URL, not a catalog. The page is a watch surface for **fundable projects** aligned with some of its statements — not a club, not a petition site. Signing is optional and free; pledging (refundable) and publishing work are the real jobs. After signing, CauseStarter **home** is a personal fundable-projects board (union over signed statements), not a private cause. +- **When a user encounters it:** In-app `/docs`, or when they ask what this cause page is; returning users on CauseStarter `/`. +- **What they might want help with:** What they can do without joining; how projects get on the list; pledging vs signing; when to start their own page vs using this one; finding “my” projects vs an organizer’s mix. + +## Do the part you’d do anyway (jobs, not an org) + +[Full page](/docs/end-user/causestarter/the-jobs.md) + +- **What it is:** The everyday pitch for CauseStarter / Commonality. Cooperate on agreement; split the work into jobs people would already take (money, attention, work, wording); remove the extra job each role used to demand. Not “join a movement.” +- **When a user encounters it:** CauseStarter landing, empty states on cause/project/statement pages, `/docs/the-jobs`. +- **What they might want help with:** Which job to take; why they don’t need a committee, grant officer, or matching manifesto; how delegation / refundable pledges / retroactive funding / bridges / reusing statements unblock the matching “ugh.” + ## Statements and the implication graph [Full page](/docs/end-user/tally/statements-and-implication-graph.md) - **What it is:** Users express what they care about by signing statements (free-text). The implication graph is a system of "S1 implies S2" relationships (generated by AI services) that connects related statements automatically. Together, these form the backbone of how supply (projects) meets demand (donors) without centralized coordination. -- **When a user encounters it:** When signing a statement, when viewing a cause board, when seeing supporter counts, when understanding why a particular project appeared in their feed. -- **What they might want help with:** Writing or finding a statement that expresses what they care about; understanding why certain projects show up in their cause board; understanding the supporter count on a statement page. +- **When a user encounters it:** When signing a statement, when viewing a fundable-projects board, when seeing supporter counts, when understanding why a particular project appeared in their feed. +- **What they might want help with:** Writing or finding a statement that expresses what they care about; understanding why certain projects show up in their fundable-projects board; understanding the supporter count on a statement page. ## Assurance contracts @@ -43,8 +59,8 @@ Each entry follows the same shape: [Full page](/docs/end-user/alignment/successful-projects.md) -- **What it is:** A second attestation type on Aligning, parallel to alignment vouches. An *alignment* vouch says a project is *trying* to serve a cause; a *success* vouch says it *delivered*. Both anchor to the same cause statement and ride the same trust graph + implication propagation. The **Successful projects** view on a cause board shows projects that (a) the viewer's trust network has vouched as successful and (b) still have donation receipts outstanding — a call-to-action queue for retroactive funders, not an official "this succeeded" pronouncement. It is the primary surface for donors who want to support proven work without predicting winners or detecting scams in advance. -- **When a user encounters it:** When viewing a cause board's Successful tab; when deciding which proven projects' early contributors to reimburse; when vouching that a project delivered (the mirror of vouching for alignment); when reading a "vouched successful" section on a LazyGiving project page. +- **What it is:** A second attestation type on Aligning, parallel to alignment vouches. An *alignment* vouch says a project is *trying* to serve a cause; a *success* vouch says it *delivered*. Both anchor to the same cause statement and ride the same trust graph + implication propagation. The **Successful projects** view on a fundable-projects board shows projects that (a) the viewer's trust network has vouched as successful and (b) still have donation receipts outstanding — a call-to-action queue for retroactive funders, not an official "this succeeded" pronouncement. It is the primary surface for donors who want to support proven work without predicting winners or detecting scams in advance. +- **When a user encounters it:** When viewing a fundable-projects board's Successful tab; when deciding which proven projects' early contributors to reimburse; when vouching that a project delivered (the mirror of vouching for alignment); when reading a "vouched successful" section on a LazyGiving project page. - **What they might want help with:** Understanding the difference between aligned and successful; understanding that the page reflects others' vouches (trust-filtered), not a platform verdict; finding proven projects with outstanding reimbursements to fund; donating to close a project's reimbursement loop; posting a success vouch for a project they believe delivered; registering disagreement with a vouch ("I don't agree this delivered") by downgrading trust in whoever vouched — the consequence of a bad success vouch is reputational, not financial. ## Credible threats @@ -60,7 +76,7 @@ Each entry follows the same shape: [Full page](/docs/end-user/content-funding/content-funding.md) - **What it is:** LazyGiving (assurance contracts) pointed at social-media content. A creator groups pieces of content (tweets, YouTube videos, Substack posts) into a contract with a funding goal; contributors contribute toward the items they value; funds release only if the goal is met, else everyone is refunded. A content contract *is* a LazyGiving project. Creators claim their channel by posting a verification code from the account; third parties can start a contract for an unclaimed channel, but funds wait for the creator. Via Aligning, cause pools (optionally using AI evaluators) can fund whole *kinds* of content — the engine behind funding noninflammatory/insightful/etc. content. -- **When a user encounters it:** When pledging toward a specific piece of content; when grouping content into a contract and setting a goal; when claiming a channel as a creator; when browsing a content-focused cause board. +- **When a user encounters it:** When pledging toward a specific piece of content; when grouping content into a contract and setting a goal; when claiming a channel as a creator; when browsing a content-focused fundable-projects board. - **What they might want help with:** Claiming their channel; setting up a contract and funding goal; understanding the pledge-and-refund mechanism; finding content to fund; understanding how cause pools fund kinds of content. ## Trust networks @@ -68,7 +84,7 @@ Each entry follows the same shape: [Full page](shared/key-ideas/trust-networks.md) - **What it is:** Each user chooses who they trust. The system follows trust transitively (if you trust A and A trusts B, you see B's endorsements). This is how the system filters noise and surfaces relevant information without central gatekeepers. -- **When a user encounters it:** When choosing who to trust in settings; when seeing project endorsements in their cause board; when understanding why certain projects or content appear (or don't appear) in their feed. +- **When a user encounters it:** When choosing who to trust in settings; when seeing project endorsements in their fundable-projects board; when understanding why certain projects or content appear (or don't appear) in their feed. - **What they might want help with:** Setting up their trust network; understanding why they're seeing (or not seeing) certain endorsements; understanding how trust chains work; using **Downtrust** to register disagreement with a vouch (alignment *or* success) by decreasing/zeroing trust in whoever made it — a first-class action aimed at the voucher, the symmetric counterpart to vouching. ## How actions compound diff --git a/inbox.md b/inbox.md index f933758e5..c38567ec9 100644 --- a/inbox.md +++ b/inbox.md @@ -29,8 +29,12 @@ Also, don't let any of the items get too long; usually there's a separate .md fi - Before deploying the CauseStarter alignment-trust bootstrap outside local Hardhat, run `node scripts/generate-wallets.mjs`, fund `ALIGNMENT_TRUST_BOOTSTRAP_ADDRESS`, install the worker's generated Render secret block, and add the configured denylist canary to its persistent disk. Never deploy the checked-in local Hardhat key; see the worker README runbook. +- **(Tell)** Personal dashboard spec + first slice: [personal-dashboard.md](specs/product/personal-dashboard.md). CauseStarter home (connected) heroes the fundable-projects union over signed statements. Not an unpublished cause board. Stars/subsets deferred. + ### Docs / UI copy +- **(Tell)** Applied [cause-page-not-a-club.md](specs/product/cause-page-not-a-club.md) copy sweep: glossary two-step rename, end-user docs, Aligning/fundable-projects UI strings, CauseStarter high-traffic docs + organizer publish copy. Leftover “cause page” in comments, `/cause/:owner/:slug` and `fundingportal*` identifiers, and incidental “funding portal” docs still lag. + - Decide whether to act on the fresh landing-copy positioning findings. The Civility grievance-first hero was reviewed and is fine; the verifier rubric was corrected so CSM’s recognition-register rule is not imposed on every vertical. Remaining findings are elsewhere: the umbrella Commonality landing still recruits generic end users despite the founder-first strategy, CSM front-loads the mediator toggle and uses “the other side’s bullshit,” Aligning repeats its main tradeoff several times, and Tally’s “Sign once, counted forever” headline presents a future goal as current capability. ### Features that I'm realizing would make a big difference diff --git a/package-lock.json b/package-lock.json index 957112aaa..c3aa70966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -168,7 +168,9 @@ "connectkit": "^1.9.1", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.18.2", + "rehype-sanitize": "^6.0.0", "viem": "2.54.3", "wagmi": "^3.6.21" }, diff --git a/package.json b/package.json index 4d888d49b..24ce0966c 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,9 @@ "build:docs": "turbo run docs --filter=@commonality/sdk --filter=@commonality/hardhat", "attester:build": "turbo run build --filter=@commonality/implication-attester", "ui:dev": "npm run dev --workspace=ui", - "causestarter:dev": "npm run dev --workspace=causestarter", - "causestarter:build": "npm run build --workspace=causestarter", - "causestarter:test": "npm run test --workspace=causestarter", + "causestarter:dev": "npm run dev:causestarter --workspace=ui", + "causestarter:build": "VITE_DOMAIN=causestarter npm run build --workspace=ui", + "causestarter:test": "npm run test:vitest --workspace=ui -- src/causestarter", "causestarter:deploy": "./scripts/deploy-causestarter.sh", "causestarter:deploy:stop": "./scripts/deploy-causestarter.sh --stop", "local:check": "./scripts/check-local-config-sync.sh", @@ -47,7 +47,7 @@ "cause-assist:typecheck": "npm run typecheck --workspace=@commonality/cause-assist", "coherence-badge-worker:test": "npm run test --workspace=@commonality/coherence-badge-worker", "alignment-trust-bootstrap:test": "npm run test --workspace=@commonality/alignment-trust-bootstrap", - "causestarter:typecheck": "npm run typecheck --workspace=causestarter", + "causestarter:typecheck": "npm run typecheck --workspace=ui", "ui:dev:commonality": "npm run dev:commonality --workspace=ui", "ui:dev:lazyGiving": "npm run dev:lazyGiving --workspace=ui", "ui:dev:alignment": "npm run dev:alignment --workspace=ui", diff --git a/scripts/data.sh b/scripts/data.sh index e71216f4b..7a5e764a1 100755 --- a/scripts/data.sh +++ b/scripts/data.sh @@ -5,6 +5,7 @@ # Usage: # ./scripts/data.sh --wipe # Wipe data directory (stops services first) # ./scripts/data.sh --seed # Tiny dataset (default; fast local UI) +# ./scripts/stop-wipe-restart.sh --seed # Wipe + start + seed in one go # ./scripts/data.sh --seed=tiny # Tiny dataset (5 users, 1 round, capped statements/actions) # ./scripts/data.sh --seed=small # Small dataset (10 users, 3 rounds) # ./scripts/data.sh --seed=medium # Medium dataset (50 users, 5 rounds) @@ -88,20 +89,32 @@ require_services_running() { fi } +# True when the events-cache JSON has at least one item. +indexer_events_nonempty() { + echo "$1" | grep -q '"items":\[{' +} + +# --start always writes TrustSet (Hardhat #0–#9 starter network) after deploy. +# That is not a previous fake-data seed. Refuse only when user-content events exist. error_if_indexer_already_has_data_unless_allowed() { local allow_existing_data="$1" - local response - response=$(curl -s "http://localhost:42069/api/events?limit=1" 2>/dev/null || true) - if echo "$response" | grep -q '"items":\[{' ; then + local support projects published + support=$(curl -s "http://localhost:42069/api/events?eventName=DirectSupport&limit=1" 2>/dev/null || true) + projects=$(curl -s "http://localhost:42069/api/events?eventName=ProjectCreated&limit=1" 2>/dev/null || true) + published=$(curl -s "http://localhost:42069/api/events?eventName=DataPublished&limit=1" 2>/dev/null || true) + if indexer_events_nonempty "$support" \ + || indexer_events_nonempty "$projects" \ + || indexer_events_nonempty "$published"; then echo "" if [ "$allow_existing_data" = "true" ]; then - echo "Warning: the Ponder indexer already has event data." + echo "Warning: the indexer already has seed-like events (signatures, projects, or published data)." echo "Proceeding because --allow-seed-on-existing-data was passed." else - echo "Error: the Ponder indexer already has event data." - echo "Seeding again would add more data on top of the current local chain, and if the chain was reset without clearing Ponder it can produce a blank or stale UI." - echo "For a clean demo seed, run './scripts/data.sh --wipe', then './scripts/services.sh --start', then seed again." - echo "If you really want to add new seed data on top of the existing data, pass --allow-seed-on-existing-data." + echo "Error: the indexer already has seed-like events (signatures, projects, or published data)." + echo "Seeding again would add more data on top of the current local chain." + echo "For a clean seed: './scripts/data.sh --wipe' then './scripts/services.sh --start' then './scripts/data.sh --seed'" + echo "(or './scripts/stop-wipe-restart.sh --seed')." + echo "To add another seed on top of existing data, pass --allow-seed-on-existing-data." echo "" exit 1 fi diff --git a/scripts/publish-ui-to-ipfs.mjs b/scripts/publish-ui-to-ipfs.mjs index c64001c99..a208f76ef 100644 --- a/scripts/publish-ui-to-ipfs.mjs +++ b/scripts/publish-ui-to-ipfs.mjs @@ -8,14 +8,12 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const rootDir = path.resolve(__dirname, '..') -// UI_PACKAGE selects the app package: "ui" (multi-domain) or "causestarter". -const uiPackage = resolveUiPackage(process.env.UI_PACKAGE) -const buildDomain = uiPackage === 'causestarter' +// CauseStarter is a VITE_DOMAIN of the ui package. UI_PACKAGE=causestarter is +// accepted as an alias for VITE_DOMAIN=causestarter. +const buildDomain = process.env.UI_PACKAGE === 'causestarter' ? 'causestarter' : resolveDomain(process.env.VITE_DOMAIN) -const distDir = uiPackage === 'causestarter' - ? path.join(rootDir, 'causestarter', 'dist') - : path.join(rootDir, 'ui', 'dist', buildDomain) +const distDir = path.join(rootDir, 'ui', 'dist', buildDomain) const artifactDir = process.env.UI_IPFS_ARTIFACT_DIR || path.join(rootDir, 'data', 'ui-ipfs', buildDomain) const ipfsApiBaseUrl = (process.env.UI_IPFS_API_URL || 'http://ipfs:5001').replace(/\/$/, '') @@ -34,6 +32,7 @@ const LOCAL_STABLE_DOMAIN_URLS = { VITE_NONINFLAMMATORY_URL: getLocalStableUrl('civility', localStableGatewayPort), VITE_CSM_URL: getLocalStableUrl('common-sense-majority', localStableGatewayPort), VITE_CONCEPTSPACE_URL: getLocalStableUrl('conceptspace', localStableGatewayPort), + VITE_CAUSESTARTER_URL: getLocalStableUrl('causestarter', localStableGatewayPort), } const UI_ENV_ADDRESS_MAPPINGS = { @@ -58,11 +57,6 @@ const UI_ENV_ADDRESS_MAPPINGS = { PROSPECTIVE_CONTENT_ROUND_FACTORY_ADDRESS: 'VITE_PROSPECTIVE_CONTENT_ROUND_FACTORY_ADDRESS', } -function resolveUiPackage(value) { - if (value === 'causestarter') return 'causestarter' - return 'ui' -} - function parseEnvFile(content) { const entries = {} @@ -98,10 +92,10 @@ async function loadEnvFile(filePath) { async function loadUiBuildEnvFromFiles() { const rootEnv = await loadEnvFile(path.join(rootDir, '.env')) const uiEnv = await loadEnvFile(path.join(rootDir, 'ui', '.env')) - const causestarterEnv = uiPackage === 'causestarter' - ? await loadEnvFile(path.join(rootDir, 'causestarter', '.env')) - : {} - const env = { ...uiEnv, ...causestarterEnv } + const env = { ...uiEnv } + if (buildDomain === 'causestarter') { + Object.assign(env, await loadEnvFile(path.join(rootDir, 'causestarter', '.env'))) + } for (const [sourceKey, viteKey] of Object.entries(UI_ENV_ADDRESS_MAPPINGS)) { if (rootEnv[sourceKey]) { @@ -250,18 +244,6 @@ async function writeArtifacts(result) { } function buildUiPackage(buildEnv) { - if (uiPackage === 'causestarter') { - console.log('Building CauseStarter for IPFS...') - runOrThrow('npm', ['run', 'build', '--workspace=@commonality/sdk'], { env: buildEnv }) - runOrThrow('npm', ['run', 'build', '--workspace=causestarter'], { - env: { - ...buildEnv, - VITE_HASH_ROUTING: 'true', - }, - }) - return - } - console.log(`Building ${buildDomain} UI in IPFS mode...`) runOrThrow('npm', ['run', 'ui:build:ipfs'], { env: buildEnv }) } @@ -276,7 +258,7 @@ async function main() { await writeArtifacts(result) console.log('') - console.log(uiPackage === 'causestarter' ? 'CauseStarter published to local IPFS.' : 'UI published to local IPFS.') + console.log(`${buildDomain} published to local IPFS.`) console.log(` CID: ${result.cid}`) console.log(` IPFS root: ${result.ipfsRootUrl}`) console.log(` SPA URL: ${result.spaUrl}`) diff --git a/scripts/seed-causestarter-vite-env.py b/scripts/seed-causestarter-vite-env.py index 5eee3afa1..c0ddf71b5 100755 --- a/scripts/seed-causestarter-vite-env.py +++ b/scripts/seed-causestarter-vite-env.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Seed causestarter/.env from the running Docker SPA config.json for Vite HMR. +"""Seed ui/.env and causestarter/.env from Docker CauseStarter config.json. Requires CauseStarter Docker on http://localhost:8090 (or CAUSESTARTER_CONFIG_URL). Keeps the compose backends; only the SPA host switches to Vite (:5174). @@ -13,7 +13,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -OUT = ROOT / "causestarter" / ".env" +OUTS = (ROOT / "ui" / ".env", ROOT / "causestarter" / ".env") CONFIG_URL = os.environ.get("CAUSESTARTER_CONFIG_URL", "http://localhost:8090/config.json") PREFERRED = [ @@ -102,8 +102,10 @@ def main() -> int: if wc: lines.append(f"VITE_WALLETCONNECT_PROJECT_ID={wc}") - OUT.write_text("\n".join(lines) + "\n") - print(f"Wrote {OUT}") + text = "\n".join(lines) + "\n" + for out in OUTS: + out.write_text(text) + print(f"Wrote {out}") return 0 diff --git a/scripts/stop-wipe-restart.sh b/scripts/stop-wipe-restart.sh index 8f885d49e..1612d1af4 100755 --- a/scripts/stop-wipe-restart.sh +++ b/scripts/stop-wipe-restart.sh @@ -62,17 +62,8 @@ timing_mark start if [ "${#seed_args[@]}" -gt 0 ]; then echo "" echo "=== Seeding data ===" - # --start already writes Hardhat trust txs, so the indexer is not empty. - already_allows=false - for arg in "${seed_args[@]}"; do - if [ "$arg" = "--allow-seed-on-existing-data" ]; then - already_allows=true - break - fi - done - if [ "$already_allows" = false ]; then - seed_args+=(--allow-seed-on-existing-data) - fi + # --start writes TrustSet bootstrap only; data.sh --seed allows that and + # refuses only if signatures/projects/published-data already exist. "$SCRIPT_DIR/data.sh" "${seed_args[@]}" timing_mark seed fi diff --git a/sdk/src/subsystems/lazy-giving/queries.ts b/sdk/src/subsystems/lazy-giving/queries.ts index 7c12b00d9..6764282d9 100644 --- a/sdk/src/subsystems/lazy-giving/queries.ts +++ b/sdk/src/subsystems/lazy-giving/queries.ts @@ -128,32 +128,34 @@ async function readSettlementCurrency( // LazyGiving Queries // ============================================================================ +export interface ProjectFoldResult { + project: Project; + accumulator: ProjectAccumulator; +} + /** - * Get a crowdfunding project by its assurance contract address. - * - * Fetches and folds all project events, then reads threshold/deadline - * from the on-chain condition contract if a publicClient is available. + * Fold a project and return the resumable accumulator alongside the view model. * - * @param machinery - SDK machinery with event cache configuration - * @param assuranceContractAddress - Address of the project's assurance contract - * @param options - Optional configuration for resumable folding - * @param options.initialAccumulator - Previously saved accumulator to resume from (enables incremental fetching) - * @param options.blockNumber_gte - Only fetch events at or after this block number (used with initialAccumulator) - * @returns The project, or null if no creation event exists + * Pass `initialAccumulator` / `blockNumber_gte` to continue from a saved cursor + * instead of replaying every event. */ -export async function getProject( +export async function getProjectFold( machinery: SDKMachinery, assuranceContractAddress: string, options?: { initialAccumulator?: ProjectAccumulator; blockNumber_gte?: string; } -): Promise { +): Promise { const projectEvents = await fetchAndDecodeProjectEvents(machinery, assuranceContractAddress, { blockNumber_gte: options?.blockNumber_gte, }); const fundingCurrency = await readSettlementCurrency(machinery, assuranceContractAddress); - const { project: partial } = foldProject(projectEvents, options?.initialAccumulator, fundingCurrency); + const { project: partial, accumulator } = foldProject( + projectEvents, + options?.initialAccumulator, + fundingCurrency, + ); if (!partial) return null; let threshold = '0'; @@ -168,7 +170,32 @@ export async function getProject( } } - return { ...partial, threshold, deadline }; + return { project: { ...partial, threshold, deadline }, accumulator }; +} + +/** + * Get a crowdfunding project by its assurance contract address. + * + * Fetches and folds all project events, then reads threshold/deadline + * from the on-chain condition contract if a publicClient is available. + * + * @param machinery - SDK machinery with event cache configuration + * @param assuranceContractAddress - Address of the project's assurance contract + * @param options - Optional configuration for resumable folding + * @param options.initialAccumulator - Previously saved accumulator to resume from (enables incremental fetching) + * @param options.blockNumber_gte - Only fetch events at or after this block number (used with initialAccumulator) + * @returns The project, or null if no creation event exists + */ +export async function getProject( + machinery: SDKMachinery, + assuranceContractAddress: string, + options?: { + initialAccumulator?: ProjectAccumulator; + blockNumber_gte?: string; + } +): Promise { + const folded = await getProjectFold(machinery, assuranceContractAddress, options); + return folded?.project ?? null; } /** diff --git a/specs/glossary.md b/specs/glossary.md index 0e567c31f..4e6f3a69c 100644 --- a/specs/glossary.md +++ b/specs/glossary.md @@ -41,7 +41,7 @@ wrong (or this file is out of date and needs an ADR — see | **Reimbursement** | What a retroactive donation pays out to an early contributor — at cost, no upside | | **Note** | A `DelegatableNote`: a bucket of deposited funds whose spending authority can be delegated down a chain, revocably. The unit of delegated giving | | **Standing pledge** | A *recurring* funding commitment registered with `RecurringPledges`, executed periodically into a note | -| **Cause board** | The fundable-projects dashboard (heading **Fundable Projects**) inlined on a statement or cause page, where a donor sees aligned projects and decides where money goes | +| **Fundable-projects board** | The list of aligned work you might fund (heading **Fundable Projects**), inlined on a statement or cause board and also a full page. Code still says `fundingportal*` / `/portal/:statementCid`. Formerly called **portal** and then **cause board**. | ### Judgments people and services publish @@ -62,7 +62,9 @@ wrong (or this file is out of date and needs an ADR — see | **Subsystem** | A capability: a contract family + SDK subsystem + UI feature module sharing one name | | **Site / UI domain** | A branded build that composes a subset of subsystems. There are eight | | **Bookmark** | A published cause or statement the user chose to keep, independently of signing. Cause bookmarks are cached locally and, with a connected wallet, stored in the `bookmarked-causes` mutable ref (public). Statement bookmarks use the separate `bookmarks` ref (statement CIDs). Unpublished cause drafts stay device-local. Never mix the two lists. User-facing verbs: bookmark / remove bookmark — never "save to device" or "delete cause" | -| **Cause page** | The versioned publication of a cause's title, summary, issue list, and mediator blurb. User-facing word for what code still calls a *roster* (`causestarter.roster`, `rosterCid`). Never say "roster" in UI copy. | +| **Cause board** | The organizer publication at `/cause/:owner/:slug`: title, summary, ordered planks, bridges, pledges, and a **fundable-projects board** as the centerpiece. Code still says *roster* (`causestarter.roster`, `rosterCid`). Never say "roster" in UI copy. Not a dashboard. Leftover **cause page** is fine. See [cause-page-not-a-club.md](./product/cause-page-not-a-club.md). | +| **Dashboard** / **my board** | Personal CauseStarter home: union of **fundable-projects** on statements this wallet signed. Derived, not a publication. See [personal-dashboard.md](./product/personal-dashboard.md). | +| **Cause page** | Leftover synonym for **cause board** (the organizer publication). Prefer **cause board** in new copy. | | **Natural cause** | A cause playing the “this camp’s position” parent role in a [bridge cluster](./product/bridge-causes.md). Usually someone else’s publication; may be a **stand-in cause** the mediator wrote because that camp had no cause yet. | | **Stand-in cause** | A mediator-authored natural parent: a thin roster the mediator thinks the other camp believes, published under the mediator’s key and labeled as such. Not a modified cause (there is no prior parent to sliver). See [the-other-cause.md](/docs/founder/the-other-cause.md). | | **Modified cause** | A mediator-authored cause: wording the mediator thinks signers of a given natural cause might also accept, without feeling misrepresented. Usually a topical sliver, not a full rewrite of the parent. | @@ -145,12 +147,27 @@ retired as synonyms. `Contribution.participant`, `Refund.participant`, and `ContributorStats.participant` are now `.contributor`; the raw decoded-event field stays `participant` because that is the ABI arg name. -### 5. Portal → cause board (already ruled, half-finished) +### 5a. Bare “cause” names three things — *swept in copy 2026-08-24* -Adam ruled 2026-06-12: **cause board** wins in user-facing copy; code identifiers, -routes, and directories keep `fundingportal*`. Still unfinished — `/portal/:statementCid` -routes, `ui/src/fundingportals/` copy, and ~22 files under `docs/end-user/` still say -"funding portal". Tracked in [TODO.md](../TODO.md). +- Ordinary English: the worldly goal. +- Glossary Part 1: a **statement** used as a funding anchor (a *role*). +- CauseStarter: the organizer **roster** — now called **cause board** in + user-facing copy (leftover **cause page** is fine). Identifiers and + `/cause/:owner/:slug` may lag. + +Keep “cause” for the first two. Do not call the publication a dashboard. +A cause board may cover multiple causes. Does not reverse ADR 0009. See +[cause-page-not-a-club.md](./product/cause-page-not-a-club.md). + +### 5. Portal → cause board → fundable-projects board (*copy swept 2026-08-24*) + +Adam ruled 2026-06-12: **cause board** for the fundable-projects list in +user-facing copy; code kept `fundingportal*`. **Superseded 2026-08-24:** +that list is the **fundable-projects board**; **cause board** is the +organizer publication. Code identifiers, routes, and directories still +lag (`fundingportal*`, `/portal/:statementCid`). Leftover “funding portal” +in older docs is unfinished, not a third noun. See +[cause-page-not-a-club.md](./product/cause-page-not-a-club.md). ### 6. Smaller ones diff --git a/specs/product/README.md b/specs/product/README.md index 47f34ad2e..f42810fe9 100644 --- a/specs/product/README.md +++ b/specs/product/README.md @@ -3,7 +3,8 @@ Product-manager-level planning documents. These describe *what* to build and *why*, not *how*. - **[founder-first.md](founder-first.md)** — The vertical-founder posture: our customer is the founder standing up a vertical, not the end user of the generic sites. Carries the triage rule for all platform work, a map of which doc holds which piece, and the consolidated pivot backlog. Frozen rationale in [ADR 0005](/specs/decisions/0005-founder-first-verticals.md). -- **[causes-as-publications.md](causes-as-publications.md)** — Accepted target model for AI-assisted cause creation: causes are mutable, shareable publications over immutable statements; organizers and vertical operators are distinct roles; anchors remain optional semantic statements rather than page identities. Frozen rationale: [ADR 0009](../decisions/0009-causes-are-publications-over-statements.md). Implementation work: [plan](causes-as-publications-implementation-plan.md). +- **[causes-as-publications.md](causes-as-publications.md)** — Accepted target model for AI-assisted cause creation: causes are mutable, shareable publications over immutable statements; organizers and vertical operators are distinct roles; anchors remain optional semantic statements rather than page identities. Frozen rationale: [ADR 0009](../decisions/0009-causes-are-publications-over-statements.md). Implementation work: [plan](causes-as-publications-implementation-plan.md). Copy/weighting of that publication: **[cause-page-not-a-club.md](cause-page-not-a-club.md)** (direction; two-step rename; not a sweep yet). Returning-signer home: **[personal-dashboard.md](personal-dashboard.md)** (derived fundable-projects union; not a roster). +- **[how-to-convey-this.md](how-to-convey-this.md)** — Landing/docs conversation log: what the system *is*, the jobs pitch, and later naming of the organizer page. - **[use-cases.md](use-cases.md)** — The canonical inventory of what people come here to *do*, organized by user goal rather than by subsystem. Each entry carries a status (Smooth / Rough / Missing / Compose / Blocked / Speculative) and its gap. Start here when prioritizing product work. - **[cause-taxonomy.md](cause-taxonomy.md)** — How a vertical founder populates an empty cause board: the gate (which legacy blocker is this cause hitting?) plus eight facets (subcause, scope, deliverable, posture, time shape, beneficiary, contestedness, publicness) that generate concrete examples. Worked example: the Christian board in [christian-pitch.md](/docs/founder/christian-pitch.md). - **[mvp.md](mvp.md)** — MVP scope: what's included in the first release, entry-point descriptions, what's deferred. diff --git a/specs/product/cause-page-not-a-club.md b/specs/product/cause-page-not-a-club.md new file mode 100644 index 000000000..ace34f6df --- /dev/null +++ b/specs/product/cause-page-not-a-club.md @@ -0,0 +1,150 @@ +# The organizer publication is a board, not a club + +**Status: copy sweep started 2026-08-24.** Adam agreed with this framing on 2026-08-23 +and refined the nouns on 2026-08-24. Glossary, high-traffic CauseStarter copy, +Aligning/fundable-projects UI strings, and end-user docs were updated in this +pass. Identifiers, routes, and leftover “cause page” / “funding portal” still lag. +It does **not** reverse [ADR 0009](../decisions/0009-causes-are-publications-over-statements.md): +the object model (immutable statements; a mutable, shareable publication over a +roster of them; verticals as a separate operator role) stays. This note is about +**what we call that publication** and **how important it should feel**. + +Related: [causes-as-publications.md](./causes-as-publications.md), +[how-to-convey-this.md](./how-to-convey-this.md), +[glossary](../glossary.md), +[the jobs](/docs/end-user/causestarter/the-jobs.md), +[start a cause](/docs/end-user/causestarter/start-a-cause.md). + +## The problem + +CauseStarter names the organizer-owned roster a **cause**, on a site named +CauseStarter. That makes the roster feel like *the* thing you join or support. + +That is the wrong instinct. The protocol atoms are statements, implications, +alignment vouches, projects, and notes. People sign statements. Projects attach +to statements. Mediators operate at the statement (and plank-pair) level. The +roster is a **named view**: a title, a mix of independent claims, and the union +of work and wording that sits on those claims. Forking the mix is success, not +a schism. + +Calling that view “a cause” trains early aggregation: swallow the bundle, join +the movement. The rest of the system exists to avoid that. + +## Three things currently named “cause” + +| Sense | What it actually is | Keep calling it “cause”? | +|---|---|---| +| Ordinary English | The worldly thing you care about (clean water, the block party, not being defunded) | **Yes** — motivation, not an entity | +| Glossary Part 1 | A **statement** in its role as a funding anchor (`causeCid` is a statement CID) | **Yes** as a *role*, not a separate ID | +| CauseStarter roster | Versioned publication `(owner, slug)` → title, summary, ordered planks | **No** — this is the over-weighted one | + +The rename below is meant to **shift “cause” toward the first two senses**. A +cause board can be for **multiple** causes (several statements-as-anchors, or +several worldly aims on one page). It is not rigidly “one cause object.” + +## Two-step rename (do this in order) + +**Today (glossary, 2026-06-12):** **cause board** = the fundable-projects +dashboard (heading **Fundable Projects**), including leftover “funding portal” +copy. Code still says `fundingportal*`. + +**Step 1 — free the name.** Rename that surface to **fundable-projects +board** (hyphenate in running text the same way: fundable-projects board). +Identifiers, routes, and directories may lag (`fundingportal*` stays until a +later code pass). Heading can stay **Fundable Projects**. + +**Step 2 — reuse “cause board” for the organizer publication.** The thing at +`/cause/:owner/:slug` (today often “cause page” / “a cause”) becomes the +**cause board**: the shareable mix — title, planks, bridges, pledges, and a +**fundable-projects board** as the centerpiece (inlined summary and/or a +link to the full list). + +Do not go overboard replacing leftover **cause page**. That phrase is fine as +a synonym for the same URL. Prefer **cause board** in new copy. + +There isn’t a huge visual difference: the fundable-projects board is already +the centerpiece of the cause board, and the cause board already links to it +(and may summarize it). The cause board also includes other jobs — especially +**bridges** — so the two names are not interchangeable. + +Do **not** call either surface a **dashboard**. That word stays reserved for a +future personal view (“projects aligned with statements I signed”). + +## What to call the surfaces + +| Surface | Job | Noun (after the sweep) | +|---|---|---| +| Statement / plank | The thing you sign; projects attach here | **statement** / **claim** / **plank**. A statement *plays the cause role* when it is a funding anchor | +| Fundable-projects list | Aligned work you might fund | **fundable-projects board** (today still “cause board” in many files) | +| Organizer publication | Shareable mix + centerpiece list + bridges etc.; flyer URL | **cause board** (fine leftover: **cause page**) | +| Personal view | Projects aligned with *my* signed statements | **dashboard** / **my board** — [personal-dashboard.md](./personal-dashboard.md) | + +User-facing verbs for the publication: *publish a board*, *start a board*, +*look at this board* — or keep *start a cause* in the English sense (“start +funding this worldly aim”) without implying membership. Never *join a cause*, +*members of this cause*, *support this cause* as if the mix were the funding +target. + +Do **not** rename the product off CauseStarter in the same pass. “Starter” can +mean “you start funding toward a cause (English)” without implying the roster +is the movement. + +## Thin ontologically, not thin for adoption + +The board is not protocol-fundamental. It **is** go-to-market-fundamental. + +Founder-first ([ADR 0005](../decisions/0005-founder-first-verticals.md)) makes +the organizer the customer. Almost everyone arrives via a circulated link; +there is no directory. The cause board is the **distribution handle**: title, +curated planks, mediator blurb, a stable URL. Organizers still do real +editorial work (retrieval, rejecting bad hits, publishing exact CIDs, +circulating, inviting bridges). Call it a board so it is a watch/fund +surface — not a club, and not a bookmark folder. + +Late aggregation still needs **attention** aggregation. It must not aggregate +**identity**. The board is a frame for attention. + +Analogy: Spotify playlists vs your library. Playlists are how music spreads; +you do not join a playlist. The personal dashboard is the returning-user loop; +organizer cause boards remain the acquisition surface. + +## Personalized dashboard + +A “projects on statements I signed” surface is the likely everyday home. It +does not replace organizer cause boards. You encounter a mix, sign the planks +you mean, then live on your own union. That personal surface can also span +**multiple causes** (several statements you signed). Spec and first slice: +[personal-dashboard.md](./personal-dashboard.md). Home shows a compact teaser; +the full list is `/dashboard`. Do not implement it as an unpublished cause +board. + +## What not to do + +- Do not rename the publication **dashboard**. That is a private updating + screen; it does not explain a shareable URL or organizer authorship; it + collides with the personal surface. +- Do not demote the board so hard that organizers think they are making a + bookmark folder, or that attention needs no frame. +- Do not reverse ADR 0009’s split (statement vs publication vs vertical). + Only stop using bare “cause” as the *name of the publication* in copy. +- Do not sweep **cause page** → **cause board** everywhere; prefer the new + term going forward. +- Do not skip step 1. If you call the publication a cause board while the + fundable-projects list is still called a cause board, the glossary is worse + than today. + +## Sweep status (copy 2026-08-24; identifiers later) + +1. **Fundable-projects board:** done in UI copy, end-user docs, glossary Part 1 + and Part 2 §5. Code `fundingportal*` / `/portal/:statementCid` still lag. +2. **Cause board** = organizer publication: done in glossary, CauseStarter home / + list / editor chrome (“Cause boards”, “Start a cause board”, bookmarked + boards), and high-traffic docs. Leftover “cause page” in comments and + incidental copy left on purpose. Identifiers (`/causes`) still lag. +3. Later: identifiers (`/cause/:owner/:slug` can lag). Contract names stay. + Bridge-cluster terms (natural / modified / bridge cause) still mean + *publications* in the compound; separate pass. + +New copy should keep following this note: if you must name the fundable-projects +list, say **fundable-projects board**; if you must name the organizer URL, +prefer **cause board**. diff --git a/specs/product/causes-as-publications.md b/specs/product/causes-as-publications.md index 8ae89eb72..6b85a2d9f 100644 --- a/specs/product/causes-as-publications.md +++ b/specs/product/causes-as-publications.md @@ -26,6 +26,13 @@ to be turned into a new statement merely to acquire a name or URL. The cause ros the named, pointable object. Statements remain the semantic objects used for signing, support propagation, project alignment, cause boards, and funding intent. +User-facing copy should still not treat that roster as a club you join. Direction +(not swept): call it a **cause board** (fine leftover: **cause page**); first +rename today’s “cause board” (the project list) to **fundable-projects board**; +keep “cause” for ordinary English and for a statement’s funding-anchor role; a +cause board may cover multiple causes; do not call the publication a dashboard. +See [cause-page-not-a-club.md](./cause-page-not-a-club.md). + AI should do most of the unnatural translation work between ordinary human intentions and implication-friendly statements. Humans remain responsible for choosing what they mean, approving what is published or signed, supplying rhetoric and judgment, and diff --git a/specs/product/founder-first.md b/specs/product/founder-first.md index 6ae6d2508..0ea2ac329 100644 --- a/specs/product/founder-first.md +++ b/specs/product/founder-first.md @@ -59,6 +59,9 @@ authoritative copy; this list is the map. - [ ] Resolve the naming question: new site, or rename Commonality to something like "CauseStarter"? The umbrella brand currently names the substrate, not the founder product. *(Adam — [inbox.md](/inbox.md))* +- [ ] Separate, copy-only: do not over-weight the organizer roster as “a cause” + you join; two-step noun change in [cause-page-not-a-club.md](./cause-page-not-a-club.md). + Does not settle the umbrella-vs-CauseStarter brand question. ### Make the founder's job easier (platform work) diff --git a/specs/product/how-to-convey-this.md b/specs/product/how-to-convey-this.md new file mode 100644 index 000000000..90d7deeec --- /dev/null +++ b/specs/product/how-to-convey-this.md @@ -0,0 +1,260 @@ +# My initial prompt + +I forget, something about "how can we make it clear what this system is for?" + +# AI response + +The current landing reads like every other organizer tool. “Start a cause. Build a Movement. Change the world.” plus Start → Grow → Deliver could sit on NationBuilder, Change.org, Kickstarter, or a 501(c)(3) starter kit. None of that is what the vision docs actually argue. + +The real claim is late aggregation. Familiar systems compress people into a platform, party, petition, or charity first, then act. Commonality keeps each person on their own words and money until the last moment, then funds the overlap. That is why the project is named Commonality, and it is the thing the homepage currently hides. + +What people will map this onto (and how to break the mapping) + +┌─────────────────────────┬───────────────────────────┬─────────────────────────────────────────────────────────────────┐ +│ They think it is… │ What they expect │ What this actually is │ +├─────────────────────────┼───────────────────────────┼─────────────────────────────────────────────────────────────────┤ +│ Change.org / petition │ Collect names, beg an │ You do not need the institution. You fund the work yourselves. │ +│ │ institution │ │ +├─────────────────────────┼───────────────────────────┼─────────────────────────────────────────────────────────────────┤ +│ Kickstarter / GoFundMe │ One creator, one campaign │ A cause is many independent claims and many projects. │ +│ │ │ Delegation and at-cost reimbursement are how it scales past one │ +│ │ │ campaign. │ +├─────────────────────────┼───────────────────────────┼─────────────────────────────────────────────────────────────────┤ +│ Charity / 501(c)(3) │ Give to an org, trust the │ No group treasury, no grant committee. Pledge a project; refund │ +│ │ annual report │ if the threshold fails; books are the chain. │ +├─────────────────────────┼───────────────────────────┼─────────────────────────────────────────────────────────────────┤ +│ Political party / “join │ Swallow a bundle, wait │ Sign one plank. No membership. Value at every step, not after a │ +│ the movement” │ for election day │ win. │ +├─────────────────────────┼───────────────────────────┼─────────────────────────────────────────────────────────────────┤ +│ Discord / meetup / │ Recruit into your group │ Organizers circulate a link. There is no directory and no │ +│ NationBuilder │ and keep them there │ membership boundary. Coalitions are discovered, not enrolled. │ +└─────────────────────────┴───────────────────────────┴─────────────────────────────────────────────────────────────────┘ + +If the first screen does not make that table felt, people will use the wrong product in their head and bounce when they hit “no browse, no ranking, sign this sentence.” + +What to put on the landing instead of a lifecycle slogan + +Lead with the mechanism, not the job-to-be-done of starting an org. + +Headline (recommended): +Don’t join a coalition. Discover one. + +Subhead: +Write claims people can sign one at a time, in their own words. The system finds who already agrees on the outcome — even if they would never share a party, a charity, or a Slack. Money moves only when enough people actually show up. + +That is the organic-coalitions thesis in one breath. It also respects founder-first: the customer is someone who would otherwise have to found an org, a PAC, or a Kickstarter and then spend years herding a bundle. + +CTA stays “Start a cause.” That part is fine. The copy around it should not promise “build a Movement.” + +Alternate headlines (if you want options) + +1. Say what you mean. Fund what you already agree on. + Closest to “no need to compromise.” +2. A cause without a party. Funding without a gatekeeper. + Contrast-first; good if the page has a comparison table below. +3. Every signature is useful. Nothing waits for election day. + Best if the visitor is a tired activist. Matches “easier than politics.” +4. State it. Sign it. Pay only if it happens. + Operational; names assurance contracts without jargon. + +I would not use 4 as the only line — it still sounds like Kickstarter — but it is a strong third beat. + +Replace Start → Grow → Deliver + +That sequence is the lifecycle of any org. It also implies “recruit a following, then ship,” which is the opposite of “late aggregation.” + +Better three beats (same UI component, different titles): + +1. State — Independent, signable claims. Not a manifesto people have to swallow. +2. Overlap — Implication and bridges connect people who used different words for the same outcome. Nobody has to join your team or adopt your framing. +3. Fund — Pledges that refund if the threshold is missed; later money can reimburse early scouts. Track records stay public. + +Shorter slogan form of that: State → Overlap → Fund. + +If you want it even less “product funnel”: Your words. Their signatures. Shared work. + +How to show the difference (not just slogan it) + +A slogan will not carry this. One screen of contrast will. + +1. A one-screen “not this / this” under the hero. Three rows is enough: party, charity, Kickstarter. One sentence each. Do not bury this in Docs. +2. Show a plank, not a brand. A cause as three independent sentences with separate signer counts beats “Start a movement.” The product already works this way; the landing talks as if a cause were a club. +3. Show two camps funding one project (the rural water example from the organic-coalitions doc). That picture is the whole thesis. No slogan beats it. +4. Name the absences as features: no directory, no membership, no pooled treasury, no permission. CauseStarter is a lens, not a marketplace. If you do not say that, “Start a cause” looks broken when they cannot browse causes. +5. Do not lead with crypto, AI, or “change the world.” Those are how it is possible. The reason to care is: you keep your wording, you do not wait for a win, and gatekeepers are optional. + +Pitch you could paste + +│ Most systems that want to “do good at scale” ask you to join something: a party, a charity, a campaign, a Discord. You compromise on the wording, pool the money, and wait for a committee or an election. +│ +│ CauseStarter is for people who will not swallow the bundle. You publish the claims you actually believe. Others sign only the sentences they mean. Overlap is discovered, not negotiated. Funding is per project, with refunds if the threshold is not met. +│ +│ You do not need a 501(c)(3), a grant officer, or a majority. You need sentences people will stand behind, and work those sentences can pay for. + +That is different from Kickstarter (ecosystem, not a campaign), different from Change.org (you fund, you do not petition), and different from a party (no membership, value before critical mass). + +If you want this on the actual homepage, the change is small: rewrite the hero + HowItWorksSteps, and add a three-row contrast. I would not ship “Build a Movement” again; it trains the wrong instinct. + + +# My response + +Late aggregation is part of it. + +Organic coalitions is neat but doesn't really feel like it should be the main headline. + +I feel like one of the big themes is less need for coordination. (I'm not suggesting we phrase it that way, I'm just trying to convey the thoughts in my head so that you'll understand what I'm getting at.) + +And another big one is designing the system so that the crowd can be tapped to fill various roles in a way that *doesn't* require them to do stuff they *don't* want to do. + +"I'd be happy to contribute $X/month (as long as enough others do too) to the cause, but..." + "...ugh, I don't have time to actually follow all the individual potential projects and make all those decisions." (Just pledge the $X/month and delegate the decisions to someone else.) + "...ugh, I don't want to just donate to the big charity org, I don't trust them not to waste the money." (Don't delegate to a big org, delegate to a friend you trust.) + +"I'd be happy to watch the ecosystem for worthwhile projects to fund, but..." + "...ugh, I don't even know what projects are out there." (Find or start a cause page with the statements you want to watch for; it should give you a nice populated list of projects, without you needing to have any special connections.) + "...ugh, I can't tell whether this project creator is a scammer or incompetent." (Be a delegate who only does retroactive funding.) + "...ugh, I can't put that much of my *own* money towards that." (Be an early funder who asks for reimbursement.) + +"I'd be happy to do this work, but..." + "...ugh, I can't afford to pay for it myself." (Get funded by others.) + "...ugh, I don't even know who to go to to get funding." (Just post the project and talk to a friend who's a bit better connected in the trust graph, get him to do an alignment attestation; you don't need to have a direct connection to a centralized gatekeeper.) + +"I'd be happy to sign a statement sorta like that, but..." + "...ugh, I don't love the way that one is phrased." (Write your own.) + "...ugh, if I write my own it'll have zero signers." (Write it in such a way that the implication attester can infer indirect support from people who've signed similar statements.) + "...ugh, the statement that others have signed doesn't actually imply the better statement I have in mind." (Write a bridge statement, submit it as a suggestion to one of the existing AI mediator services; if it agrees, it'll send out nudges to its subscribers.) + +"I'd be happy to follow a cause page sorta like that one, but..." + "...ugh, I don't love the exact combination of statements they chose." (Create your own cause.) + "...ugh, if I write my own it'll have zero signers or projects." (If you're willing to choose *some* of the same statements, your cause will show the signers and projects that those statements have. Even if you need to write your own, the implication system and bridging system may help.) + +The bottom line is something like: we could actually do a *much* better job of collectively making this cause happen. Project-doers will be able to get money for worthwhile projects; donors who care about the cause will be able to help financially without spending much time or too much risk of wasting their money; people who have time or expertise can contribute those without needing to contribute money; all of this information and money can be collected and disseminated without needing to get everyone to agree on every idea or agree on who the leaders should be. In general, we can cooperate, pool our money and our skills, in a way that has always felt like it *should* be possible ("there are *so* many of us, *why* can't we get anything done???") but was previously blocked due to inability to coordinate. This ecosystem can just *work*. Like... all of the above "I'd be happy to do X, but ugh, Y" statements are really just addressing various obstacles; the core vision is simple ("let's have cooperate on stuff they agree about") and a bunch of the features of this system are just about addressing all the reasons why that's not as easy as it feels like it should be. + +Does that make sense? How can we convey all that (in the landing page or docs or UI in general or whatever)? + + +# AI response + +Yes. That is the product. Organic coalitions and late aggregation are how the machine is allowed to be this uncoordinated. They should not be the first thing a stranger has to care about. + +The thing in your head is two layers: + +1. The simple job: people who already agree should be able to put money, work, and attention on the same outcomes. +2. The actual invention: split that job into roles people would already take, and remove the “ugh” that currently forces everyone to also do the parts they hate (committee, wording compromise, full-time project diligence, trusting a big org, knowing a grant officer, writing a statement nobody else will sign). + +That is not “less coordination” as a slogan. It is you only contribute the part you would contribute anyway. The coordination tax is what made “there are so many of us — why can’t we get anything done?” feel like a law of nature. This system is the claim that it was never a law; it was a pile of ughs. + +Headline + +Do not lead with mechanism. Lead with the stuck crowd. + +Recommended: +There are enough of us. We just couldn’t work together. + +Subhead: +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. + +That is the “I’d be happy to, but ugh” thesis without listing every ugh. + +Other headlines in the same family: + +• Do the part you’d do anyway. +• Cooperate without a committee. +• The cause doesn’t need a boss. It needs the jobs people will actually take. + +I would not use “coordination,” “aggregation,” or “organic coalitions” on the hero. Keep those for people who click “how.” + +CTA can stay Start a cause for founders, with a quieter second line: or pick one job below. CauseStarter is founder-first, but the story has to be about the whole crowd or founders will think they still have to recruit a complete org. + +Landing shape + +Three blocks. That is enough. + +1. Hero — the stuck-crowd line above. + +2. Jobs, not features. Four cards, each one ugh → one out. Not “delegation / LazyGiving / implication.” Those names are implementation. + +┌────────────────┬───────────────────────────────────────────┬──────────────────────────────────────────────────────────┐ +│ I’d be happy │ …but ugh │ So you │ +│ to… │ │ │ +├────────────────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ +│ Put in $X/ │ I won’t read every project, and I don’t │ Pledge, and hand the choices to a person you actually │ +│ month │ trust the big org │ trust │ +├────────────────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ +│ Hunt for good │ I don’t know what’s out there, and I can │ Follow statements you care about; fund proven work, or │ +│ work │ ’t tell who’s a scam │ fund early and get reimbursed │ +├────────────────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ +│ Do the work │ I can’t self-fund, and I don’t know a │ Publish the project; a friend who is one hop better- │ +│ │ gatekeeper │ connected can vouch that it fits │ +├────────────────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────┤ +│ Stand behind │ I hate that wording, and my wording will │ Write yours; similar signatures still count, and a │ +│ an idea │ have zero signers │ bridge can invite the rest │ +└────────────────┴───────────────────────────────────────────┴──────────────────────────────────────────────────────────┘ + +Under each card, one sentence of “you never have to do the other jobs.” That is the design principle made visible. + +3. The punchline, once: +Nobody has to agree on a leader, a manifesto, or a treasury. Overlap is enough. Money and work meet there. + +Then Start a cause. Optional: “See a worked example” → Christianity / local food, used as a crowd of roles, not as a pretty brand. + +Skip Start → Grow → Deliver. The sequence is not a lifecycle. It is pick a job. + +Docs: one page that is this, verbatim + +The vision tree already has pieces (delegation, no-compromise, pitches, legacy frustrations) but they are argued as advantages of a system. Your note is argued as unblocking people. Those are different documents. + +Add something like docs/end-user/commonality/vision-and-strategy/the-jobs.md (name it in product language, not “roles in the protocol”) that is almost exactly your ugh list. That page is the canonical pitch. pitches.md can point at it. + +Structure: + +• One paragraph: the job is cooperate on agreement; everything else is removing ughs. +• Then your four sections (money, attention/judgment, work, wording/causes), each ugh as a heading. +• Close: this is why it can just work. No civilizational-competence encore on that page. + +Founder docs should say: you are not recruiting members into an org. You are publishing a place where those jobs can attach. That is the difference from “start a movement.” + +UI: every empty state is an ugh, not a feature name + +This is how the landing stays true after click. + +• Cause with no pledges: “People will put in $X/month if they don’t have to pick projects. Invite a delegate they already trust.” +• Project list empty: “Publish a project. You don’t need a grant officer — an alignment vouch from someone in the graph is enough to show up for people watching these statements.” +• Statement with few signers: “Write it so it can sit next to similar claims. Direct signatures are not the only support.” +• “Create your own cause”: “Reuse some of the same statements and you inherit their signers and projects. You are not starting from zero unless you insist on a totally new set.” +• Delegation CTA: not “Become a delegate.” “Only want to judge, not pay? Watch this list. Only fund what’s already done if you don’t want to gamble.” + +Tool cards (Tally, LazyGiving, boards) should answer which ugh they remove, not which domain they are. + +Do not add a cause directory to tell this story. The story is “follow statements / a link an organizer sent,” which is already the lens posture. + +What not to lead with + +• Organic coalitions: a bonus of not forcing a bundle. Put it under wording/bridges, or a “and sometimes people who thought they disagreed…” aside. +• Late aggregation: true, but it is the engineer’s name for “you don’t have to agree on a leader first.” +• Kickstarter/Change.org comparison table: useful later; the first contrast is org vs crowd-of-jobs, not platform vs platform. +• “Change the world / build a Movement”: that reintroduces the committee in the visitor’s head. + +One paragraph you could paste on the homepage + +│ People who care about the same things have always felt like they should be able to pool money and skill. They usually can’t, because every existing way of doing it demands extra jobs nobody wanted: pick every project, trust a big org, phrase the manifesto, elect a board, know a gatekeeper, wait until enough people join. +│ +│ Here you take the job you’d take anyway. Money can be lazy and cautious. Attention can be expert and cheap. Work can ask for funding without a foundation. Wording can be yours without isolating you. Agreement does the collecting. Nobody has to run the whole thing. + +If you want this in the product next, the smallest honest change is: rewrite the hero, replace How it works with the four jobs, and retarget the empty-state copy on cause / project / statement pages to the matching ugh. I would not expand the landing into the full ugh catalog; that catalog belongs on one docs page and in the UI at the moment the ugh appears. + +--- + +# 2026-08-23 — is a “cause” even the thing? + +Adam: the CauseStarter “cause” (organizer-owned mix of statements + project board) +is not protocol-fundamental; labelling it a cause on a site named CauseStarter +makes it feel like the thing you join. Maybe it is just a dashboard. Maybe the +primary loop is a personalized board of projects on statements *you* signed. + +Direction (agreed, not swept): keep “cause” as ordinary English and as a +statement’s funding-anchor role; call the publication a **cause board** (fine +leftover: **cause page**); first rename today’s “cause board” (the project list) +to **fundable-projects board**. A cause board can cover multiple causes. +Reserve **dashboard** for the personal surface. Full write-up: +[cause-page-not-a-club.md](./cause-page-not-a-club.md). diff --git a/specs/product/personal-dashboard.md b/specs/product/personal-dashboard.md new file mode 100644 index 000000000..f38def3e1 --- /dev/null +++ b/specs/product/personal-dashboard.md @@ -0,0 +1,80 @@ +# Personal dashboard (projects on statements you signed) + +**Status: first slice specified and implemented 2026-08-24** (home teaser + +`/dashboard` full list). Starring / named subsets remain out of scope. +This is the surface reserved by [cause-page-not-a-club.md](./cause-page-not-a-club.md) +under the names **dashboard** / **my board**. It does **not** reverse +[ADR 0005](../decisions/0005-founder-first-verticals.md) or +[ADR 0009](../decisions/0009-causes-are-publications-over-statements.md). + +Related: [composability.md](./composability.md) (the *portfolio* of reserved +capital is a different object — do not conflate), [the jobs](/docs/end-user/causestarter/the-jobs.md), +glossary **Cause board** / **Dashboard**. + +## What it is + +A derived **fundable-projects board**: the union of projects vouched as +advancing any statement the connected wallet has signed. Same list component +and trust gear as a cause board’s Fundable Projects, keyed by +`signedStatementCids` instead of a roster. + +It is the likely **everyday home** for a returning signer. Organizer **cause +boards** remain the acquisition surface (circulated links; no directory). +Spotify analogy from the parent note: playlists vs library. + +Copy: “Projects on statements you’ve signed.” Not membership. Not a private +cause. Not a publication. + +## What it is not + +- **Not a cause board.** No title, slug, ordered planks, Publish, bridges, or + share-as-flyer URL. Unpublished cause-board drafts stay the organizer + compose-then-publish path (device `localStorage`). Do not reuse “owner can + view unpublished, others see nothing” as the personal home. +- **Not the money portfolio** in [composability.md](./composability.md). This + is landscape (work on claims you already made), not allocation policy over + reserved capital. +- **Not a privacy product.** Signed statements are already public. The union + of aligned projects is reconstructable. Do not invent a private roster. + Optional later filters (pin/hide) may use a wallet MutableRef in the same + family as `bookmarked-causes`. + +## First slice (build this) + +1. CauseStarter **home**, when the wallet is connected **or** this device + already has cause boards: hero is a **teaser** of the personal + fundable-projects board (a few compact rows). Organizer drafts and + bookmarks stay below (existing **Cause boards** section). First-visit + **Welcome** remains when disconnected and there are no local/bookmarked + boards. +2. Reuse `CauseBoard` with `statementCids` = this wallet’s direct beliefs. + Same starter-network / personal trust filter as other CauseStarter lists. + Home passes `preview` (compact cards, cap, no metrics/tabs). Content + contracts drop the channel-details block on the teaser. +3. Empty: not connected → connect hint. Connected, no signatures → short + empty copy (sign from a cause board or statement). Do not mount the list + on an empty CID set. +4. Full list lives at **`/dashboard`** (same query; still not a roster). + Home “See all” is the way in. No new publication, no starring, no named + subsets. + +## Later (do not build yet) + +- **Stars / pin-hide** as a *filter on the derived list*, not a new mix + object. Persist with MutableRef if needed. +- **Named subsets you title and share** are cause boards. Point people at + **Start a cause board** instead of a third object. +- Overwhelm: first honest answers are trust-gear tightness and/or publishing + a cause board for the mix you actually watch. + +## Home vs cause board + +| | Cause board | Dashboard | +|---|---|---| +| Author | Organizer | Derived from wallet signatures | +| Job | Circulate a mix; acquire attention | Return and watch work you already claimed | +| Storage | Roster `(owner, slug)` + CID | None (query). Filters later optional | +| Visibility | Public URL | Reconstructable from public signatures; treat as personal chrome, not a secret | + +Founder-first still holds: organizers are the customer for distribution. +Signers stop treating the organizer URL as home. diff --git a/specs/product/ui-domains.md b/specs/product/ui-domains.md index 7a7835874..de64be28e 100644 --- a/specs/product/ui-domains.md +++ b/specs/product/ui-domains.md @@ -14,7 +14,7 @@ It is **not** the source of truth for landing-page copy, CTA wording, spotlight Four product sites for funding (LazyGiving, Aligning, Content Funding, Civility), one product site for signing (Tally), two movement sites (Commonality, CSM), and one mostly developer-facing infrastructure site (Conceptspace). -**CauseStarter** ([`causestarter/`](../../causestarter/)) is the primary cause-first reference lens on the same substrate (separate package from the eight multi-domain `ui/` builds for now). Organizers retrieve, review, publish, and circulate versioned cause rosters there; signing, aligned-project, and funding capabilities appear in cause context. The eight independent sites remain available as focused tools and verticals, consistent with ADR 0005; CauseStarter has no cause directory, ranking, or promotion. Known gaps: [`causestarter/TODO.md`](../../causestarter/TODO.md). Local stack: included in `./scripts/services.sh --start` (gateway + dedicated SPA) and `./scripts/deploy-causestarter.sh`. +**CauseStarter** is the primary cause-first reference lens on the same substrate, built as a ninth `VITE_DOMAIN` in [`ui/`](../../ui/) (`ui/src/causestarter/`, `ui/src/domains/causestarter/`). The package directory [`causestarter/`](../../causestarter/) still holds Docker/nginx/e2e glue and the product backlog. Organizers retrieve, review, publish, and circulate versioned cause rosters there; signing, aligned-project, and funding capabilities appear in cause context. The eight independent sites remain available as focused tools and verticals, consistent with ADR 0005; CauseStarter has no cause directory, ranking, or promotion. Known gaps: [`causestarter/TODO.md`](../../causestarter/TODO.md). Local stack: included in `./scripts/services.sh --start` (gateway + dedicated SPA) and `./scripts/deploy-causestarter.sh`. This four-bucket grouping — **funding / signing / movement / infrastructure** — is the canonical taxonomy of the eight sites. Other docs may cut the same eight sites along a different axis (subsystems vs. branded builds in [specs/README.md](../README.md), purpose-neutral vs. cause vertical in [marketing.md](./marketing.md)); those are orthogonal cuts for their own purposes and do not replace this one. Two placements are easy to get wrong: diff --git a/specs/tech/indexer/README.md b/specs/tech/indexer/README.md index 140947c56..c9c8868b9 100644 --- a/specs/tech/indexer/README.md +++ b/specs/tech/indexer/README.md @@ -69,13 +69,15 @@ All contract event emitters share the single event cache, including Conceptspace ## Fold Versioning and Upgrades -### Current state: accumulators exist, storage doesn't +### Current state: per-project accumulators are stored in the browser; most queries still fold from scratch The fold functions are designed for resumable folding. `foldProject` and `foldContributionsFromEvents` accept an optional `initialAccumulator` parameter and return the updated accumulator alongside the result. The intent is: store the accumulator + the block number it's current through, then on the next query fetch only new events and pass the saved accumulator in. -**However, no code currently stores or retrieves these accumulators.** The query layer (`getProject`, etc.) always folds from scratch. The resumable-fold infrastructure is there but unconnected. +The UI persists **per-project** accumulators in IndexedDB (`ui/src/shared/stores/foldCache.ts`) and `loadProjectWithCache` resumes from that cursor when present. Cause-board pages also persist the last successful **view snapshot** (metrics + aligned-project rows) so a reload can paint immediately while a live fold revalidates. -For now this is fine — folds are fast and entities are small. It becomes worth wiring up if fold latency becomes noticeable (e.g. a project with tens of thousands of contributions). +The default query helpers (`getProject`, `getAllAlignedProjectsForCause`, etc.) still fold from scratch unless a caller passes an accumulator. Alignment/implication walks are **not** resumable yet; that waits until entities are large enough that from-scratch folds hurt. + +For now this is fine at expected MVP scale. Wire more fold types into storage if fold latency becomes noticeable (e.g. a project with tens of thousands of contributions). ### If/when we store accumulators: versioning is required @@ -107,7 +109,7 @@ The only case where raw event interpretation changes is if a contract's ABI chan | Change | Impact | Action needed | |--------|--------|---------------| -| Fold logic fix (no accumulator shape change) | None while accumulators aren't stored | Once stored: bump `foldVersion` | +| Fold logic fix (no accumulator shape change) | Stored project accumulators invalid | Bump `foldVersion`; clients discard and re-fold | | Accumulator shape change | Stored accumulators invalid | Bump `foldVersion`; clients discard and re-fold | | New event type added to contract | None for old clients | Deploy new SDK to handle new events | | Event ABI breaking change | Raw events misinterpreted | Update decoder; rebuild event cache if needed | diff --git a/specs/tech/subsystems/aligning/ui.md b/specs/tech/subsystems/aligning/ui.md index 9a3146f97..c5fe06f1a 100644 --- a/specs/tech/subsystems/aligning/ui.md +++ b/specs/tech/subsystems/aligning/ui.md @@ -88,16 +88,16 @@ If a wallet is connected, highlight the current user's row and show a summary ca ## Integration with Concept Space (Statement Page) -Not a cause board page itself, but the cause board adds a section to the concept space's statement page (`/statement/:statementCid`). +Not a fundable-projects board page itself, but the fundable-projects board adds a section to the concept space's statement page (`/statement/:statementCid`). -### "Cause Board" Section +### "Fundable Projects" Section - **Total Funding Raised** - Count of aligned projects (direct + indirect) -- A "View Cause Board" link/button going to `/portal/:statementCid` +- A "View fundable-projects board" link/button going to `/portal/:statementCid` - Top 3 projects by funding progress (as a preview) -This is the primary entry point from the concept space into the cause board. +This is the primary entry point from the concept space into the fundable-projects board. ## Integration with LazyGiving (Project Detail Page) diff --git a/specs/tech/subsystems/fundingportals/README.md b/specs/tech/subsystems/fundingportals/README.md index d6ca6621a..49cffbca5 100644 --- a/specs/tech/subsystems/fundingportals/README.md +++ b/specs/tech/subsystems/fundingportals/README.md @@ -1,10 +1,13 @@ -# Cause boards / funding portals +# Fundable-projects boards / funding portals -A cause board (historically called a funding portal in code and older docs) is a statement-anchored view that helps donors fund projects aligned with a cause. +A **fundable-projects board** (historically a funding portal in code, then +briefly called a cause board) is a statement-anchored list of aligned work +donors might fund. **Cause board** now means the organizer publication; see +[cause-page-not-a-club.md](../../../product/cause-page-not-a-club.md). ## Current status -The implementation still uses the `fundingportal` package/path name in several places, but user-facing copy should prefer **cause board**. Treat `fundingportal` as a technical/internal name until the code paths are renamed. +The implementation still uses the `fundingportal` package/path name in several places. Treat `fundingportal` as a technical/internal name until the code paths are renamed. ## Product role @@ -17,7 +20,7 @@ The implementation still uses the `fundingportal` package/path name in several p - UI components: `ui/src/fundingportals/` - Product boundary: `specs/product/ui-domains.md` under **Aligning — cause-based funding** -- Related tests: `ui/test-plan.md` under **Cause board** +- Related tests: `ui/test-plan.md` under **Fundable-projects board** ## Naming note diff --git a/specs/tech/ui-domains.md b/specs/tech/ui-domains.md index 23665133d..ea9935409 100644 --- a/specs/tech/ui-domains.md +++ b/specs/tech/ui-domains.md @@ -1,6 +1,6 @@ # Multi-Domain UI Architecture -The eight UI domains (Commonality, LazyGiving, Aligning, Tally, Content Funding, Civility, Common Sense Majority, Conceptspace) are built from a single codebase but deployed as separate artifacts. For the product-level description of what each site is and why they exist, see [specs/product/ui-domains.md](../product/ui-domains.md). +The UI domains (the eight focused sites plus CauseStarter) are built from a single codebase but deployed as separate artifacts. For the product-level description of what each site is and why they exist, see [specs/product/ui-domains.md](../product/ui-domains.md). ## Shared codebase, separate builds @@ -11,7 +11,7 @@ All eight sites share: - Authentication and wallet infrastructure - Attestation display components -Each site is a separate build artifact that includes only the routes and features relevant to it. A `VITE_DOMAIN` environment variable selects which domain is built; it defaults to `commonality`. +Each site is a separate build artifact that includes only the routes and features relevant to it. A `VITE_DOMAIN` environment variable selects which domain is built; it defaults to `commonality`. CauseStarter is `VITE_DOMAIN=causestarter` (feature module `ui/src/causestarter/`). ## Directory shape @@ -21,6 +21,7 @@ This tree shows the important architectural folders, not every helper directory ``` ui/src/ ├── shared/ # Shared SDK, components, hooks, routing, branding helpers +├── causestarter/ # CauseStarter feature module (ninth domain) ├── conceptspace/ # Statement-signing feature module (used by Tally) ├── lazy-giving/ # Project/funding feature module (used by LazyGiving and funding verticals) ├── delegation/ # Delegation feature module (used by LazyGiving and funding verticals) @@ -36,6 +37,7 @@ ui/src/ │ ├── civility/ │ ├── common-sense-majority/ │ ├── conceptspace/ +│ ├── causestarter/ │ ├── components/ # Shared per-domain landing/shell components │ └── delegation/ # Legacy compatibility folder; Delegation is not a standalone build └── main.tsx # Selects the active domain build via VITE_DOMAIN @@ -57,16 +59,17 @@ dist/ ├── content-funding/ ├── civility/ ├── common-sense-majority/ -└── conceptspace/ +├── conceptspace/ +└── causestarter/ ``` Useful build commands (from the `ui/` directory): ``` npm run build # builds the active domain (VITE_DOMAIN, defaults to commonality) -npm run build:domains # builds all eight domains in one pass +npm run build:domains # builds all domains in one pass npm run build:ipfs # builds active domain in hash-routing mode for IPFS deployment -npm run build:ipfs:domains # builds all eight domains in IPFS mode +npm run build:ipfs:domains # builds all domains in IPFS mode ``` diff --git a/specs/user-docs.md b/specs/user-docs.md index cd6acf40e..0ee4457c1 100644 --- a/specs/user-docs.md +++ b/specs/user-docs.md @@ -29,5 +29,6 @@ Write the docs for humans (narrative, plain language). Then add a block to the d **User-facing docs live in:** - Role-based how-tos live on the site where the role is actually performed (e.g. `lazyGiving/get-your-project-funded.md`, `alignment/become-a-delegate.md`, `tally/express-what-you-care-about.md`). The cross-ecosystem index is in [docs/end-user/commonality/index.md](/docs/end-user/commonality/index.md) under "What can I do across the ecosystem?". Each role doc ends with an "On other sites" footer pointing at the cross-site connections. +- CauseStarter’s in-app docs (`causestarter/src` `/docs/*`) bundle `docs/end-user/causestarter/`, `shared/`, and `commonality/`. The everyday pitch is [the-jobs.md](/docs/end-user/causestarter/the-jobs.md). - [docs/end-user/shared/use-case-walkthroughs/](/docs/end-user/shared/use-case-walkthroughs/README.md) — concrete scenarios - [docs/end-user/shared/key-ideas/](/docs/end-user/shared/key-ideas/README.md) — concept reference pages diff --git a/tsconfig.json b/tsconfig.json index 3f9322531..632ccd6a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,8 +27,6 @@ "integration-tests/src/**/*.ts", "ui/src/**/*.ts", "ui/src/**/*.tsx", - "causestarter/src/**/*.ts", - "causestarter/src/**/*.tsx", "cause-assist/src/**/*.ts" ], "exclude": ["node_modules", "dist", ".turbo"] diff --git a/ui/endUserDocsPlugin.ts b/ui/endUserDocsPlugin.ts index ccf9180f9..1f7e92313 100644 --- a/ui/endUserDocsPlugin.ts +++ b/ui/endUserDocsPlugin.ts @@ -22,8 +22,10 @@ interface EndUserDocsPluginOptions { function foldersForDomain(domain: string): string[] { // `shared/` renders in-context on every site; `commonality/` is the vision - // site and bundles vision + shared only. - if (domain === 'commonality') return ['shared', 'commonality'] + // site and bundles vision + shared only. CauseStarter is the founder lens: + // it also ships the vision tree so in-app /docs can open those pages. + if (domain === 'commonality') return ['shared', 'commonality', 'causestarter'] + if (domain === 'causestarter') return ['shared', 'causestarter', 'commonality'] return ['shared', domain] } diff --git a/ui/package.json b/ui/package.json index 0648373b1..08671297f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,7 @@ "dev:civility": "VITE_DOMAIN=civility ../node_modules/.bin/vite", "dev:common-sense-majority": "VITE_DOMAIN=common-sense-majority ../node_modules/.bin/vite", "dev:conceptspace": "VITE_DOMAIN=conceptspace ../node_modules/.bin/vite", + "dev:causestarter": "VITE_DOMAIN=causestarter ../node_modules/.bin/vite --port 5174", "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit", "build": "tsc -b && tsc -p tsconfig.test.json --noEmit && ../node_modules/.bin/vite build", "build:domains": "tsc -b && tsc -p tsconfig.test.json --noEmit && node ./scripts/build-domains.mjs", diff --git a/ui/scripts/build-domains.mjs b/ui/scripts/build-domains.mjs index 28eeb9671..b44efbdc0 100644 --- a/ui/scripts/build-domains.mjs +++ b/ui/scripts/build-domains.mjs @@ -8,7 +8,7 @@ const __dirname = path.dirname(__filename) const uiDir = path.resolve(__dirname, '..') const distDir = path.join(uiDir, 'dist') const viteBin = path.join(uiDir, '..', 'node_modules', '.bin', 'vite') -const domains = ['commonality', 'lazyGiving', 'alignment', 'tally', 'content-funding', 'civility', 'common-sense-majority', 'conceptspace'] +const domains = ['commonality', 'lazyGiving', 'alignment', 'tally', 'content-funding', 'civility', 'common-sense-majority', 'conceptspace', 'causestarter'] const args = process.argv.slice(2) const modeIndex = args.indexOf('--mode') const mode = modeIndex >= 0 ? args[modeIndex + 1] : undefined diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1ead8ea62..ea2619094 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,10 +1,26 @@ -import { useEffect } from 'react' +import { useEffect, type ReactNode } from 'react' import { BrowserRouter, HashRouter, Route, Routes } from 'react-router-dom' import { AppShell } from './shared/components/AppShell' import { CrossDomainUnavailablePage } from './shared' import { NotFoundPage } from './shared' import { getActiveDomain } from './domains' import { isHashRouting, loadDisplayDenylist } from './shared' +import { CauseShell } from './causestarter/shell/CauseShell' + +function DomainChrome({ children }: { children: ReactNode }) { + const domain = getActiveDomain() + if (domain.useCauseShell) { + return {children} + } + return ( + + {children} + + ) +} function App() { const Router = isHashRouting() ? HashRouter : BrowserRouter @@ -16,16 +32,13 @@ function App() { return ( - + {domain.routes} } /> } /> - + ) } diff --git a/causestarter/src/components/AlignmentTrustGate.test.tsx b/ui/src/causestarter/components/AlignmentTrustGate.test.tsx similarity index 100% rename from causestarter/src/components/AlignmentTrustGate.test.tsx rename to ui/src/causestarter/components/AlignmentTrustGate.test.tsx diff --git a/causestarter/src/components/AlignmentTrustGate.tsx b/ui/src/causestarter/components/AlignmentTrustGate.tsx similarity index 100% rename from causestarter/src/components/AlignmentTrustGate.tsx rename to ui/src/causestarter/components/AlignmentTrustGate.tsx diff --git a/causestarter/src/components/BridgeClusterAssist.test.tsx b/ui/src/causestarter/components/BridgeClusterAssist.test.tsx similarity index 100% rename from causestarter/src/components/BridgeClusterAssist.test.tsx rename to ui/src/causestarter/components/BridgeClusterAssist.test.tsx diff --git a/causestarter/src/components/BridgeClusterAssist.tsx b/ui/src/causestarter/components/BridgeClusterAssist.tsx similarity index 100% rename from causestarter/src/components/BridgeClusterAssist.tsx rename to ui/src/causestarter/components/BridgeClusterAssist.tsx diff --git a/causestarter/src/components/CauseBridgesSection.test.tsx b/ui/src/causestarter/components/CauseBridgesSection.test.tsx similarity index 100% rename from causestarter/src/components/CauseBridgesSection.test.tsx rename to ui/src/causestarter/components/CauseBridgesSection.test.tsx diff --git a/causestarter/src/components/CauseBridgesSection.tsx b/ui/src/causestarter/components/CauseBridgesSection.tsx similarity index 97% rename from causestarter/src/components/CauseBridgesSection.tsx rename to ui/src/causestarter/components/CauseBridgesSection.tsx index e30a18ae0..7cd761cd5 100644 --- a/causestarter/src/components/CauseBridgesSection.tsx +++ b/ui/src/causestarter/components/CauseBridgesSection.tsx @@ -168,7 +168,9 @@ export function CauseBridgesSection({ cause, variant = 'organizer' }: CauseBridg {rows.length === 0 && !(organizer && cause.mediator) && ( - No bridges yet. + No bridges yet. If you would sign something like their statement but theirs + does not imply yours, write a bridge. A suggester can nudge its subscribers + if it agrees. )} diff --git a/causestarter/src/components/CauseCard.tsx b/ui/src/causestarter/components/CauseCard.tsx similarity index 57% rename from causestarter/src/components/CauseCard.tsx rename to ui/src/causestarter/components/CauseCard.tsx index 08fd81b02..51fdbae04 100644 --- a/causestarter/src/components/CauseCard.tsx +++ b/ui/src/causestarter/components/CauseCard.tsx @@ -21,8 +21,9 @@ export function CauseCard({ cause }: CauseCardProps) { elevation={0} sx={{ display: 'block', - p: 2.5, - borderRadius: 3, + px: 1.5, + py: 1, + borderRadius: 2, border: '1px solid', borderColor: 'divider', textDecoration: 'none', @@ -31,33 +32,34 @@ export function CauseCard({ cause }: CauseCardProps) { '&:active': { transform: 'scale(0.99)' }, '&:hover': { borderColor: 'primary.main', - boxShadow: (theme) => `0 8px 24px ${theme.palette.mode === 'light' ? 'rgba(15,118,110,0.12)' : 'rgba(0,0,0,0.35)'}`, + boxShadow: (theme) => `0 4px 12px ${theme.palette.mode === 'light' ? 'rgba(15,118,110,0.10)' : 'rgba(0,0,0,0.28)'}`, }, }} > - + - + {causeTitle(cause)} - {!isLive(cause) && ( - - )} + + {planks.length > 0 && ( + + {planks.length} statement{planks.length === 1 ? '' : 's'} + {publishedCount < planks.length && ` · ${publishedCount} published`} + + )} + {!isLive(cause) && ( + + )} + - - {planks.length > 0 && ( - - {planks.length} statement{planks.length === 1 ? '' : 's'} - {publishedCount < planks.length && ` · ${publishedCount} published`} - - )} ) } diff --git a/causestarter/src/components/CauseConjunctionEarmark.test.tsx b/ui/src/causestarter/components/CauseConjunctionEarmark.test.tsx similarity index 100% rename from causestarter/src/components/CauseConjunctionEarmark.test.tsx rename to ui/src/causestarter/components/CauseConjunctionEarmark.test.tsx diff --git a/causestarter/src/components/CauseConjunctionEarmark.tsx b/ui/src/causestarter/components/CauseConjunctionEarmark.tsx similarity index 100% rename from causestarter/src/components/CauseConjunctionEarmark.tsx rename to ui/src/causestarter/components/CauseConjunctionEarmark.tsx diff --git a/causestarter/src/components/CauseFundingSummary.test.tsx b/ui/src/causestarter/components/CauseFundingSummary.test.tsx similarity index 100% rename from causestarter/src/components/CauseFundingSummary.test.tsx rename to ui/src/causestarter/components/CauseFundingSummary.test.tsx diff --git a/causestarter/src/components/CauseFundingSummary.tsx b/ui/src/causestarter/components/CauseFundingSummary.tsx similarity index 100% rename from causestarter/src/components/CauseFundingSummary.tsx rename to ui/src/causestarter/components/CauseFundingSummary.tsx diff --git a/causestarter/src/components/CauseMediatorCard.test.tsx b/ui/src/causestarter/components/CauseMediatorCard.test.tsx similarity index 100% rename from causestarter/src/components/CauseMediatorCard.test.tsx rename to ui/src/causestarter/components/CauseMediatorCard.test.tsx diff --git a/causestarter/src/components/CauseMediatorCard.tsx b/ui/src/causestarter/components/CauseMediatorCard.tsx similarity index 100% rename from causestarter/src/components/CauseMediatorCard.tsx rename to ui/src/causestarter/components/CauseMediatorCard.tsx diff --git a/causestarter/src/components/CauseViewStrip.test.tsx b/ui/src/causestarter/components/CauseViewStrip.test.tsx similarity index 100% rename from causestarter/src/components/CauseViewStrip.test.tsx rename to ui/src/causestarter/components/CauseViewStrip.test.tsx diff --git a/causestarter/src/components/CauseViewStrip.tsx b/ui/src/causestarter/components/CauseViewStrip.tsx similarity index 100% rename from causestarter/src/components/CauseViewStrip.tsx rename to ui/src/causestarter/components/CauseViewStrip.tsx diff --git a/causestarter/src/components/ClusterMediatorOptIn.test.tsx b/ui/src/causestarter/components/ClusterMediatorOptIn.test.tsx similarity index 100% rename from causestarter/src/components/ClusterMediatorOptIn.test.tsx rename to ui/src/causestarter/components/ClusterMediatorOptIn.test.tsx diff --git a/causestarter/src/components/ClusterMediatorOptIn.tsx b/ui/src/causestarter/components/ClusterMediatorOptIn.tsx similarity index 100% rename from causestarter/src/components/ClusterMediatorOptIn.tsx rename to ui/src/causestarter/components/ClusterMediatorOptIn.tsx diff --git a/causestarter/src/components/ConnectWalletHint.test.tsx b/ui/src/causestarter/components/ConnectWalletHint.test.tsx similarity index 100% rename from causestarter/src/components/ConnectWalletHint.test.tsx rename to ui/src/causestarter/components/ConnectWalletHint.test.tsx diff --git a/causestarter/src/components/ConnectWalletHint.tsx b/ui/src/causestarter/components/ConnectWalletHint.tsx similarity index 100% rename from causestarter/src/components/ConnectWalletHint.tsx rename to ui/src/causestarter/components/ConnectWalletHint.tsx diff --git a/ui/src/causestarter/components/CrowdJobs.tsx b/ui/src/causestarter/components/CrowdJobs.tsx new file mode 100644 index 000000000..107396872 --- /dev/null +++ b/ui/src/causestarter/components/CrowdJobs.tsx @@ -0,0 +1,46 @@ +import { Box, Button, Paper, Stack, Typography } from '@mui/material' +import { Link as RouterLink } from 'react-router-dom' +import { CROWD_JOBS, JOBS_DOC_PATH } from '../lib/jobs' + +export function CrowdJobs() { + return ( + + {CROWD_JOBS.map((job) => ( + + + {job.title} + + + {job.happyTo} + + + But: {job.ugh} + + + So: {job.soYou} + + + ))} + + + + + ) +} diff --git a/causestarter/src/components/HeaderInfoTip.tsx b/ui/src/causestarter/components/HeaderInfoTip.tsx similarity index 100% rename from causestarter/src/components/HeaderInfoTip.tsx rename to ui/src/causestarter/components/HeaderInfoTip.tsx diff --git a/ui/src/causestarter/components/JobTip.tsx b/ui/src/causestarter/components/JobTip.tsx new file mode 100644 index 000000000..3aebfa8ec --- /dev/null +++ b/ui/src/causestarter/components/JobTip.tsx @@ -0,0 +1,34 @@ +import { Alert, Link, Typography } from '@mui/material' +import { Link as RouterLink } from 'react-router-dom' +import { CROWD_JOBS, jobsDocHref, type CrowdJobId } from '../lib/jobs' + +export function JobTip({ + job, + title, + children, + testId, +}: { + job?: CrowdJobId + title?: string + children: string + testId?: string +}) { + const catalog = job ? CROWD_JOBS.find((entry) => entry.id === job) : undefined + const href = jobsDocHref(catalog) + + return ( + + {title && ( + + {title} + + )} + + {children}{' '} + + Do the part you’d do anyway + + + + ) +} diff --git a/causestarter/src/components/MediatorEditor.tsx b/ui/src/causestarter/components/MediatorEditor.tsx similarity index 100% rename from causestarter/src/components/MediatorEditor.tsx rename to ui/src/causestarter/components/MediatorEditor.tsx diff --git a/causestarter/src/components/MonthlyPledgeSignal.test.tsx b/ui/src/causestarter/components/MonthlyPledgeSignal.test.tsx similarity index 100% rename from causestarter/src/components/MonthlyPledgeSignal.test.tsx rename to ui/src/causestarter/components/MonthlyPledgeSignal.test.tsx diff --git a/causestarter/src/components/MonthlyPledgeSignal.tsx b/ui/src/causestarter/components/MonthlyPledgeSignal.tsx similarity index 100% rename from causestarter/src/components/MonthlyPledgeSignal.tsx rename to ui/src/causestarter/components/MonthlyPledgeSignal.tsx diff --git a/causestarter/src/components/OrganizerIdentity.test.tsx b/ui/src/causestarter/components/OrganizerIdentity.test.tsx similarity index 100% rename from causestarter/src/components/OrganizerIdentity.test.tsx rename to ui/src/causestarter/components/OrganizerIdentity.test.tsx diff --git a/causestarter/src/components/OrganizerIdentity.tsx b/ui/src/causestarter/components/OrganizerIdentity.tsx similarity index 100% rename from causestarter/src/components/OrganizerIdentity.tsx rename to ui/src/causestarter/components/OrganizerIdentity.tsx diff --git a/causestarter/src/components/PlankRow.test.tsx b/ui/src/causestarter/components/PlankRow.test.tsx similarity index 100% rename from causestarter/src/components/PlankRow.test.tsx rename to ui/src/causestarter/components/PlankRow.test.tsx diff --git a/causestarter/src/components/PlankRow.tsx b/ui/src/causestarter/components/PlankRow.tsx similarity index 100% rename from causestarter/src/components/PlankRow.tsx rename to ui/src/causestarter/components/PlankRow.tsx diff --git a/causestarter/src/components/ProjectBookmarkButton.tsx b/ui/src/causestarter/components/ProjectBookmarkButton.tsx similarity index 100% rename from causestarter/src/components/ProjectBookmarkButton.tsx rename to ui/src/causestarter/components/ProjectBookmarkButton.tsx diff --git a/causestarter/src/components/ProjectCard.tsx b/ui/src/causestarter/components/ProjectCard.tsx similarity index 100% rename from causestarter/src/components/ProjectCard.tsx rename to ui/src/causestarter/components/ProjectCard.tsx diff --git a/causestarter/src/components/RosterHistory.tsx b/ui/src/causestarter/components/RosterHistory.tsx similarity index 100% rename from causestarter/src/components/RosterHistory.tsx rename to ui/src/causestarter/components/RosterHistory.tsx diff --git a/causestarter/src/components/RosterPublishPanel.test.tsx b/ui/src/causestarter/components/RosterPublishPanel.test.tsx similarity index 100% rename from causestarter/src/components/RosterPublishPanel.test.tsx rename to ui/src/causestarter/components/RosterPublishPanel.test.tsx diff --git a/causestarter/src/components/RosterPublishPanel.tsx b/ui/src/causestarter/components/RosterPublishPanel.tsx similarity index 94% rename from causestarter/src/components/RosterPublishPanel.tsx rename to ui/src/causestarter/components/RosterPublishPanel.tsx index b19fb10f4..31018f35b 100644 --- a/causestarter/src/components/RosterPublishPanel.tsx +++ b/ui/src/causestarter/components/RosterPublishPanel.tsx @@ -68,7 +68,7 @@ export function RosterPublishPanel({ onPublish, onPublishAnyway, }: RosterPublishPanelProps) { - const slugError = slug ? validateSlug(normalizeSlug(slug)) : 'Choose a URL slug for this cause.' + const slugError = slug ? validateSlug(normalizeSlug(slug)) : 'Choose a URL slug for this cause board.' const busy = checking || publishing || disabled const badgeMatches = Boolean( coherence @@ -83,10 +83,10 @@ export function RosterPublishPanel({ return ( - Publish this cause + Publish this cause board Title, summary, statement list, and mediator blurb publish together as a versioned - cause page. The URL stays stable when you edit; each publish is a new version. + cause board. The URL stays stable when you edit; each publish is a new version. @@ -118,7 +118,7 @@ export function RosterPublishPanel({ fullWidth size="small" disabled={busy} - helperText="Shown at the top of the cause page and sealed into the published version." + helperText="Shown at the top of the cause board and sealed into the published version." slotProps={{ htmlInput: { 'data-testid': 'roster-title' } }} /> - Connect a wallet to publish the cause page on chain. + Connect a wallet to publish the cause board on chain. )} {!canPublish && ( - Publish at least one statement before publishing the cause page. + Publish at least one statement before publishing the cause board. )} diff --git a/causestarter/src/components/SafetyRejectionDialog.tsx b/ui/src/causestarter/components/SafetyRejectionDialog.tsx similarity index 100% rename from causestarter/src/components/SafetyRejectionDialog.tsx rename to ui/src/causestarter/components/SafetyRejectionDialog.tsx diff --git a/causestarter/src/components/SelectedPlankSupport.test.tsx b/ui/src/causestarter/components/SelectedPlankSupport.test.tsx similarity index 95% rename from causestarter/src/components/SelectedPlankSupport.test.tsx rename to ui/src/causestarter/components/SelectedPlankSupport.test.tsx index 94c7d5947..31d21f09f 100644 --- a/causestarter/src/components/SelectedPlankSupport.test.tsx +++ b/ui/src/causestarter/components/SelectedPlankSupport.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SelectedPlankSupport } from './SelectedPlankSupport' import { sendCallsPreferAtomic } from '../lib/causeRoster' import { getUserBelief } from '@commonality/sdk/conceptspace' +import type { IpfsCidV1 } from '@commonality/sdk/utils' vi.mock('wagmi', () => ({ useAccount: vi.fn(() => ({ address: '0x1111111111111111111111111111111111111111', isConnected: true })), @@ -21,7 +22,7 @@ vi.mock('./WalletButton', () => ({ WalletButton: () => const BELIEVES = 1 const NO_OPINION = 0 -const planks = [ +const planks: { cid: IpfsCidV1; text: string }[] = [ { cid: 'bafybeidagx4zc6phhtjng6f3sjzlicqm2ssq4eb6wskinjtuvkt275fmpy', text: 'School crossings should be safer.' }, { cid: 'bafybeifjzv3oc6zqklqvfmv2j5xgqqjped3zrm4y2a3s4u5v6w7x2y3z4a', text: 'Public parks should remain open.' }, ] @@ -59,7 +60,7 @@ describe('SelectedPlankSupport', () => { it('hides when every selected statement is already signed', async () => { vi.mocked(getUserBelief).mockResolvedValue({ - statementCid: planks[0].cid, + statementCid: planks[0].cid as IpfsCidV1, beliefState: BELIEVES, }) const { container } = render() diff --git a/causestarter/src/components/SelectedPlankSupport.tsx b/ui/src/causestarter/components/SelectedPlankSupport.tsx similarity index 100% rename from causestarter/src/components/SelectedPlankSupport.tsx rename to ui/src/causestarter/components/SelectedPlankSupport.tsx diff --git a/causestarter/src/components/StarterNetworkFilterNotice.test.tsx b/ui/src/causestarter/components/StarterNetworkFilterNotice.test.tsx similarity index 100% rename from causestarter/src/components/StarterNetworkFilterNotice.test.tsx rename to ui/src/causestarter/components/StarterNetworkFilterNotice.test.tsx diff --git a/causestarter/src/components/StarterNetworkFilterNotice.tsx b/ui/src/causestarter/components/StarterNetworkFilterNotice.tsx similarity index 100% rename from causestarter/src/components/StarterNetworkFilterNotice.tsx rename to ui/src/causestarter/components/StarterNetworkFilterNotice.tsx diff --git a/causestarter/src/components/StatementPicker.test.tsx b/ui/src/causestarter/components/StatementPicker.test.tsx similarity index 100% rename from causestarter/src/components/StatementPicker.test.tsx rename to ui/src/causestarter/components/StatementPicker.test.tsx diff --git a/causestarter/src/components/StatementPicker.tsx b/ui/src/causestarter/components/StatementPicker.tsx similarity index 100% rename from causestarter/src/components/StatementPicker.tsx rename to ui/src/causestarter/components/StatementPicker.tsx diff --git a/causestarter/src/components/StatementSupportStats.tsx b/ui/src/causestarter/components/StatementSupportStats.tsx similarity index 100% rename from causestarter/src/components/StatementSupportStats.tsx rename to ui/src/causestarter/components/StatementSupportStats.tsx diff --git a/causestarter/src/components/SupportButton.test.tsx b/ui/src/causestarter/components/SupportButton.test.tsx similarity index 100% rename from causestarter/src/components/SupportButton.test.tsx rename to ui/src/causestarter/components/SupportButton.test.tsx diff --git a/causestarter/src/components/SupportButton.tsx b/ui/src/causestarter/components/SupportButton.tsx similarity index 100% rename from causestarter/src/components/SupportButton.tsx rename to ui/src/causestarter/components/SupportButton.tsx diff --git a/causestarter/src/components/ToolCard.tsx b/ui/src/causestarter/components/ToolCard.tsx similarity index 100% rename from causestarter/src/components/ToolCard.tsx rename to ui/src/causestarter/components/ToolCard.tsx diff --git a/causestarter/src/components/WalletButton.tsx b/ui/src/causestarter/components/WalletButton.tsx similarity index 100% rename from causestarter/src/components/WalletButton.tsx rename to ui/src/causestarter/components/WalletButton.tsx diff --git a/causestarter/src/components/YourCauses.tsx b/ui/src/causestarter/components/YourCauses.tsx similarity index 79% rename from causestarter/src/components/YourCauses.tsx rename to ui/src/causestarter/components/YourCauses.tsx index a1593a432..d96e56c18 100644 --- a/causestarter/src/components/YourCauses.tsx +++ b/ui/src/causestarter/components/YourCauses.tsx @@ -10,11 +10,13 @@ export function YourCauses({ loading, footer, testId, + headingComponent = 'h1', }: { causes: CauseDraft[] loading: boolean footer?: ReactNode testId?: string + headingComponent?: 'h1' | 'h2' }) { const navigate = useNavigate() // "Live" is derived, not a status flag: a cause is live once any of its @@ -33,8 +35,8 @@ export function YourCauses({ }} > - - Causes + + Cause boards @@ -51,7 +53,7 @@ export function YourCauses({ }} onClick={() => navigate(createCausePath())} > - Start a cause + Start a cause board @@ -60,14 +62,16 @@ export function YourCauses({ - Loading causes… + Loading cause boards… )} {!loading && causes.length === 0 && ( - No causes yet on this device. Start one, or open a cause from its organizer’s link. + No cause boards on this device. Start one if you want a different combination of + statements — reuse overlapping ones so you are not starting from zero. Or open + a cause board from its organizer’s link; there is no directory. )} @@ -75,14 +79,14 @@ export function YourCauses({ - Bookmarked causes + Bookmarked cause boards - + {launched.map((cause) => ( ))} @@ -94,14 +98,14 @@ export function YourCauses({ - Cause drafts + Cause board drafts - + {drafts.map((cause) => ( ))} diff --git a/ui/src/causestarter/components/YourDashboard.test.tsx b/ui/src/causestarter/components/YourDashboard.test.tsx new file mode 100644 index 000000000..8f8d1e399 --- /dev/null +++ b/ui/src/causestarter/components/YourDashboard.test.tsx @@ -0,0 +1,133 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { YourDashboard } from './YourDashboard' + +const { useUserStatements, useAlignmentTrust } = vi.hoisted(() => ({ + useUserStatements: vi.fn(), + useAlignmentTrust: vi.fn(), +})) + +vi.mock('../hooks/useUserStatements', () => ({ + useUserStatements, +})) + +vi.mock('../hooks/useAlignmentTrust', () => ({ + useAlignmentTrust, +})) + +vi.mock('@ui/fundingportals', () => ({ + CauseBoard: ({ + statementCids, + preview, + }: { + statementCids: string[] + preview?: { limit: number; fullPageTo: string } + }) => ( +
+ {preview ? `preview:${preview.limit}:${preview.fullPageTo}:` : 'full:'} + {statementCids.join(',')} +
+ ), +})) + +vi.mock('@ui/shared', () => ({ + TrustNetworkRefreshIndicator: () => null, +})) + +vi.mock('./AlignmentTrustGate', () => ({ + AlignmentTrustGate: () =>
, +})) + +vi.mock('./ConnectWalletHint', () => ({ + ConnectWalletHint: ({ children }: { children: string }) =>
{children}
, +})) + +vi.mock('./StarterNetworkFilterNotice', () => ({ + StarterNetworkFilterCopy: () => null, +})) + +describe('YourDashboard', () => { + afterEach(cleanup) + + beforeEach(() => { + useAlignmentTrust.mockReturnValue({ + trustedAlignmentAttesters: new Set(), + alignmentTrustUnavailable: false, + showInitialTrustLoad: false, + trustError: null, + }) + }) + + it('asks to connect when there is no wallet', () => { + useUserStatements.mockReturnValue({ + statements: [], + loading: false, + connected: false, + error: null, + refresh: vi.fn(), + }) + render( + + + , + ) + expect(screen.getByText(/connect a wallet/i)).toBeInTheDocument() + expect(screen.queryByTestId('fundable-projects')).toBeNull() + }) + + it('shows an empty state when the wallet has not signed anything', () => { + useUserStatements.mockReturnValue({ + statements: [], + loading: false, + connected: true, + error: null, + refresh: vi.fn(), + }) + render( + + + , + ) + expect(screen.getByTestId('home-dashboard-empty')).toBeInTheDocument() + expect(screen.queryByTestId('fundable-projects')).toBeNull() + }) + + it('unions signed statement CIDs into the fundable-projects board', () => { + useUserStatements.mockReturnValue({ + statements: [ + { cid: 'bafy1' }, + { cid: 'bafy2' }, + ], + loading: false, + connected: true, + error: null, + refresh: vi.fn(), + }) + render( + + + , + ) + expect(screen.getByTestId('fundable-projects')).toHaveTextContent('preview:3:/dashboard:bafy1,bafy2') + expect(screen.queryByTestId('home-dashboard-see-all')).toBeNull() + }) + + it('renders the uncapped board on the dedicated page', () => { + useUserStatements.mockReturnValue({ + statements: [{ cid: 'bafy1' }], + loading: false, + connected: true, + error: null, + refresh: vi.fn(), + }) + render( + + + , + ) + expect(screen.getByTestId('personal-dashboard-page')).toBeInTheDocument() + expect(screen.getByTestId('fundable-projects')).toHaveTextContent('full:bafy1') + expect(screen.queryByTestId('home-dashboard-see-all')).toBeNull() + }) +}) diff --git a/ui/src/causestarter/components/YourDashboard.tsx b/ui/src/causestarter/components/YourDashboard.tsx new file mode 100644 index 000000000..e2ea179c1 --- /dev/null +++ b/ui/src/causestarter/components/YourDashboard.tsx @@ -0,0 +1,113 @@ +import { Alert, Box, Button, CircularProgress, Stack, Typography } from '@mui/material' +import { CauseBoard } from '@ui/fundingportals' +import { TrustNetworkRefreshIndicator } from '@ui/shared' +import { AlignmentTrustGate } from './AlignmentTrustGate' +import { ConnectWalletHint } from './ConnectWalletHint' +import { HeaderInfoTip } from './HeaderInfoTip' +import { StarterNetworkFilterCopy } from './StarterNetworkFilterNotice' +import { useAlignmentTrust } from '../hooks/useAlignmentTrust' +import { useUserStatements } from '../hooks/useUserStatements' + +const sectionHeadingSx = { fontWeight: 800, fontSize: { xs: '1.6rem', sm: '2rem' } } + +export const PERSONAL_DASHBOARD_PATH = '/dashboard' +const HOME_PREVIEW_LIMIT = 3 + +export function YourDashboard({ + layout = 'preview', +}: { + layout?: 'preview' | 'page' +}) { + const { statements, loading, connected, error, refresh } = useUserStatements() + const { + trustedAlignmentAttesters, + alignmentTrustUnavailable, + showInitialTrustLoad, + trustError, + } = useAlignmentTrust() + const statementCids = statements.map((row) => row.cid).filter(Boolean) + + const preview = layout === 'preview' + const headingId = preview ? 'home-dashboard-board' : 'personal-dashboard-page' + + return ( + + + + Fundable projects + + + + + {!connected && ( + + Connect a wallet to see fundable projects vouched for as advancing statements you have signed. + + )} + + {connected && loading && ( + + + + Loading signed statements… + + + )} + + {connected && !loading && error && ( + + {error} + + + )} + + {connected && !loading && !error && statementCids.length === 0 && ( + + Sign a statement from a cause board or a statement page. This list is the union of + work vouched as advancing those claims — it is not a private cause. + + )} + + {connected && !loading && !error && statementCids.length > 0 && ( + <> + {showInitialTrustLoad && ( + + + + )} + {(trustError || alignmentTrustUnavailable) && ( + + )} + + + Union of projects vouched as advancing any statement you signed. Alignment + attaches to a statement, never to a cause board as a club. + + + + ) + } + /> + + )} + + ) +} diff --git a/causestarter/src/components/YourNudgersAndNudges.test.tsx b/ui/src/causestarter/components/YourNudgersAndNudges.test.tsx similarity index 100% rename from causestarter/src/components/YourNudgersAndNudges.test.tsx rename to ui/src/causestarter/components/YourNudgersAndNudges.test.tsx diff --git a/causestarter/src/components/YourNudgersAndNudges.tsx b/ui/src/causestarter/components/YourNudgersAndNudges.tsx similarity index 97% rename from causestarter/src/components/YourNudgersAndNudges.tsx rename to ui/src/causestarter/components/YourNudgersAndNudges.tsx index 72c6e0caf..e6c2b66be 100644 --- a/causestarter/src/components/YourNudgersAndNudges.tsx +++ b/ui/src/causestarter/components/YourNudgersAndNudges.tsx @@ -101,8 +101,8 @@ export function YourNudgersAndNudges() { {trustedNudgers.length === 0 ? ( - No suggesters yet. Open a cause and opt in to a suggester to see - suggestions here. + No suggesters yet. Opt in on a cause if you would sign a better wording but + do not want to hunt for it — a mediator can nudge you when a bridge fits. ) : ( diff --git a/causestarter/src/components/YourProjects.test.tsx b/ui/src/causestarter/components/YourProjects.test.tsx similarity index 94% rename from causestarter/src/components/YourProjects.test.tsx rename to ui/src/causestarter/components/YourProjects.test.tsx index 9ce30f32f..1b8860e15 100644 --- a/causestarter/src/components/YourProjects.test.tsx +++ b/ui/src/causestarter/components/YourProjects.test.tsx @@ -27,7 +27,7 @@ describe('YourProjects', () => { , ) - expect(screen.getByRole('heading', { name: 'Projects' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Bookmarked projects' })).toBeInTheDocument() expect(screen.getByTestId('home-create-project')).toBeInTheDocument() expect(screen.getByText(/No projects yet/)).toBeInTheDocument() }) diff --git a/causestarter/src/components/YourProjects.tsx b/ui/src/causestarter/components/YourProjects.tsx similarity index 89% rename from causestarter/src/components/YourProjects.tsx rename to ui/src/causestarter/components/YourProjects.tsx index dc06b8034..1eacdf761 100644 --- a/causestarter/src/components/YourProjects.tsx +++ b/ui/src/causestarter/components/YourProjects.tsx @@ -21,7 +21,7 @@ export function YourProjects() { > - Projects + Bookmarked projects - No projects yet. Create one, contribute, or bookmark a project from its page. + No projects yet. If you would do the work but cannot self-fund, create one + and ask a better-connected friend for an alignment vouch — no grant officer. + Contribute or bookmark if your job is money or attention instead. )} diff --git a/causestarter/src/components/YourSignedStatements.test.tsx b/ui/src/causestarter/components/YourSignedStatements.test.tsx similarity index 100% rename from causestarter/src/components/YourSignedStatements.test.tsx rename to ui/src/causestarter/components/YourSignedStatements.test.tsx diff --git a/causestarter/src/components/YourSignedStatements.tsx b/ui/src/causestarter/components/YourSignedStatements.tsx similarity index 93% rename from causestarter/src/components/YourSignedStatements.tsx rename to ui/src/causestarter/components/YourSignedStatements.tsx index f68e779a0..e446f1a5d 100644 --- a/causestarter/src/components/YourSignedStatements.tsx +++ b/ui/src/causestarter/components/YourSignedStatements.tsx @@ -17,7 +17,7 @@ export function YourSignedStatements() { Statements diff --git a/causestarter/src/hooks/useAlignmentTrust.test.ts b/ui/src/causestarter/hooks/useAlignmentTrust.test.ts similarity index 100% rename from causestarter/src/hooks/useAlignmentTrust.test.ts rename to ui/src/causestarter/hooks/useAlignmentTrust.test.ts diff --git a/causestarter/src/hooks/useAlignmentTrust.ts b/ui/src/causestarter/hooks/useAlignmentTrust.ts similarity index 100% rename from causestarter/src/hooks/useAlignmentTrust.ts rename to ui/src/causestarter/hooks/useAlignmentTrust.ts diff --git a/causestarter/src/hooks/useCauseMonthlyPledges.test.tsx b/ui/src/causestarter/hooks/useCauseMonthlyPledges.test.tsx similarity index 100% rename from causestarter/src/hooks/useCauseMonthlyPledges.test.tsx rename to ui/src/causestarter/hooks/useCauseMonthlyPledges.test.tsx diff --git a/causestarter/src/hooks/useCauseMonthlyPledges.ts b/ui/src/causestarter/hooks/useCauseMonthlyPledges.ts similarity index 100% rename from causestarter/src/hooks/useCauseMonthlyPledges.ts rename to ui/src/causestarter/hooks/useCauseMonthlyPledges.ts diff --git a/causestarter/src/hooks/useCauseProjects.test.tsx b/ui/src/causestarter/hooks/useCauseProjects.test.tsx similarity index 100% rename from causestarter/src/hooks/useCauseProjects.test.tsx rename to ui/src/causestarter/hooks/useCauseProjects.test.tsx diff --git a/causestarter/src/hooks/useCauseProjects.ts b/ui/src/causestarter/hooks/useCauseProjects.ts similarity index 100% rename from causestarter/src/hooks/useCauseProjects.ts rename to ui/src/causestarter/hooks/useCauseProjects.ts diff --git a/causestarter/src/hooks/useToolExamples.ts b/ui/src/causestarter/hooks/useToolExamples.ts similarity index 100% rename from causestarter/src/hooks/useToolExamples.ts rename to ui/src/causestarter/hooks/useToolExamples.ts diff --git a/causestarter/src/hooks/useUserCauses.ts b/ui/src/causestarter/hooks/useUserCauses.ts similarity index 100% rename from causestarter/src/hooks/useUserCauses.ts rename to ui/src/causestarter/hooks/useUserCauses.ts diff --git a/causestarter/src/hooks/useUserProjects.ts b/ui/src/causestarter/hooks/useUserProjects.ts similarity index 100% rename from causestarter/src/hooks/useUserProjects.ts rename to ui/src/causestarter/hooks/useUserProjects.ts diff --git a/causestarter/src/hooks/useUserStatements.test.ts b/ui/src/causestarter/hooks/useUserStatements.test.ts similarity index 100% rename from causestarter/src/hooks/useUserStatements.test.ts rename to ui/src/causestarter/hooks/useUserStatements.test.ts diff --git a/causestarter/src/hooks/useUserStatements.ts b/ui/src/causestarter/hooks/useUserStatements.ts similarity index 100% rename from causestarter/src/hooks/useUserStatements.ts rename to ui/src/causestarter/hooks/useUserStatements.ts diff --git a/causestarter/src/hooks/useViewCounts.test.tsx b/ui/src/causestarter/hooks/useViewCounts.test.tsx similarity index 100% rename from causestarter/src/hooks/useViewCounts.test.tsx rename to ui/src/causestarter/hooks/useViewCounts.test.tsx diff --git a/causestarter/src/hooks/useViewCounts.ts b/ui/src/causestarter/hooks/useViewCounts.ts similarity index 100% rename from causestarter/src/hooks/useViewCounts.ts rename to ui/src/causestarter/hooks/useViewCounts.ts diff --git a/causestarter/src/index.css b/ui/src/causestarter/index.css similarity index 100% rename from causestarter/src/index.css rename to ui/src/causestarter/index.css diff --git a/causestarter/src/lib/alignedContent.test.ts b/ui/src/causestarter/lib/alignedContent.test.ts similarity index 100% rename from causestarter/src/lib/alignedContent.test.ts rename to ui/src/causestarter/lib/alignedContent.test.ts diff --git a/causestarter/src/lib/alignedContent.ts b/ui/src/causestarter/lib/alignedContent.ts similarity index 100% rename from causestarter/src/lib/alignedContent.ts rename to ui/src/causestarter/lib/alignedContent.ts diff --git a/causestarter/src/lib/believerSetsCache.test.ts b/ui/src/causestarter/lib/believerSetsCache.test.ts similarity index 100% rename from causestarter/src/lib/believerSetsCache.test.ts rename to ui/src/causestarter/lib/believerSetsCache.test.ts diff --git a/causestarter/src/lib/believerSetsCache.ts b/ui/src/causestarter/lib/believerSetsCache.ts similarity index 100% rename from causestarter/src/lib/believerSetsCache.ts rename to ui/src/causestarter/lib/believerSetsCache.ts diff --git a/causestarter/src/lib/bridgeAssistBrief.test.ts b/ui/src/causestarter/lib/bridgeAssistBrief.test.ts similarity index 100% rename from causestarter/src/lib/bridgeAssistBrief.test.ts rename to ui/src/causestarter/lib/bridgeAssistBrief.test.ts diff --git a/causestarter/src/lib/bridgeAssistBrief.ts b/ui/src/causestarter/lib/bridgeAssistBrief.ts similarity index 100% rename from causestarter/src/lib/bridgeAssistBrief.ts rename to ui/src/causestarter/lib/bridgeAssistBrief.ts diff --git a/causestarter/src/lib/bridgeCluster.test.ts b/ui/src/causestarter/lib/bridgeCluster.test.ts similarity index 100% rename from causestarter/src/lib/bridgeCluster.test.ts rename to ui/src/causestarter/lib/bridgeCluster.test.ts diff --git a/causestarter/src/lib/bridgeCluster.ts b/ui/src/causestarter/lib/bridgeCluster.ts similarity index 100% rename from causestarter/src/lib/bridgeCluster.ts rename to ui/src/causestarter/lib/bridgeCluster.ts diff --git a/causestarter/src/lib/bridgeNudges.test.ts b/ui/src/causestarter/lib/bridgeNudges.test.ts similarity index 100% rename from causestarter/src/lib/bridgeNudges.test.ts rename to ui/src/causestarter/lib/bridgeNudges.test.ts diff --git a/causestarter/src/lib/bridgeNudges.ts b/ui/src/causestarter/lib/bridgeNudges.ts similarity index 100% rename from causestarter/src/lib/bridgeNudges.ts rename to ui/src/causestarter/lib/bridgeNudges.ts diff --git a/causestarter/src/lib/bridgeStore.test.ts b/ui/src/causestarter/lib/bridgeStore.test.ts similarity index 100% rename from causestarter/src/lib/bridgeStore.test.ts rename to ui/src/causestarter/lib/bridgeStore.test.ts diff --git a/causestarter/src/lib/bridgeStore.ts b/ui/src/causestarter/lib/bridgeStore.ts similarity index 100% rename from causestarter/src/lib/bridgeStore.ts rename to ui/src/causestarter/lib/bridgeStore.ts diff --git a/causestarter/src/lib/bridgeTriple.test.ts b/ui/src/causestarter/lib/bridgeTriple.test.ts similarity index 100% rename from causestarter/src/lib/bridgeTriple.test.ts rename to ui/src/causestarter/lib/bridgeTriple.test.ts diff --git a/causestarter/src/lib/bridgeTriple.ts b/ui/src/causestarter/lib/bridgeTriple.ts similarity index 100% rename from causestarter/src/lib/bridgeTriple.ts rename to ui/src/causestarter/lib/bridgeTriple.ts diff --git a/causestarter/src/lib/causeAssistClient.ts b/ui/src/causestarter/lib/causeAssistClient.ts similarity index 100% rename from causestarter/src/lib/causeAssistClient.ts rename to ui/src/causestarter/lib/causeAssistClient.ts diff --git a/causestarter/src/lib/causeBookmarks.test.ts b/ui/src/causestarter/lib/causeBookmarks.test.ts similarity index 100% rename from causestarter/src/lib/causeBookmarks.test.ts rename to ui/src/causestarter/lib/causeBookmarks.test.ts diff --git a/causestarter/src/lib/causeBookmarks.ts b/ui/src/causestarter/lib/causeBookmarks.ts similarity index 100% rename from causestarter/src/lib/causeBookmarks.ts rename to ui/src/causestarter/lib/causeBookmarks.ts diff --git a/causestarter/src/lib/causeCompatibility.test.ts b/ui/src/causestarter/lib/causeCompatibility.test.ts similarity index 100% rename from causestarter/src/lib/causeCompatibility.test.ts rename to ui/src/causestarter/lib/causeCompatibility.test.ts diff --git a/causestarter/src/lib/causeRoster.test.ts b/ui/src/causestarter/lib/causeRoster.test.ts similarity index 100% rename from causestarter/src/lib/causeRoster.test.ts rename to ui/src/causestarter/lib/causeRoster.test.ts diff --git a/causestarter/src/lib/causeRoster.ts b/ui/src/causestarter/lib/causeRoster.ts similarity index 100% rename from causestarter/src/lib/causeRoster.ts rename to ui/src/causestarter/lib/causeRoster.ts diff --git a/causestarter/src/lib/causeStore.test.ts b/ui/src/causestarter/lib/causeStore.test.ts similarity index 100% rename from causestarter/src/lib/causeStore.test.ts rename to ui/src/causestarter/lib/causeStore.test.ts diff --git a/causestarter/src/lib/causeStore.ts b/ui/src/causestarter/lib/causeStore.ts similarity index 100% rename from causestarter/src/lib/causeStore.ts rename to ui/src/causestarter/lib/causeStore.ts diff --git a/causestarter/src/lib/concurrency.test.ts b/ui/src/causestarter/lib/concurrency.test.ts similarity index 100% rename from causestarter/src/lib/concurrency.test.ts rename to ui/src/causestarter/lib/concurrency.test.ts diff --git a/causestarter/src/lib/concurrency.ts b/ui/src/causestarter/lib/concurrency.ts similarity index 100% rename from causestarter/src/lib/concurrency.ts rename to ui/src/causestarter/lib/concurrency.ts diff --git a/causestarter/src/lib/domainUrls.ts b/ui/src/causestarter/lib/domainUrls.ts similarity index 100% rename from causestarter/src/lib/domainUrls.ts rename to ui/src/causestarter/lib/domainUrls.ts diff --git a/causestarter/src/lib/exampleBank.test.ts b/ui/src/causestarter/lib/exampleBank.test.ts similarity index 100% rename from causestarter/src/lib/exampleBank.test.ts rename to ui/src/causestarter/lib/exampleBank.test.ts diff --git a/causestarter/src/lib/exampleBank.ts b/ui/src/causestarter/lib/exampleBank.ts similarity index 100% rename from causestarter/src/lib/exampleBank.ts rename to ui/src/causestarter/lib/exampleBank.ts diff --git a/causestarter/src/lib/fixtures/causeCompatibility.ts b/ui/src/causestarter/lib/fixtures/causeCompatibility.ts similarity index 100% rename from causestarter/src/lib/fixtures/causeCompatibility.ts rename to ui/src/causestarter/lib/fixtures/causeCompatibility.ts diff --git a/causestarter/src/lib/hardhatAccounts.test.ts b/ui/src/causestarter/lib/hardhatAccounts.test.ts similarity index 100% rename from causestarter/src/lib/hardhatAccounts.test.ts rename to ui/src/causestarter/lib/hardhatAccounts.test.ts diff --git a/causestarter/src/lib/hardhatAccounts.ts b/ui/src/causestarter/lib/hardhatAccounts.ts similarity index 100% rename from causestarter/src/lib/hardhatAccounts.ts rename to ui/src/causestarter/lib/hardhatAccounts.ts diff --git a/causestarter/src/lib/hardhatLocalConnector.ts b/ui/src/causestarter/lib/hardhatLocalConnector.ts similarity index 100% rename from causestarter/src/lib/hardhatLocalConnector.ts rename to ui/src/causestarter/lib/hardhatLocalConnector.ts diff --git a/causestarter/src/lib/implicationAttesterClient.test.ts b/ui/src/causestarter/lib/implicationAttesterClient.test.ts similarity index 100% rename from causestarter/src/lib/implicationAttesterClient.test.ts rename to ui/src/causestarter/lib/implicationAttesterClient.test.ts diff --git a/causestarter/src/lib/implicationAttesterClient.ts b/ui/src/causestarter/lib/implicationAttesterClient.ts similarity index 100% rename from causestarter/src/lib/implicationAttesterClient.ts rename to ui/src/causestarter/lib/implicationAttesterClient.ts diff --git a/ui/src/causestarter/lib/jobs.test.ts b/ui/src/causestarter/lib/jobs.test.ts new file mode 100644 index 000000000..adb5d798c --- /dev/null +++ b/ui/src/causestarter/lib/jobs.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { CROWD_JOBS, jobsDocHref } from './jobs' + +describe('jobs catalog', () => { + it('has four jobs with in-app doc anchors', () => { + expect(CROWD_JOBS.map((job) => job.id)).toEqual(['money', 'attention', 'work', 'wording']) + expect(jobsDocHref()).toBe('/docs/the-jobs') + expect(jobsDocHref(CROWD_JOBS[0])).toBe('/docs/the-jobs#money') + }) +}) diff --git a/ui/src/causestarter/lib/jobs.ts b/ui/src/causestarter/lib/jobs.ts new file mode 100644 index 000000000..4b5699cfd --- /dev/null +++ b/ui/src/causestarter/lib/jobs.ts @@ -0,0 +1,52 @@ +export type CrowdJobId = 'money' | 'attention' | 'work' | 'wording' + +export interface CrowdJob { + id: CrowdJobId + title: string + happyTo: string + ugh: string + soYou: string + docsHash: string +} + +/** The four jobs the landing and in-product tips keep repeating. */ +export const CROWD_JOBS: CrowdJob[] = [ + { + id: 'money', + title: 'Money', + happyTo: 'I’d put in $X/month if enough others do too.', + ugh: 'I will not read every project, and I do not trust a big org with a black box.', + soYou: 'Pledge with a refund if the threshold is missed. Hand the picking to a person you already trust.', + docsHash: 'money', + }, + { + id: 'attention', + title: 'Attention', + happyTo: 'I’d watch for work worth funding.', + ugh: 'I don’t know what’s out there, who’s a scam, or how to float early bets.', + soYou: 'Follow statements you mean. Fund proven work, or fund early and ask to be reimbursed at cost.', + docsHash: 'attention-and-judgment', + }, + { + id: 'work', + title: 'Work', + happyTo: 'I’d do this project.', + ugh: 'I can’t self-fund, and I don’t know a grant officer.', + soYou: 'Publish it. A friend one hop better-connected can vouch that it advances a statement people already watch.', + docsHash: 'work', + }, + { + id: 'wording', + title: 'Wording', + happyTo: 'I’d stand behind an idea like that.', + ugh: 'Not in those words — and my words will have zero signers.', + soYou: 'Write yours. Similar signatures can still count. A bridge can invite people whose statement does not yet imply yours.', + docsHash: 'wording', + }, +] + +export const JOBS_DOC_PATH = '/docs/the-jobs' + +export function jobsDocHref(job?: CrowdJob): string { + return job ? `${JOBS_DOC_PATH}#${job.docsHash}` : JOBS_DOC_PATH +} diff --git a/causestarter/src/lib/nearDuplicatePlanks.test.ts b/ui/src/causestarter/lib/nearDuplicatePlanks.test.ts similarity index 100% rename from causestarter/src/lib/nearDuplicatePlanks.test.ts rename to ui/src/causestarter/lib/nearDuplicatePlanks.test.ts diff --git a/causestarter/src/lib/nearDuplicatePlanks.ts b/ui/src/causestarter/lib/nearDuplicatePlanks.ts similarity index 100% rename from causestarter/src/lib/nearDuplicatePlanks.ts rename to ui/src/causestarter/lib/nearDuplicatePlanks.ts diff --git a/causestarter/src/lib/projectBookmarks.test.ts b/ui/src/causestarter/lib/projectBookmarks.test.ts similarity index 100% rename from causestarter/src/lib/projectBookmarks.test.ts rename to ui/src/causestarter/lib/projectBookmarks.test.ts diff --git a/causestarter/src/lib/projectBookmarks.ts b/ui/src/causestarter/lib/projectBookmarks.ts similarity index 100% rename from causestarter/src/lib/projectBookmarks.ts rename to ui/src/causestarter/lib/projectBookmarks.ts diff --git a/causestarter/src/lib/publishCombinator.ts b/ui/src/causestarter/lib/publishCombinator.ts similarity index 100% rename from causestarter/src/lib/publishCombinator.ts rename to ui/src/causestarter/lib/publishCombinator.ts diff --git a/causestarter/src/lib/publishPlank.ts b/ui/src/causestarter/lib/publishPlank.ts similarity index 100% rename from causestarter/src/lib/publishPlank.ts rename to ui/src/causestarter/lib/publishPlank.ts diff --git a/causestarter/src/lib/runtimeConfig.ts b/ui/src/causestarter/lib/runtimeConfig.ts similarity index 100% rename from causestarter/src/lib/runtimeConfig.ts rename to ui/src/causestarter/lib/runtimeConfig.ts diff --git a/causestarter/src/lib/statementPicker.test.ts b/ui/src/causestarter/lib/statementPicker.test.ts similarity index 100% rename from causestarter/src/lib/statementPicker.test.ts rename to ui/src/causestarter/lib/statementPicker.test.ts diff --git a/causestarter/src/lib/statementPicker.ts b/ui/src/causestarter/lib/statementPicker.ts similarity index 100% rename from causestarter/src/lib/statementPicker.ts rename to ui/src/causestarter/lib/statementPicker.ts diff --git a/causestarter/src/lib/toolExamples.test.ts b/ui/src/causestarter/lib/toolExamples.test.ts similarity index 100% rename from causestarter/src/lib/toolExamples.test.ts rename to ui/src/causestarter/lib/toolExamples.test.ts diff --git a/causestarter/src/lib/toolExamples.ts b/ui/src/causestarter/lib/toolExamples.ts similarity index 100% rename from causestarter/src/lib/toolExamples.ts rename to ui/src/causestarter/lib/toolExamples.ts diff --git a/causestarter/src/lib/tools.test.ts b/ui/src/causestarter/lib/tools.test.ts similarity index 100% rename from causestarter/src/lib/tools.test.ts rename to ui/src/causestarter/lib/tools.test.ts diff --git a/causestarter/src/lib/tools.ts b/ui/src/causestarter/lib/tools.ts similarity index 92% rename from causestarter/src/lib/tools.ts rename to ui/src/causestarter/lib/tools.ts index cfd8ecde8..e437121d4 100644 --- a/causestarter/src/lib/tools.ts +++ b/ui/src/causestarter/lib/tools.ts @@ -30,7 +30,7 @@ export const SUPPORTING_TOOLS: SupportingTool[] = [ id: 'delegation', name: 'Delegation', role: 'Trust others with funding judgment', - description: 'Let supporters who lack time follow a volunteer or collaborator they trust.', + description: 'Happy to put in money but not to pick projects? Hand the choices to a person you already trust.', domain: 'lazyGiving', path: '/delegation/notes', internalPath: '/delegation/notes', @@ -72,7 +72,7 @@ export const SUPPORTING_TOOLS: SupportingTool[] = [ id: 'commonality', name: 'Commonality', role: 'Thesis & movement layer', - description: 'Background on why public-goods funding can work without a central owner.', + description: 'The longer argument: cooperate on agreement without a committee, and why that can actually work.', domain: 'commonality', path: '/', levers: [], diff --git a/causestarter/src/lib/useMachinery.ts b/ui/src/causestarter/lib/useMachinery.ts similarity index 100% rename from causestarter/src/lib/useMachinery.ts rename to ui/src/causestarter/lib/useMachinery.ts diff --git a/causestarter/src/lib/useWriteClients.ts b/ui/src/causestarter/lib/useWriteClients.ts similarity index 100% rename from causestarter/src/lib/useWriteClients.ts rename to ui/src/causestarter/lib/useWriteClients.ts diff --git a/causestarter/src/lib/userProjects.test.ts b/ui/src/causestarter/lib/userProjects.test.ts similarity index 100% rename from causestarter/src/lib/userProjects.test.ts rename to ui/src/causestarter/lib/userProjects.test.ts diff --git a/causestarter/src/lib/userProjects.ts b/ui/src/causestarter/lib/userProjects.ts similarity index 89% rename from causestarter/src/lib/userProjects.ts rename to ui/src/causestarter/lib/userProjects.ts index a4c767b6a..fdfa28b77 100644 --- a/causestarter/src/lib/userProjects.ts +++ b/ui/src/causestarter/lib/userProjects.ts @@ -1,5 +1,6 @@ import { getProject, getUserContributions, getUserCreatedProjects, type Project } from '@commonality/sdk/lazy-giving' import type { SDKMachinery } from '@commonality/sdk/machinery' +import { loadProjectWithCache, projectFoldCacheOptions } from '@ui/shared' import { readLazyGivingProjectMetadata } from '@ui/lazy-giving/metadata' import type { IpfsCidV1 } from '@commonality/sdk/utils' import { mapWithConcurrency, PLANK_QUERY_CONCURRENCY } from './concurrency' @@ -59,8 +60,13 @@ export async function loadUserProjects( } const ids = [...new Set([...created, ...contributed, ...bookmarked])] + const cacheOptions = projectFoldCacheOptions(machinery) const loaded = await mapWithConcurrency(ids, PLANK_QUERY_CONCURRENCY, async (id) => { - const project = await getProject(machinery, id).catch(() => null) + const project = ( + cacheOptions + ? await loadProjectWithCache(machinery, id, cacheOptions).catch(() => null) + : await getProject(machinery, id).catch(() => null) + ) if (!project) return null let name: string | undefined if (project.metadataCid) { diff --git a/causestarter/src/pages/BridgeClusterPage.tsx b/ui/src/causestarter/pages/BridgeClusterPage.tsx similarity index 99% rename from causestarter/src/pages/BridgeClusterPage.tsx rename to ui/src/causestarter/pages/BridgeClusterPage.tsx index 90c343b28..3732ea00e 100644 --- a/causestarter/src/pages/BridgeClusterPage.tsx +++ b/ui/src/causestarter/pages/BridgeClusterPage.tsx @@ -911,7 +911,7 @@ export function BridgeClusterPage() { { const [owner, slug] = event.target.value.split('|') diff --git a/causestarter/src/pages/BridgeTriplePage.test.tsx b/ui/src/causestarter/pages/BridgeTriplePage.test.tsx similarity index 100% rename from causestarter/src/pages/BridgeTriplePage.test.tsx rename to ui/src/causestarter/pages/BridgeTriplePage.test.tsx diff --git a/causestarter/src/pages/BridgeTriplePage.tsx b/ui/src/causestarter/pages/BridgeTriplePage.tsx similarity index 100% rename from causestarter/src/pages/BridgeTriplePage.tsx rename to ui/src/causestarter/pages/BridgeTriplePage.tsx diff --git a/causestarter/src/pages/CauseBoardLeaderboardPage.tsx b/ui/src/causestarter/pages/CauseBoardLeaderboardPage.tsx similarity index 97% rename from causestarter/src/pages/CauseBoardLeaderboardPage.tsx rename to ui/src/causestarter/pages/CauseBoardLeaderboardPage.tsx index c66d6edd4..d7250d112 100644 --- a/causestarter/src/pages/CauseBoardLeaderboardPage.tsx +++ b/ui/src/causestarter/pages/CauseBoardLeaderboardPage.tsx @@ -125,7 +125,7 @@ export function CauseBoardLeaderboardPage() { {loadError || 'Cause not found on this device.'} ) @@ -137,10 +137,10 @@ export function CauseBoardLeaderboardPage() { return ( - Publish a statement to see contributors across this cause. + Publish a statement to see contributors across this cause board. ) @@ -149,7 +149,7 @@ export function CauseBoardLeaderboardPage() { return ( ) } diff --git a/causestarter/src/pages/CauseContentBoardPage.tsx b/ui/src/causestarter/pages/CauseContentBoardPage.tsx similarity index 99% rename from causestarter/src/pages/CauseContentBoardPage.tsx rename to ui/src/causestarter/pages/CauseContentBoardPage.tsx index a9ac21629..846d630c3 100644 --- a/causestarter/src/pages/CauseContentBoardPage.tsx +++ b/ui/src/causestarter/pages/CauseContentBoardPage.tsx @@ -135,7 +135,7 @@ export function CauseContentBoardPage() { {loadError || 'Cause not found on this device.'} ) @@ -153,7 +153,7 @@ export function CauseContentBoardPage() { return ( diff --git a/causestarter/src/pages/CauseDetailPage.tsx b/ui/src/causestarter/pages/CauseDetailPage.tsx similarity index 95% rename from causestarter/src/pages/CauseDetailPage.tsx rename to ui/src/causestarter/pages/CauseDetailPage.tsx index b37524716..cc8df4360 100644 --- a/causestarter/src/pages/CauseDetailPage.tsx +++ b/ui/src/causestarter/pages/CauseDetailPage.tsx @@ -77,6 +77,10 @@ function safetyState(verdict: { * Editing published rosters requires the organizer's connected wallet. * Unpublished local drafts can still be shaped on this device before publish. */ +export function CauseDetailEditPage() { + return +} + export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { const params = useParams<{ causeId?: string; owner?: string; slugPart?: string }>() const navigate = useNavigate() @@ -499,7 +503,7 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { {loadError || 'Cause not found on this device.'} ) @@ -672,7 +676,7 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { return } if (!isConnected || !address || !writeClients) { - setError('Connect your wallet to publish the cause page.') + setError('Connect your wallet to publish the cause board.') return } setPublishingRoster(true) @@ -773,7 +777,7 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { label="Unpublished" sx={{ mb: 0.75 }} data-testid="cause-unpublished" - title="This cause exists only on this device so far. Others cannot open it until you publish." + title="This cause board exists only on this device so far. Others cannot open it until you publish." /> )} {routeRef?.versionCid && ( @@ -782,7 +786,7 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { color="info" label="Pinned version" sx={{ mb: 0.75 }} - title="This link is pinned to one published version. Later edits to the live cause will not change what you see here." + title="This link is pinned to one published version. Later edits to the live cause board will not change what you see here." /> )} {!isFreshDraft && ( @@ -790,7 +794,7 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { variant="overline" sx={{ letterSpacing: '0.14em', fontWeight: 700, color: 'primary.main', display: 'block' }} > - Cause + Cause board )} {cause.bridgeCluster && ( @@ -828,7 +832,7 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { component="h1" sx={{ fontWeight: 800, fontSize: { xs: '1.55rem', sm: '1.9rem' }, flex: 1, minWidth: 0 }} > - {isFreshDraft ? 'Start a cause' : displayTitle} + {isFreshDraft ? 'Start a cause board' : displayTitle} {stable && ( @@ -846,7 +850,7 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { } void navigator.clipboard.writeText(url).then(() => setShareCopiedOpen(true)) }} - aria-label="Share cause" + aria-label="Share cause board" sx={{ mt: 0.25, color: 'text.secondary' }} > @@ -854,7 +858,7 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { )} {canKeepOnDevice && ( - + { @@ -905,10 +909,10 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { )} {isFreshDraft ? ( - Tell CauseStarter what you want people to be able to support. It searches - published statements first and can propose new wording when none fit. - You decide what belongs in the cause. Nothing is published until you review - the exact statement text and CID in the page below and explicitly approve it. + A cause board is a mix of statements people can sign one at a time — not a + club they join. Reuse published statements when they already say what you + mean; you inherit their signers and projects. Write your own when they don’t. + Nothing is published until you review the exact text and CID below. ) : null} @@ -999,7 +1003,9 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { {!live && ( - Write the statements this cause is made of. Publish each one when it is ready. + People sign these sentences, not your brand. If the wording you want would + have “zero signers,” write it so similar claims can still count — or start + from a published statement and keep your own extras beside it. )} @@ -1018,8 +1024,9 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { Describe your intent in the picker. It looks for reusable published statements - before offering new drafts. Reject or correct any suggestion that misses your - meaning; broad statements are fine when their proposition is clear. + before offering new drafts — that is how you avoid starting from zero signers. + Reject or correct any suggestion that misses your meaning; write one manually + if you need to. )} @@ -1028,7 +1035,7 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { {isEditing ? 'No statements selected yet. Start with the picker; you can reject every suggestion and write one manually.' - : 'This cause has no published statements yet.'} + : 'This cause board has no published statements yet.'} )} @@ -1138,8 +1145,10 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { {publishedCids.length === 0 ? ( Fundable Projects - - Publish a statement to see projects aligned with it. + + Publish a statement to see projects vouched as advancing it. You do not need a + grant officer: a friend who is one hop better-connected can vouch, and the + project shows up for everyone watching that statement. ) : ( @@ -1159,8 +1168,10 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { projectsHelp={ - Union of projects vouched for as advancing any published statement of this - cause. Alignment attaches to a statement, never to the cause as a whole. + Union of projects vouched as advancing any published statement here. + Alignment attaches to a statement, never to the cause as a whole. Watch + this list if your job is judgment, not a monthly check — including if you + only fund work that has already delivered. @@ -1235,7 +1246,7 @@ export function CauseDetailPage({ editMode = false }: { editMode?: boolean }) { if (reason === 'clickaway') return setBookmarkUndoOpen(false) }} - message="Removed from your causes" + message="Removed from your cause boards" action={( ) @@ -207,7 +208,7 @@ export function CauseFundingPage() { return ( @@ -250,13 +251,13 @@ export function CauseFundingPage() { )} - - Create a one-time delegated fund or a monthly pledge earmarked for one immutable - statement — or for a combination you endorse as a package. The earmark is public, - auditable guidance — not a binding restriction on a delegate. If they direct the - money elsewhere, that will also be public. Choosing a delegate is public too. - The earmark does not follow later edits to this cause publication. - + + Happy to put in $X/month but not to pick every project? Pledge, and hand the + choices to a person you already trust — not a black-box charity. The earmark is + public guidance, not a lock; if they send the money elsewhere, that is public too. + Pledges that miss their threshold refund. The earmark stays on these statement + CIDs even if this cause page later changes. + {published.length === 0 ? ( diff --git a/causestarter/src/pages/CauseMediatorPage.tsx b/ui/src/causestarter/pages/CauseMediatorPage.tsx similarity index 97% rename from causestarter/src/pages/CauseMediatorPage.tsx rename to ui/src/causestarter/pages/CauseMediatorPage.tsx index 9345e8fc9..f7f48050c 100644 --- a/causestarter/src/pages/CauseMediatorPage.tsx +++ b/ui/src/causestarter/pages/CauseMediatorPage.tsx @@ -82,7 +82,7 @@ export function CauseMediatorPage() { this link. ) @@ -110,7 +110,7 @@ export function CauseMediatorPage() { For {causeTitle(cause)}. {mediator ? ' A service its organizer runs, under their own key and strategy prompt. Its suggestions reach you only if you opt in, and signing stays your choice.' - : ' This cause has no mediator service attached.'} + : ' This cause board has no mediator service attached.'} @@ -137,7 +137,7 @@ export function CauseMediatorPage() { Advanced. If you just want to write one bridge yourself, use{' '} Create a cluster - {' '}(parents are causes) or{' '} + {' '}(parents are cause boards) or{' '} write a statement-level triple {' '}— no service required. diff --git a/causestarter/src/pages/CausesPage.tsx b/ui/src/causestarter/pages/CausesPage.tsx similarity index 100% rename from causestarter/src/pages/CausesPage.tsx rename to ui/src/causestarter/pages/CausesPage.tsx diff --git a/causestarter/src/pages/ContentFundingPages.tsx b/ui/src/causestarter/pages/ContentFundingPages.tsx similarity index 100% rename from causestarter/src/pages/ContentFundingPages.tsx rename to ui/src/causestarter/pages/ContentFundingPages.tsx diff --git a/causestarter/src/pages/CreateProjectPage.tsx b/ui/src/causestarter/pages/CreateProjectPage.tsx similarity index 100% rename from causestarter/src/pages/CreateProjectPage.tsx rename to ui/src/causestarter/pages/CreateProjectPage.tsx diff --git a/causestarter/src/pages/DelegationPages.tsx b/ui/src/causestarter/pages/DelegationPages.tsx similarity index 100% rename from causestarter/src/pages/DelegationPages.tsx rename to ui/src/causestarter/pages/DelegationPages.tsx diff --git a/ui/src/causestarter/pages/DocsPage.test.tsx b/ui/src/causestarter/pages/DocsPage.test.tsx new file mode 100644 index 000000000..13203b1a9 --- /dev/null +++ b/ui/src/causestarter/pages/DocsPage.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { DocsPage } from './DocsPage' + +vi.mock('../components/ToolCard', () => ({ + ToolCard: () => null, +})) + +function renderDocs(path: string) { + return render( + + + } /> + } /> + + , + ) +} + +describe('DocsPage', () => { + it('renders the CauseStarter home guide', () => { + renderDocs('/docs') + expect(screen.getByTestId('docs-page')).toBeInTheDocument() + expect(screen.getByText('CauseStarter')).toBeInTheDocument() + expect(screen.getByText(/Do the part you’d do anyway/i)).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Start a cause board/i })).toHaveAttribute( + 'href', + '/docs/start-a-cause', + ) + }) + + it('renders the jobs catalog', () => { + renderDocs('/docs/the-jobs') + expect(screen.getByRole('heading', { name: /Do the part you’d do anyway/i })).toBeInTheDocument() + expect(screen.getByText('Money')).toBeInTheDocument() + expect(screen.getByText('Work')).toBeInTheDocument() + }) + + it('opens vision-and-strategy from the bundled commonality tree', () => { + renderDocs('/docs/vision-and-strategy') + expect(screen.getByRole('heading', { name: /Why Commonality/i })).toBeInTheDocument() + }) +}) diff --git a/ui/src/causestarter/pages/DocsPage.tsx b/ui/src/causestarter/pages/DocsPage.tsx new file mode 100644 index 000000000..9b1984e13 --- /dev/null +++ b/ui/src/causestarter/pages/DocsPage.tsx @@ -0,0 +1,258 @@ +import { Link as RouterLink, useLocation } from 'react-router-dom' +import ReactMarkdown from 'react-markdown' +import rehypeSanitize from 'rehype-sanitize' +import type { Components } from 'react-markdown' +import { Box, Button, Divider, Paper, Stack, Typography } from '@mui/material' +import docModulesByRelativePath from 'virtual:end-user-docs' +import { ToolCard } from '../components/ToolCard' +import { SUPPORTING_TOOLS } from '../lib/tools' +import { getDomainUrl, type DomainId } from '../lib/domainUrls' + +const docModules: Record = docModulesByRelativePath + +const DOMAIN_FOLDERS: ReadonlySet = new Set([ + 'commonality', + 'lazyGiving', + 'alignment', + 'tally', + 'content-funding', + 'civility', + 'common-sense-majority', + 'conceptspace', + 'causestarter', +]) + +interface LoadedDoc { + content: string + pathForRelativeLinks: string +} + +function lookupMarkdown(internalPath: string): LoadedDoc | null { + const normalized = internalPath.replace(/\/$/, '') + const exact = `${normalized}.md` + if (docModules[exact]) return { content: docModules[exact], pathForRelativeLinks: normalized } + const readme = `${normalized}/README.md` + if (docModules[readme]) { + return { content: docModules[readme], pathForRelativeLinks: `${normalized}/README` } + } + const index = `${normalized}/index.md` + if (docModules[index]) { + return { content: docModules[index], pathForRelativeLinks: `${normalized}/index` } + } + return null +} + +function getDocContent(docPath: string): LoadedDoc | null { + const raw = docPath.replace(/^end-user\//, '').replace(/\/$/, '') || 'index' + if (raw === 'index' || raw === 'causestarter') { + return lookupMarkdown('causestarter') + } + const prefixed = [ + raw, + raw.startsWith('causestarter/') ? null : `causestarter/${raw}`, + raw.startsWith('commonality/') ? null : `commonality/${raw}`, + raw.startsWith('shared/') ? null : `shared/${raw}`, + ].filter((path): path is string => Boolean(path)) + for (const candidate of prefixed) { + const found = lookupMarkdown(candidate) + if (found) return found + } + return null +} + +function normalizeDocsRoute(href: string): string { + return href.replace(/\/README\.md$/, '').replace(/\.md$/, '').replace(/\/README$/, '') +} + +function publicDocsRoute(internalPath: string): string { + if (internalPath === 'causestarter' || internalPath === 'causestarter/index') return 'index' + if (internalPath.startsWith('causestarter/')) return internalPath.slice('causestarter/'.length) + if (internalPath === 'commonality') return 'vision-and-strategy' + if (internalPath.startsWith('commonality/')) return internalPath.slice('commonality/'.length) + if (internalPath.startsWith('shared/')) return internalPath.slice('shared/'.length) + return internalPath +} + +function docHomeDomain(internalPath: string): string | null { + const top = internalPath.split('/')[0] + if (top === 'shared' || top === 'causestarter' || top === 'commonality') return null + return DOMAIN_FOLDERS.has(top) ? top : null +} + +function buildDocHref(internalPath: string): string { + const home = docHomeDomain(internalPath) + const route = normalizeDocsRoute(`/docs/${publicDocsRoute(internalPath)}`) + if (home) { + return getDomainUrl(home as DomainId, route, route) + } + return route +} + +function headingText(children: unknown): string { + if (typeof children === 'string' || typeof children === 'number') return String(children) + if (Array.isArray(children)) return children.map(headingText).join('') + if (children && typeof children === 'object' && 'props' in children) { + return headingText((children as { props: { children?: unknown } }).props.children) + } + return '' +} + +function headingId(children: unknown): string { + return headingText(children) + .toLowerCase() + .replace(/['’]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') +} + +function resolveHref(href: string, currentDocPath: string): string { + if (!href || href.startsWith('http') || href.startsWith('#')) return href + if (href.startsWith('/docs/end-user/')) { + return buildDocHref(href.replace('/docs/end-user/', '')) + } + if (href.startsWith('/docs/')) { + return normalizeDocsRoute(href) + } + if (href.startsWith('/')) return href + const currentDir = currentDocPath.includes('/') + ? currentDocPath.substring(0, currentDocPath.lastIndexOf('/')) + : '' + const combined = currentDir ? `${currentDir}/${href}` : href + const resolved: string[] = [] + for (const part of combined.split('/')) { + if (part === '..') resolved.pop() + else if (part !== '' && part !== '.') resolved.push(part) + } + return buildDocHref(resolved.join('/')) +} + +export function DocsPage() { + const { pathname } = useLocation() + const docPath = pathname.replace(/^\/docs\/?/, '').replace(/\/$/, '') || 'index' + const loadedDoc = getDocContent(docPath) + const content = loadedDoc?.content ?? null + const pathForRelativeLinks = loadedDoc?.pathForRelativeLinks ?? docPath + + const components: Components = { + h1: ({ children }) => ( + + {children} + + ), + h2: ({ children }) => ( + + {children} + + ), + h3: ({ children }) => ( + + {children} + + ), + p: ({ children }) => {children}, + ul: ({ children }) => ( + + {children} + + ), + ol: ({ children }) => ( + + {children} + + ), + li: ({ children }) => ( + + {children} + + ), + a: ({ href, children }) => { + const resolved = href ? resolveHref(href, pathForRelativeLinks) : '#' + if (/^https?:\/\//.test(resolved)) { + return ( + + {children} + + ) + } + return {children} + }, + hr: () => , + blockquote: ({ children }) => ( + + {children} + + ), + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + } + + if (!content) { + return ( + + + + We could not find that guide. + + + Try the CauseStarter docs home, or the jobs catalog. + + + + + ) + } + + const showExamples = docPath === 'index' || docPath === '' || docPath === 'causestarter' + + return ( + + + {content} + + {showExamples && } + + ) +} + +function DocsExampleTools() { + const sections = [ + { + key: 'reference' as const, + title: 'Example cause boards', + description: 'Worked examples of focused cause boards you can learn from.', + }, + { + key: 'thesis' as const, + title: 'On the other sites', + description: 'Optional reading and tools. They open in their own UIs.', + }, + ] + return ( + + {sections.map((section) => { + const tools = SUPPORTING_TOOLS.filter((tool) => tool.kind === section.key) + if (tools.length === 0) return null + return ( + + + {section.title} + + + {section.description} + + + {tools.map((tool) => ( + + ))} + + + ) + })} + + ) +} diff --git a/ui/src/causestarter/pages/HomePage.test.tsx b/ui/src/causestarter/pages/HomePage.test.tsx new file mode 100644 index 000000000..a680a4f6d --- /dev/null +++ b/ui/src/causestarter/pages/HomePage.test.tsx @@ -0,0 +1,65 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HomePage } from './HomePage' + +const { useUserCauses, useAccount } = vi.hoisted(() => ({ + useUserCauses: vi.fn(), + useAccount: vi.fn(), +})) + +vi.mock('../hooks/useUserCauses', () => ({ + useUserCauses, +})) + +vi.mock('wagmi', () => ({ + useAccount, +})) + +vi.mock('../components/YourDashboard', () => ({ + YourDashboard: () =>
, +})) + +vi.mock('../components/YourProjects', () => ({ + YourProjects: () => null, +})) + +vi.mock('../components/YourSignedStatements', () => ({ + YourSignedStatements: () => null, +})) + +vi.mock('../components/YourNudgersAndNudges', () => ({ + YourNudgersAndNudges: () => null, +})) + +describe('HomePage landing', () => { + afterEach(cleanup) + + it('leads with jobs, not Start → Grow → Deliver', () => { + useUserCauses.mockReturnValue({ causes: [], loading: false }) + useAccount.mockReturnValue({ isConnected: false }) + render( + + + , + ) + expect(screen.getByTestId('home-landing')).toBeInTheDocument() + expect(screen.getByText(/there are enough of us/i)).toBeInTheDocument() + expect(screen.getByTestId('crowd-jobs')).toBeInTheDocument() + expect(screen.queryByText(/change the world/i)).toBeNull() + expect(screen.queryByTestId('home-dashboard-board')).toBeNull() + }) + + it('puts the personal fundable-projects board first when the wallet is connected', () => { + useUserCauses.mockReturnValue({ causes: [], loading: false }) + useAccount.mockReturnValue({ isConnected: true, address: '0xabc' }) + render( + + + , + ) + expect(screen.getByTestId('home-dashboard')).toBeInTheDocument() + expect(screen.getByTestId('home-dashboard-board')).toBeInTheDocument() + expect(screen.queryByTestId('home-landing')).toBeNull() + }) +}) diff --git a/ui/src/causestarter/pages/HomePage.tsx b/ui/src/causestarter/pages/HomePage.tsx new file mode 100644 index 000000000..7d6f330f0 --- /dev/null +++ b/ui/src/causestarter/pages/HomePage.tsx @@ -0,0 +1,48 @@ +import { CircularProgress, Stack, Typography } from '@mui/material' +import { useAccount } from 'wagmi' +import { YourCauses } from '../components/YourCauses' +import { YourDashboard } from '../components/YourDashboard' +import { YourNudgersAndNudges } from '../components/YourNudgersAndNudges' +import { YourProjects } from '../components/YourProjects' +import { YourSignedStatements } from '../components/YourSignedStatements' +import { useUserCauses } from '../hooks/useUserCauses' +import { WelcomePage } from './WelcomePage' + +export function HomePage() { + const { causes, loading } = useUserCauses() + const { isConnected } = useAccount() + const occupied = causes.length > 0 || isConnected + + if (occupied) { + return ( + + + + + + + + )} + /> + + ) + } + + if (loading) { + return ( + + + + Loading cause boards… + + + ) + } + + return +} diff --git a/causestarter/src/pages/NotFoundPage.tsx b/ui/src/causestarter/pages/NotFoundPage.tsx similarity index 100% rename from causestarter/src/pages/NotFoundPage.tsx rename to ui/src/causestarter/pages/NotFoundPage.tsx diff --git a/ui/src/causestarter/pages/PersonalDashboardPage.tsx b/ui/src/causestarter/pages/PersonalDashboardPage.tsx new file mode 100644 index 000000000..877b1d2c3 --- /dev/null +++ b/ui/src/causestarter/pages/PersonalDashboardPage.tsx @@ -0,0 +1,5 @@ +import { YourDashboard } from '../components/YourDashboard' + +export function PersonalDashboardPage() { + return +} diff --git a/causestarter/src/pages/ProjectDetailPage.tsx b/ui/src/causestarter/pages/ProjectDetailPage.tsx similarity index 96% rename from causestarter/src/pages/ProjectDetailPage.tsx rename to ui/src/causestarter/pages/ProjectDetailPage.tsx index 86f18f0cb..ac1999379 100644 --- a/causestarter/src/pages/ProjectDetailPage.tsx +++ b/ui/src/causestarter/pages/ProjectDetailPage.tsx @@ -26,7 +26,7 @@ export function ProjectLeaderboardPage() { return ( ) diff --git a/causestarter/src/pages/SettingsPage.tsx b/ui/src/causestarter/pages/SettingsPage.tsx similarity index 100% rename from causestarter/src/pages/SettingsPage.tsx rename to ui/src/causestarter/pages/SettingsPage.tsx diff --git a/causestarter/src/pages/StartBridgeRedirect.test.ts b/ui/src/causestarter/pages/StartBridgeRedirect.test.ts similarity index 100% rename from causestarter/src/pages/StartBridgeRedirect.test.ts rename to ui/src/causestarter/pages/StartBridgeRedirect.test.ts diff --git a/causestarter/src/pages/StartBridgeRedirect.tsx b/ui/src/causestarter/pages/StartBridgeRedirect.tsx similarity index 100% rename from causestarter/src/pages/StartBridgeRedirect.tsx rename to ui/src/causestarter/pages/StartBridgeRedirect.tsx diff --git a/causestarter/src/pages/StartCauseRedirect.tsx b/ui/src/causestarter/pages/StartCauseRedirect.tsx similarity index 100% rename from causestarter/src/pages/StartCauseRedirect.tsx rename to ui/src/causestarter/pages/StartCauseRedirect.tsx diff --git a/causestarter/src/pages/StatementBoardLeaderboardPage.tsx b/ui/src/causestarter/pages/StatementBoardLeaderboardPage.tsx similarity index 96% rename from causestarter/src/pages/StatementBoardLeaderboardPage.tsx rename to ui/src/causestarter/pages/StatementBoardLeaderboardPage.tsx index c5c58e502..2e3466f0e 100644 --- a/causestarter/src/pages/StatementBoardLeaderboardPage.tsx +++ b/ui/src/causestarter/pages/StatementBoardLeaderboardPage.tsx @@ -11,7 +11,7 @@ export function StatementBoardLeaderboardPage() { No statement specified. ) diff --git a/causestarter/src/pages/StatementBoardRedirect.tsx b/ui/src/causestarter/pages/StatementBoardRedirect.tsx similarity index 100% rename from causestarter/src/pages/StatementBoardRedirect.tsx rename to ui/src/causestarter/pages/StatementBoardRedirect.tsx diff --git a/causestarter/src/pages/StatementPage.tsx b/ui/src/causestarter/pages/StatementPage.tsx similarity index 91% rename from causestarter/src/pages/StatementPage.tsx rename to ui/src/causestarter/pages/StatementPage.tsx index 817c44043..0e519dd2c 100644 --- a/causestarter/src/pages/StatementPage.tsx +++ b/ui/src/causestarter/pages/StatementPage.tsx @@ -330,8 +330,10 @@ export function StatementPage() { projectsHelp={ - Projects vouched for as advancing this statement. Each is aligned with this - statement, not with a cause as a whole. + Projects vouched as advancing this statement — not a cause as a whole. + Do the work? Publish a project and get an alignment vouch; you do not need + a foundation intro. Only want to judge? Fund proven work, or fund early and + ask to be reimbursed at cost. @@ -345,13 +347,24 @@ export function StatementPage() { fullPageTo={`/statement/${statementCid}/board/leaderboard`} /> - + + Hate this statement’s company on someone else’s cause board? Start your own + board and reuse this statement — you keep its signers and projects. Write a + different sentence if this wording is not what you mean; similar claims can + still count. + + + - Start a cause. Build a Movement. Change the world. + There are enough of us. We just couldn’t work together. - - Write clear statements people can support one at a time, then grow support with funding - and media tools when you need them. + + 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. @@ -55,19 +53,28 @@ function LandingHome() { onClick={() => navigate(createCausePath())} sx={{ minHeight: 48, borderRadius: 999, fontWeight: 700, textTransform: 'none', px: 3 }} > - Start a cause + Start a cause board + + - {/* There is deliberately no "support a cause" counterpart: we list no - causes and rank none. You reach a cause through its organizer's own - link. Bridge writing lives on the cause-editing mediator section. */} - - How it works + + Take the job you’d take anyway + + + Nobody has to agree on a leader, a manifesto, or a treasury. Overlap is enough. - + - Examples and background + Docs - Worked example causes and optional reading live under Docs. Funding and growth - tools stay on each cause page. + 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 function HomePage() { - const { causes, loading } = useUserCauses() - - if (causes.length > 0) { - return ( - - - - - - )} - /> - ) - } - - if (loading) { - return ( - - - - Loading causes… - - - ) - } - - return -} diff --git a/causestarter/src/shell/CauseShell.tsx b/ui/src/causestarter/shell/CauseShell.tsx similarity index 95% rename from causestarter/src/shell/CauseShell.tsx rename to ui/src/causestarter/shell/CauseShell.tsx index fb3ec2612..35259ff69 100644 --- a/causestarter/src/shell/CauseShell.tsx +++ b/ui/src/causestarter/shell/CauseShell.tsx @@ -22,8 +22,8 @@ import { WalletButton } from '../components/WalletButton' const GITHUB_REPO_URL = 'https://github.com/AdamSpitz/commonality' const navItems = [ - { label: 'Causes', path: '/causes', icon: }, - { label: 'Docs', path: '/docs', icon: }, + { label: 'Cause boards', path: '/causes', testId: 'nav-causes', icon: }, + { label: 'Docs', path: '/docs', testId: 'nav-docs', icon: }, ] as const function activeNavPath(pathname: string): string { @@ -98,7 +98,7 @@ export function CauseShell({ children }: CauseShellProps) { key={item.path} component={Link} to={item.path} - data-testid={`nav-${item.label.toLowerCase()}`} + data-testid={item.testId} sx={{ px: 1.5, py: 0.75, @@ -202,6 +202,7 @@ export function CauseShell({ children }: CauseShellProps) { label={item.label} value={item.path} icon={item.icon} + data-testid={item.testId} /> ))} + export default docs +} diff --git a/ui/src/causestarter/wagmi.ts b/ui/src/causestarter/wagmi.ts new file mode 100644 index 000000000..fa1b58b88 --- /dev/null +++ b/ui/src/causestarter/wagmi.ts @@ -0,0 +1,9 @@ +export { + config, + createMockConfig, + isE2E, + useLocalHardhatWallets, + wagmiChains, + wagmiTransports, + walletConnectProjectId, +} from '../wagmi' diff --git a/ui/src/conceptspace/pages/BrowseStatementsPage.tsx b/ui/src/conceptspace/pages/BrowseStatementsPage.tsx index 4cbc7825c..2a4eacc06 100644 --- a/ui/src/conceptspace/pages/BrowseStatementsPage.tsx +++ b/ui/src/conceptspace/pages/BrowseStatementsPage.tsx @@ -81,7 +81,7 @@ export function BrowseStatementsPage() { - A statement is a plain-English belief or value that anyone can sign. The system automatically connects related statements — even when worded differently — so people who care about the same things find each other without having to coordinate. Statements are also the entry point to cause boards for aligned projects and content. + A statement is a plain-English belief or value that anyone can sign. The system automatically connects related statements — even when worded differently — so people who care about the same things find each other without having to coordinate. Statements are also the entry point to fundable-projects boards for aligned projects and content. The supporter chips below count direct signatures on each statement. Open a statement to see indirect support inferred through trusted implication sources. diff --git a/ui/src/conceptspace/pages/HomePage.tsx b/ui/src/conceptspace/pages/HomePage.tsx index a23ff2889..965222b61 100644 --- a/ui/src/conceptspace/pages/HomePage.tsx +++ b/ui/src/conceptspace/pages/HomePage.tsx @@ -25,8 +25,8 @@ const gettingStartedSteps = [ { title: 'Learn about cause funding', description: - 'Cause exploration lives on Alignment, where statements connect to cause boards and aligned projects.', - cta: 'Learn about cause boards', + 'Cause exploration lives on Alignment, where statements connect to fundable-projects boards and aligned projects.', + cta: 'Learn about fundable-projects boards', to: '/docs/key-ideas/funding-portals', }, ] diff --git a/ui/src/domains/CrossDomainSmoke.test.tsx b/ui/src/domains/CrossDomainSmoke.test.tsx index 37e261a42..2284aad51 100644 --- a/ui/src/domains/CrossDomainSmoke.test.tsx +++ b/ui/src/domains/CrossDomainSmoke.test.tsx @@ -6,7 +6,7 @@ import { domainManifests } from './index' import { isRouteResolvableDocLink } from './publicDocLinks' import type { DomainId } from './types' -const domainIds: DomainId[] = ['commonality', 'lazyGiving', 'alignment', 'tally', 'content-funding', 'civility', 'common-sense-majority', 'conceptspace'] +const domainIds: DomainId[] = ['commonality', 'lazyGiving', 'alignment', 'tally', 'content-funding', 'civility', 'common-sense-majority', 'conceptspace', 'causestarter'] const publicDocModules = import.meta.glob('../../../docs/end-user/**/*.md', { query: '?raw', import: 'default', eager: true }) as Record function renderDomainRoute(domainId: DomainId, path = '/') { @@ -90,6 +90,7 @@ describe.each(domainIds)('cross-domain smoke: %s', (domainId) => { civility: 'Civility', 'common-sense-majority': 'Common Sense Majority', conceptspace: 'Conceptspace', + causestarter: 'CauseStarter', } it('has branding copy for the domain', () => { @@ -122,7 +123,8 @@ describe.each(domainIds)('cross-domain smoke: %s', (domainId) => { }) }) - describe('landing page', () => { + // CauseStarter `/` is a wallet-backed dashboard, not a static pitch page. + describe.skipIf(domainId === 'causestarter')('landing page', () => { it('renders a hero title', () => { renderDomainRoute(domainId) const heading = screen.getByRole('heading', { level: 1 }) diff --git a/ui/src/domains/CrossLinkCrawler.test.tsx b/ui/src/domains/CrossLinkCrawler.test.tsx index e18488f6b..be202c82d 100644 --- a/ui/src/domains/CrossLinkCrawler.test.tsx +++ b/ui/src/domains/CrossLinkCrawler.test.tsx @@ -5,7 +5,10 @@ import { domainManifests } from './index' import { isRouteResolvableDocLink } from './publicDocLinks' import type { DomainId } from './types' + const domainIds = Object.keys(domainManifests) as DomainId[] +/** CauseStarter routes are eager wallet-backed pages, not the lazyRoute samples these crawls assume. */ +const crawledDomainIds = domainIds.filter((id) => id !== 'causestarter') const publicDocModules = import.meta.glob('../../../docs/end-user/**/*.md', { query: '?raw', import: 'default', eager: true }) as Record const routeParamSamples: Record = { @@ -16,6 +19,10 @@ const routeParamSamples: Record = { roundAddress: '0x0000000000000000000000000000000000000003', statementCid: 'bafybeigdyrztktxq5mkrl3zpnczqtyse534w7y576guthacdf5uloxx3za', channelId: 'demo-channel', + owner: '0x0000000000000000000000000000000000000004', + slugPart: 'demo-cause', + causeId: 'demo-cause-id', + draftId: 'draft-1', } type CrawledPage = { @@ -132,7 +139,7 @@ afterEach(() => { describe('cross-link crawler for rendered UI and public docs', () => { it('renders every public domain route sample without React console errors', () => { - for (const domainId of domainIds) { + for (const domainId of crawledDomainIds) { for (const routePath of extractRoutePaths(domainManifests[domainId].routes)) { cleanup() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) @@ -144,7 +151,7 @@ describe('cross-link crawler for rendered UI and public docs', () => { }) it('crawls rendered route samples and only finds resolvable internal app links', () => { - const pages: CrawledPage[] = domainIds.flatMap(domainId => + const pages: CrawledPage[] = crawledDomainIds.flatMap(domainId => extractRoutePaths(domainManifests[domainId].routes).map(routePath => ({ domainId, path: samplePath(routePath), @@ -165,7 +172,7 @@ describe('cross-link crawler for rendered UI and public docs', () => { }) it('keeps rendered route-sample external links on intentionally allowed hosts', () => { - for (const domainId of domainIds) { + for (const domainId of crawledDomainIds) { for (const routePath of extractRoutePaths(domainManifests[domainId].routes)) { cleanup() const path = samplePath(routePath) diff --git a/ui/src/domains/DomainDeepLinksSmoke.test.tsx b/ui/src/domains/DomainDeepLinksSmoke.test.tsx index 832a2508d..044ffd002 100644 --- a/ui/src/domains/DomainDeepLinksSmoke.test.tsx +++ b/ui/src/domains/DomainDeepLinksSmoke.test.tsx @@ -19,6 +19,10 @@ const sampleParamValues: Record = { projectAddress: '0x2222222222222222222222222222222222222222', roundAddress: '0x3333333333333333333333333333333333333333', statementCid: 'bafybeigdyrzt', + owner: '0x1111111111111111111111111111111111111111', + slugPart: 'demo-cause', + causeId: 'demo-cause-id', + draftId: 'draft-1', } const representativeDocsPath = '/docs/why-trust-it' @@ -120,6 +124,7 @@ async function expectPathToRender(domainId: DomainId, path: string) { describe('domain representative deep links', () => { it('renders every declared domain route pattern with representative params', async () => { for (const [domainId, manifest] of Object.entries(domainManifests) as [DomainId, typeof domainManifests[DomainId]][]) { + if (domainId === 'causestarter') continue for (const routePattern of extractRoutePaths(manifest.routes)) { cleanup() const path = samplePathForRoutePattern(routePattern) diff --git a/ui/src/domains/ExternalLinksAllowlist.test.tsx b/ui/src/domains/ExternalLinksAllowlist.test.tsx index a4bf5a3e3..f618ea479 100644 --- a/ui/src/domains/ExternalLinksAllowlist.test.tsx +++ b/ui/src/domains/ExternalLinksAllowlist.test.tsx @@ -70,6 +70,7 @@ describe('external link allowlist', () => { it('keeps rendered landing-page external links on intentionally allowed hosts', () => { for (const domainId of domainIds) { + if (domainId === 'causestarter') continue cleanup() renderDomainLanding(domainId) for (const link of screen.queryAllByRole('link')) { diff --git a/ui/src/domains/alignment/LandingPage.tsx b/ui/src/domains/alignment/LandingPage.tsx index 2000791a6..7cae86180 100644 --- a/ui/src/domains/alignment/LandingPage.tsx +++ b/ui/src/domains/alignment/LandingPage.tsx @@ -19,14 +19,14 @@ const sections = [ { title: 'Or pick projects yourself', description: - "Prefer the hands-on path? Start with Explore Causes, open a cause statement, then use its cause board to see the projects aligned with it — curated by your trust network, not a gatekeeper. Fund the ones you like directly on their LazyGiving project pages.", + "Prefer the hands-on path? Start with Explore Causes, open a cause statement, then use its fundable-projects board to see the projects aligned with it — curated by your trust network, not a gatekeeper. Fund the ones you like directly on their LazyGiving project pages.", path: '/explore', cta: 'Explore causes', }, { title: "Causes don't need exact wording", description: - "A cause is just a Conceptspace statement. The implication graph connects statements that mean similar things — so a cause board pulls in projects vouched against any cause that implies yours, even when phrased differently. Organic coalitions, no coordination required.", + "A cause is just a Conceptspace statement. The implication graph connects statements that mean similar things — so a fundable-projects board pulls in projects vouched against any cause that implies yours, even when phrased differently. Organic coalitions, no coordination required.", domain: 'tally', path: '/docs/tally/statements-and-implication-graph', cta: 'More on implication', diff --git a/ui/src/domains/alignment/manifest.tsx b/ui/src/domains/alignment/manifest.tsx index 3d920d56b..4d7ac64e4 100644 --- a/ui/src/domains/alignment/manifest.tsx +++ b/ui/src/domains/alignment/manifest.tsx @@ -32,7 +32,7 @@ export const alignmentManifest: DomainManifest = { { label: 'Set up delegation', domain: 'lazyGiving', path: '/delegation/notes/new' }, { label: 'Open LazyGiving', domain: 'lazyGiving', path: '/' }, ], - footerText: 'Aligning helps donors fund causes through cause boards and transparent alignment attestations; delegation is managed from LazyGiving and Content Funding.', + footerText: 'Aligning helps donors fund causes through fundable-projects boards and transparent alignment attestations; delegation is managed from LazyGiving and Content Funding.', }, features: { conceptspace: false, diff --git a/ui/src/domains/causestarter/manifest.tsx b/ui/src/domains/causestarter/manifest.tsx new file mode 100644 index 000000000..97aa0b0de --- /dev/null +++ b/ui/src/domains/causestarter/manifest.tsx @@ -0,0 +1,92 @@ +import type { ReactNode } from 'react' +import { Navigate, Route } from 'react-router-dom' +import type { DomainManifest } from '../types' +import { lazyRoute } from '../lazyRoute' +import { WelcomePage } from '../../causestarter/pages/WelcomePage' + +const routes: ReactNode = ( + <> + import('../../causestarter/pages/HomePage'), 'HomePage')} /> + import('../../causestarter/pages/PersonalDashboardPage'), 'PersonalDashboardPage')} /> + import('../../causestarter/pages/WelcomePage'), 'WelcomePage')} /> + import('../../causestarter/pages/StartCauseRedirect'), 'StartCauseRedirect')} /> + import('../../causestarter/pages/StartBridgeRedirect'), 'StartBridgeRedirect')} /> + import('../../causestarter/pages/BridgeTriplePage'), 'BridgeTriplePage')} /> + import('../../causestarter/pages/BridgeClusterPage'), 'BridgeClusterPage')} /> + import('../../causestarter/pages/BridgeClusterPage'), 'BridgeClusterPage')} /> + import('../../causestarter/pages/CausesPage'), 'CausesPage')} /> + import('../../causestarter/pages/StatementsPage'), 'StatementsPage')} /> + } /> + import('../../causestarter/pages/DelegationPages'), 'MyNotesPage')} /> + import('../../causestarter/pages/DelegationPages'), 'DepositPage')} /> + import('../../causestarter/pages/DelegationPages'), 'NoteDetailPage')} /> + import('../../causestarter/pages/DelegationPages'), 'DelegateProfilePage')} /> + import('../../causestarter/pages/DelegationPages'), 'DelegateProfilePage')} /> + import('../../causestarter/pages/CauseContentBoardPage'), 'CauseContentBoardPage')} /> + import('../../causestarter/pages/CauseContentBoardPage'), 'CauseContentBoardPage')} /> + import('../../causestarter/pages/CauseFundingPage'), 'CauseFundingPage')} /> + import('../../causestarter/pages/CauseFundingPage'), 'CauseFundingPage')} /> + import('../../causestarter/pages/CauseBoardLeaderboardPage'), 'CauseBoardLeaderboardPage')} /> + import('../../causestarter/pages/CauseBoardLeaderboardPage'), 'CauseBoardLeaderboardPage')} /> + import('../../causestarter/pages/CauseMediatorPage'), 'CauseMediatorPage')} /> + import('../../causestarter/pages/CauseMediatorPage'), 'CauseMediatorPage')} /> + import('../../causestarter/pages/CauseDetailPage'), 'CauseDetailEditPage')} /> + import('../../causestarter/pages/CauseDetailPage'), 'CauseDetailEditPage')} /> + import('../../causestarter/pages/CauseDetailPage'), 'CauseDetailPage')} /> + import('../../causestarter/pages/CauseDetailPage'), 'CauseDetailPage')} /> + import('../../causestarter/pages/StatementPage'), 'StatementPage')} /> + import('../../causestarter/pages/StatementBoardRedirect'), 'StatementBoardRedirect')} /> + import('../../causestarter/pages/StatementBoardLeaderboardPage'), 'StatementBoardLeaderboardPage')} /> + import('../../causestarter/pages/CreateProjectPage'), 'CreateProjectPage')} /> + import('../../causestarter/pages/ProjectDetailPage'), 'ProjectLeaderboardPage')} /> + import('../../causestarter/pages/ProjectDetailPage'), 'ProjectDetailPage')} /> + import('../../causestarter/pages/ContentFundingPages'), 'ContentFundingLandingPage')} /> + import('../../causestarter/pages/ContentFundingPages'), 'ContentFundingAboutPage')} /> + import('../../causestarter/pages/ContentFundingPages'), 'ContentFundingCreatorsPage')} /> + import('../../causestarter/pages/ContentFundingPages'), 'ContentFundingStartContractPage')} /> + import('../../causestarter/pages/ContentFundingPages'), 'ContentFundingCreatorDashboardPage')} /> + import('../../causestarter/pages/ContentFundingPages'), 'ContentFundingContractPage')} /> + import('../../causestarter/pages/ContentFundingPages'), 'ContentFundingBrowsePage')} /> + import('../../causestarter/pages/ContentFundingPages'), 'ContentFundingChannelPage')} /> + import('../../causestarter/pages/ContentFundingPages'), 'ContentFundingCreateContractPage')} /> + import('../../causestarter/pages/ContentFundingPages'), 'ContentFundingMaterializeFutureContentPage')} /> + import('../../causestarter/pages/ContentFundingPages'), 'ContentFundingExploreKindsPage')} /> + import('../../causestarter/pages/DocsPage'), 'DocsPage')} /> + import('../../causestarter/pages/DocsPage'), 'DocsPage')} /> + } /> + import('../../causestarter/pages/SettingsPage'), 'SettingsPage')} /> + +) + +export const causestarterManifest: DomainManifest = { + id: 'causestarter', + branding: { + name: 'CauseStarter', + tagline: 'Organize a cause, enroll people, fund the work.', + }, + shell: { + primaryNavigation: [ + { label: 'Cause boards', path: '/causes' }, + { label: 'Docs', path: '/docs' }, + ], + secondaryNavigation: [ + { 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.', + }, + features: { + conceptspace: true, + lazyGiving: true, + fundingportal: true, + delegation: true, + mutablerefs: true, + contentFunding: true, + docs: true, + }, + basePath: '/', + routes, + useCauseShell: true, + LandingPage: WelcomePage, +} + +export const causestarterRoutes: ReactNode = routes diff --git a/ui/src/domains/civility/ContentPages.tsx b/ui/src/domains/civility/ContentPages.tsx index a827b1763..d99c3e8ea 100644 --- a/ui/src/domains/civility/ContentPages.tsx +++ b/ui/src/domains/civility/ContentPages.tsx @@ -275,7 +275,7 @@ export function NoninflammatoryAboutPage() { The whole thing, in one breath - "Sure — I'll put $10 a month toward making more noninflammatory content exist. I'll let my friend Andrew, who follows this stuff more closely than I do, make the actual picks." …and then never think about it again. From the other side, a creator looks at the cause board, sees a real pool of money earmarked for noninflammatory content, and thinks, "I could write some of that." Visible demand pulls supply into existence. + "Sure — I'll put $10 a month toward making more noninflammatory content exist. I'll let my friend Andrew, who follows this stuff more closely than I do, make the actual picks." …and then never think about it again. From the other side, a creator looks at the fundable-projects board, sees a real pool of money earmarked for noninflammatory content, and thinks, "I could write some of that." Visible demand pulls supply into existence. diff --git a/ui/src/domains/common-sense-majority/CsmPages.tsx b/ui/src/domains/common-sense-majority/CsmPages.tsx index 18368add1..22fddb495 100644 --- a/ui/src/domains/common-sense-majority/CsmPages.tsx +++ b/ui/src/domains/common-sense-majority/CsmPages.tsx @@ -18,7 +18,7 @@ const csmProductSignposts = [ }, { title: 'Fund ongoing causes on Aligning', - description: 'Aligning hosts cause boards for causes and cause-aligned projects; CSM uses it rather than embedding its board routes here.', + description: 'Aligning hosts fundable-projects boards for causes and cause-aligned projects; CSM uses it rather than embedding its board routes here.', href: getDomainUrl('alignment', '/', { fallbackHref: '#' }), cta: 'Go to Aligning', }, diff --git a/ui/src/domains/common-sense-majority/LandingPage.tsx b/ui/src/domains/common-sense-majority/LandingPage.tsx index b11a23afe..d86839165 100644 --- a/ui/src/domains/common-sense-majority/LandingPage.tsx +++ b/ui/src/domains/common-sense-majority/LandingPage.tsx @@ -60,10 +60,10 @@ export function CsmLandingPage() { eyebrow: 'Funding surface', title: 'Browse CSM-aligned causes and content', description: - 'Aligning uses the mission statement as the cause root for CSM-aligned projects, content, and organizing work. Follow the cause board to see what trusted attesters say is aligned with it.', + 'Aligning uses the mission statement as the cause root for CSM-aligned projects, content, and organizing work. Follow the fundable-projects board to see what trusted attesters say is aligned with it.', domain: 'alignment' as const, path: missionStatementAlignmentPath, - cta: 'Open the CSM cause board', + cta: 'Open the CSM fundable-projects board', }, ] diff --git a/ui/src/domains/commonality/ForOrganizationsPage.tsx b/ui/src/domains/commonality/ForOrganizationsPage.tsx index a594d0e82..892c0bc04 100644 --- a/ui/src/domains/commonality/ForOrganizationsPage.tsx +++ b/ui/src/domains/commonality/ForOrganizationsPage.tsx @@ -58,12 +58,12 @@ export function CommonalityForOrganizationsPage() { spotlights={[ { label: 'You do not have to switch anything', - text: 'The first useful step is to hardcode your org as the only trusted attester for your own cause board. Your process is unchanged — you are simply recording "this project fits our mission" in public. Everything past that point is a dial you control, not a switch someone else flips.', + text: 'The first useful step is to hardcode your org as the only trusted attester for your own fundable-projects board. Your process is unchanged — you are simply recording "this project fits our mission" in public. Everything past that point is a dial you control, not a switch someone else flips.', }, ]} heroActions={[ { label: 'Read the case for established orgs', path: '/docs/vision-and-strategy/ease-of-adoption/for-established-orgs' }, - { label: 'Browse cause boards', href: getDomainUrl('alignment', '/', { fallbackHref: '#' }), variant: 'outlined' }, + { label: 'Browse fundable-projects boards', href: getDomainUrl('alignment', '/', { fallbackHref: '#' }), variant: 'outlined' }, ]} sections={sections} > diff --git a/ui/src/domains/commonality/FounderPage.tsx b/ui/src/domains/commonality/FounderPage.tsx index 91c7f6f30..54213285d 100644 --- a/ui/src/domains/commonality/FounderPage.tsx +++ b/ui/src/domains/commonality/FounderPage.tsx @@ -28,7 +28,7 @@ const sections = [ const verticals = [ ['LazyGiving', 'Individual assurance contracts for public-goods projects.', 'lazyGiving'], - ['Aligning', 'Ongoing cause funding through cause boards and alignment attestations.', 'alignment'], + ['Aligning', 'Ongoing cause funding through fundable-projects boards and alignment attestations.', 'alignment'], ['Delegation', 'Donor-delegate relationships and transparent delegate track records.', 'lazyGiving'], ['Tally', 'Statement signing and indirect support counts.', 'tally'], ['Content Funding', 'Funding contracts for content and creators.', 'content-funding'], diff --git a/ui/src/domains/content-funding/ContentPages.tsx b/ui/src/domains/content-funding/ContentPages.tsx index 1e549ed49..8b068f8b1 100644 --- a/ui/src/domains/content-funding/ContentPages.tsx +++ b/ui/src/domains/content-funding/ContentPages.tsx @@ -180,7 +180,7 @@ export function ContentFundingExploreKindsPage() { {kind} - Fund creator/channel contracts for this kind of work here, or use Aligning when you want a cause board organized around a statement. + Fund creator/channel contracts for this kind of work here, or use Aligning when you want a fundable-projects board organized around a statement. ))} @@ -279,7 +279,7 @@ export function ContentFundingAboutPage() { How this site relates to the other sites - Content Funding uses LazyGiving-style escrow and payout contracts specialized for creators and content items, with channel verification handled by the platform API. It is organized around channels and contracts; for statement- or cause-centric cause boards, use Aligning. Tally is where people inspect or sign statements. + Content Funding uses LazyGiving-style escrow and payout contracts specialized for creators and content items, with channel verification handled by the platform API. It is organized around channels and contracts; for statement- or cause-centric fundable-projects boards, use Aligning. Tally is where people inspect or sign statements. diff --git a/ui/src/domains/index.ts b/ui/src/domains/index.ts index f22e913c1..624187435 100644 --- a/ui/src/domains/index.ts +++ b/ui/src/domains/index.ts @@ -7,6 +7,7 @@ import { contentFundingManifest } from './content-funding/manifest.tsx' import { civilityManifest } from './civility/manifest.tsx' import { commonSenseMajorityManifest } from './common-sense-majority/manifest.tsx' import { conceptspaceManifest } from './conceptspace/manifest.tsx' +import { causestarterManifest } from './causestarter/manifest.tsx' export * from './types' @@ -19,6 +20,7 @@ export const domainManifests: Record = { civility: civilityManifest, 'common-sense-majority': commonSenseMajorityManifest, conceptspace: conceptspaceManifest, + causestarter: causestarterManifest, } export function getDomainManifest(domainId: DomainId): DomainManifest { @@ -42,7 +44,8 @@ function getDomainIdFromEnv(): DomainId { envDomain === 'content-funding' || envDomain === 'civility' || envDomain === 'common-sense-majority' || - envDomain === 'conceptspace' + envDomain === 'conceptspace' || + envDomain === 'causestarter' ) { return envDomain } @@ -57,3 +60,4 @@ export { contentFundingManifest } export { civilityManifest } export { commonSenseMajorityManifest } export { conceptspaceManifest } +export { causestarterManifest } diff --git a/ui/src/domains/lazyRoute.tsx b/ui/src/domains/lazyRoute.tsx index 9e1e6e06f..10d51356a 100644 --- a/ui/src/domains/lazyRoute.tsx +++ b/ui/src/domains/lazyRoute.tsx @@ -1,11 +1,11 @@ import { lazy, Suspense, type ComponentType, type ReactElement } from 'react' -type PageModule = Record +type PageModule = Record export function lazyRoute(loadModule: () => Promise, exportName: string): ReactElement { const Page = lazy(async () => { const module = await loadModule() - return { default: module[exportName] } + return { default: module[exportName] as ComponentType } }) return ( diff --git a/ui/src/domains/types.ts b/ui/src/domains/types.ts index c24a54c68..5bbf2ffa0 100644 --- a/ui/src/domains/types.ts +++ b/ui/src/domains/types.ts @@ -32,6 +32,8 @@ export interface DomainManifest { basePath: string routes: ReactNode LandingPage?: () => ReactNode + /** CauseStarter keeps its own bottom-nav shell instead of AppShell. */ + useCauseShell?: boolean } // `DomainId` lives in `shared/routing/domainUrls` (cross-brand URL resolution is a cross-cutting diff --git a/ui/src/fundingportals/components/AlignedProjectCard.test.tsx b/ui/src/fundingportals/components/AlignedProjectCard.test.tsx index 8bbcbf58d..a6b88dab7 100644 --- a/ui/src/fundingportals/components/AlignedProjectCard.test.tsx +++ b/ui/src/fundingportals/components/AlignedProjectCard.test.tsx @@ -388,6 +388,32 @@ describe('AlignedProjectCard', () => { expect(screen.getByText('Fan-created')).toBeInTheDocument() }) + it('keeps the content-funding badge in compact mode but drops the channel details block', () => { + vi.mocked(useContentFundingState).mockReturnValue({ + state: {} as any, + channels: [makeChannelEntry({ canonicalChannelId: 'twitter:user:alice' })], + loading: false, + error: null, + projects: [], + contentAttestations: new Map(), + channelDisplayMetadata: new Map(), + vetoedEvents: [], + machinery: {} as any, + }) + + render( + , + ) + + expect(screen.getByText('Content Funding')).toBeInTheDocument() + expect(screen.queryByText('Channel Status')).not.toBeInTheDocument() + expect(screen.queryByText('Content Items')).not.toBeInTheDocument() + }) + it('shows channel display name for Twitter channels', () => { vi.mocked(useContentFundingState).mockReturnValue({ state: {} as any, diff --git a/ui/src/fundingportals/components/AlignedProjectCard.tsx b/ui/src/fundingportals/components/AlignedProjectCard.tsx index 0d6b0b5e4..35647af05 100644 --- a/ui/src/fundingportals/components/AlignedProjectCard.tsx +++ b/ui/src/fundingportals/components/AlignedProjectCard.tsx @@ -203,11 +203,14 @@ export function AlignedProjectCard({ metadata, causeCid, projectLinks = 'lazyGiving', + compact = false, }: { project: AlignedProject metadata: ProjectMetadata | undefined causeCid?: string projectLinks?: ProjectLinkMode + /** Home teaser: smaller title, no content-contract channel block. */ + compact?: boolean }) { const status = getProjectStatus(project) const hasMinimum = BigInt(project.threshold) > 0n @@ -252,7 +255,7 @@ export function AlignedProjectCard({ {projectNav.kind === 'route' ? ( ) : ( @@ -372,12 +376,14 @@ function AlignedProjectCardDetails({ fundingProgress, progressPercent, contentFundingInfo, + compact, }: { project: AlignedProject hasMinimum: boolean fundingProgress: number progressPercent: number contentFundingInfo: ContentFundingInfo | null + compact: boolean }) { return ( @@ -393,10 +399,10 @@ function AlignedProjectCardDetails({ )} - {contentFundingInfo && } + {contentFundingInfo && !compact && } ) } diff --git a/ui/src/fundingportals/components/AlignedProjectsList.test.tsx b/ui/src/fundingportals/components/AlignedProjectsList.test.tsx index e3dc39522..7bf51581d 100644 --- a/ui/src/fundingportals/components/AlignedProjectsList.test.tsx +++ b/ui/src/fundingportals/components/AlignedProjectsList.test.tsx @@ -10,11 +10,12 @@ vi.mock('./projectMetadata', () => ({ })) -vi.mock('react-router-dom', () => ({ - Link: vi.fn(({ to, children, ...props }: any) => ( - {children} - )), -})) +vi.mock('react-router-dom', () => { + function Link({ to, children, ...props }: any) { + return {children} + } + return { Link, RouterLink: Link } +}) vi.mock('wagmi', () => ({ useAccount: vi.fn(), @@ -54,6 +55,18 @@ vi.mock('@commonality/sdk/lazy-giving', async () => { return { ...actual, getProject: vi.fn(), + getProjectFold: vi.fn(), + } +}) + +vi.mock('../../shared/stores/foldCache', async () => { + const actual = await vi.importActual( + '../../shared/stores/foldCache', + ) + return { + ...actual, + loadAlignedListSnapshot: vi.fn(async () => null), + saveAlignedListSnapshot: vi.fn(async () => {}), } }) @@ -93,7 +106,7 @@ import { getProject } from '@commonality/sdk/lazy-giving' import { createSDKMachinery } from '@commonality/sdk/machinery' import { readProjectMetadata } from './projectMetadata' import { useAccount } from 'wagmi' -import { getDomainUrl, isDomainConfigured, useTrustedSet } from '../../shared' +import { getDomainUrl, isDomainConfigured, loadAlignedListSnapshot, useTrustedSet } from '../../shared' import { useContentFundingState } from '../../content-funding' const mockMachinery = {} as any @@ -294,17 +307,29 @@ describe('AlignedProjectsList', () => { expect(screen.queryByRole('link', { name: 'Browse all projects' })).not.toBeInTheDocument() }) - it('explains missing LazyGiving config instead of path-only create links', async () => { + it('creates locally when projectLinks is local', async () => { vi.mocked(getAllAlignedProjectsForCause).mockResolvedValue([]) vi.mocked(isDomainConfigured).mockReturnValue(false) render() + await waitFor(() => { + expect(screen.getByText(/No aligned projects yet/)).toBeInTheDocument() + }) + expect(screen.getByRole('link', { name: /Create a project/i })).toHaveAttribute('href', '/projects/new') + }) + + it('explains missing LazyGiving config instead of path-only create links', async () => { + vi.mocked(getAllAlignedProjectsForCause).mockResolvedValue([]) + vi.mocked(isDomainConfigured).mockReturnValue(false) + + render() + await waitFor(() => { expect(screen.getByText(/No aligned projects yet/)).toBeInTheDocument() }) expect(screen.queryByRole('link', { name: /Create a project/i })).not.toBeInTheDocument() - expect(screen.getByText(/Project creation still happens on LazyGiving/i)).toBeInTheDocument() + expect(screen.getByText(/Configure VITE_LAZYGIVING_URL/i)).toBeInTheDocument() }) it('shows "No projects match" message when all projects are filtered out', async () => { @@ -685,4 +710,67 @@ describe('AlignedProjectsList', () => { }) }) }) + + describe('Compact preview', () => { + it('hides sort/status chrome, caps the list, and links to the full page', async () => { + vi.mocked(getAllAlignedProjectsForCause).mockResolvedValue([ + makeProject({ projectAddress: ADDR_A, deadline: FAR_FUTURE }), + makeProject({ projectAddress: ADDR_B, deadline: String(Number(FAR_FUTURE) - 10) }), + makeProject({ projectAddress: ADDR_C, deadline: String(Number(FAR_FUTURE) - 20) }), + ]) + + render( + , + ) + + await waitFor(() => { + expect(screen.getAllByRole('link', { name: /Open project/i }).length).toBe(2) + }) + expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Funding' })).toBeNull() + const seeAll = screen.getByTestId('aligned-projects-see-all') + expect(seeAll).toHaveAttribute('href', '/dashboard') + expect(seeAll).toHaveTextContent(/see all 3 projects/i) + }) + }) + + describe('Cached snapshot', () => { + it('paints the last list immediately and shows a corner spinner until the live fold returns', async () => { + vi.mocked(createSDKMachinery).mockReturnValue({ + eventCacheUrl: 'http://localhost:42069/api', + contractAddresses: { + assuranceContractFactory: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + } as any) + vi.mocked(loadAlignedListSnapshot).mockResolvedValue({ + snapshotVersion: 1, + projects: [makeProject({ projectAddress: ADDR_A })], + metadata: { [ADDR_A]: { name: 'Cached Garden' } }, + }) + let resolveLive: (value: ReturnType[]) => void = () => {} + vi.mocked(getAllAlignedProjectsForCause).mockReturnValue( + new Promise((resolve) => { + resolveLive = resolve + }), + ) + + render() + + expect(await screen.findByText('Cached Garden')).toBeInTheDocument() + expect(screen.getByTestId('trust-network-refresh')).toHaveAttribute( + 'aria-label', + 'Updating aligned projects from the latest events.', + ) + + resolveLive([makeProject({ projectAddress: ADDR_B })]) + await waitFor(() => { + expect(screen.queryByText('Cached Garden')).toBeNull() + }) + }) + }) }) diff --git a/ui/src/fundingportals/components/AlignedProjectsList.tsx b/ui/src/fundingportals/components/AlignedProjectsList.tsx index 4cb2103ee..29a2df31b 100644 --- a/ui/src/fundingportals/components/AlignedProjectsList.tsx +++ b/ui/src/fundingportals/components/AlignedProjectsList.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo } from 'react' +import { Link as RouterLink } from 'react-router-dom' import { useAccount } from 'wagmi' import { Box, @@ -15,7 +16,19 @@ import SortIcon from '@mui/icons-material/Sort' import { getAllAlignedProjectsForCause } from '@commonality/sdk/fundingportals' import { getProject } from '@commonality/sdk/lazy-giving' import { ETH_CURRENCY, type IpfsCidV1 } from '@commonality/sdk/utils' -import { getDomainUrl, isDomainConfigured, useMachinery, useTrustedContentAttesters, useTrustedSet, TrustNetworkRefreshIndicator } from '../../shared' +import { + boardSnapshotCacheOptions, + getDomainUrl, + isDomainConfigured, + loadAlignedListSnapshot, + loadProjectWithCache, + projectFoldCacheOptions, + saveAlignedListSnapshot, + useMachinery, + useTrustedContentAttesters, + useTrustedSet, + TrustNetworkRefreshIndicator, +} from '../../shared' import { selectAlignedContentContracts, useContentFundingState } from '../../content-funding' import { getProjectStatus } from '../../lazy-giving' import { @@ -62,6 +75,9 @@ export function AlignedProjectsList({ projectLinks = 'lazyGiving', statusFilterLock, embedded = false, + compact = false, + limit, + fullPageTo, }: { statementCid: string statementCids?: string[] @@ -72,6 +88,12 @@ export function AlignedProjectsList({ statusFilterLock?: Exclude /** Flatten heading/paper chrome when nested in the cause-board card. */ embedded?: boolean + /** Teaser density: hide sort/status chrome and shrink cards. */ + compact?: boolean + /** Cap how many cards to show (home preview). */ + limit?: number + /** In-app path for “See all” when {@link compact} or {@link limit} is set. */ + fullPageTo?: string }) { const cids = resolveStatementCids(statementCid, statementCids) const cidsKey = cids.join('\0') @@ -110,6 +132,7 @@ export function AlignedProjectsList({ const [projects, setProjects] = useState([]) const [metadata, setMetadata] = useState>({}) const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) const [sortBy, setSortBy] = useState('latest') const [statusFilter, setStatusFilter] = useState(statusFilterLock ?? 'all') @@ -117,12 +140,32 @@ export function AlignedProjectsList({ useEffect(() => { let cancelled = false + const loadCids = cidsKey ? cidsKey.split('\0').filter(Boolean) : [] + const snapshotOptions = boardSnapshotCacheOptions(machinery, { + kind: 'aligned-list', + statementCids: loadCids, + implicationTrustKey, + alignmentTrustKey, + contentTrustKey, + }) async function load() { - keepPainted.beginLoad(setLoading) + const cached = snapshotOptions + ? await loadAlignedListSnapshot(snapshotOptions) + : null + if (cancelled) return + if (cached) { + setProjects(cached.projects as AlignedProject[]) + setMetadata(cached.metadata as Record) + keepPainted.markResolved() + setLoading(false) + setRefreshing(true) + } else { + keepPainted.beginLoad(setLoading) + setRefreshing(false) + } setError(null) try { - const loadCids = cidsKey ? cidsKey.split('\0') : [] const implicationForLoad = implicationTrustKey ? implicationTrustKey.split(',') : undefined @@ -161,9 +204,12 @@ export function AlignedProjectsList({ // Load metadata for every displayed row, including content-funding // contracts that never appear in the aligned-project query. + const projectCacheOptions = projectFoldCacheOptions(machinery) const metadataEntries = await Promise.all( displayed.map(async (p) => { - const fullProject = await getProject(machinery, p.projectAddress).catch(() => null) + const fullProject = projectCacheOptions + ? await loadProjectWithCache(machinery, p.projectAddress, projectCacheOptions).catch(() => null) + : await getProject(machinery, p.projectAddress).catch(() => null) if (!fullProject?.metadataCid) return [p.projectAddress, null] as const const data = await readProjectMetadata(machinery, fullProject.metadataCid as IpfsCidV1).catch(() => null) return [p.projectAddress, data] as const @@ -176,15 +222,24 @@ export function AlignedProjectsList({ if (data) newMetadata[addr] = data } setMetadata(newMetadata) + if (snapshotOptions) { + await saveAlignedListSnapshot(snapshotOptions, { + projects: displayed, + metadata: newMetadata, + }) + } } catch (err) { if (!cancelled) { console.error('Error loading aligned projects:', err) - setError(err instanceof Error ? err.message : 'Failed to load aligned projects') + if (!cached) { + setError(err instanceof Error ? err.message : 'Failed to load aligned projects') + } } } finally { if (!cancelled) { keepPainted.markResolved() setLoading(false) + setRefreshing(false) } } } @@ -226,6 +281,8 @@ export function AlignedProjectsList({ return Number(b.deadline) - Number(a.deadline) } }) + const visible = limit != null ? sorted.slice(0, limit) : sorted + const hiddenCount = limit != null ? Math.max(0, sorted.length - limit) : 0 if (loading) { return ( @@ -247,16 +304,21 @@ export function AlignedProjectsList({ )} - {address && trustedSetLoading && trustedAlignmentAttesters === undefined && ( + {(refreshing || (address && trustedSetLoading && trustedAlignmentAttesters === undefined)) && ( )} + {!compact && ( + )} {sorted.length === 0 ? ( {projects.length === 0 - ? 'No aligned projects yet. Create one for this cause to get started.' + ? (fullPageTo + ? 'No aligned projects on the statements you have signed yet.' + : 'No aligned projects yet. Create one for this cause to get started.') : 'No projects match the current filters.'} {projects.length === 0 && ( - isDomainConfigured('lazyGiving') ? ( + projectLinks === 'local' ? ( + + + + ) : isDomainConfigured('lazyGiving') ? ( ) : ( - {projectLinks === 'local' - ? 'Project creation still happens on LazyGiving once its domain URL is configured.' - : 'Configure VITE_LAZYGIVING_URL to create a project for this cause.'} + Configure VITE_LAZYGIVING_URL to create a project for this cause. ) )} ) : ( - - {sorted.map((project) => ( + + {visible.map((project) => ( ))} + {fullPageTo && (compact || hiddenCount > 0) && ( + + )} )} diff --git a/ui/src/fundingportals/components/CauseBoard.test.tsx b/ui/src/fundingportals/components/CauseBoard.test.tsx new file mode 100644 index 000000000..538d56767 --- /dev/null +++ b/ui/src/fundingportals/components/CauseBoard.test.tsx @@ -0,0 +1,173 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CauseBoard } from './CauseBoard' + +vi.mock('react-router-dom', () => { + function Link({ to, children, ...props }: any) { + return {children} + } + return { Link, RouterLink: Link } +}) + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), +})) + +vi.mock('@commonality/sdk/conceptspace', async () => { + const actual = await vi.importActual('@commonality/sdk/conceptspace') + return { + ...actual, + getStatementWithContent: vi.fn(), + } +}) + +vi.mock('@commonality/sdk/delegation', async () => { + const actual = await vi.importActual('@commonality/sdk/delegation') + return { + ...actual, + getMonthlyPledgedByCause: vi.fn(), + } +}) + +vi.mock('@commonality/sdk/fundingportals', async () => { + const actual = await vi.importActual('@commonality/sdk/fundingportals') + return { + ...actual, + getAllAlignedProjectsForCause: vi.fn(), + foldAlignedProjectFunding: vi.fn(), + } +}) + +vi.mock('../../shared/hooks/useMachinery', () => ({ + useMachinery: vi.fn(), +})) + +vi.mock('../../shared/hooks/useTrustedSet', () => ({ + useTrustedSet: vi.fn(), +})) + +vi.mock('../../shared/hooks/useTrustedAttesters', () => ({ + useTrustedAttesters: vi.fn(), +})) + +vi.mock('../../shared/hooks/useTrustedContentAttesters', () => ({ + useTrustedContentAttesters: vi.fn(() => []), +})) + +vi.mock('../../shared/stores/foldCache', async () => { + const actual = await vi.importActual( + '../../shared/stores/foldCache', + ) + return { + ...actual, + loadBoardMetricsSnapshot: vi.fn(async () => null), + saveBoardMetricsSnapshot: vi.fn(async () => {}), + } +}) + +vi.mock('../../content-funding', async () => { + const actual = await vi.importActual('../../content-funding') + return { + ...actual, + useContentFundingState: vi.fn(() => ({ + state: null, + channels: [], + contentAttestations: new Map(), + loading: false, + })), + } +}) + +vi.mock('./AlignedProjectsList', () => ({ + AlignedProjectsList: vi.fn(() =>
Aligned Projects List
), +})) + +vi.mock('./SuccessfulProjectsTab', () => ({ + SuccessfulProjectsTab: vi.fn(() =>
Successful Projects Tab
), +})) + +vi.mock('./AttestAlignmentForm', () => ({ + AttestAlignmentForm: vi.fn(() =>
Attest Alignment Form
), +})) + +import { getStatementWithContent } from '@commonality/sdk/conceptspace' +import { getMonthlyPledgedByCause } from '@commonality/sdk/delegation' +import { foldAlignedProjectFunding, getAllAlignedProjectsForCause } from '@commonality/sdk/fundingportals' +import { loadBoardMetricsSnapshot, useMachinery, useTrustedAttesters, useTrustedSet } from '../../shared' +import { useAccount } from 'wagmi' + +const USER_ADDRESS = '0x1111111111111111111111111111111111111111' + +const mockMachinery = { + eventCacheUrl: 'http://localhost:42069/api', + contractAddresses: { + recurringPledges: '0x9999999999999999999999999999999999999999', + assuranceContractFactory: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, +} as any + +describe('CauseBoard', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useAccount).mockReturnValue({ address: USER_ADDRESS } as any) + vi.mocked(useMachinery).mockReturnValue(mockMachinery) + vi.mocked(useTrustedSet).mockReturnValue({ + trustedSet: new Set([USER_ADDRESS]), + isLoading: false, + } as any) + vi.mocked(useTrustedAttesters).mockReturnValue([]) + vi.mocked(loadBoardMetricsSnapshot).mockResolvedValue(null) + vi.mocked(getAllAlignedProjectsForCause).mockResolvedValue([]) + vi.mocked(foldAlignedProjectFunding).mockResolvedValue({ + totalRaisedAcrossProjects: [], + remainingToThreshold: [], + totalUnreimbursed: [], + projectCount: 0, + } as any) + vi.mocked(getMonthlyPledgedByCause).mockResolvedValue(new Map()) + vi.mocked(getStatementWithContent).mockResolvedValue({ + statement: { title: 'Live title', excerpt: 'Live excerpt' }, + content: null, + } as any) + }) + + it('paints cached metrics immediately while the live fold is in flight', async () => { + vi.mocked(loadBoardMetricsSnapshot).mockResolvedValue({ + snapshotVersion: 1, + title: 'Cached cause', + summary: 'From last visit', + totalRaised: [], + remainingToThreshold: [], + totalUnreimbursed: [], + monthlyPledged: '0', + projectCount: 4, + }) + let resolveFold: (value: unknown) => void = () => {} + vi.mocked(foldAlignedProjectFunding).mockReturnValue( + new Promise((resolve) => { + resolveFold = resolve + }) as any, + ) + + render() + + expect(await screen.findByText('Cached cause')).toBeInTheDocument() + expect(screen.getByText('4')).toBeInTheDocument() + expect(screen.getByTestId('trust-network-refresh')).toHaveAttribute( + 'aria-label', + 'Updating this fundable-projects board from the latest events.', + ) + + resolveFold({ + totalRaisedAcrossProjects: [], + remainingToThreshold: [], + totalUnreimbursed: [], + projectCount: 1, + }) + + await waitFor(() => { + expect(screen.getByText('Live title')).toBeInTheDocument() + expect(screen.getByText('1')).toBeInTheDocument() + }) + }) +}) diff --git a/ui/src/fundingportals/components/CauseBoard.tsx b/ui/src/fundingportals/components/CauseBoard.tsx index fd47f7a4c..cb372ae44 100644 --- a/ui/src/fundingportals/components/CauseBoard.tsx +++ b/ui/src/fundingportals/components/CauseBoard.tsx @@ -25,10 +25,13 @@ import type { IpfsCidV1 } from '@commonality/sdk/utils' import { useMachinery } from '../../shared' import { DEFAULT_PAYMENT_CURRENCY, + boardSnapshotCacheOptions, formatCurrencyAmount, formatCurrencyTotals, getConfiguredPaymentCurrency, getDomainUrl, + loadBoardMetricsSnapshot, + saveBoardMetricsSnapshot, useTrustedSet, useTrustedAttesters, useTrustedContentAttesters, @@ -89,6 +92,11 @@ export interface CauseBoardProps { * matches plank counts and the starter-network notice. */ trustedAlignmentAttesters?: Iterable + /** + * Home teaser: skip metrics/tabs, cap the aligned list, compact cards. + * Full board lives at {@link preview.fullPageTo}. + */ + preview?: { limit: number; fullPageTo: string } } function defaultNavLinks(statementCid: string | undefined): CauseBoardNavLink[] { @@ -141,6 +149,7 @@ export function CauseBoard({ projectsHelp, projectLinks = 'lazyGiving', trustedAlignmentAttesters, + preview, }: CauseBoardProps) { const cids = useMemo( () => resolveStatementCids(statementCid, statementCids), @@ -212,6 +221,7 @@ export function CauseBoard({ const [projectTab, setProjectTab] = useState< 'aligned' | 'successful' | 'reimbursed' | 'failed' >('aligned') + const [refreshing, setRefreshing] = useState(false) const keepPainted = useKeepPaintedWhileRefreshing() useEffect(() => { @@ -223,14 +233,44 @@ export function CauseBoard({ const attestersForLoad = trustedAttestersKey ? trustedAttestersKey.split(',') : undefined + const snapshotOptions = boardSnapshotCacheOptions(machinery, { + kind: 'board-metrics', + statementCids: loadCids, + implicationTrustKey: trustedAttestersKey, + alignmentTrustKey: trustedSetKey, + contentTrustKey, + }) async function load() { + if (preview) { + setLoading(false) + setError(null) + return + } if (loadCids.length === 0) { setLoading(false) setError('No statement specified.') return } - keepPainted.beginLoad(setLoading) + const cached = snapshotOptions + ? await loadBoardMetricsSnapshot(snapshotOptions) + : null + if (cancelled) return + if (cached) { + setTitle(cached.title) + setSummary(cached.summary) + setTotalRaised(cached.totalRaised as typeof totalRaised) + setRemainingToThreshold(cached.remainingToThreshold as typeof remainingToThreshold) + setTotalUnreimbursed(cached.totalUnreimbursed as typeof totalUnreimbursed) + setMonthlyPledged(BigInt(cached.monthlyPledged || '0')) + setProjectCount(cached.projectCount) + keepPainted.markResolved() + setLoading(false) + setRefreshing(true) + } else { + keepPainted.beginLoad(setLoading) + setRefreshing(false) + } setError(null) try { const cid = loadCids[0]! @@ -273,15 +313,21 @@ export function CauseBoard({ if (cancelled) return - if (stmtResult) { - const t = + const nextTitle = stmtResult + ? ( stmtResult.statement.title ?? (stmtResult.content?.content ? stmtResult.content.content.split('\n')[0].replace(/^#+\s*/, '').trim() : null) ?? `Statement ${cid.slice(0, 12)}...` - setTitle(t) - setSummary(stmtResult.statement.excerpt ?? null) + ) + : (cached?.title ?? null) + const nextSummary = stmtResult + ? (stmtResult.statement.excerpt ?? null) + : (cached?.summary ?? null) + if (stmtResult) { + setTitle(nextTitle) + setSummary(nextSummary) } setTotalRaised(fundingMetrics.totalRaisedAcrossProjects) @@ -298,15 +344,30 @@ export function CauseBoard({ monthly += monthlyTotals.get(plankCid) ?? 0n } setMonthlyPledged(monthly) + + if (snapshotOptions) { + await saveBoardMetricsSnapshot(snapshotOptions, { + title: nextTitle, + summary: nextSummary, + totalRaised: fundingMetrics.totalRaisedAcrossProjects, + remainingToThreshold: fundingMetrics.remainingToThreshold, + totalUnreimbursed: fundingMetrics.totalUnreimbursed, + monthlyPledged: monthly.toString(), + projectCount: fundingMetrics.projectCount, + }) + } } catch (err) { if (!cancelled) { console.error('Error loading cause board:', err) - setError(err instanceof Error ? err.message : 'Failed to load cause board') + if (!cached) { + setError(err instanceof Error ? err.message : 'Failed to load fundable-projects board') + } } } finally { if (!cancelled) { keepPainted.markResolved() setLoading(false) + setRefreshing(false) } } } @@ -319,6 +380,7 @@ export function CauseBoard({ machinery, cidsKey, embedded, + preview, trustedAttestersKey, trustedSetKey, alignmentOverrideKey, @@ -327,6 +389,24 @@ export function CauseBoard({ contentTrustKey, ]) + if (preview) { + return ( + + + + ) + } + if (loading) { return ( @@ -494,12 +574,16 @@ export function CauseBoard({ {headerExtra} - {address && trustedSetLoading && trustedAlignmentAttesters === undefined && ( + {(refreshing || (address && trustedSetLoading && trustedAlignmentAttesters === undefined)) && ( )} @@ -509,7 +593,7 @@ export function CauseBoard({ onChange={(_, value: 'aligned' | 'successful' | 'reimbursed' | 'failed') => setProjectTab(value) } - aria-label="Cause board project views" + aria-label="Fundable-projects board views" variant="scrollable" allowScrollButtonsMobile sx={{ diff --git a/ui/src/fundingportals/components/CauseLeaderboard.tsx b/ui/src/fundingportals/components/CauseLeaderboard.tsx index 523cb40d6..c0dcf76d6 100644 --- a/ui/src/fundingportals/components/CauseLeaderboard.tsx +++ b/ui/src/fundingportals/components/CauseLeaderboard.tsx @@ -216,7 +216,7 @@ export function CauseLeaderboard({ } const resolvedBack: CauseBoardNavLink = backLink ?? { - label: '← Back to Cause Board', + label: '← Back to fundable-projects board', to: `/portal/${loadCids[0] ?? ''}`, } diff --git a/ui/src/fundingportals/components/FundingPortalSummary.test.tsx b/ui/src/fundingportals/components/FundingPortalSummary.test.tsx index 6bb02cd54..377aad999 100644 --- a/ui/src/fundingportals/components/FundingPortalSummary.test.tsx +++ b/ui/src/fundingportals/components/FundingPortalSummary.test.tsx @@ -190,7 +190,7 @@ describe('FundingPortalSummary', () => { render() await waitFor(() => { - expect(screen.getByText('Failed to load cause board summary')).toBeInTheDocument() + expect(screen.getByText('Failed to load fundable-projects board summary')).toBeInTheDocument() }) }) }) @@ -201,19 +201,19 @@ describe('FundingPortalSummary', () => { vi.mocked(getAllAlignedProjectsForCause).mockResolvedValue([]) }) - it('shows "Cause Board" heading', async () => { + it('shows "Fundable Projects" heading', async () => { render() await waitFor(() => { - expect(screen.getByText('Cause Board')).toBeInTheDocument() + expect(screen.getByText('Fundable Projects')).toBeInTheDocument() }) }) - it('shows "View Cause Board" button linking to /portal/:statementCid', async () => { + it('shows "View fundable-projects board" button linking to /portal/:statementCid', async () => { render() await waitFor(() => { - const link = screen.getByRole('link', { name: 'View Cause Board' }) + const link = screen.getByRole('link', { name: 'View fundable-projects board' }) expect(link).toHaveAttribute('href', '/portal/QmMyCid') }) }) @@ -316,7 +316,7 @@ describe('FundingPortalSummary', () => { render() await waitFor(() => { - expect(screen.getByText('Cause Board')).toBeInTheDocument() + expect(screen.getByText('Fundable Projects')).toBeInTheDocument() }) expect(screen.queryByText('Top Projects by Funding Progress')).not.toBeInTheDocument() }) diff --git a/ui/src/fundingportals/components/FundingPortalSummary.tsx b/ui/src/fundingportals/components/FundingPortalSummary.tsx index c9cece1ca..9aafd2f22 100644 --- a/ui/src/fundingportals/components/FundingPortalSummary.tsx +++ b/ui/src/fundingportals/components/FundingPortalSummary.tsx @@ -105,7 +105,7 @@ export function FundingPortalSummary({ } catch (err) { if (!cancelled) { console.error('Error loading cause board summary:', err) - setError(err instanceof Error ? err.message : 'Failed to load cause board summary') + setError(err instanceof Error ? err.message : 'Failed to load fundable-projects board summary') } } finally { if (!cancelled) setLoading(false) @@ -134,14 +134,14 @@ export function FundingPortalSummary({ - Cause Board + Fundable Projects diff --git a/ui/src/fundingportals/pages/ExplorerPage.test.tsx b/ui/src/fundingportals/pages/ExplorerPage.test.tsx index 63aaa16cc..76dbce909 100644 --- a/ui/src/fundingportals/pages/ExplorerPage.test.tsx +++ b/ui/src/fundingportals/pages/ExplorerPage.test.tsx @@ -318,13 +318,13 @@ describe('ExplorerPage', () => { }) }) - it('shows Open Cause Board links', async () => { + it('shows Open fundable-projects board links', async () => { mockExplorerData() renderWithRouter() await waitFor(() => { - const portalLinks = screen.getAllByRole('link', { name: 'Open Cause Board' }) + const portalLinks = screen.getAllByRole('link', { name: 'Open fundable-projects board' }) expect(portalLinks.length).toBeGreaterThan(0) expect(portalLinks[0]).toHaveAttribute('href', '/portal/bafyEntry1') }) diff --git a/ui/src/fundingportals/pages/ExplorerPage.tsx b/ui/src/fundingportals/pages/ExplorerPage.tsx index 607d3d611..491252b1e 100644 --- a/ui/src/fundingportals/pages/ExplorerPage.tsx +++ b/ui/src/fundingportals/pages/ExplorerPage.tsx @@ -255,7 +255,7 @@ export function ExplorerPage() { - No curated cause collection is available yet, but you can still discover causes without configuring anything: browse public cause statements on Tally, open a statement you care about, then use its cause board link to continue. + No curated cause collection is available yet, but you can still discover causes without configuring anything: browse public cause statements on Tally, open a statement you care about, then use its fundable-projects board link to continue. If you want a personalized explorer later, add a trusted explorer nudger in Tally's trust settings. @@ -371,7 +371,7 @@ export function ExplorerPage() { component={RouterLink} to={`/portal/${entry.cid}`} > - Open Cause Board + Open fundable-projects board diff --git a/ui/src/fundingportals/pages/StatementFundingPortalPage.test.tsx b/ui/src/fundingportals/pages/StatementFundingPortalPage.test.tsx index d05593585..13ba9ddb0 100644 --- a/ui/src/fundingportals/pages/StatementFundingPortalPage.test.tsx +++ b/ui/src/fundingportals/pages/StatementFundingPortalPage.test.tsx @@ -203,7 +203,7 @@ describe('StatementFundingPortalPage', () => { await waitFor(() => { expect( screen.getByLabelText( - 'Refreshing your trust network. This portal is currently filtered using 2 accounts in your network. Results may still change as more are discovered.' + 'Refreshing your trust network. This fundable-projects board is currently filtered using 2 accounts in your network. Results may still change as more are discovered.' ) ).toBeInTheDocument() }) @@ -220,7 +220,7 @@ describe('StatementFundingPortalPage', () => { await waitFor(() => { expect( screen.getByLabelText( - 'Refreshing your trust network. Until any trusted accounts are found, this cause board still shows all project vouches.' + 'Refreshing your trust network. Until any trusted accounts are found, this fundable-projects board still shows all project vouches.' ) ).toBeInTheDocument() }) diff --git a/ui/src/fundingportals/pages/StatementFundingPortalPage.tsx b/ui/src/fundingportals/pages/StatementFundingPortalPage.tsx index e3d590cea..fb7ced018 100644 --- a/ui/src/fundingportals/pages/StatementFundingPortalPage.tsx +++ b/ui/src/fundingportals/pages/StatementFundingPortalPage.tsx @@ -10,7 +10,7 @@ export function StatementFundingPortalPage() { const { statementCid } = useParams<{ statementCid: string }>() if (!statementCid) { - return Missing statement for this cause board. + return Missing statement for this fundable-projects board. } return diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 3ea18a2bb..255e0fe60 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -149,7 +149,8 @@ declare global { export function Root() { const [mode, setMode] = useState(getInitialColorMode) - const [wagmiConfig, setWagmiConfig] = useState(config) + const [testWagmiConfig, setTestWagmiConfig] = useState(null) + const wagmiConfig = testWagmiConfig ?? config const theme = useMemo(() => createAppTheme(mode), [mode]) const themeModeContextValue = useMemo(() => ({ @@ -166,7 +167,7 @@ export function Root() { const setupTestWallet = useCallback( (...args: Parameters) => { const newConfig = createMockConfig(...args) - setWagmiConfig(newConfig) + setTestWagmiConfig(newConfig) return newConfig }, [] diff --git a/ui/src/shared/config/runtimeConfig.ts b/ui/src/shared/config/runtimeConfig.ts index 46c7802fc..d22bf0942 100644 --- a/ui/src/shared/config/runtimeConfig.ts +++ b/ui/src/shared/config/runtimeConfig.ts @@ -56,6 +56,7 @@ export type RuntimeConfigKey = | 'VITE_CIVILITY_URL' | 'VITE_COMMON_SENSE_MAJORITY_URL' | 'VITE_CONCEPTSPACE_URL' + | 'VITE_CAUSESTARTER_URL' const buildTimeConfig: UiRuntimeConfig = { VITE_EVENT_CACHE_URL: import.meta.env.VITE_EVENT_CACHE_URL, @@ -112,6 +113,7 @@ const buildTimeConfig: UiRuntimeConfig = { VITE_CIVILITY_URL: import.meta.env.VITE_CIVILITY_URL, VITE_COMMON_SENSE_MAJORITY_URL: import.meta.env.VITE_COMMON_SENSE_MAJORITY_URL, VITE_CONCEPTSPACE_URL: import.meta.env.VITE_CONCEPTSPACE_URL, + VITE_CAUSESTARTER_URL: import.meta.env.VITE_CAUSESTARTER_URL, } let runtimeConfig: UiRuntimeConfig = stripEmptyValues(buildTimeConfig) diff --git a/ui/src/shared/hooks/useCachedProject.test.ts b/ui/src/shared/hooks/useCachedProject.test.ts index f73351380..889db06b2 100644 --- a/ui/src/shared/hooks/useCachedProject.test.ts +++ b/ui/src/shared/hooks/useCachedProject.test.ts @@ -13,11 +13,13 @@ vi.mock('./useMachinery', () => ({ })) const mockGetProject = vi.fn() +const mockGetProjectFold = vi.fn() vi.mock('@commonality/sdk/lazy-giving', async () => { const actual = await vi.importActual('@commonality/sdk/lazy-giving') return { ...actual, getProject: mockGetProject, + getProjectFold: mockGetProjectFold, PROJECT_FOLD_VERSION: 1, } }) @@ -115,83 +117,93 @@ describe('loadProjectWithCache', () => { it('fetches from SDK when no cache exists', async () => { ;(loadCachedProjectAccumulator as any).mockResolvedValue(null) - mockGetProject.mockResolvedValue(mockProject) + mockGetProjectFold.mockResolvedValue({ project: mockProject, accumulator: mockAccumulator }) const result = await loadProjectWithCache(mockMachinery, '0xProject', mockCacheOptions) expect(result).toEqual(mockProject) expect(loadCachedProjectAccumulator).toHaveBeenCalled() - expect(mockGetProject).toHaveBeenCalledWith(mockMachinery, '0xProject') + expect(mockGetProjectFold).toHaveBeenCalledWith(mockMachinery, '0xProject') expect(saveCachedProjectAccumulator).toHaveBeenCalled() }) - it('saves to cache after fresh fetch', async () => { + it('saves the fold accumulator after a fresh fetch', async () => { ;(loadCachedProjectAccumulator as any).mockResolvedValue(null) - mockGetProject.mockResolvedValue(mockProject) + mockGetProjectFold.mockResolvedValue({ project: mockProject, accumulator: mockAccumulator }) await loadProjectWithCache(mockMachinery, '0xProject', mockCacheOptions) expect(saveCachedProjectAccumulator).toHaveBeenCalledWith( expect.objectContaining({ address: '0xProject' }), - expect.objectContaining({ id: '1', totalReceived: 500n }), + mockAccumulator, '100' ) }) - it('refetches from SDK when cached accumulator is available', async () => { + it('resumes from the cached accumulator instead of replaying every event', async () => { ;(loadCachedProjectAccumulator as any).mockResolvedValue({ accumulator: mockAccumulator, blockNumber: '100', }) - mockGetProject.mockResolvedValue(mockProject) + mockGetProjectFold.mockResolvedValue({ project: mockProject, accumulator: mockAccumulator }) const result = await loadProjectWithCache(mockMachinery, '0xProject', mockCacheOptions) expect(result).toEqual(mockProject) - expect(mockGetProject).toHaveBeenCalledWith(mockMachinery, '0xProject') + expect(mockGetProjectFold).toHaveBeenCalledWith(mockMachinery, '0xProject', { + initialAccumulator: mockAccumulator, + blockNumber_gte: '100', + }) expect(saveCachedProjectAccumulator).toHaveBeenCalledWith( expect.objectContaining({ address: '0xProject' }), - expect.objectContaining({ id: '1', totalReceived: 500n }), + mockAccumulator, '100' ) }) - it('updates cache when block number changes', async () => { + it('updates cache when the resumed fold advances the cursor', async () => { const updatedProject = { ...mockProject, blockNumber: '200', totalReceived: '800' } + const updatedAccumulator = { + ...mockAccumulator, + blockNumber: '200', + lastEventBlockNumber: '200', + lastEventLogIndex: 3, + totalReceived: 800n, + } ;(loadCachedProjectAccumulator as any).mockResolvedValue({ accumulator: mockAccumulator, blockNumber: '100', }) - mockGetProject.mockResolvedValue(updatedProject) + mockGetProjectFold.mockResolvedValue({ project: updatedProject, accumulator: updatedAccumulator }) await loadProjectWithCache(mockMachinery, '0xProject', mockCacheOptions) expect(saveCachedProjectAccumulator).toHaveBeenCalledWith( expect.objectContaining({ address: '0xProject' }), - expect.objectContaining({ blockNumber: '200' }), + updatedAccumulator, '200' ) }) - it('rewrites cache after a cached refetch even when block number is unchanged', async () => { + it('rewrites cache after a resumed fold even when the cursor is unchanged', async () => { ;(loadCachedProjectAccumulator as any).mockResolvedValue({ accumulator: mockAccumulator, blockNumber: '100', }) - mockGetProject.mockResolvedValue(mockProject) + mockGetProjectFold.mockResolvedValue({ project: mockProject, accumulator: mockAccumulator }) await loadProjectWithCache(mockMachinery, '0xProject', mockCacheOptions) expect(saveCachedProjectAccumulator).toHaveBeenCalledWith( expect.objectContaining({ address: '0xProject' }), - expect.objectContaining({ blockNumber: '100', totalReceived: 500n }), + mockAccumulator, '100' ) }) it('returns null when SDK returns null and no cache', async () => { ;(loadCachedProjectAccumulator as any).mockResolvedValue(null) - mockGetProject.mockResolvedValue(null) + mockGetProjectFold.mockResolvedValue(null) const result = await loadProjectWithCache(mockMachinery, '0xProject', mockCacheOptions) @@ -199,21 +211,21 @@ describe('loadProjectWithCache', () => { expect(saveCachedProjectAccumulator).not.toHaveBeenCalled() }) - it('returns cached result when SDK returns null but cache exists', async () => { + it('still returns the resumed project when no new events arrive', async () => { ;(loadCachedProjectAccumulator as any).mockResolvedValue({ accumulator: mockAccumulator, blockNumber: '100', }) - mockGetProject.mockResolvedValue(null) + mockGetProjectFold.mockResolvedValue({ project: mockProject, accumulator: mockAccumulator }) const result = await loadProjectWithCache(mockMachinery, '0xProject', mockCacheOptions) - expect(result).toBeNull() + expect(result).toEqual(mockProject) }) - it('normalizes address to lowercase in cache key', async () => { + it('passes the original address into the cache key options', async () => { ;(loadCachedProjectAccumulator as any).mockResolvedValue(null) - mockGetProject.mockResolvedValue(mockProject) + mockGetProjectFold.mockResolvedValue({ project: mockProject, accumulator: mockAccumulator }) await loadProjectWithCache(mockMachinery, '0xPROJECT', mockCacheOptions) @@ -224,7 +236,7 @@ describe('loadProjectWithCache', () => { it('throws when SDK call fails', async () => { ;(loadCachedProjectAccumulator as any).mockResolvedValue(null) - mockGetProject.mockRejectedValue(new Error('Network error')) + mockGetProjectFold.mockRejectedValue(new Error('Network error')) await expect(loadProjectWithCache(mockMachinery, '0xProject', mockCacheOptions)).rejects.toThrow('Network error') }) diff --git a/ui/src/shared/hooks/useCachedProject.ts b/ui/src/shared/hooks/useCachedProject.ts index 05c855974..e3d6051d2 100644 --- a/ui/src/shared/hooks/useCachedProject.ts +++ b/ui/src/shared/hooks/useCachedProject.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { PROJECT_FOLD_VERSION, type Project, type ProjectAccumulator } from '@commonality/sdk/lazy-giving'; +import type { Project, ProjectAccumulator } from '@commonality/sdk/lazy-giving'; import type { SDKMachinery } from '@commonality/sdk/machinery'; import { loadCachedProjectAccumulator, @@ -20,20 +20,21 @@ interface UseCachedProjectOptions { cacheOptions: Omit; } -function projectToAccumulator(project: Project): ProjectAccumulator { +/** IndexedDB key material shared by every project-fold cache caller. */ +export function projectFoldCacheOptions( + machinery: SDKMachinery, +): Omit | null { + const factory = machinery.contractAddresses?.assuranceContractFactory + if (!machinery.eventCacheUrl || !factory) return null return { - foldVersion: PROJECT_FOLD_VERSION, - id: project.id, - erc1155Address: project.erc1155Address, - recipient: project.recipient, - conditionAddress: project.conditionAddress, - metadataCid: project.metadataCid, - createdAt: project.createdAt, - blockNumber: project.blockNumber, - lastEventBlockNumber: undefined, - lastEventLogIndex: undefined, - totalReceived: BigInt(project.totalReceived), - }; + eventCacheUrl: machinery.eventCacheUrl, + contractAddresses: { assuranceContractFactory: factory }, + foldType: 'project', + } +} + +function resumeFromBlock(accumulator: ProjectAccumulator, fallbackBlock: string): string { + return accumulator.lastEventBlockNumber ?? accumulator.blockNumber ?? fallbackBlock } export async function loadProjectWithCache( @@ -41,7 +42,7 @@ export async function loadProjectWithCache( projectAddress: string, cacheOptions: Omit ): Promise { - const { getProject } = await import('@commonality/sdk/lazy-giving'); + const { getProject, getProjectFold } = await import('@commonality/sdk/lazy-giving'); if (!projectAddress) { return null; @@ -61,27 +62,26 @@ export async function loadProjectWithCache( }; const cached = await loadCachedProjectAccumulator(cacheKeyOptions); - if (cached) { - const project = await getProject(machinery, projectAddress); - if (project) { - await saveCachedProjectAccumulator( - cacheKeyOptions, - projectToAccumulator(project), - project.blockNumber ?? cached.blockNumber - ); - } - return project; - } + const folded = cached + ? await getProjectFold(machinery, projectAddress, { + initialAccumulator: cached.accumulator, + blockNumber_gte: resumeFromBlock(cached.accumulator, cached.blockNumber), + }) + : await getProjectFold(machinery, projectAddress); - const project = await getProject(machinery, projectAddress); - if (project) { + if (folded) { await saveCachedProjectAccumulator( cacheKeyOptions, - projectToAccumulator(project), - project.blockNumber ?? '0' + folded.accumulator, + folded.accumulator.lastEventBlockNumber + ?? folded.project.blockNumber + ?? cached?.blockNumber + ?? '0' ); + return folded.project; } - return project; + + return null; } export function useCachedProject({ diff --git a/ui/src/shared/index.ts b/ui/src/shared/index.ts index cc906a078..04f14edf7 100644 --- a/ui/src/shared/index.ts +++ b/ui/src/shared/index.ts @@ -85,6 +85,20 @@ export type { BridgeLabels, MediatorBridgeAnchor, MediatorBridgeCard } from './m // === stores/ — client-side persistence (contacts; folded-state cache via hooks) === export { addContact, getContacts } from './stores/contactStore' export type { ContactKind, SavedContact } from './stores/contactStore' +export { + BOARD_SNAPSHOT_VERSION, + boardSnapshotCacheOptions, + loadAlignedListSnapshot, + loadBoardMetricsSnapshot, + saveAlignedListSnapshot, + saveBoardMetricsSnapshot, +} from './stores/foldCache' +export type { + AlignedListSnapshot, + BoardMetricsSnapshot, + BoardSnapshotKeyOptions, + BoardSnapshotKind, +} from './stores/foldCache' // === trust/ — subjectiv trust network (computation + cache + worker live behind hooks) === export { notifySubjectivTrustNetworkInvalidated } from './trust/subjectivTrust' @@ -103,7 +117,7 @@ export { } from './hooks/useBeatAgentTrustPolicy' export type { BeatAgentTrustPolicy } from './hooks/useBeatAgentTrustPolicy' -export { useCachedProject } from './hooks/useCachedProject' +export { loadProjectWithCache, projectFoldCacheOptions, useCachedProject } from './hooks/useCachedProject' export { useCachedProjects } from './hooks/useCachedProjects' export { getEventCacheUrl, getIpfsApiUrl, useMachinery } from './hooks/useMachinery' export { useMutedNudgers } from './hooks/useMutedNudgers' diff --git a/ui/src/shared/routing/domainUrls.ts b/ui/src/shared/routing/domainUrls.ts index 9cdd6b7fc..e184e691a 100644 --- a/ui/src/shared/routing/domainUrls.ts +++ b/ui/src/shared/routing/domainUrls.ts @@ -14,6 +14,7 @@ export type DomainId = | 'civility' | 'common-sense-majority' | 'conceptspace' + | 'causestarter' type DomainUrlRuntimeConfigKey = | 'VITE_COMMONALITY_URL' @@ -24,6 +25,7 @@ type DomainUrlRuntimeConfigKey = | 'VITE_CIVILITY_URL' | 'VITE_COMMON_SENSE_MAJORITY_URL' | 'VITE_CONCEPTSPACE_URL' + | 'VITE_CAUSESTARTER_URL' const domainUrlKeys: Record = { commonality: 'VITE_COMMONALITY_URL', @@ -34,6 +36,7 @@ const domainUrlKeys: Record = { civility: 'VITE_CIVILITY_URL', 'common-sense-majority': 'VITE_COMMON_SENSE_MAJORITY_URL', conceptspace: 'VITE_CONCEPTSPACE_URL', + causestarter: 'VITE_CAUSESTARTER_URL', } const domainHostLabels: Record = { @@ -45,6 +48,7 @@ const domainHostLabels: Record = { civility: 'civility', 'common-sense-majority': 'common-sense-majority', conceptspace: 'conceptspace', + causestarter: 'causestarter', } const knownDomainHostLabels = new Set(Object.values(domainHostLabels)) diff --git a/ui/src/shared/stores/foldCache.test.ts b/ui/src/shared/stores/foldCache.test.ts index db22167fa..da158e7d1 100644 --- a/ui/src/shared/stores/foldCache.test.ts +++ b/ui/src/shared/stores/foldCache.test.ts @@ -223,6 +223,73 @@ describe('foldCache', () => { ).resolves.toBeNull(); }); + it('round-trips cause-board snapshots isolated by trust fingerprint', async () => { + const { + boardSnapshotCacheOptions, + loadAlignedListSnapshot, + loadBoardMetricsSnapshot, + saveAlignedListSnapshot, + saveBoardMetricsSnapshot, + } = await import('./foldCache'); + + const machinery = { + eventCacheUrl: 'http://localhost:42069/api/board', + contractAddresses: { + assuranceContractFactory: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + }; + const metricsKey = boardSnapshotCacheOptions(machinery as never, { + kind: 'board-metrics', + statementCids: ['QmB', 'QmA'], + implicationTrustKey: '0x1', + alignmentTrustKey: '0x2', + contentTrustKey: '0x3', + }); + const listKey = boardSnapshotCacheOptions(machinery as never, { + kind: 'aligned-list', + statementCids: ['QmA', 'QmB'], + implicationTrustKey: '0x1', + alignmentTrustKey: '0x2', + contentTrustKey: '0x3', + }); + expect(metricsKey).not.toBeNull(); + expect(listKey).not.toBeNull(); + + await saveBoardMetricsSnapshot(metricsKey!, { + title: 'Garden', + summary: 'Grow food', + totalRaised: [{ symbol: 'ETH', amount: '1' }], + remainingToThreshold: [], + totalUnreimbursed: [], + monthlyPledged: '5', + projectCount: 2, + }); + await saveAlignedListSnapshot(listKey!, { + projects: [{ projectAddress: '0xabc', totalReceived: '1' }], + metadata: { '0xabc': { name: 'Beds' } }, + }); + + await expect(loadBoardMetricsSnapshot(metricsKey!)).resolves.toMatchObject({ + title: 'Garden', + projectCount: 2, + monthlyPledged: '5', + }); + await expect(loadAlignedListSnapshot(listKey!)).resolves.toMatchObject({ + projects: [{ projectAddress: '0xabc', totalReceived: '1' }], + metadata: { '0xabc': { name: 'Beds' } }, + }); + + const otherTrust = boardSnapshotCacheOptions(machinery as never, { + kind: 'board-metrics', + statementCids: ['QmA', 'QmB'], + implicationTrustKey: '0x1', + alignmentTrustKey: '0xother', + contentTrustKey: '0x3', + }); + await expect(loadBoardMetricsSnapshot(otherTrust!)).resolves.toBeNull(); + await expect(loadAlignedListSnapshot(metricsKey!)).resolves.toBeNull(); + }); + it('returns null when foldVersion mismatches', async () => { const { loadCachedProjectAccumulator, saveCachedProjectAccumulator } = await import( './foldCache' diff --git a/ui/src/shared/stores/foldCache.ts b/ui/src/shared/stores/foldCache.ts index 5081ea23f..8b52ef607 100644 --- a/ui/src/shared/stores/foldCache.ts +++ b/ui/src/shared/stores/foldCache.ts @@ -1,11 +1,12 @@ import type { ProjectAccumulator } from '@commonality/sdk/lazy-giving'; -import type { ContractAddresses } from '@commonality/sdk/machinery'; +import type { ContractAddresses, SDKMachinery } from '@commonality/sdk/machinery'; const FOLD_CACHE_DB_NAME = 'commonality-fold-cache'; const FOLD_CACHE_DB_VERSION = 1; const FOLD_CACHE_STORE_NAME = 'fold-accumulators'; const FOLD_CACHE_VERSION = 'v2'; const CURRENT_PROJECT_FOLD_VERSION = 1; +export const BOARD_SNAPSHOT_VERSION = 1; export interface FoldCacheRecord { cacheKey: string; @@ -22,6 +23,43 @@ export interface FoldCacheOptions { foldType: 'project'; } +export type BoardSnapshotKind = 'board-metrics' | 'aligned-list'; + +export interface BoardSnapshotKeyOptions { + eventCacheUrl: string; + contractAddresses: Pick; + kind: BoardSnapshotKind; + statementCids: string[]; + implicationTrustKey: string; + alignmentTrustKey: string; + contentTrustKey: string; +} + +export interface BoardMetricsSnapshot { + snapshotVersion: number; + title: string | null; + summary: string | null; + totalRaised: unknown; + remainingToThreshold: unknown; + totalUnreimbursed: unknown; + monthlyPledged: string; + projectCount: number; +} + +export interface AlignedListSnapshot { + snapshotVersion: number; + projects: unknown[]; + metadata: Record; +} + +interface BoardSnapshotRecord { + cacheKey: string; + snapshotVersion: number; + kind: BoardSnapshotKind; + payload: unknown; + updatedAt: number; +} + let openDatabasePromise: Promise | null = null; function getCacheKey({ @@ -39,6 +77,51 @@ function getCacheKey({ ].join('::'); } +function getBoardSnapshotCacheKey({ + eventCacheUrl, + contractAddresses, + kind, + statementCids, + implicationTrustKey, + alignmentTrustKey, + contentTrustKey, +}: BoardSnapshotKeyOptions): string { + return [ + FOLD_CACHE_VERSION, + 'board-snapshot', + String(BOARD_SNAPSHOT_VERSION), + kind, + eventCacheUrl, + contractAddresses.assuranceContractFactory.toLowerCase(), + [...statementCids].sort().join('\0'), + implicationTrustKey, + alignmentTrustKey, + contentTrustKey, + ].join('::'); +} + +function toJsonValue(value: unknown): unknown { + return JSON.parse( + JSON.stringify(value, (_, entry) => + typeof entry === 'bigint' ? entry.toString() : entry, + ), + ); +} + +/** IndexedDB key material for a cause-board snapshot, or null when cache is unavailable. */ +export function boardSnapshotCacheOptions( + machinery: SDKMachinery, + rest: Omit, +): BoardSnapshotKeyOptions | null { + const factory = machinery.contractAddresses?.assuranceContractFactory; + if (!machinery.eventCacheUrl || !factory) return null; + return { + eventCacheUrl: machinery.eventCacheUrl, + contractAddresses: { assuranceContractFactory: factory }, + ...rest, + }; +} + function waitForRequest(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.addEventListener('success', () => { @@ -149,3 +232,85 @@ export async function saveCachedProjectAccumulator( await waitForRequest(store.put(record)); await waitForTransaction(transaction); } + +async function loadBoardSnapshotRecord( + options: BoardSnapshotKeyOptions, +): Promise { + try { + const database = await openFoldCacheDatabase(); + const transaction = database.transaction(FOLD_CACHE_STORE_NAME, 'readonly'); + const store = transaction.objectStore(FOLD_CACHE_STORE_NAME); + const record = await waitForRequest( + store.get(getBoardSnapshotCacheKey(options)) as IDBRequest, + ); + await waitForTransaction(transaction); + if (!record || record.snapshotVersion !== BOARD_SNAPSHOT_VERSION) { + return null; + } + return record; + } catch { + return null; + } +} + +async function saveBoardSnapshotRecord( + options: BoardSnapshotKeyOptions, + payload: unknown, +): Promise { + try { + const database = await openFoldCacheDatabase(); + const transaction = database.transaction(FOLD_CACHE_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(FOLD_CACHE_STORE_NAME); + const record: BoardSnapshotRecord = { + cacheKey: getBoardSnapshotCacheKey(options), + snapshotVersion: BOARD_SNAPSHOT_VERSION, + kind: options.kind, + payload: toJsonValue(payload), + updatedAt: Date.now(), + }; + await waitForRequest(store.put(record)); + await waitForTransaction(transaction); + } catch { + // IndexedDB is a best-effort paint cache; a miss just shows the spinner. + } +} + +export async function loadBoardMetricsSnapshot( + options: BoardSnapshotKeyOptions, +): Promise { + const record = await loadBoardSnapshotRecord(options); + if (!record || record.kind !== 'board-metrics') return null; + const payload = record.payload as BoardMetricsSnapshot | null; + if (!payload || payload.snapshotVersion !== BOARD_SNAPSHOT_VERSION) return null; + return payload; +} + +export async function saveBoardMetricsSnapshot( + options: BoardSnapshotKeyOptions, + snapshot: Omit, +): Promise { + await saveBoardSnapshotRecord(options, { + ...snapshot, + snapshotVersion: BOARD_SNAPSHOT_VERSION, + }); +} + +export async function loadAlignedListSnapshot( + options: BoardSnapshotKeyOptions, +): Promise { + const record = await loadBoardSnapshotRecord(options); + if (!record || record.kind !== 'aligned-list') return null; + const payload = record.payload as AlignedListSnapshot | null; + if (!payload || payload.snapshotVersion !== BOARD_SNAPSHOT_VERSION) return null; + return payload; +} + +export async function saveAlignedListSnapshot( + options: BoardSnapshotKeyOptions, + snapshot: Omit, +): Promise { + await saveBoardSnapshotRecord(options, { + ...snapshot, + snapshotVersion: BOARD_SNAPSHOT_VERSION, + }); +} diff --git a/ui/src/test/setup.ts b/ui/src/test/setup.ts index 5b8f1760e..880a4c311 100644 --- a/ui/src/test/setup.ts +++ b/ui/src/test/setup.ts @@ -14,6 +14,7 @@ const envVarsThatShouldNotLeakFromLocalDeployment = [ 'VITE_CIVILITY_URL', 'VITE_COMMON_SENSE_MAJORITY_URL', 'VITE_CONCEPTSPACE_URL', + 'VITE_CAUSESTARTER_URL', 'VITE_DEFAULT_TRUSTED_ATTESTERS', 'VITE_DEFAULT_TRUSTED_CONTENT_ATTESTERS', 'VITE_DEFAULT_TRUSTED_BEAT_AGENTS', diff --git a/ui/src/wagmi.ts b/ui/src/wagmi.ts index b8773f3bc..a94bbc240 100644 --- a/ui/src/wagmi.ts +++ b/ui/src/wagmi.ts @@ -6,6 +6,8 @@ import { isAddress } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import type { MockParameters } from 'wagmi/connectors' import { isPrivySmartWalletEnabled } from './privy/config' +import { HARDHAT_DEV_ACCOUNTS, isLocalDevHost } from './causestarter/lib/hardhatAccounts' +import { hardhatLocalConnector } from './causestarter/lib/hardhatLocalConnector' export const walletConnectProjectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || '' export const isE2E = import.meta.env.VITE_E2E === 'true' @@ -13,7 +15,17 @@ export const privyAppId = import.meta.env.VITE_PRIVY_APP_ID?.trim() || '' export const privyClientId = import.meta.env.VITE_PRIVY_CLIENT_ID?.trim() || undefined export const privySmartWalletBundlerUrl = import.meta.env.VITE_PRIVY_SMART_WALLET_BUNDLER_URL?.trim() || '' export const privySmartWalletPaymasterUrl = import.meta.env.VITE_PRIVY_SMART_WALLET_PAYMASTER_URL?.trim() || undefined -export const isPrivyEnabled = !isE2E && privyAppId.length > 0 +/** + * Local Docker / vite: unlocked Hardhat accounts instead of browser wallets. + * + * Takes precedence over `VITE_E2E`. The ui package's `.env` sets `VITE_E2E=true` + * for Playwright, but that would otherwise install only the mock connector and + * leave the CauseStarter Hardhat account menu disabled. Playwright tests that + * need a specific account still call `window._setupTestWallet`. + */ +export const useLocalHardhatWallets = isLocalDevHost() + +export const isPrivyEnabled = !isE2E && !useLocalHardhatWallets && privyAppId.length > 0 export { isPrivySmartWalletEnabled } const mainnetRpcUrl = import.meta.env.VITE_MAINNET_RPC_URL || 'https://ethereum-rpc.publicnode.com' @@ -61,15 +73,29 @@ export function createMockConfig( }) } -export const config = isE2E - ? createMockConfig() - : createConfig( - getDefaultConfig({ - chains: wagmiChains, - transports: wagmiTransports, - walletConnectProjectId, - appName: 'Commonality', - appDescription: 'Fund projects and content around shared values', - appUrl: 'https://commonality.app', - }), - ) +function buildLocalHardhatConfig() { + return createConfig({ + chains: [hardhat], + transports: { + [hardhat.id]: http(hardhatRpcUrl), + }, + connectors: HARDHAT_DEV_ACCOUNTS.map((account) => hardhatLocalConnector(account)), + multiInjectedProviderDiscovery: false, + ssr: false, + }) +} + +export const config = useLocalHardhatWallets + ? buildLocalHardhatConfig() + : isE2E + ? createMockConfig() + : createConfig( + getDefaultConfig({ + chains: wagmiChains, + transports: wagmiTransports, + walletConnectProjectId, + appName: 'Commonality', + appDescription: 'Fund projects and content around shared values', + appUrl: 'https://commonality.app', + }), + ) diff --git a/ui/test-plan.md b/ui/test-plan.md index 1d2e1cae2..c62604d04 100644 --- a/ui/test-plan.md +++ b/ui/test-plan.md @@ -67,7 +67,7 @@ - `ConnectWalletPrompt` (4 tests — wallet prompt message, Paper wrapper, typography styling, padding/margin styles) - `utils.ts` (41 tests — getProjectStatus: succeeded/refunding/active states, bigint inputs, deadline edge cases; STATUS_COLORS/LABELS mappings; formatRelativeDeadline: ended/minutes/hours/days formatting; computeUserTokenBalance: contributions/refunds/burns aggregation, address filtering, zero/negative balance filtering, address normalization; computeContributorStats: aggregation, filtering, sorting, currency defaults) -### Cause board +### Fundable-projects board - `AttestAlignmentForm` (18 tests) - `AlignedProjectCard` (19 tests) - `computeAvailableDelegatableFunding` utility (7 tests — empty attestations, inactive notes, fetch failures, single-currency sum, multi-currency grouping, null filtering, mixed active/inactive) @@ -123,7 +123,7 @@ Maps each route surface to its Vitest and/or Playwright coverage. | `/projects` | `lazy-giving/pages/BrowseProjectsPage.test.tsx` | `lazyGiving-flow.spec.ts` | | `/projects/new` | `lazy-giving/pages/CreateProjectPage.test.tsx` | `lazyGiving-flow.spec.ts` | | `/projects/:address` | `lazy-giving/pages/ProjectDetailPage.test.tsx` | `lazyGiving-flow.spec.ts` | -| `/portal/:cid` (cause board) | `fundingportals/pages/StatementFundingPortalPage.test.tsx` | — | +| `/portal/:cid` (fundable-projects board) | `fundingportals/pages/StatementFundingPortalPage.test.tsx` | — | | `/portal/:cid/leaderboard` | `fundingportals/pages/CauseLeaderboardPage.test.tsx` | — | ### Content Funding domain routes (wrapped) diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json index 04005d462..a36f2395a 100644 --- a/ui/tsconfig.app.json +++ b/ui/tsconfig.app.json @@ -22,7 +22,10 @@ "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "paths": { + "@ui/*": ["./src/*"] + } }, "include": ["src"], "exclude": ["src/**/*.test.tsx", "src/**/*.test.ts", "src/test"] diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 0ae9d656b..756f060b3 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -30,6 +30,7 @@ export default defineConfig(({ mode }) => { // notice SDK rebuilds while the dev server is running. One alias per SDK // subpath (the package has no flat barrel). ...sdkSourceAliases(), + '@ui': path.resolve(process.cwd(), 'src'), events: 'events', }, }, @@ -53,6 +54,16 @@ export default defineConfig(({ mode }) => { '/conceptspace': indexerUrl, // /status is polled by waitForIndexerToSyncToTxHash in E2E tests '/status': indexerUrl, + '/api/cause-assist': { + target: process.env.CAUSE_ASSIST_URL ?? 'http://localhost:3002', + changeOrigin: true, + rewrite: (proxyPath: string) => proxyPath.replace(/^\/api\/cause-assist/, ''), + }, + '/api/implication-attester': { + target: process.env.IMPLICATION_ATTESTER_URL ?? 'http://localhost:3006/implication-attester', + changeOrigin: true, + rewrite: (proxyPath: string) => proxyPath.replace(/^\/api\/implication-attester/, ''), + }, '/api': indexerUrl, // Proxy platform-api-service requests (runs at localhost:3001) '/api/platform-api': 'http://localhost:3001', @@ -161,6 +172,7 @@ function buildRuntimeConfig(env: Record) { 'VITE_NONINFLAMMATORY_URL', 'VITE_CSM_URL', 'VITE_CONCEPTSPACE_URL', + 'VITE_CAUSESTARTER_URL', ] return Object.fromEntries(keys.flatMap(key => env[key] ? [[key, env[key]]] : [])) } @@ -175,6 +187,7 @@ function resolveDomain(value: string | undefined) { case 'civility': case 'common-sense-majority': case 'conceptspace': + case 'causestarter': return value default: return 'commonality' diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index a685a3053..2d5c5f198 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -14,6 +14,11 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url)) export default defineConfig({ plugins: [react(), endUserDocsPlugin({ domain: 'commonality', includeAll: true })], + resolve: { + alias: { + '@ui': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, server: { fs: { allow: [repoRoot], diff --git a/verifier/checks/review/founder-e2e-causestarter.def.json b/verifier/checks/review/founder-e2e-causestarter.def.json index 0ffea6d46..fcd072e78 100644 --- a/verifier/checks/review/founder-e2e-causestarter.def.json +++ b/verifier/checks/review/founder-e2e-causestarter.def.json @@ -23,15 +23,14 @@ "founderBriefFile": "checks/review/founder-e2e/causestarter.md", "startingPoints": [ "causestarter/README.md", - "causestarter/src/App.tsx", - "causestarter/src/pages/HomePage.tsx", - "causestarter/src/pages/StartCauseRedirect.tsx", - "causestarter/src/pages/CauseDetailPage.tsx", - "causestarter/src/pages/StatementBoardPage.tsx", - "causestarter/src/pages/StatementPage.tsx", - "causestarter/src/pages/ProjectDetailPage.tsx", - "causestarter/src/pages/CausesPage.tsx", - "causestarter/src/components/CauseViewStrip.tsx" + "ui/src/domains/causestarter/manifest.tsx", + "ui/src/causestarter/pages/HomePage.tsx", + "ui/src/causestarter/pages/StartCauseRedirect.tsx", + "ui/src/causestarter/pages/CauseDetailPage.tsx", + "ui/src/causestarter/pages/StatementPage.tsx", + "ui/src/causestarter/pages/ProjectDetailPage.tsx", + "ui/src/causestarter/pages/CausesPage.tsx", + "ui/src/causestarter/components/CauseViewStrip.tsx" ] } } diff --git a/verifier/checks/review/founder-e2e.mjs b/verifier/checks/review/founder-e2e.mjs index eb66ed7af..7373f1ae8 100644 --- a/verifier/checks/review/founder-e2e.mjs +++ b/verifier/checks/review/founder-e2e.mjs @@ -44,7 +44,7 @@ function buildPrompt({ surface, founderBrief, startingPoints }) { The description is written by the founder in their own words. Do NOT reword, summarize, or "improve" it into a different spec. Treat the verbatim text as the checklist. Docs and code comments are evidence about the product, not a replacement for this brief.` })} Where to look: -- CauseStarter lives in \`causestarter/\` (not the eight-domain \`ui/\` package). Start from \`causestarter/README.md\` and the pages under \`causestarter/src/pages/\`. +- CauseStarter SPA source lives in \`ui/src/causestarter/\` (\`VITE_DOMAIN=causestarter\`). Glue/backlog stay in \`causestarter/\`. Start from \`causestarter/README.md\` and \`ui/src/domains/causestarter/manifest.tsx\`. - Local CauseStarter is typically http://localhost:5174 (Vite) or http://localhost:8090 (Docker SPA). You do not have a browser tool in this run; judge from source and docs unless a later run gives you live access. - Suggested starting points (open these first, then follow the product wherever it leads): ${hints.length > 0 ? hints.map((p) => ` - \`${p}\``).join("\n") : " - (none specified — locate the surface yourself from the README)"} diff --git a/workflow/local-development.md b/workflow/local-development.md index 04fbe7f42..b3698267b 100644 --- a/workflow/local-development.md +++ b/workflow/local-development.md @@ -46,7 +46,7 @@ For a clean local reset, use: The Anvil container (`hardhat-node`) persists blocks to `data/hardhat/state.json` via `--state` plus a 15s `--state-interval`. Docker stop is SIGTERM; a small entrypoint (`scripts/anvil-docker-entrypoint.sh`) forwards that as SIGINT so Anvil dumps instead of dying empty. Recreate the node after changing that compose service (`docker compose up -d --force-recreate --no-deps hardhat-node`) so the wrapper is mounted. -`--wipe` removes the saved local chain, IPFS repo, and Ponder indexer database. Do not delete only one of `data/hardhat/` or `data/ponder/`: a reset chain with an old Ponder database can make the UI look empty because the indexer thinks old blocks were already processed. `services.sh --start` clears Ponder automatically when it sees Ponder data without a saved local chain, and `data.sh --seed` now errors if the indexer already contains events. If you intentionally want to add another seed run on top of existing data, pass `--allow-seed-on-existing-data`. +`--wipe` removes the saved local chain, IPFS repo, and Ponder indexer database. Do not delete only one of `data/hardhat/` or `data/ponder/`: a reset chain with an old Ponder database can make the UI look empty because the indexer thinks old blocks were already processed. `services.sh --start` clears Ponder automatically when it sees Ponder data without a saved local chain. `--start` also records Hardhat-account `TrustSet`s (CauseStarter’s starter network), so the indexer is **not** empty after a fresh start — that is bootstrap, not a seed. `data.sh --seed` only refuses if it already sees signatures / projects / published-data events. If you intentionally want to add another seed run on top of existing data, pass `--allow-seed-on-existing-data`. One-shot reset+seed: `./scripts/stop-wipe-restart.sh --seed`. For a richer first-run demo that uses the formal seed-content corpus (excluding proliferation variants) and publishes one-shot Explorer/nudge fixtures without live AI worker calls, run: