diff --git a/PLAN.md b/PLAN.md
deleted file mode 100644
index 07a8d7f..0000000
--- a/PLAN.md
+++ /dev/null
@@ -1,267 +0,0 @@
-# P2P Poll App — Implementation Plan
-
-A simple peer-to-peer polling app where users can add options and vote, built with no central server (beyond a public relay for WebSocket sync).
-
----
-
-## 1. Project Setup
-
-```bash
-# Scaffold a Waku project with Bun
-bunx create-waku@latest . --template basic --package-manager bun
-
-# Install dependencies
-bun add @automerge/automerge @automerge/automerge-repo @automerge/automerge-repo-react-hooks @automerge/automerge-repo-network-websocket @automerge/automerge-repo-network-broadcastchannel @automerge/automerge-repo-storage-indexeddb
-
-# Tailwind CSS v4
-bun add tailwindcss @tailwindcss/vite
-```
-
-Waku uses Vite under the hood, so add `@tailwindcss/vite` to the Vite plugin list in `waku.config.ts` (see file structure below). Import Tailwind in a global CSS file with `@import "tailwindcss"`.
-
----
-
-## 2. Data Model
-
-A single Automerge document represents one poll:
-
-```typescript
-interface Poll {
- title: string;
- options: PollOption[];
-}
-
-interface PollOption {
- id: string; // nanoid or crypto.randomUUID()
- text: string;
- votes: string[]; // list of peer IDs who voted for this option
-}
-```
-
-- **votes as `string[]`** — storing peer IDs instead of a count lets us prevent double-voting and support vote retracting. The count is derived via `.length`.
-- Each peer generates a stable random ID on first visit (stored in `localStorage`).
-
----
-
-## 3. File Structure
-
-```
-waku.config.ts — Waku + Vite config (Tailwind plugin)
-src/
- pages/
- _layout.tsx — root layout (server component), imports Providers + global CSS
- index.tsx — landing page: create new poll or join via URL
- poll/
- [id].tsx — main poll view (resolved from Automerge document URL)
- components/
- Providers.tsx — 'use client': initializes Automerge Repo + RepoContext.Provider
- PollView.tsx — displays poll title, options, votes; contains AddOption form and VoteButtons
- ConnectionStatus.tsx — shows sync/connection state
- lib/
- repo.ts — creates and exports the Automerge Repo singleton
- types.ts — Poll and PollOption type definitions
- peer.ts — get-or-create local peer ID from localStorage
- poll.ts — poll mutation logic (pure functions)
- __tests__/
- poll.test.ts — tests for poll logic
- styles/
- global.css — Tailwind import
-```
-
-**Total: ~12 files** (5 components/pages + 1 config + 4 lib modules + 1 test file + 1 CSS)
-
----
-
-## 4. Implementation Steps
-
-### Step 1 — Scaffold and configure
-
-- Run the setup commands from Section 1.
-- Create `waku.config.ts`:
- ```typescript
- import { defineConfig } from 'waku/config';
- import tailwindcss from '@tailwindcss/vite';
-
- export default defineConfig({
- vite: {
- plugins: [tailwindcss()],
- },
- });
- ```
-- Create `src/styles/global.css` with `@import "tailwindcss"`.
-- Verify `bun run dev` starts without errors.
-
-### Step 2 — Define types, peer identity, and poll logic
-
-- Create `src/lib/types.ts` with the `Poll` and `PollOption` interfaces.
-- Create `src/lib/peer.ts`: read `localStorage.getItem("peerId")`, or generate one with `crypto.randomUUID()` and persist it.
-- Create `src/lib/poll.ts`: extract poll mutation logic into pure functions (`createPoll`, `addOption`, `vote`, `unvote`, `hasVoted`) so they are testable independently of Automerge and React.
-
-### Step 3 — Initialize Automerge Repo
-
-- Create `src/lib/repo.ts`:
- ```typescript
- import { Repo } from "@automerge/automerge-repo";
- import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
- import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel";
- import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
-
- export const repo = new Repo({
- network: [
- new WebSocketClientAdapter("wss://sync.automerge.org"),
- new BroadcastChannelNetworkAdapter(),
- ],
- storage: new IndexedDBStorageAdapter("p2p-poll"),
- });
- ```
-
-> **Sync server note:** `wss://sync.automerge.org` is a free public relay provided by the Automerge team. For production use or better reliability, you can run your own sync server using `@automerge/automerge-repo-network-websocket`'s server adapter with a simple Node/Bun WebSocket server.
-
-### Step 4 — Providers (client component) and root layout (server component)
-
-Waku layouts are **server components** — they cannot use browser APIs or React context directly. Instead, create a client component wrapper:
-
-- Create `src/components/Providers.tsx`:
- ```typescript
- 'use client';
-
- import { RepoContext } from "@automerge/automerge-repo-react-hooks";
- import { repo } from "../lib/repo";
-
- export const Providers = ({ children }: { children: React.ReactNode }) => {
- return (
-
- {children}
-
- );
- };
- ```
-
-- Create `src/pages/_layout.tsx` (server component — no `'use client'`):
- ```typescript
- import '../styles/global.css';
- import { Providers } from '../components/Providers';
-
- export default async function RootLayout({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
- }
- ```
-
-### Step 5 — Landing page (create / join)
-
-- `src/pages/index.tsx` (`'use client'`):
- - "Create Poll" button: calls `repo.create()` to make a new Automerge doc, initializes it with a default title and empty options array, then navigates to `/poll/`.
- - "Join Poll" input: paste a document ID or full URL, navigate to `/poll/`.
- - Show a list of recently visited polls from `localStorage` for convenience.
-
-### Step 6 — Poll view page
-
-- `src/pages/poll/[id].tsx` (`'use client'`):
- - Read `id` from the route segment prop (Waku passes dynamic segments as props automatically).
- - Reconstruct the Automerge `DocumentUrl` from `id`.
- - Use `useDocument(url)` from `@automerge/automerge-repo-react-hooks` to get `[doc, changeDoc]`.
- - Wrap the document-dependent UI in `Loading poll...}>` — `useDocument` may suspend while the document is being fetched/synced.
- - Render `` and ``.
-
-### Step 7 — PollView component
-
-- `src/components/PollView.tsx` (`'use client'`):
- - Display poll title (editable inline).
- - List each option with its text, vote count (`option.votes.length`), and a Vote/Unvote button.
- - Vote button logic:
- ```typescript
- changeDoc(d => {
- const opt = d.options.find(o => o.id === optionId);
- if (!opt.votes.includes(peerId)) {
- opt.votes.push(peerId);
- }
- });
- ```
- - Unvote: filter out the peer ID.
- - "Add Option" form at the bottom: text input + submit button that pushes a new `PollOption` into `doc.options`.
- - Show a shareable link/document ID so users can invite peers.
-
-### Step 8 — ConnectionStatus component
-
-- `src/components/ConnectionStatus.tsx` (`'use client'`):
- - Simple indicator (green dot / grey dot) based on whether the WebSocket adapter is connected.
- - Can use `repo.networkSubsystem.on("peer", ...)` to track connected peers.
-
-### Step 9 — Polish and test
-
-- Style with Tailwind: center layout, card-style poll, colored vote bars.
-- Test with two browser tabs (BroadcastChannel sync).
-- Test with two devices on the same network pointing at the sync server.
-- Handle loading state (doc is `undefined` until synced — use `` or a null check).
-- Handle invalid/missing document IDs gracefully.
-
----
-
-## 5. UI Components Summary
-
-| Component | Responsibility |
-|---|---|
-| `_layout.tsx` | Server-component root layout, imports Providers + global styles |
-| `Providers.tsx` | Client component: Repo initialization, RepoContext provider |
-| `index.tsx` | Create new poll, join existing poll by ID |
-| `[id].tsx` | Load Automerge doc by URL, wrap in Suspense, pass to PollView |
-| `PollView.tsx` | Render poll title, options list, vote buttons, add-option form, share link |
-| `ConnectionStatus.tsx` | Show online/offline and peer count |
-
----
-
-## 6. P2P Setup Details
-
-**How peers connect:**
-
-1. Creator makes a new poll — Automerge generates a unique document URL (e.g., `automerge:2Xp9Q3...`).
-2. The app extracts the document ID and puts it in the page URL: `https://localhost:3000/poll/2Xp9Q3...`.
-3. Creator shares that URL with others (copy button in UI).
-4. When another peer opens the URL, the app reconstructs the Automerge document URL and calls `useDocument(url)` — Automerge syncs the doc via the WebSocket relay automatically.
-5. Cross-tab sync happens instantly via BroadcastChannel (no network needed).
-6. IndexedDB ensures the poll persists across page reloads.
-
-**No server to deploy** — the public `wss://sync.automerge.org` relay handles document sync. For production, consider running your own sync server for reliability and control.
-
----
-
-## 8. Tests
-
-Tests use **Bun's built-in test runner** (`bun test`) — no extra test dependencies needed. Bun provides `describe`, `it`, and `expect` out of the box.
-
-Tests target the pure functions in `src/lib/poll.ts`, not React components. Test file: `src/lib/__tests__/poll.test.ts`.
-
-**Pure functions under test:**
-
-- `createPoll(title: string): Poll`
-- `addOption(poll: Poll, text: string): Poll`
-- `vote(poll: Poll, optionId: string, peerId: string): Poll`
-- `unvote(poll: Poll, optionId: string, peerId: string): Poll`
-- `hasVoted(poll: Poll, optionId: string, peerId: string): boolean`
-
-**Test cases (6 total):**
-
-- **Peer ID management** — `getPeerId()` generates a UUID and returns the same value on subsequent calls (persisted in localStorage)
-- **Poll creation** — `createPoll("Lunch?")` returns a poll with the given title and an empty options array
-- **Adding an option** — `addOption(poll, "Pizza")` appends an option with the given text, a generated ID, and an empty votes array
-- **Voting** — `vote(poll, optionId, peerId)` adds the peer ID to that option's votes array
-- **Double-vote prevention** — calling `vote` twice with the same peer ID and option does not duplicate the entry
-- **Vote retracting** — `unvote(poll, optionId, peerId)` removes the peer ID from the votes array
-
-> Run with: `bun test`
-
----
-
-## 9. Nice-to-Haves (if time permits)
-
-- **Poll title editing** — inline editable title field (trivial with `changeDoc`).
-- **Vote retracting** — toggle vote on/off (already supported by the data model).
-- **Peer count display** — show how many peers are currently connected to this document.
-- **Option reordering** — drag to reorder or sort by vote count.
-- **Results visualization** — simple bar chart showing vote distribution using Tailwind width utilities.
-- **Multiple polls** — list/manage multiple polls from the landing page.
-- **Poll closing** — creator can lock a poll to prevent further votes.
diff --git a/README.md b/README.md
index 0217c70..4a90c77 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,91 @@
-# P2P Poll App
\ No newline at end of file
+# P2P Poll App
+
+A peer-to-peer polling application where users can create polls, add options, and vote — all without a central server. Built for the [Evocracy democratic coding research experiment](https://demcode.evocracy.org/).
+
+## How It Works
+
+Data is synchronized directly between peers using [Automerge](https://automerge.org/) CRDTs (Conflict-free Replicated Data Types). There is no backend database — every client holds a full copy of the poll document and changes merge automatically, even when made offline or concurrently.
+
+**Sync layers:**
+
+- **WebSocket** (`wss://sync.automerge.org`) — cross-device sync via a public relay
+- **BroadcastChannel** — instant cross-tab sync within the same browser
+- **IndexedDB** — local persistence across page reloads and offline use
+
+## Requirements
+
+- [Bun](https://bun.sh/) (standalone runtime, no Node.js needed)
+
+## Getting Started
+
+```bash
+bun install # Install dependencies
+bun run dev # Start dev server (http://localhost:3000)
+```
+
+## Usage
+
+1. **Create a poll** — Enter a title on the home page and click "Create"
+2. **Share it** — Copy the shareable link and send it to others
+3. **Vote** — Click an option to vote; click again to unvote
+4. **Add options** — Anyone with the link can add new poll options
+
+Each browser gets a stable peer ID (stored in localStorage) so votes are tracked per-device and double-voting is prevented.
+
+## Tech Stack
+
+| Layer | Technology |
+|---|---|
+| Runtime | [Bun](https://bun.sh/) |
+| Framework | [Waku](https://waku.gg/) (React Server Components) |
+| Styling | Tailwind CSS v4 |
+| P2P sync | Automerge + automerge-repo |
+| Storage | IndexedDB (client-side) |
+
+## Project Structure
+
+```
+src/
+├── pages/ # Waku page router
+│ ├── _root.tsx # HTML document shell
+│ ├── _layout.tsx # Root layout + Providers
+│ ├── index.tsx # Home page (create/join polls)
+│ └── poll/[id].tsx # Poll view page
+├── components/
+│ ├── Providers.tsx # Automerge Repo initialization
+│ ├── HomeClient.tsx # Create/join poll UI
+│ ├── PollPageClient.tsx # Poll ID validation
+│ ├── PollView.tsx # Poll display, voting, options
+│ └── ConnectionStatus.tsx # P2P connection indicator
+├── lib/
+│ ├── types.ts # Poll & PollOption interfaces
+│ ├── repo.ts # Automerge Repo singleton
+│ ├── poll.ts # Pure poll mutation functions
+│ ├── peer.ts # Peer ID management
+│ └── __tests__/ # Unit tests
+└── styles/
+ └── global.css # Tailwind CSS
+```
+
+## Testing
+
+```bash
+bun test
+```
+
+Covers poll creation, voting/unvoting, double-vote prevention, option management, and peer ID persistence.
+
+## Architecture Notes
+
+- **Pure business logic** — Poll mutations in `src/lib/poll.ts` are pure functions, used inside Automerge's `changeDoc()` for CRDT-safe updates
+- **No server state** — The WebSocket relay only forwards sync messages; it never stores or processes poll data
+- **Offline-first** — The app works fully offline; changes sync when connectivity resumes
+- **Conflict-free** — Concurrent edits (e.g., two users voting at the same time) merge automatically without conflicts
+
+## Built With
+
+This project was built in collaboration with [Claude Code](https://claude.ai/code), Anthropic's agentic coding tool.
+
+## License
+
+MIT
diff --git a/src/lib/__tests__/peer.test.ts b/src/lib/__tests__/peer.test.ts
index 5ca39bb..059a4f6 100644
--- a/src/lib/__tests__/peer.test.ts
+++ b/src/lib/__tests__/peer.test.ts
@@ -26,13 +26,6 @@ describe("getPeerId", () => {
storage.clear();
});
- test("generates a UUID", () => {
- const id = getPeerId();
- expect(id).toMatch(
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
- );
- });
-
test("persists and returns the same ID on subsequent calls", () => {
const id1 = getPeerId();
const id2 = getPeerId();
@@ -43,4 +36,21 @@ describe("getPeerId", () => {
const id = getPeerId();
expect(storage.get("p2p-poll-peer-id")).toBe(id);
});
+
+ test("returns a new UUID each time when localStorage is unavailable", () => {
+ const saved = globalThis.localStorage;
+ // @ts-expect-error — deliberately removing localStorage to test fallback
+ globalThis.localStorage = undefined;
+
+ const id1 = getPeerId();
+ const id2 = getPeerId();
+
+ expect(id1).not.toBe(id2);
+ expect(id1).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
+ );
+
+ // Restore
+ globalThis.localStorage = saved;
+ });
});
diff --git a/src/lib/__tests__/poll.test.ts b/src/lib/__tests__/poll.test.ts
index f3bb1f5..ea4b1cd 100644
--- a/src/lib/__tests__/poll.test.ts
+++ b/src/lib/__tests__/poll.test.ts
@@ -1,47 +1,7 @@
import { describe, test, expect } from "bun:test";
import { createPoll, addOption, vote, unvote, hasVoted } from "../poll.js";
-describe("createPoll", () => {
- test("returns poll with title and empty options", () => {
- const poll = createPoll("Favorite color?");
- expect(poll.title).toBe("Favorite color?");
- expect(poll.options).toEqual([]);
- });
-});
-
-describe("addOption", () => {
- test("appends option with text, id, and empty votes", () => {
- const poll = createPoll("Test");
- addOption(poll, "Red");
-
- expect(poll.options).toHaveLength(1);
- expect(poll.options[0]!.text).toBe("Red");
- expect(poll.options[0]!.id).toBeDefined();
- expect(poll.options[0]!.votes).toEqual([]);
- });
-
- test("appends multiple options", () => {
- const poll = createPoll("Test");
- addOption(poll, "Red");
- addOption(poll, "Blue");
-
- expect(poll.options).toHaveLength(2);
- expect(poll.options[0]!.text).toBe("Red");
- expect(poll.options[1]!.text).toBe("Blue");
- });
-});
-
describe("vote", () => {
- test("adds peer ID to option's votes array", () => {
- const poll = createPoll("Test");
- addOption(poll, "Red");
- const optionId = poll.options[0]!.id;
-
- vote(poll, optionId, "peer-1");
-
- expect(poll.options[0]!.votes).toEqual(["peer-1"]);
- });
-
test("prevents double-vote (no duplicate entries)", () => {
const poll = createPoll("Test");
addOption(poll, "Red");
@@ -64,6 +24,33 @@ describe("vote", () => {
expect(poll.options[0]!.votes).toEqual(["peer-1", "peer-2"]);
});
+
+ test("is a no-op for non-existent option ID", () => {
+ const poll = createPoll("Test");
+ addOption(poll, "Red");
+
+ vote(poll, "non-existent-id", "peer-1");
+
+ expect(poll.options[0]!.votes).toEqual([]);
+ });
+
+ test("voting on one option does not affect another option", () => {
+ const poll = createPoll("Test");
+ addOption(poll, "Red");
+ addOption(poll, "Blue");
+ const redId = poll.options[0]!.id;
+ const blueId = poll.options[1]!.id;
+
+ vote(poll, redId, "peer-1");
+
+ expect(poll.options[0]!.votes).toEqual(["peer-1"]);
+ expect(poll.options[1]!.votes).toEqual([]);
+
+ vote(poll, blueId, "peer-2");
+
+ expect(poll.options[0]!.votes).toEqual(["peer-1"]);
+ expect(poll.options[1]!.votes).toEqual(["peer-2"]);
+ });
});
describe("unvote", () => {
@@ -100,14 +87,6 @@ describe("hasVoted", () => {
expect(hasVoted(poll, optionId, "peer-1")).toBe(true);
});
- test("returns false when peer has not voted", () => {
- const poll = createPoll("Test");
- addOption(poll, "Red");
- const optionId = poll.options[0]!.id;
-
- expect(hasVoted(poll, optionId, "peer-1")).toBe(false);
- });
-
test("returns false for non-existent option", () => {
const poll = createPoll("Test");
expect(hasVoted(poll, "non-existent", "peer-1")).toBe(false);