feat: Add conviction voting interactive demo

- Core conviction math engine with charge/discharge dynamics
- Interactive participant cards with capacitor visualization
- D3.js conviction charts showing individual and collective conviction
- Trigger threshold visualization with water tank metaphor
- Dual mode: Narrative walkthrough and free simulation
- Docker setup for deployment with Traefik labels

Based on BlockScience cadCAD model and formal derivations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-03 09:18:41 +01:00
parent c06748f3da
commit 024bc3f2a3
16 changed files with 2773 additions and 61 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
node_modules
.next
.git
.gitignore
README.md
Dockerfile
docker-compose.yml
.dockerignore
.env*.local
*.log

44
Dockerfile Normal file
View File

@ -0,0 +1,44 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built application
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Set ownership
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

22
docker-compose.yml Normal file
View File

@ -0,0 +1,22 @@
services:
conviction-voting-demo:
build: .
container_name: conviction-voting-demo
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.conviction.rule=Host(`conviction.jeffemmett.com`)"
- "traefik.http.routers.conviction.entrypoints=web"
- "traefik.http.services.conviction.loadbalancer.server.port=3000"
networks:
- traefik-public
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
traefik-public:
external: true

View File

@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
};
export default nextConfig;

798
package-lock.json generated
View File

@ -8,9 +8,13 @@
"name": "conviction-voting-demo",
"version": "0.1.0",
"dependencies": {
"@types/d3": "^7.4.3",
"d3": "^7.9.0",
"framer-motion": "^12.23.26",
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"zustand": "^5.0.9"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -1524,6 +1528,259 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1531,6 +1788,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -1559,7 +1822,7 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -2596,6 +2859,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -2629,9 +2901,410 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -2754,6 +3427,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -3585,6 +4267,33 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/framer-motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -3890,6 +4599,18 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3942,6 +4663,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@ -4900,6 +5630,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -5502,6 +6247,12 @@
"node": ">=0.10.0"
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -5526,6 +6277,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@ -5581,6 +6338,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@ -6533,6 +7296,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@ -9,9 +9,13 @@
"lint": "eslint"
},
"dependencies": {
"@types/d3": "^7.4.3",
"d3": "^7.9.0",
"framer-motion": "^12.23.26",
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"zustand": "^5.0.9"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@ -13,8 +13,9 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Conviction Voting Demo",
description: "Interactive demonstration of conviction voting dynamics - continuous charge up & discharge of collective preferences",
keywords: ["conviction voting", "governance", "DAO", "commons", "BlockScience"],
};
export default function RootLayout({

View File

@ -1,64 +1,210 @@
import Image from "next/image";
'use client';
import { motion } from 'framer-motion';
import { useSimulationStore, AppMode } from '@/stores/simulation';
import {
ParticipantCard,
ConvictionChart,
TriggerProgress,
ControlPanel,
NarrativePanel,
} from '@/components';
export default function Home() {
const mode = useSimulationStore((s) => s.mode);
const setMode = useSimulationStore((s) => s.setMode);
const participants = useSimulationStore((s) => s.participants);
const narrativeStep = useSimulationStore((s) => s.narrativeStep);
const narrativeSteps = useSimulationStore((s) => s.narrativeSteps);
const currentStep = narrativeSteps[narrativeStep];
const highlight = currentStep?.highlight;
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
{/* Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-800">
Conviction Voting
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<p className="text-sm text-gray-500">
Interactive demonstration of continuous governance
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
{/* Mode toggle */}
<div className="flex gap-1 p-1 bg-gray-100 rounded-lg">
{(['narrative', 'simulation'] as AppMode[]).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`
px-4 py-2 rounded-md text-sm font-medium transition-all
${
mode === m
? 'bg-white text-gray-800 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}
`}
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
{m === 'narrative' ? '📖 Narrative' : '🎮 Simulation'}
</button>
))}
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column - Controls or Narrative */}
<div className="lg:col-span-1 space-y-6">
{mode === 'narrative' ? (
<NarrativePanel />
) : (
<ControlPanel />
)}
{/* Quick explanation */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-xl">
<h3 className="font-semibold text-blue-800 mb-2">
How it works
</h3>
<ul className="text-sm text-blue-700 space-y-1">
<li> <strong>Stake</strong> tokens to support proposals</li>
<li> Conviction <strong>charges up</strong> over time</li>
<li> Unstaking causes conviction to <strong>decay</strong></li>
<li> When collective conviction exceeds the <strong>trigger</strong>, the proposal passes</li>
</ul>
</div>
</div>
{/* Middle column - Participants */}
<div className="lg:col-span-1 space-y-6">
<div
className={`
${highlight === 'participants' ? 'ring-4 ring-yellow-400 ring-opacity-50 rounded-xl' : ''}
`}
>
<h2 className="text-lg font-semibold text-gray-700 mb-4">
Participants
</h2>
<div className="space-y-4">
{participants.map((p) => (
<ParticipantCard
key={p.id}
participantId={p.id}
highlight={highlight === p.id}
/>
))}
</div>
</div>
</div>
{/* Right column - Proposal and Chart */}
<div className="lg:col-span-1 space-y-6">
<TriggerProgress highlight={highlight === 'trigger'} />
<div
className={`
bg-white p-4 rounded-xl border border-gray-200
${highlight === 'chart' ? 'ring-4 ring-yellow-400 ring-opacity-50' : ''}
`}
>
<h2 className="text-lg font-semibold text-gray-700 mb-4">
Conviction Over Time
</h2>
<ConvictionChart height={280} />
</div>
</div>
</div>
{/* Full-width chart for aggregate view */}
<motion.div
className="mt-8 bg-white p-6 rounded-xl border border-gray-200"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<h2 className="text-lg font-semibold text-gray-700 mb-2">
Signal Processing View
</h2>
<p className="text-sm text-gray-500 mb-4">
Individual participant convictions aggregate into a collective signal.
The system acts as a low-pass filter, smoothing noisy preferences into
stable decisions.
</p>
<ConvictionChart height={200} showTrigger />
</motion.div>
{/* Mathematical reference */}
<div className="mt-8 p-6 bg-gray-800 text-gray-100 rounded-xl">
<h2 className="text-lg font-semibold mb-4">Mathematical Foundation</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-sm">
<div>
<h3 className="font-bold text-blue-400 mb-2">Conviction Update</h3>
<code className="block bg-gray-900 p-2 rounded font-mono">
y<sub>t+1</sub> = α × y<sub>t</sub> + x<sub>t</sub>
</code>
<p className="mt-2 text-gray-400">
New conviction = decay × old + current stake
</p>
</div>
<div>
<h3 className="font-bold text-green-400 mb-2">Maximum Conviction</h3>
<code className="block bg-gray-900 p-2 rounded font-mono">
y<sub>max</sub> = x / (1 - α)
</code>
<p className="mt-2 text-gray-400">
Equilibrium when stake is held constant
</p>
</div>
<div>
<h3 className="font-bold text-red-400 mb-2">Trigger Threshold</h3>
<code className="block bg-gray-900 p-2 rounded font-mono">
y* = ρS / ((1-α)(β - r/R)²)
</code>
<p className="mt-2 text-gray-400">
Larger requests need exponentially more conviction
</p>
</div>
</div>
</div>
{/* Credits */}
<footer className="mt-8 text-center text-sm text-gray-500">
<p>
Based on research by{' '}
<a
href="https://block.science"
className="text-blue-600 hover:underline"
target="_blank"
rel="noopener"
>
BlockScience
</a>
{' '}and the{' '}
<a
href="https://commonsstack.org"
className="text-blue-600 hover:underline"
target="_blank"
rel="noopener"
>
Commons Stack
</a>
</p>
<p className="mt-1">
<a
href="https://github.com/BlockScience/Aragon_Conviction_Voting"
className="text-gray-400 hover:text-gray-600"
target="_blank"
rel="noopener"
>
View original cadCAD model
</a>
</p>
</footer>
</main>
</div>
);

View File

@ -0,0 +1,185 @@
'use client';
import { useSimulationStore } from '@/stores/simulation';
import { halfLifeFromAlpha, alphaFromHalfLife } from '@/lib/conviction';
export function ControlPanel() {
const epoch = useSimulationStore((s) => s.epoch);
const isRunning = useSimulationStore((s) => s.isRunning);
const speed = useSimulationStore((s) => s.speed);
const params = useSimulationStore((s) => s.params);
const start = useSimulationStore((s) => s.start);
const stop = useSimulationStore((s) => s.stop);
const tick = useSimulationStore((s) => s.tick);
const reset = useSimulationStore((s) => s.reset);
const setSpeed = useSimulationStore((s) => s.setSpeed);
const setParams = useSimulationStore((s) => s.setParams);
const halfLife = halfLifeFromAlpha(params.alpha);
return (
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200">
<h3 className="text-sm font-semibold text-gray-700 mb-4 uppercase tracking-wide">
Simulation Controls
</h3>
{/* Playback controls */}
<div className="flex gap-2 mb-4">
{!isRunning ? (
<button
onClick={start}
className="flex-1 py-2 px-4 bg-green-500 hover:bg-green-600 text-white rounded-lg font-medium transition-colors"
>
Play
</button>
) : (
<button
onClick={stop}
className="flex-1 py-2 px-4 bg-yellow-500 hover:bg-yellow-600 text-white rounded-lg font-medium transition-colors"
>
Pause
</button>
)}
<button
onClick={tick}
disabled={isRunning}
className="py-2 px-4 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 text-white rounded-lg font-medium transition-colors"
>
Step
</button>
<button
onClick={reset}
className="py-2 px-4 bg-red-500 hover:bg-red-600 text-white rounded-lg font-medium transition-colors"
>
Reset
</button>
</div>
{/* Epoch counter */}
<div className="text-center mb-4 py-2 bg-gray-100 rounded-lg">
<span className="text-sm text-gray-500">Epoch: </span>
<span className="text-2xl font-mono font-bold text-gray-800">{epoch}</span>
</div>
{/* Speed control */}
<div className="mb-4">
<label className="block text-sm text-gray-600 mb-1">
Speed: {(1000 / speed).toFixed(1)}x
</label>
<input
type="range"
min={100}
max={2000}
step={100}
value={2100 - speed}
onChange={(e) => setSpeed(2100 - Number(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-400">
<span>Slow</span>
<span>Fast</span>
</div>
</div>
{/* Alpha / Half-life control */}
<div className="mb-4">
<label className="block text-sm text-gray-600 mb-1">
Half-life: {halfLife.toFixed(1)} epochs (α = {params.alpha.toFixed(3)})
</label>
<input
type="range"
min={2}
max={50}
step={1}
value={halfLife}
onChange={(e) => {
const newHalfLife = Number(e.target.value);
const newAlpha = alphaFromHalfLife(newHalfLife);
setParams({ alpha: newAlpha });
}}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-400">
<span>Fast decay</span>
<span>Slow decay</span>
</div>
</div>
{/* Advanced params (collapsible) */}
<details className="mt-4">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
Advanced Parameters
</summary>
<div className="mt-2 space-y-3 pl-2 border-l-2 border-gray-200">
<div>
<label className="block text-xs text-gray-500">
β (max share): {(params.beta * 100).toFixed(0)}%
</label>
<input
type="range"
min={0.05}
max={0.5}
step={0.05}
value={params.beta}
onChange={(e) => setParams({ beta: Number(e.target.value) })}
className="w-full"
/>
</div>
<div>
<label className="block text-xs text-gray-500">
ρ (scale): {params.rho.toFixed(4)}
</label>
<input
type="range"
min={0.0001}
max={0.01}
step={0.0001}
value={params.rho}
onChange={(e) => setParams({ rho: Number(e.target.value) })}
className="w-full"
/>
</div>
<div>
<label className="block text-xs text-gray-500">
Supply: {params.supply.toLocaleString()}
</label>
<input
type="range"
min={10000}
max={1000000}
step={10000}
value={params.supply}
onChange={(e) => setParams({ supply: Number(e.target.value) })}
className="w-full"
/>
</div>
<div>
<label className="block text-xs text-gray-500">
Funds: {params.funds.toLocaleString()}
</label>
<input
type="range"
min={1000}
max={100000}
step={1000}
value={params.funds}
onChange={(e) => setParams({ funds: Number(e.target.value) })}
className="w-full"
/>
</div>
</div>
</details>
{/* Formula reference */}
<div className="mt-4 p-3 bg-gray-100 rounded-lg text-xs font-mono text-gray-600">
<div className="mb-1">
<strong>Conviction:</strong> y<sub>t+1</sub> = α × y<sub>t</sub> + x
</div>
<div>
<strong>Max:</strong> y<sub>max</sub> = x / (1 - α)
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,213 @@
'use client';
import { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import { useSimulationStore } from '@/stores/simulation';
interface ConvictionChartProps {
height?: number;
showTrigger?: boolean;
}
export function ConvictionChart({ height = 300, showTrigger = true }: ConvictionChartProps) {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const participants = useSimulationStore((s) => s.participants);
const proposals = useSimulationStore((s) => s.proposals);
const epochHistory = useSimulationStore((s) => s.epochHistory);
useEffect(() => {
if (!svgRef.current || !containerRef.current) return;
const svg = d3.select(svgRef.current);
const containerWidth = containerRef.current.clientWidth;
const width = containerWidth;
const margin = { top: 20, right: 80, bottom: 40, left: 60 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
// Clear previous content
svg.selectAll('*').remove();
// Get the current proposal's trigger
const proposal = proposals[0];
const trigger = proposal?.trigger || Infinity;
// Calculate max conviction for y-axis
const allConvictions = participants.flatMap((p) => p.convictionHistory);
const totalConvictions = proposal?.convictionHistory || [0];
const maxY = Math.max(
d3.max(allConvictions) || 0,
d3.max(totalConvictions) || 0,
showTrigger && trigger !== Infinity ? trigger * 1.1 : 0,
100
);
// Scales
const xScale = d3
.scaleLinear()
.domain([0, Math.max(epochHistory.length - 1, 10)])
.range([0, innerWidth]);
const yScale = d3.scaleLinear().domain([0, maxY]).range([innerHeight, 0]);
// Create main group
const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Grid lines
g.append('g')
.attr('class', 'grid')
.attr('opacity', 0.1)
.call(
d3
.axisLeft(yScale)
.tickSize(-innerWidth)
.tickFormat(() => '')
);
// Trigger line (if showing)
if (showTrigger && trigger !== Infinity) {
g.append('line')
.attr('x1', 0)
.attr('x2', innerWidth)
.attr('y1', yScale(trigger))
.attr('y2', yScale(trigger))
.attr('stroke', '#ef4444')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '8,4');
g.append('text')
.attr('x', innerWidth + 5)
.attr('y', yScale(trigger))
.attr('dy', '0.35em')
.attr('fill', '#ef4444')
.attr('font-size', '12px')
.attr('font-weight', 'bold')
.text('TRIGGER');
}
// Line generator
const line = d3
.line<number>()
.x((_, i) => xScale(i))
.y((d) => yScale(d))
.curve(d3.curveMonotoneX);
// Area generator for individual convictions
const area = d3
.area<number>()
.x((_, i) => xScale(i))
.y0(innerHeight)
.y1((d) => yScale(d))
.curve(d3.curveMonotoneX);
// Draw individual participant conviction areas (stacked feel)
participants.forEach((participant, index) => {
const data = participant.convictionHistory;
// Area fill
g.append('path')
.datum(data)
.attr('fill', participant.color)
.attr('fill-opacity', 0.2)
.attr('d', area);
// Line
g.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', participant.color)
.attr('stroke-width', 2)
.attr('d', line);
// Current value dot
if (data.length > 0) {
g.append('circle')
.attr('cx', xScale(data.length - 1))
.attr('cy', yScale(data[data.length - 1]))
.attr('r', 5)
.attr('fill', participant.color)
.attr('stroke', 'white')
.attr('stroke-width', 2);
}
});
// Draw total conviction line (bold)
if (proposal && totalConvictions.length > 0) {
g.append('path')
.datum(totalConvictions)
.attr('fill', 'none')
.attr('stroke', '#1f2937')
.attr('stroke-width', 3)
.attr('d', line);
// Total label
g.append('text')
.attr('x', innerWidth + 5)
.attr('y', yScale(totalConvictions[totalConvictions.length - 1]))
.attr('dy', '0.35em')
.attr('fill', '#1f2937')
.attr('font-size', '12px')
.attr('font-weight', 'bold')
.text('TOTAL');
}
// X Axis
g.append('g')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(10))
.append('text')
.attr('x', innerWidth / 2)
.attr('y', 35)
.attr('fill', '#374151')
.attr('text-anchor', 'middle')
.text('Epoch');
// Y Axis
g.append('g')
.call(d3.axisLeft(yScale).ticks(5))
.append('text')
.attr('transform', 'rotate(-90)')
.attr('x', -innerHeight / 2)
.attr('y', -45)
.attr('fill', '#374151')
.attr('text-anchor', 'middle')
.text('Conviction');
// Legend
const legend = g
.append('g')
.attr('transform', `translate(10, 10)`);
participants.forEach((p, i) => {
const legendRow = legend
.append('g')
.attr('transform', `translate(0, ${i * 18})`);
legendRow
.append('rect')
.attr('width', 12)
.attr('height', 12)
.attr('fill', p.color)
.attr('rx', 2);
legendRow
.append('text')
.attr('x', 18)
.attr('y', 10)
.attr('font-size', '11px')
.attr('fill', '#374151')
.text(p.name);
});
}, [participants, proposals, epochHistory, height, showTrigger]);
return (
<div ref={containerRef} className="w-full">
<svg ref={svgRef} width="100%" height={height} />
</div>
);
}

View File

@ -0,0 +1,104 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { useSimulationStore } from '@/stores/simulation';
export function NarrativePanel() {
const narrativeStep = useSimulationStore((s) => s.narrativeStep);
const narrativeSteps = useSimulationStore((s) => s.narrativeSteps);
const nextNarrativeStep = useSimulationStore((s) => s.nextNarrativeStep);
const prevNarrativeStep = useSimulationStore((s) => s.prevNarrativeStep);
const resetNarrative = useSimulationStore((s) => s.resetNarrative);
const setMode = useSimulationStore((s) => s.setMode);
const currentStep = narrativeSteps[narrativeStep];
const isFirst = narrativeStep === 0;
const isLast = narrativeStep === narrativeSteps.length - 1;
if (!currentStep) return null;
return (
<motion.div
className="p-6 bg-gradient-to-br from-indigo-50 to-purple-50 rounded-xl border-2 border-indigo-200"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
{/* Progress indicator */}
<div className="flex gap-1 mb-4">
{narrativeSteps.map((_, i) => (
<div
key={i}
className={`
h-1 flex-1 rounded-full transition-all duration-300
${i <= narrativeStep ? 'bg-indigo-500' : 'bg-gray-200'}
`}
/>
))}
</div>
{/* Step counter */}
<div className="text-sm text-indigo-600 font-medium mb-2">
Step {narrativeStep + 1} of {narrativeSteps.length}
</div>
{/* Content */}
<AnimatePresence mode="wait">
<motion.div
key={narrativeStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
<h2 className="text-xl font-bold text-gray-800 mb-3">
{currentStep.title}
</h2>
<p className="text-gray-600 leading-relaxed whitespace-pre-line">
{currentStep.description}
</p>
</motion.div>
</AnimatePresence>
{/* Navigation */}
<div className="flex gap-3 mt-6">
<button
onClick={prevNarrativeStep}
disabled={isFirst}
className={`
flex-1 py-2 px-4 rounded-lg font-medium transition-colors
${
isFirst
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}
`}
>
Back
</button>
{isLast ? (
<button
onClick={() => setMode('simulation')}
className="flex-1 py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors"
>
Try Simulation Mode
</button>
) : (
<button
onClick={nextNarrativeStep}
className="flex-1 py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors"
>
Next
</button>
)}
</div>
{/* Restart option */}
<button
onClick={resetNarrative}
className="mt-3 w-full py-1 text-sm text-gray-500 hover:text-gray-700"
>
Restart from beginning
</button>
</motion.div>
);
}

View File

@ -0,0 +1,128 @@
'use client';
import { motion } from 'framer-motion';
import { useSimulationStore } from '@/stores/simulation';
import { maxConviction } from '@/lib/conviction';
interface ParticipantCardProps {
participantId: string;
highlight?: boolean;
}
export function ParticipantCard({ participantId, highlight }: ParticipantCardProps) {
const participant = useSimulationStore((s) =>
s.participants.find((p) => p.id === participantId)
);
const params = useSimulationStore((s) => s.params);
const toggleStake = useSimulationStore((s) => s.toggleStake);
const isRunning = useSimulationStore((s) => s.isRunning);
if (!participant) return null;
const { name, color, holdings, stakedTokens, conviction } = participant;
const isStaking = stakedTokens > 0;
const maxConv = maxConviction(stakedTokens, params.alpha);
const convictionPercent = maxConv > 0 ? (conviction / maxConv) * 100 : 0;
const chargeLevel = Math.min(convictionPercent, 100);
return (
<motion.div
className={`
relative p-4 rounded-xl border-2 transition-all duration-300
${highlight ? 'ring-4 ring-yellow-400 ring-opacity-50' : ''}
${isStaking ? 'border-opacity-100' : 'border-opacity-30'}
`}
style={{
borderColor: color,
backgroundColor: `${color}10`,
}}
animate={{
scale: highlight ? 1.02 : 1,
}}
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="font-semibold text-gray-800">{name}</span>
</div>
<span className="text-sm text-gray-500">{holdings} tokens</span>
</div>
{/* Conviction Bar (Capacitor visualization) */}
<div className="relative h-16 mb-3 rounded-lg overflow-hidden bg-gray-100">
{/* Max conviction indicator */}
{isStaking && (
<div
className="absolute inset-0 border-2 border-dashed rounded-lg opacity-30"
style={{ borderColor: color }}
/>
)}
{/* Current conviction fill */}
<motion.div
className="absolute bottom-0 left-0 right-0 rounded-b-lg"
style={{ backgroundColor: color }}
animate={{
height: `${chargeLevel}%`,
}}
transition={{ type: 'spring', stiffness: 50 }}
/>
{/* Conviction value */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-mono font-bold text-gray-700 bg-white/70 px-2 py-1 rounded">
{conviction.toFixed(0)}
</span>
</div>
{/* Charging animation */}
{isStaking && isRunning && (
<motion.div
className="absolute bottom-0 left-0 right-0 h-1"
style={{ backgroundColor: color }}
animate={{
opacity: [0.5, 1, 0.5],
}}
transition={{
repeat: Infinity,
duration: 1,
}}
/>
)}
</div>
{/* Stake button */}
<button
onClick={() => toggleStake(participantId)}
className={`
w-full py-2 px-4 rounded-lg font-medium transition-all
${
isStaking
? 'bg-red-100 text-red-700 hover:bg-red-200'
: 'bg-green-100 text-green-700 hover:bg-green-200'
}
`}
>
{isStaking ? `Unstake ${stakedTokens}` : `Stake ${holdings}`}
</button>
{/* Status indicator */}
<div className="mt-2 text-xs text-center text-gray-500">
{isStaking ? (
<span>
{chargeLevel.toFixed(0)}% charged
{chargeLevel < 99 && ' (charging...)'}
</span>
) : conviction > 0 ? (
<span>Discharging...</span>
) : (
<span>Ready to stake</span>
)}
</div>
</motion.div>
);
}

View File

@ -0,0 +1,153 @@
'use client';
import { motion } from 'framer-motion';
import { useSimulationStore } from '@/stores/simulation';
import { triggerProgress } from '@/lib/conviction';
interface TriggerProgressProps {
proposalId?: string;
highlight?: boolean;
}
export function TriggerProgress({ proposalId, highlight }: TriggerProgressProps) {
const proposals = useSimulationStore((s) => s.proposals);
const proposal = proposalId
? proposals.find((p) => p.id === proposalId)
: proposals[0];
if (!proposal) return null;
const { title, fundsRequested, totalConviction, trigger, status } = proposal;
const progress = triggerProgress(totalConviction, trigger);
const progressPercent = Math.min(progress * 100, 100);
const isPassed = status === 'active' || status === 'completed';
return (
<motion.div
className={`
p-6 rounded-xl border-2 bg-white
${highlight ? 'ring-4 ring-yellow-400 ring-opacity-50' : ''}
${isPassed ? 'border-green-500' : 'border-gray-200'}
`}
animate={{
scale: highlight ? 1.01 : 1,
}}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-bold text-gray-800">{title}</h3>
<p className="text-sm text-gray-500">
Requesting: {fundsRequested.toLocaleString()} funds
</p>
</div>
{isPassed && (
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
PASSED
</span>
)}
</div>
{/* Progress bar */}
<div className="relative h-8 bg-gray-100 rounded-full overflow-hidden mb-2">
{/* Trigger threshold marker */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-10"
style={{ left: '100%', transform: 'translateX(-2px)' }}
/>
{/* Progress fill */}
<motion.div
className={`
absolute top-0 bottom-0 left-0 rounded-full
${isPassed ? 'bg-green-500' : 'bg-blue-500'}
`}
animate={{
width: `${progressPercent}%`,
}}
transition={{ type: 'spring', stiffness: 50 }}
/>
{/* Progress label */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-gray-700 bg-white/80 px-2 py-0.5 rounded">
{progressPercent.toFixed(1)}%
</span>
</div>
</div>
{/* Stats */}
<div className="flex justify-between text-sm text-gray-600">
<div>
<span className="font-medium">Current: </span>
<span className="font-mono">{totalConviction.toFixed(0)}</span>
</div>
<div>
<span className="font-medium">Threshold: </span>
<span className="font-mono text-red-600">
{trigger === Infinity ? '∞' : trigger.toFixed(0)}
</span>
</div>
</div>
{/* Visual metaphor: Water tank */}
<div className="mt-4 relative h-24 border-2 border-gray-300 rounded-lg overflow-hidden bg-gradient-to-b from-gray-50 to-gray-100">
{/* Water level */}
<motion.div
className={`
absolute bottom-0 left-0 right-0
${isPassed ? 'bg-green-400/60' : 'bg-blue-400/60'}
`}
animate={{
height: `${progressPercent}%`,
}}
transition={{ type: 'spring', stiffness: 30 }}
style={{
background: isPassed
? 'linear-gradient(to top, rgba(34, 197, 94, 0.7), rgba(134, 239, 172, 0.4))'
: 'linear-gradient(to top, rgba(59, 130, 246, 0.7), rgba(147, 197, 253, 0.4))',
}}
/>
{/* Threshold line */}
<div className="absolute w-full border-t-2 border-red-500 border-dashed" style={{ top: '0%' }}>
<span className="absolute right-1 -top-4 text-xs font-bold text-red-500">
TRIGGER
</span>
</div>
{/* Ripple effect when filling */}
{!isPassed && progressPercent > 0 && (
<motion.div
className="absolute left-1/2 w-full h-1 bg-white/30 rounded-full"
style={{
bottom: `${progressPercent}%`,
transform: 'translateX(-50%)',
}}
animate={{
scaleX: [0.3, 1, 0.3],
opacity: [0.3, 0.6, 0.3],
}}
transition={{
repeat: Infinity,
duration: 2,
}}
/>
)}
</div>
{/* Explanation */}
<p className="mt-3 text-xs text-gray-500 text-center">
{isPassed
? 'Collective conviction exceeded the threshold!'
: progressPercent > 80
? 'Almost there! Keep the conviction growing...'
: progressPercent > 50
? 'Past halfway! The bucket is filling up.'
: progressPercent > 0
? 'Conviction is accumulating. Hold steady.'
: 'Stake tokens to start building conviction.'}
</p>
</motion.div>
);
}

5
src/components/index.ts Normal file
View File

@ -0,0 +1,5 @@
export { ParticipantCard } from './ParticipantCard';
export { ConvictionChart } from './ConvictionChart';
export { TriggerProgress } from './TriggerProgress';
export { ControlPanel } from './ControlPanel';
export { NarrativePanel } from './NarrativePanel';

392
src/lib/conviction.ts Normal file
View File

@ -0,0 +1,392 @@
/**
* Conviction Voting Math Engine
*
* Based on BlockScience's cadCAD model and formal derivations:
* - Deriving_Alpha.ipynb
* - Trigger_Function_Explanation.ipynb
*
* Core formula: y_{t+1} = α × y_t + x_t
* Where:
* y = conviction (accumulated support)
* x = tokens currently staked
* α = decay parameter (0,1)
*/
// ============================================================================
// Core Types
// ============================================================================
export interface Participant {
id: string;
name: string;
color: string;
holdings: number; // Total tokens available
stakedTokens: number; // Currently staked on proposal
conviction: number; // Accumulated conviction
convictionHistory: number[];
stakingHistory: number[];
}
export interface Proposal {
id: string;
title: string;
description: string;
fundsRequested: number;
totalConviction: number;
trigger: number; // Threshold to pass
status: 'candidate' | 'active' | 'completed' | 'failed';
age: number;
convictionHistory: number[];
}
export interface ConvictionParams {
alpha: number; // Decay parameter (0.5-0.99)
beta: number; // Max share of funds per proposal (e.g., 0.2)
rho: number; // Trigger function scale factor
supply: number; // Total token supply
funds: number; // Available funds in pool
tmin: number; // Minimum age before proposal can pass
}
// ============================================================================
// Alpha / Half-Life Conversion
// ============================================================================
/**
* Calculate alpha from desired half-life
* α = 2^(-1/T)
*/
export function alphaFromHalfLife(halfLifeEpochs: number): number {
return Math.pow(2, -1 / halfLifeEpochs);
}
/**
* Calculate half-life from alpha
* T = -log_α(2) = -ln(2) / ln(α)
*/
export function halfLifeFromAlpha(alpha: number): number {
if (alpha <= 0 || alpha >= 1) {
throw new Error('Alpha must be in (0, 1)');
}
return -Math.log(2) / Math.log(alpha);
}
/**
* Convert half-life between time units
* e.g., 7 days with 24 epochs/day = 168 epoch half-life
*/
export function scaleHalfLife(halfLifeDays: number, epochsPerDay: number): number {
return halfLifeDays * epochsPerDay;
}
// ============================================================================
// Conviction Dynamics
// ============================================================================
/**
* Single step conviction update
* y_{t+1} = α × y_t + x_t
*/
export function updateConviction(
currentConviction: number,
stakedTokens: number,
alpha: number
): number {
return alpha * currentConviction + stakedTokens;
}
/**
* Calculate maximum possible conviction for a given stake
* y_max = x / (1 - α)
*
* This is the equilibrium conviction when tokens are held constant indefinitely
*/
export function maxConviction(stakedTokens: number, alpha: number): number {
return stakedTokens / (1 - alpha);
}
/**
* Calculate conviction at time T given initial conviction and constant stake
* y_T = α^T × y_0 + x × (1 - α^T) / (1 - α)
*
* Uses geometric series: Σ(α^t) from t=0 to T-1 = (1 - α^T) / (1 - α)
*/
export function convictionAtTime(
initialConviction: number,
stakedTokens: number,
alpha: number,
epochs: number
): number {
const alphaT = Math.pow(alpha, epochs);
const geometricSum = (1 - alphaT) / (1 - alpha);
return alphaT * initialConviction + stakedTokens * geometricSum;
}
/**
* Calculate conviction decay (when tokens are unstaked, x=0)
* y_T = α^T × y_0
*/
export function convictionDecay(
initialConviction: number,
alpha: number,
epochs: number
): number {
return Math.pow(alpha, epochs) * initialConviction;
}
/**
* Time to reach a target conviction percentage (0-1) of max
* Solving: targetFraction = (1 - α^T)
* T = ln(1 - targetFraction) / ln(α)
*/
export function epochsToReachFraction(
targetFraction: number,
alpha: number
): number {
if (targetFraction <= 0 || targetFraction >= 1) {
return targetFraction <= 0 ? 0 : Infinity;
}
return Math.log(1 - targetFraction) / Math.log(alpha);
}
// ============================================================================
// Trigger Function
// ============================================================================
/**
* Calculate trigger threshold for a proposal
* y*(r) = ρS / ((1-α)(β - r/R)²)
*
* @param requested - Funds requested by proposal
* @param funds - Total available funds (R)
* @param supply - Total token supply (S)
* @param params - Conviction parameters (α, β, ρ)
*/
export function triggerThreshold(
requested: number,
funds: number,
supply: number,
params: Pick<ConvictionParams, 'alpha' | 'beta' | 'rho'>
): number {
const { alpha, beta, rho } = params;
const share = requested / funds;
if (share >= beta) {
return Infinity; // Request too large
}
const denominator = Math.pow(beta - share, 2) * (1 - alpha);
return (rho * supply) / denominator;
}
/**
* Calculate minimum conviction required (for r=0)
* y*(0) = ρS / ((1-α)β²)
*/
export function minRequiredConviction(
supply: number,
params: Pick<ConvictionParams, 'alpha' | 'beta' | 'rho'>
): number {
return triggerThreshold(0, 1, supply, params);
}
/**
* Calculate maximum achievable conviction
* y_max = S / (1-α)
*/
export function maxAchievableConviction(
supply: number,
alpha: number
): number {
return supply / (1 - alpha);
}
/**
* Calculate maximum achievable request
* r_max = (β - ρ) × R
*/
export function maxAchievableRequest(
funds: number,
params: Pick<ConvictionParams, 'beta' | 'rho'>
): number {
const { beta, rho } = params;
return (beta - Math.sqrt(rho)) * funds;
}
/**
* Calculate progress toward trigger (0-1, can exceed 1)
*/
export function triggerProgress(
conviction: number,
trigger: number
): number {
if (trigger === Infinity) return 0;
return conviction / trigger;
}
// ============================================================================
// Simulation Engine
// ============================================================================
export interface SimulationState {
epoch: number;
participants: Participant[];
proposals: Proposal[];
params: ConvictionParams;
events: SimulationEvent[];
}
export interface SimulationEvent {
epoch: number;
type: 'stake' | 'unstake' | 'proposal_passed' | 'proposal_failed' | 'participant_joined';
participantId?: string;
proposalId?: string;
amount?: number;
description: string;
}
/**
* Advance simulation by one epoch
*/
export function simulateEpoch(state: SimulationState): SimulationState {
const { participants, proposals, params } = state;
const newEpoch = state.epoch + 1;
const newEvents: SimulationEvent[] = [];
// Update individual convictions
const updatedParticipants = participants.map(p => {
const newConviction = updateConviction(p.conviction, p.stakedTokens, params.alpha);
return {
...p,
conviction: newConviction,
convictionHistory: [...p.convictionHistory, newConviction],
stakingHistory: [...p.stakingHistory, p.stakedTokens]
};
});
// Calculate total conviction per proposal and check triggers
const updatedProposals = proposals.map(proposal => {
if (proposal.status !== 'candidate') {
return {
...proposal,
age: proposal.age + 1,
convictionHistory: [...proposal.convictionHistory, proposal.totalConviction]
};
}
// Sum individual convictions for this proposal
const totalConviction = updatedParticipants.reduce(
(sum, p) => sum + p.conviction,
0
);
const newAge = proposal.age + 1;
const trigger = triggerThreshold(
proposal.fundsRequested,
params.funds,
params.supply,
params
);
let newStatus: Proposal['status'] = proposal.status;
// Check if proposal passes
if (newAge >= params.tmin && totalConviction >= trigger) {
newStatus = 'active';
newEvents.push({
epoch: newEpoch,
type: 'proposal_passed',
proposalId: proposal.id,
description: `Proposal "${proposal.title}" passed with ${totalConviction.toFixed(0)} conviction (threshold: ${trigger.toFixed(0)})`
});
}
return {
...proposal,
totalConviction,
trigger,
status: newStatus,
age: newAge,
convictionHistory: [...proposal.convictionHistory, totalConviction]
};
});
return {
epoch: newEpoch,
participants: updatedParticipants,
proposals: updatedProposals,
params: state.params,
events: [...state.events, ...newEvents]
};
}
/**
* Generate charging curve data for visualization
* Shows how conviction builds up over time with constant stake
*/
export function generateChargingCurve(
stakedTokens: number,
alpha: number,
epochs: number
): { epoch: number; conviction: number; percentOfMax: number }[] {
const max = maxConviction(stakedTokens, alpha);
const data = [];
for (let t = 0; t <= epochs; t++) {
const conviction = convictionAtTime(0, stakedTokens, alpha, t);
data.push({
epoch: t,
conviction,
percentOfMax: (conviction / max) * 100
});
}
return data;
}
/**
* Generate discharge curve data for visualization
* Shows how conviction decays over time after unstaking
*/
export function generateDischargeCurve(
initialConviction: number,
alpha: number,
epochs: number
): { epoch: number; conviction: number; percentOfInitial: number }[] {
const data = [];
for (let t = 0; t <= epochs; t++) {
const conviction = convictionDecay(initialConviction, alpha, t);
data.push({
epoch: t,
conviction,
percentOfInitial: (conviction / initialConviction) * 100
});
}
return data;
}
// ============================================================================
// Default Parameters (from BlockScience 1Hive example)
// ============================================================================
export const DEFAULT_PARAMS: ConvictionParams = {
alpha: 0.9, // ~7 epoch half-life
beta: 0.2, // Max 20% of funds per proposal
rho: 0.0025, // Trigger scale factor
supply: 100000, // Total token supply
funds: 50000, // Available funds
tmin: 3 // Min 3 epochs before passing
};
export const PARTICIPANT_COLORS = [
'#FF6B6B', // Red
'#4ECDC4', // Teal
'#45B7D1', // Blue
'#96CEB4', // Green
'#FFEAA7', // Yellow
'#DDA0DD', // Plum
'#98D8C8', // Mint
'#F7DC6F', // Gold
];

513
src/stores/simulation.ts Normal file
View File

@ -0,0 +1,513 @@
import { create } from 'zustand';
import {
Participant,
Proposal,
ConvictionParams,
SimulationEvent,
DEFAULT_PARAMS,
PARTICIPANT_COLORS,
updateConviction,
triggerThreshold,
alphaFromHalfLife,
halfLifeFromAlpha,
} from '@/lib/conviction';
// ============================================================================
// Store Types
// ============================================================================
export type AppMode = 'simulation' | 'narrative';
export interface NarrativeStep {
id: number;
title: string;
description: string;
action: () => void;
highlight?: string; // Element to highlight
}
interface SimulationStore {
// Mode
mode: AppMode;
setMode: (mode: AppMode) => void;
// Simulation state
epoch: number;
isRunning: boolean;
speed: number; // ms per epoch
// Entities
participants: Participant[];
proposals: Proposal[];
params: ConvictionParams;
events: SimulationEvent[];
// History for charts
epochHistory: number[];
// Actions
addParticipant: (name: string, holdings: number) => void;
removeParticipant: (id: string) => void;
setStake: (participantId: string, amount: number) => void;
toggleStake: (participantId: string) => void;
addProposal: (title: string, fundsRequested: number) => void;
setParams: (params: Partial<ConvictionParams>) => void;
setHalfLife: (days: number) => void;
// Simulation controls
tick: () => void;
start: () => void;
stop: () => void;
reset: () => void;
setSpeed: (speed: number) => void;
// Narrative mode
narrativeStep: number;
narrativeSteps: NarrativeStep[];
nextNarrativeStep: () => void;
prevNarrativeStep: () => void;
resetNarrative: () => void;
}
// ============================================================================
// Initial State
// ============================================================================
const createInitialParticipants = (): Participant[] => [
{
id: 'alice',
name: 'Alice',
color: PARTICIPANT_COLORS[0],
holdings: 1000,
stakedTokens: 0,
conviction: 0,
convictionHistory: [0],
stakingHistory: [0],
},
{
id: 'bob',
name: 'Bob',
color: PARTICIPANT_COLORS[1],
holdings: 800,
stakedTokens: 0,
conviction: 0,
convictionHistory: [0],
stakingHistory: [0],
},
{
id: 'charlie',
name: 'Charlie',
color: PARTICIPANT_COLORS[2],
holdings: 1200,
stakedTokens: 0,
conviction: 0,
convictionHistory: [0],
stakingHistory: [0],
},
];
const createInitialProposal = (params: ConvictionParams): Proposal => ({
id: 'proposal-1',
title: 'Fund the Community Garden',
description: 'Build a community garden for local food production',
fundsRequested: 5000,
totalConviction: 0,
trigger: triggerThreshold(5000, params.funds, params.supply, params),
status: 'candidate',
age: 0,
convictionHistory: [0],
});
// ============================================================================
// Narrative Steps
// ============================================================================
const createNarrativeSteps = (store: SimulationStore): NarrativeStep[] => [
{
id: 0,
title: 'Welcome to Conviction Voting',
description: `Imagine a community pool of funds that needs to be allocated fairly.
Unlike traditional voting with fixed deadlines, conviction voting lets preferences
accumulate continuously over time - like water filling a bucket.`,
action: () => {},
},
{
id: 1,
title: 'Meet the Participants',
description: `Three community members - Alice, Bob, and Charlie - each hold tokens
that represent their voice in governance. They can stake these tokens on proposals
they support.`,
action: () => {},
highlight: 'participants',
},
{
id: 2,
title: 'Alice Stakes First',
description: `Alice believes strongly in the garden proposal. She stakes all 1000
of her tokens. Watch how her conviction begins to charge up like a capacitor...`,
action: () => {
store.setStake('alice', 1000);
store.start();
},
highlight: 'alice',
},
{
id: 3,
title: 'Conviction Grows Over Time',
description: `Notice how Alice's conviction doesn't jump instantly to maximum -
it grows asymptotically. With α=0.9, her conviction approaches 10x her staked
tokens (1000 / (1-0.9) = 10,000) over time.`,
action: () => {},
highlight: 'chart',
},
{
id: 4,
title: 'Bob Joins the Cause',
description: `Seeing Alice's commitment, Bob decides to stake 800 tokens too.
His conviction starts from zero and begins charging up, adding to the collective.`,
action: () => {
store.setStake('bob', 800);
},
highlight: 'bob',
},
{
id: 5,
title: 'Collective Conviction',
description: `The proposal tracks total conviction from all supporters.
Watch the collective bar approach the trigger threshold...`,
action: () => {},
highlight: 'trigger',
},
{
id: 6,
title: 'Charlie Has Doubts',
description: `Charlie initially supported the proposal but changed his mind.
What happens when someone unstakes? Let's have Bob unstake and watch the decay...`,
action: () => {
store.setStake('bob', 0);
},
highlight: 'bob',
},
{
id: 7,
title: 'Conviction Decays (The Leaky Bucket)',
description: `When Bob unstakes, his conviction doesn't vanish instantly - it
decays exponentially with the same half-life. This is the "leaky bucket" -
conviction slowly drains away without continued commitment.`,
action: () => {},
highlight: 'chart',
},
{
id: 8,
title: 'The Signal Processing View',
description: `Conviction voting is fundamentally signal processing: noisy,
fluctuating individual preferences are smoothed into a stable collective signal.
The α parameter controls how much "memory" the system has.`,
action: () => {
store.setStake('charlie', 1200);
store.setStake('bob', 800);
},
},
{
id: 9,
title: 'Crossing the Threshold',
description: `When collective conviction exceeds the trigger threshold, the
proposal passes! The threshold is higher for larger funding requests - the
community must show stronger, more sustained conviction for bigger asks.`,
action: () => {},
highlight: 'trigger',
},
{
id: 10,
title: 'Explore on Your Own',
description: `Now switch to Simulation mode to experiment freely! Try adjusting
the half-life, adding more participants, or creating competing proposals.`,
action: () => {
store.stop();
},
},
];
// ============================================================================
// Store Implementation
// ============================================================================
export const useSimulationStore = create<SimulationStore>((set, get) => {
let intervalId: NodeJS.Timeout | null = null;
const store: SimulationStore = {
// Mode
mode: 'narrative',
setMode: (mode) => set({ mode }),
// Simulation state
epoch: 0,
isRunning: false,
speed: 500,
// Entities
participants: createInitialParticipants(),
proposals: [createInitialProposal(DEFAULT_PARAMS)],
params: DEFAULT_PARAMS,
events: [],
// History
epochHistory: [0],
// Participant actions
addParticipant: (name, holdings) => {
const id = `participant-${Date.now()}`;
const colorIndex = get().participants.length % PARTICIPANT_COLORS.length;
const newParticipant: Participant = {
id,
name,
color: PARTICIPANT_COLORS[colorIndex],
holdings,
stakedTokens: 0,
conviction: 0,
convictionHistory: [0],
stakingHistory: [0],
};
set((state) => ({
participants: [...state.participants, newParticipant],
events: [
...state.events,
{
epoch: state.epoch,
type: 'participant_joined',
participantId: id,
description: `${name} joined with ${holdings} tokens`,
},
],
}));
},
removeParticipant: (id) => {
set((state) => ({
participants: state.participants.filter((p) => p.id !== id),
}));
},
setStake: (participantId, amount) => {
set((state) => {
const participant = state.participants.find((p) => p.id === participantId);
if (!participant) return state;
const clampedAmount = Math.min(Math.max(0, amount), participant.holdings);
const wasStaked = participant.stakedTokens > 0;
const isNowStaked = clampedAmount > 0;
const newEvents = [...state.events];
if (!wasStaked && isNowStaked) {
newEvents.push({
epoch: state.epoch,
type: 'stake',
participantId,
amount: clampedAmount,
description: `${participant.name} staked ${clampedAmount} tokens`,
});
} else if (wasStaked && !isNowStaked) {
newEvents.push({
epoch: state.epoch,
type: 'unstake',
participantId,
amount: 0,
description: `${participant.name} unstaked all tokens`,
});
}
return {
participants: state.participants.map((p) =>
p.id === participantId ? { ...p, stakedTokens: clampedAmount } : p
),
events: newEvents,
};
});
},
toggleStake: (participantId) => {
const participant = get().participants.find((p) => p.id === participantId);
if (!participant) return;
if (participant.stakedTokens > 0) {
get().setStake(participantId, 0);
} else {
get().setStake(participantId, participant.holdings);
}
},
// Proposal actions
addProposal: (title, fundsRequested) => {
const { params } = get();
const newProposal: Proposal = {
id: `proposal-${Date.now()}`,
title,
description: '',
fundsRequested,
totalConviction: 0,
trigger: triggerThreshold(fundsRequested, params.funds, params.supply, params),
status: 'candidate',
age: 0,
convictionHistory: [0],
};
set((state) => ({
proposals: [...state.proposals, newProposal],
}));
},
// Params
setParams: (newParams) => {
set((state) => {
const params = { ...state.params, ...newParams };
// Recalculate triggers
const proposals = state.proposals.map((p) => ({
...p,
trigger: triggerThreshold(p.fundsRequested, params.funds, params.supply, params),
}));
return { params, proposals };
});
},
setHalfLife: (days) => {
const alpha = alphaFromHalfLife(days);
get().setParams({ alpha });
},
// Simulation
tick: () => {
set((state) => {
const newEpoch = state.epoch + 1;
// Update individual convictions
const updatedParticipants = state.participants.map((p) => {
const newConviction = updateConviction(
p.conviction,
p.stakedTokens,
state.params.alpha
);
return {
...p,
conviction: newConviction,
convictionHistory: [...p.convictionHistory, newConviction],
stakingHistory: [...p.stakingHistory, p.stakedTokens],
};
});
// Calculate total conviction and check triggers
const newEvents: SimulationEvent[] = [];
const updatedProposals = state.proposals.map((proposal) => {
if (proposal.status !== 'candidate') {
return {
...proposal,
age: proposal.age + 1,
convictionHistory: [...proposal.convictionHistory, proposal.totalConviction],
};
}
const totalConviction = updatedParticipants.reduce(
(sum, p) => sum + p.conviction,
0
);
const newAge = proposal.age + 1;
let newStatus: Proposal['status'] = proposal.status;
if (newAge >= state.params.tmin && totalConviction >= proposal.trigger) {
newStatus = 'active';
newEvents.push({
epoch: newEpoch,
type: 'proposal_passed',
proposalId: proposal.id,
description: `"${proposal.title}" passed!`,
});
}
return {
...proposal,
totalConviction,
status: newStatus,
age: newAge,
convictionHistory: [...proposal.convictionHistory, totalConviction],
};
});
return {
epoch: newEpoch,
epochHistory: [...state.epochHistory, newEpoch],
participants: updatedParticipants,
proposals: updatedProposals,
events: [...state.events, ...newEvents],
};
});
},
start: () => {
if (intervalId) return;
set({ isRunning: true });
intervalId = setInterval(() => {
get().tick();
}, get().speed);
},
stop: () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
set({ isRunning: false });
},
reset: () => {
get().stop();
set({
epoch: 0,
epochHistory: [0],
participants: createInitialParticipants(),
proposals: [createInitialProposal(get().params)],
events: [],
narrativeStep: 0,
});
},
setSpeed: (speed) => {
set({ speed });
const { isRunning } = get();
if (isRunning) {
get().stop();
get().start();
}
},
// Narrative
narrativeStep: 0,
narrativeSteps: [],
nextNarrativeStep: () => {
const { narrativeStep, narrativeSteps } = get();
if (narrativeStep < narrativeSteps.length - 1) {
const nextStep = narrativeStep + 1;
narrativeSteps[nextStep]?.action();
set({ narrativeStep: nextStep });
}
},
prevNarrativeStep: () => {
const { narrativeStep } = get();
if (narrativeStep > 0) {
set({ narrativeStep: narrativeStep - 1 });
}
},
resetNarrative: () => {
get().reset();
set({ narrativeStep: 0 });
},
};
// Initialize narrative steps with store reference
store.narrativeSteps = createNarrativeSteps(store);
return store;
});