diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..3b462cb
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,23 @@
+node_modules
+
+# Output
+.output
+.vercel
+.netlify
+.wrangler
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
diff --git a/app/.npmrc b/app/.npmrc
new file mode 100644
index 0000000..b6f27f1
--- /dev/null
+++ b/app/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/app/.vscode/extensions.json b/app/.vscode/extensions.json
new file mode 100644
index 0000000..28d1e67
--- /dev/null
+++ b/app/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["svelte.svelte-vscode"]
+}
diff --git a/app/README.md b/app/README.md
new file mode 100644
index 0000000..e49a5b5
--- /dev/null
+++ b/app/README.md
@@ -0,0 +1,42 @@
+# sv
+
+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
+
+## Creating a project
+
+If you're seeing this, you've probably already done this step. Congrats!
+
+```sh
+# create a new project
+npx sv create my-app
+```
+
+To recreate this project with the same configuration:
+
+```sh
+# recreate this project
+npx sv@0.12.7 create --template minimal --types ts --no-install app
+```
+
+## Developing
+
+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+
+```sh
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+To create a production version of your app:
+
+```sh
+npm run build
+```
+
+You can preview the production build with `npm run preview`.
+
+> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
diff --git a/app/bun.lock b/app/bun.lock
new file mode 100644
index 0000000..e105ae7
--- /dev/null
+++ b/app/bun.lock
@@ -0,0 +1,326 @@
+{
+ "lockfileVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "app",
+ "dependencies": {
+ "idb-keyval": "^6.2.2",
+ "peerjs": "^1.5.5",
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^7.0.0",
+ "@sveltejs/kit": "^2.50.2",
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
+ "@tailwindcss/vite": "^4.2.1",
+ "svelte": "^5.51.0",
+ "svelte-check": "^4.4.2",
+ "tailwindcss": "^4.2.1",
+ "typescript": "^5.9.3",
+ "vite": "^7.3.1",
+ },
+ },
+ },
+ "packages": {
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@msgpack/msgpack": ["@msgpack/msgpack@2.8.0", "", {}, "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ=="],
+
+ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
+
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
+
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
+
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
+
+ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
+ "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
+
+ "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="],
+
+ "@sveltejs/kit": ["@sveltejs/kit@2.55.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA=="],
+
+ "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
+
+ "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
+
+ "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
+
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
+
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
+
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
+
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
+
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
+
+ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
+
+ "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
+
+ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
+ "@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
+
+ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
+
+ "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
+
+ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
+
+ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
+
+ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
+
+ "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
+
+ "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
+
+ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
+
+ "esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="],
+
+ "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="],
+
+ "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
+
+ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
+
+ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
+
+ "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
+
+ "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
+
+ "peerjs": ["peerjs@1.5.5", "", { "dependencies": { "@msgpack/msgpack": "^2.8.0", "eventemitter3": "^4.0.7", "peerjs-js-binarypack": "^2.1.0", "webrtc-adapter": "^9.0.0" } }, "sha512-viMUCPDL6CSfOu0ZqVcFqbWRXNHIbv2lPqNbrBIjbFYrflebOjItJ4hPfhjnuUCstqciHVu9vVJ7jFqqKi/EuQ=="],
+
+ "peerjs-js-binarypack": ["peerjs-js-binarypack@2.1.0", "", {}, "sha512-YIwCC+pTzp3Bi8jPI9UFKO0t0SLo6xALnHkiNt/iUFmUUZG0fEEmEyFKvjsDKweiFitzHRyhuh6NvyJZ4nNxMg=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
+
+ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+
+ "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
+
+ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
+
+ "sdp": ["sdp@3.2.1", "", {}, "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw=="],
+
+ "set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
+
+ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "svelte": ["svelte@5.53.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA=="],
+
+ "svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="],
+
+ "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
+
+ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
+
+ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+
+ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
+
+ "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
+
+ "webrtc-adapter": ["webrtc-adapter@9.0.4", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw=="],
+
+ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ }
+}
diff --git a/app/package.json b/app/package.json
new file mode 100644
index 0000000..a38d39c
--- /dev/null
+++ b/app/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "app",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync || echo ''",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^7.0.0",
+ "@sveltejs/kit": "^2.50.2",
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
+ "@tailwindcss/vite": "^4.2.1",
+ "svelte": "^5.51.0",
+ "svelte-check": "^4.4.2",
+ "tailwindcss": "^4.2.1",
+ "typescript": "^5.9.3",
+ "vite": "^7.3.1"
+ },
+ "dependencies": {
+ "idb-keyval": "^6.2.2",
+ "peerjs": "^1.5.5"
+ }
+}
diff --git a/app/src/app.css b/app/src/app.css
new file mode 100644
index 0000000..98a58f8
--- /dev/null
+++ b/app/src/app.css
@@ -0,0 +1,17 @@
+@import 'tailwindcss';
+
+@theme {
+ --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Helvetica Neue', Arial, sans-serif;
+ --color-primary: #6366f1;
+ --color-primary-hover: #4f46e5;
+ --color-surface: #ffffff;
+ --color-surface-dark: #0f172a;
+ --color-muted: #64748b;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ color-scheme: dark;
+ }
+}
diff --git a/app/src/app.d.ts b/app/src/app.d.ts
new file mode 100644
index 0000000..da08e6d
--- /dev/null
+++ b/app/src/app.d.ts
@@ -0,0 +1,13 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/app/src/app.html b/app/src/app.html
new file mode 100644
index 0000000..f273cc5
--- /dev/null
+++ b/app/src/app.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/app/src/lib/assets/favicon.svg b/app/src/lib/assets/favicon.svg
new file mode 100644
index 0000000..cc5dc66
--- /dev/null
+++ b/app/src/lib/assets/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/src/lib/crypto.ts b/app/src/lib/crypto.ts
new file mode 100644
index 0000000..f76d508
--- /dev/null
+++ b/app/src/lib/crypto.ts
@@ -0,0 +1,91 @@
+import { loadKeypair, saveKeypair, loadPublicKeyRaw, savePublicKeyRaw } from './db.js';
+
+const ALGO = { name: 'Ed25519' } as const;
+
+let cachedKeypair: CryptoKeyPair | undefined;
+let cachedPublicKeyRaw: Uint8Array | undefined;
+
+export async function getOrCreateKeypair(): Promise {
+ if (cachedKeypair) return cachedKeypair;
+
+ const existing = await loadKeypair();
+ if (existing) {
+ cachedKeypair = existing;
+ cachedPublicKeyRaw = await loadPublicKeyRaw() ?? undefined;
+ return existing;
+ }
+
+ const keypair = await crypto.subtle.generateKey(ALGO, false, ['sign', 'verify']);
+ const raw = new Uint8Array(await crypto.subtle.exportKey('raw', keypair.publicKey));
+
+ await saveKeypair(keypair);
+ await savePublicKeyRaw(raw);
+
+ cachedKeypair = keypair;
+ cachedPublicKeyRaw = raw;
+ return keypair;
+}
+
+export async function getPublicKeyRaw(): Promise {
+ if (cachedPublicKeyRaw) return cachedPublicKeyRaw;
+ await getOrCreateKeypair();
+ return cachedPublicKeyRaw!;
+}
+
+export async function getUserId(): Promise {
+ const raw = await getPublicKeyRaw();
+ return base58Encode(raw);
+}
+
+export async function sign(data: string): Promise {
+ const keypair = await getOrCreateKeypair();
+ const encoded = new TextEncoder().encode(data);
+ const sig = await crypto.subtle.sign(ALGO, keypair.privateKey, encoded);
+ return base64Encode(new Uint8Array(sig));
+}
+
+export async function verify(data: string, signature: string, publicKeyRaw: Uint8Array): Promise {
+ try {
+ const key = await crypto.subtle.importKey('raw', publicKeyRaw as unknown as ArrayBuffer, ALGO, false, ['verify']);
+ const sig = base64Decode(signature);
+ const encoded = new TextEncoder().encode(data);
+ return crypto.subtle.verify(ALGO, key, sig as unknown as ArrayBuffer, encoded as unknown as ArrayBuffer);
+ } catch {
+ return false;
+ }
+}
+
+// --- Base58 (Bitcoin-style) ---
+
+const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
+
+export function base58Encode(bytes: Uint8Array): string {
+ let num = 0n;
+ for (const b of bytes) num = num * 256n + BigInt(b);
+
+ let result = '';
+ while (num > 0n) {
+ result = BASE58_ALPHABET[Number(num % 58n)] + result;
+ num = num / 58n;
+ }
+
+ for (const b of bytes) {
+ if (b !== 0) break;
+ result = '1' + result;
+ }
+
+ return result || '1';
+}
+
+function base64Encode(bytes: Uint8Array): string {
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary);
+}
+
+function base64Decode(str: string): Uint8Array {
+ const binary = atob(str);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
diff --git a/app/src/lib/db.ts b/app/src/lib/db.ts
new file mode 100644
index 0000000..61e5150
--- /dev/null
+++ b/app/src/lib/db.ts
@@ -0,0 +1,86 @@
+import { get, set, del, keys, createStore } from 'idb-keyval';
+import type { Poll, UserProfile } from './types.js';
+
+const profileStore = createStore('evocracy-profiles', 'profiles');
+const pollStore = createStore('evocracy-polls', 'polls');
+const outboxStore = createStore('evocracy-outbox', 'outbox');
+const metaStore = createStore('evocracy-meta', 'meta');
+
+// --- Profile ---
+
+export async function loadProfile(): Promise {
+ return get('self', profileStore);
+}
+
+export async function saveProfile(profile: UserProfile): Promise {
+ await set('self', profile, profileStore);
+}
+
+// --- Keypair ---
+
+export async function loadKeypair(): Promise {
+ return get('keypair', metaStore);
+}
+
+export async function saveKeypair(keypair: CryptoKeyPair): Promise {
+ await set('keypair', keypair, metaStore);
+}
+
+export async function loadPublicKeyRaw(): Promise {
+ return get('publicKeyRaw', metaStore);
+}
+
+export async function savePublicKeyRaw(raw: Uint8Array): Promise {
+ await set('publicKeyRaw', raw, metaStore);
+}
+
+// --- Polls ---
+
+export async function loadPoll(id: string): Promise {
+ return get(id, pollStore);
+}
+
+export async function savePoll(poll: Poll): Promise {
+ await set(poll.id, poll, pollStore);
+}
+
+export async function deletePoll(id: string): Promise {
+ await del(id, pollStore);
+}
+
+export async function loadAllPolls(): Promise {
+ const allKeys = await keys(pollStore);
+ const polls: Poll[] = [];
+ for (const key of allKeys) {
+ const poll = await get(key, pollStore);
+ if (poll) polls.push(poll);
+ }
+ return polls;
+}
+
+// --- Outbox ---
+
+export interface OutboxEntry {
+ commandId: string;
+ pollId: string;
+ message: unknown;
+ createdAt: number;
+}
+
+export async function addToOutbox(entry: OutboxEntry): Promise {
+ await set(entry.commandId, entry, outboxStore);
+}
+
+export async function removeFromOutbox(commandId: string): Promise {
+ await del(commandId, outboxStore);
+}
+
+export async function getOutboxEntries(): Promise {
+ const allKeys = await keys(outboxStore);
+ const entries: OutboxEntry[] = [];
+ for (const key of allKeys) {
+ const entry = await get(key, outboxStore);
+ if (entry) entries.push(entry);
+ }
+ return entries;
+}
diff --git a/app/src/lib/index.ts b/app/src/lib/index.ts
new file mode 100644
index 0000000..856f2b6
--- /dev/null
+++ b/app/src/lib/index.ts
@@ -0,0 +1 @@
+// place files you want to import through the `$lib` alias in this folder.
diff --git a/app/src/lib/peer.ts b/app/src/lib/peer.ts
new file mode 100644
index 0000000..d12022d
--- /dev/null
+++ b/app/src/lib/peer.ts
@@ -0,0 +1,174 @@
+import Peer, { type DataConnection } from 'peerjs';
+import type { Message, Poll } from './types.js';
+import { getUserId } from './crypto.js';
+import { addToOutbox, removeFromOutbox, getOutboxEntries } from './db.js';
+
+export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
+
+type MessageHandler = (msg: Message, peerId: string) => void;
+type StateHandler = (state: ConnectionState) => void;
+
+let peer: Peer | null = null;
+const connections = new Map();
+const messageHandlers = new Set();
+const stateHandlers = new Set();
+let currentState: ConnectionState = 'disconnected';
+
+function setState(state: ConnectionState) {
+ currentState = state;
+ stateHandlers.forEach((h) => h(state));
+}
+
+export function getConnectionState(): ConnectionState {
+ return currentState;
+}
+
+export function onMessage(handler: MessageHandler): () => void {
+ messageHandlers.add(handler);
+ return () => messageHandlers.delete(handler);
+}
+
+export function onStateChange(handler: StateHandler): () => void {
+ stateHandlers.add(handler);
+ return () => stateHandlers.delete(handler);
+}
+
+export function getConnectedPeers(): string[] {
+ return Array.from(connections.keys());
+}
+
+export async function initPeer(): Promise {
+ if (peer && !peer.destroyed) return peer;
+
+ const userId = await getUserId();
+ setState('connecting');
+
+ return new Promise((resolve, reject) => {
+ const p = new Peer(userId, { debug: 1 });
+
+ p.on('open', () => {
+ peer = p;
+ setState('connected');
+ resolve(p);
+ });
+
+ p.on('connection', (conn) => {
+ setupConnection(conn);
+ });
+
+ p.on('error', (err) => {
+ console.error('[peer] error:', err);
+ setState('error');
+ reject(err);
+ });
+
+ p.on('disconnected', () => {
+ setState('disconnected');
+ // Auto-reconnect
+ setTimeout(() => {
+ if (p && !p.destroyed) p.reconnect();
+ }, 2000);
+ });
+ });
+}
+
+function setupConnection(conn: DataConnection) {
+ conn.on('open', () => {
+ connections.set(conn.peer, conn);
+ });
+
+ conn.on('data', (data) => {
+ const msg = data as Message;
+ messageHandlers.forEach((h) => h(msg, conn.peer));
+ });
+
+ conn.on('close', () => {
+ connections.delete(conn.peer);
+ });
+
+ conn.on('error', (err) => {
+ console.error(`[peer] connection error with ${conn.peer}:`, err);
+ connections.delete(conn.peer);
+ });
+}
+
+export async function connectToPeer(peerId: string): Promise {
+ const p = await initPeer();
+
+ const existing = connections.get(peerId);
+ if (existing && existing.open) return existing;
+
+ return new Promise((resolve, reject) => {
+ const conn = p.connect(peerId, { reliable: true });
+
+ conn.on('open', () => {
+ connections.set(peerId, conn);
+ // Flush outbox for this peer
+ flushOutbox(peerId);
+ resolve(conn);
+ });
+
+ conn.on('error', (err) => {
+ reject(err);
+ });
+
+ setupConnection(conn);
+ });
+}
+
+export function sendToPeer(peerId: string, msg: Message): boolean {
+ const conn = connections.get(peerId);
+ if (!conn || !conn.open) return false;
+ conn.send(msg);
+ return true;
+}
+
+export function broadcast(msg: Message, exclude?: string): void {
+ for (const [peerId, conn] of connections) {
+ if (peerId === exclude) continue;
+ if (conn.open) conn.send(msg);
+ }
+}
+
+export async function sendWithOutbox(
+ peerId: string,
+ msg: Message & { commandId: string },
+ pollId: string
+): Promise {
+ await addToOutbox({
+ commandId: msg.commandId,
+ pollId,
+ message: msg,
+ createdAt: Date.now()
+ });
+
+ sendToPeer(peerId, msg);
+}
+
+export function acknowledgeCommand(commandId: string): void {
+ removeFromOutbox(commandId);
+}
+
+async function flushOutbox(peerId: string): Promise {
+ const entries = await getOutboxEntries();
+ for (const entry of entries) {
+ sendToPeer(peerId, entry.message as Message);
+ }
+}
+
+export function disconnectPeer(peerId: string): void {
+ const conn = connections.get(peerId);
+ if (conn) {
+ conn.close();
+ connections.delete(peerId);
+ }
+}
+
+export function destroyPeer(): void {
+ if (peer) {
+ peer.destroy();
+ peer = null;
+ }
+ connections.clear();
+ setState('disconnected');
+}
diff --git a/app/src/lib/permissions.ts b/app/src/lib/permissions.ts
new file mode 100644
index 0000000..f1a046e
--- /dev/null
+++ b/app/src/lib/permissions.ts
@@ -0,0 +1,39 @@
+import type { Poll, PollAction, Role } from './types.js';
+
+const ROLE_PERMISSIONS: Record> = {
+ viewer: new Set(['view']),
+ participant: new Set(['view', 'vote', 'addOption']),
+ moderator: new Set(['view', 'vote', 'addOption', 'manageUsers', 'managePoll']),
+ owner: new Set(['view', 'vote', 'addOption', 'manageUsers', 'managePoll', 'deletePoll'])
+};
+
+export function getRole(poll: Poll, userId: string): Role | 'owner' {
+ if (poll.ownerId === userId) return 'owner';
+ const assignment = poll.roles.find((r) => r.userId === userId);
+ return assignment?.role ?? 'viewer';
+}
+
+export function can(poll: Poll, userId: string, action: PollAction): boolean {
+ const role = getRole(poll, userId);
+ return ROLE_PERMISSIONS[role].has(action);
+}
+
+export function canVote(poll: Poll, userId: string): boolean {
+ return poll.status === 'open' && can(poll, userId, 'vote');
+}
+
+export function canAddOption(poll: Poll, userId: string): boolean {
+ return poll.status === 'open' && can(poll, userId, 'addOption');
+}
+
+export function canManageUsers(poll: Poll, userId: string): boolean {
+ return can(poll, userId, 'manageUsers');
+}
+
+export function canManagePoll(poll: Poll, userId: string): boolean {
+ return can(poll, userId, 'managePoll');
+}
+
+export function canDeletePoll(poll: Poll, userId: string): boolean {
+ return can(poll, userId, 'deletePoll');
+}
diff --git a/app/src/lib/poll-client.ts b/app/src/lib/poll-client.ts
new file mode 100644
index 0000000..7d8d5e9
--- /dev/null
+++ b/app/src/lib/poll-client.ts
@@ -0,0 +1,105 @@
+import { connectToPeer, sendToPeer, sendWithOutbox, onMessage, acknowledgeCommand } from './peer.js';
+import { updatePollInStore } from './stores/polls.svelte.js';
+import type { Message, Poll, PollOption, Vote } from './types.js';
+
+const pendingCallbacks = new Map void; reject: (err: Error) => void }>();
+
+let clientUnsub: (() => void) | null = null;
+
+export function startClient(): () => void {
+ if (clientUnsub) return clientUnsub;
+
+ const unsub = onMessage(async (msg: Message) => {
+ switch (msg.type) {
+ case 'poll:state': {
+ await updatePollInStore(msg.payload);
+ break;
+ }
+
+ case 'ack': {
+ acknowledgeCommand(msg.payload.commandId);
+ const cb = pendingCallbacks.get(msg.payload.commandId);
+ if (cb) {
+ cb.resolve();
+ pendingCallbacks.delete(msg.payload.commandId);
+ }
+ break;
+ }
+
+ case 'error': {
+ const cb = pendingCallbacks.get(msg.payload.commandId);
+ if (cb) {
+ cb.reject(new Error(msg.payload.message));
+ pendingCallbacks.delete(msg.payload.commandId);
+ }
+ break;
+ }
+ }
+ });
+
+ clientUnsub = () => {
+ unsub();
+ clientUnsub = null;
+ };
+ return clientUnsub;
+}
+
+export async function joinPoll(ownerPeerId: string, pollId: string): Promise {
+ await connectToPeer(ownerPeerId);
+ sendToPeer(ownerPeerId, { type: 'sync:request', payload: { pollId } });
+}
+
+export async function submitVote(ownerPeerId: string, pollId: string, optionId: string, anonymous: boolean): Promise {
+ const commandId = crypto.randomUUID();
+ const vote: Vote = {
+ optionId,
+ voterId: anonymous ? null : undefined as unknown as string, // owner will set
+ timestamp: Date.now()
+ };
+
+ const msg: Message = {
+ type: 'poll:vote',
+ commandId,
+ payload: { ...vote, pollId }
+ };
+
+ await sendWithOutbox(ownerPeerId, msg as Message & { commandId: string }, pollId);
+
+ return new Promise((resolve, reject) => {
+ pendingCallbacks.set(commandId, { resolve, reject });
+ setTimeout(() => {
+ if (pendingCallbacks.has(commandId)) {
+ pendingCallbacks.delete(commandId);
+ reject(new Error('Vote timed out'));
+ }
+ }, 10000);
+ });
+}
+
+export async function submitOption(ownerPeerId: string, pollId: string, text: string): Promise {
+ const commandId = crypto.randomUUID();
+ const option: PollOption = {
+ id: crypto.randomUUID(),
+ text,
+ addedBy: '',
+ addedAt: Date.now()
+ };
+
+ const msg: Message = {
+ type: 'poll:option:add',
+ commandId,
+ payload: { ...option, pollId }
+ };
+
+ await sendWithOutbox(ownerPeerId, msg as Message & { commandId: string }, pollId);
+
+ return new Promise((resolve, reject) => {
+ pendingCallbacks.set(commandId, { resolve, reject });
+ setTimeout(() => {
+ if (pendingCallbacks.has(commandId)) {
+ pendingCallbacks.delete(commandId);
+ reject(new Error('Add option timed out'));
+ }
+ }, 10000);
+ });
+}
diff --git a/app/src/lib/poll-host.ts b/app/src/lib/poll-host.ts
new file mode 100644
index 0000000..b4e9ca3
--- /dev/null
+++ b/app/src/lib/poll-host.ts
@@ -0,0 +1,157 @@
+import { onMessage, sendToPeer, broadcast } from './peer.js';
+import { getUserId } from './crypto.js';
+import { getPollById, updatePollInStore, setRole, removeRole, setPollStatus } from './stores/polls.svelte.js';
+import { canVote, canAddOption, canManageUsers, canManagePoll } from './permissions.js';
+import type { Message, Poll, PollOption, Vote } from './types.js';
+
+const processedCommands = new Set();
+let revision = 0;
+
+export function startHosting(): () => void {
+ const unsub = onMessage(async (msg, peerId) => {
+ const userId = await getUserId();
+
+ switch (msg.type) {
+ case 'sync:request': {
+ const poll = getPollById(msg.payload.pollId);
+ if (poll && poll.ownerId === userId) {
+ sendToPeer(peerId, { type: 'poll:state', payload: poll });
+ }
+ break;
+ }
+
+ case 'poll:vote': {
+ if (processedCommands.has(msg.commandId)) {
+ sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
+ break;
+ }
+
+ const poll = getPollById(msg.payload.pollId);
+ if (!poll || poll.ownerId !== userId) break;
+
+ if (!canVote(poll, peerId)) {
+ sendToPeer(peerId, {
+ type: 'error',
+ payload: { commandId: msg.commandId, message: 'Not authorized to vote' }
+ });
+ break;
+ }
+
+ const vote: Vote = {
+ optionId: msg.payload.optionId,
+ voterId: poll.anonymous ? null : peerId,
+ timestamp: msg.payload.timestamp
+ };
+
+ const filteredVotes = poll.anonymous
+ ? poll.votes
+ : poll.votes.filter((v) => v.voterId !== peerId);
+
+ const updated: Poll = { ...poll, votes: [...filteredVotes, vote] };
+ await updatePollInStore(updated);
+ revision++;
+ processedCommands.add(msg.commandId);
+
+ sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
+ broadcast({ type: 'poll:state', payload: updated }, peerId);
+ break;
+ }
+
+ case 'poll:option:add': {
+ if (processedCommands.has(msg.commandId)) {
+ sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
+ break;
+ }
+
+ const poll2 = getPollById(msg.payload.pollId);
+ if (!poll2 || poll2.ownerId !== userId) break;
+
+ if (!canAddOption(poll2, peerId)) {
+ sendToPeer(peerId, {
+ type: 'error',
+ payload: { commandId: msg.commandId, message: 'Not authorized to add options' }
+ });
+ break;
+ }
+
+ const option: PollOption = {
+ id: msg.payload.id,
+ text: msg.payload.text,
+ addedBy: peerId,
+ addedAt: msg.payload.addedAt
+ };
+
+ const updated2: Poll = { ...poll2, options: [...poll2.options, option] };
+ await updatePollInStore(updated2);
+ revision++;
+ processedCommands.add(msg.commandId);
+
+ sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
+ broadcast({ type: 'poll:state', payload: updated2 }, peerId);
+ break;
+ }
+
+ case 'poll:role:update': {
+ if (processedCommands.has(msg.commandId)) {
+ sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
+ break;
+ }
+
+ const poll3 = getPollById(msg.payload.pollId);
+ if (!poll3 || poll3.ownerId !== userId) break;
+
+ if (!canManageUsers(poll3, peerId)) {
+ sendToPeer(peerId, {
+ type: 'error',
+ payload: { commandId: msg.commandId, message: 'Not authorized to manage users' }
+ });
+ break;
+ }
+
+ await setRole(msg.payload.pollId, msg.payload.userId, msg.payload.role);
+ revision++;
+ processedCommands.add(msg.commandId);
+
+ const refreshed = getPollById(msg.payload.pollId);
+ sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
+ if (refreshed) broadcast({ type: 'poll:state', payload: refreshed }, peerId);
+ break;
+ }
+
+ case 'poll:status:update': {
+ if (processedCommands.has(msg.commandId)) {
+ sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
+ break;
+ }
+
+ const poll4 = getPollById(msg.payload.pollId);
+ if (!poll4 || poll4.ownerId !== userId) break;
+
+ if (!canManagePoll(poll4, peerId)) {
+ sendToPeer(peerId, {
+ type: 'error',
+ payload: { commandId: msg.commandId, message: 'Not authorized to manage poll' }
+ });
+ break;
+ }
+
+ await setPollStatus(msg.payload.pollId, msg.payload.status);
+ revision++;
+ processedCommands.add(msg.commandId);
+
+ const refreshed2 = getPollById(msg.payload.pollId);
+ sendToPeer(peerId, { type: 'ack', payload: { commandId: msg.commandId, revision } });
+ if (refreshed2) broadcast({ type: 'poll:state', payload: refreshed2 }, peerId);
+ break;
+ }
+
+ case 'user:profile': {
+ // Store peer's profile for display purposes
+ // Could be extended to a peer profile cache
+ break;
+ }
+ }
+ });
+
+ return unsub;
+}
diff --git a/app/src/lib/snapshot.ts b/app/src/lib/snapshot.ts
new file mode 100644
index 0000000..2e0051c
--- /dev/null
+++ b/app/src/lib/snapshot.ts
@@ -0,0 +1,44 @@
+import { getUserId } from './crypto.js';
+import type { Poll, PollSnapshot } from './types.js';
+
+const SNAPSHOT_API = typeof import.meta !== 'undefined'
+ ? (import.meta.env?.VITE_SNAPSHOT_API || '')
+ : '';
+
+export async function pushSnapshot(poll: Poll): Promise {
+ if (!SNAPSHOT_API) return false;
+ if (poll.visibility !== 'public') return false;
+
+ const userId = await getUserId();
+ if (poll.ownerId !== userId) return false;
+
+ const voteCounts: Record = {};
+ for (const opt of poll.options) voteCounts[opt.id] = 0;
+ for (const vote of poll.votes) voteCounts[vote.optionId] = (voteCounts[vote.optionId] || 0) + 1;
+
+ const snapshot: PollSnapshot & { signature: string } = {
+ pollId: poll.id,
+ ownerId: poll.ownerId,
+ ownerPeerId: userId, // peerId === userId in our design
+ title: poll.title,
+ description: poll.description,
+ options: poll.options.map((o) => ({ id: o.id, text: o.text })),
+ voteCounts,
+ totalVotes: poll.votes.length,
+ status: poll.status,
+ anonymous: poll.anonymous,
+ updatedAt: Date.now(),
+ signature: '' // TODO: sign with Ed25519
+ };
+
+ try {
+ const res = await fetch(`${SNAPSHOT_API}/api/polls/${poll.id}/snapshot`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(snapshot)
+ });
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
diff --git a/app/src/lib/stores/polls.svelte.ts b/app/src/lib/stores/polls.svelte.ts
new file mode 100644
index 0000000..0fbf14e
--- /dev/null
+++ b/app/src/lib/stores/polls.svelte.ts
@@ -0,0 +1,157 @@
+import { loadAllPolls, savePoll, deletePoll as dbDeletePoll, loadPoll } from '$lib/db.js';
+import type { Poll, PollOption, Vote, RoleAssignment } from '$lib/types.js';
+import { getUserId } from '$lib/crypto.js';
+
+let polls = $state([]);
+let loading = $state(true);
+
+export function getPolls() {
+ return {
+ get all() { return polls; },
+ get loading() { return loading; },
+ get owned() {
+ const id = currentUserId;
+ return id ? polls.filter((p) => p.ownerId === id) : [];
+ },
+ get participating() {
+ const id = currentUserId;
+ return id ? polls.filter((p) => p.ownerId !== id) : [];
+ }
+ };
+}
+
+let currentUserId: string | null = null;
+
+export async function initPolls(): Promise {
+ loading = true;
+ currentUserId = await getUserId();
+ polls = await loadAllPolls();
+ loading = false;
+}
+
+export async function createPoll(data: {
+ title: string;
+ description: string;
+ anonymous: boolean;
+ visibility: Poll['visibility'];
+ options?: string[];
+}): Promise {
+ const userId = await getUserId();
+ const now = Date.now();
+
+ const poll: Poll = {
+ id: crypto.randomUUID(),
+ ownerId: userId,
+ title: data.title,
+ description: data.description,
+ anonymous: data.anonymous,
+ status: 'draft',
+ visibility: data.visibility,
+ createdAt: now,
+ options: (data.options ?? []).map((text) => ({
+ id: crypto.randomUUID(),
+ text,
+ addedBy: userId,
+ addedAt: now
+ })),
+ votes: [],
+ roles: []
+ };
+
+ await savePoll(poll);
+ polls = [...polls, poll];
+ return poll;
+}
+
+export async function updatePollInStore(poll: Poll): Promise {
+ await savePoll(poll);
+ polls = polls.map((p) => (p.id === poll.id ? poll : p));
+}
+
+export function getPollById(id: string): Poll | undefined {
+ return polls.find((p) => p.id === id);
+}
+
+export async function refreshPoll(id: string): Promise {
+ const poll = await loadPoll(id);
+ if (poll) {
+ polls = polls.map((p) => (p.id === id ? poll : p));
+ if (!polls.find((p) => p.id === id)) {
+ polls = [...polls, poll];
+ }
+ }
+ return poll;
+}
+
+export async function deletePollFromStore(id: string): Promise {
+ await dbDeletePoll(id);
+ polls = polls.filter((p) => p.id !== id);
+}
+
+export async function addOptionToPoll(pollId: string, text: string): Promise {
+ const poll = getPollById(pollId);
+ if (!poll || poll.status !== 'open') return null;
+
+ const userId = await getUserId();
+ const option: PollOption = {
+ id: crypto.randomUUID(),
+ text,
+ addedBy: userId,
+ addedAt: Date.now()
+ };
+
+ const updated = { ...poll, options: [...poll.options, option] };
+ await updatePollInStore(updated);
+ return option;
+}
+
+export async function castVote(pollId: string, optionId: string): Promise {
+ const poll = getPollById(pollId);
+ if (!poll || poll.status !== 'open') return null;
+
+ const userId = await getUserId();
+ const vote: Vote = {
+ optionId,
+ voterId: poll.anonymous ? null : userId,
+ timestamp: Date.now()
+ };
+
+ // Remove previous vote by this user (for vote changes)
+ const filteredVotes = poll.anonymous
+ ? poll.votes // Can't deduplicate anonymous votes by voterId
+ : poll.votes.filter((v) => v.voterId !== userId);
+
+ const updated = { ...poll, votes: [...filteredVotes, vote] };
+ await updatePollInStore(updated);
+ return vote;
+}
+
+export async function setPollStatus(pollId: string, status: Poll['status']): Promise {
+ const poll = getPollById(pollId);
+ if (!poll) return;
+
+ const updated = {
+ ...poll,
+ status,
+ ...(status === 'closed' ? { closedAt: Date.now() } : {})
+ };
+ await updatePollInStore(updated);
+}
+
+export async function setRole(pollId: string, userId: string, role: RoleAssignment['role']): Promise {
+ const poll = getPollById(pollId);
+ if (!poll) return;
+
+ const roles = poll.roles.filter((r) => r.userId !== userId);
+ roles.push({ userId, role });
+ const updated = { ...poll, roles };
+ await updatePollInStore(updated);
+}
+
+export async function removeRole(pollId: string, userId: string): Promise {
+ const poll = getPollById(pollId);
+ if (!poll) return;
+
+ const updated = { ...poll, roles: poll.roles.filter((r) => r.userId !== userId) };
+ await updatePollInStore(updated);
+}
diff --git a/app/src/lib/stores/profile.svelte.ts b/app/src/lib/stores/profile.svelte.ts
new file mode 100644
index 0000000..abb2d32
--- /dev/null
+++ b/app/src/lib/stores/profile.svelte.ts
@@ -0,0 +1,62 @@
+import { loadProfile, saveProfile } from '$lib/db.js';
+import { getUserId } from '$lib/crypto.js';
+import type { Tag, UserProfile } from '$lib/types.js';
+
+let profile = $state(null);
+let loading = $state(true);
+
+export function getProfile() {
+ return {
+ get current() { return profile; },
+ get loading() { return loading; }
+ };
+}
+
+export async function initProfile(): Promise {
+ loading = true;
+ const existing = await loadProfile();
+ if (existing) {
+ profile = existing;
+ loading = false;
+ return existing;
+ }
+
+ const userId = await getUserId();
+ const newProfile: UserProfile = {
+ id: userId,
+ name: '',
+ bio: '',
+ tags: [],
+ updatedAt: Date.now(),
+ signature: ''
+ };
+
+ await saveProfile(newProfile);
+ profile = newProfile;
+ loading = false;
+ return newProfile;
+}
+
+export async function updateProfile(updates: Partial>): Promise {
+ if (!profile) return;
+
+ profile = {
+ ...profile,
+ ...updates,
+ updatedAt: Date.now()
+ };
+
+ await saveProfile(profile);
+}
+
+export async function addTag(tag: Tag): Promise {
+ if (!profile) return;
+ const tags = [...profile.tags, tag];
+ await updateProfile({ tags });
+}
+
+export async function removeTag(index: number): Promise {
+ if (!profile) return;
+ const tags = profile.tags.filter((_, i) => i !== index);
+ await updateProfile({ tags });
+}
diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts
new file mode 100644
index 0000000..58605bd
--- /dev/null
+++ b/app/src/lib/types.ts
@@ -0,0 +1,91 @@
+// === User Identity ===
+
+export interface Tag {
+ category: 'location' | 'interest' | 'expertise' | (string & {});
+ value: string;
+}
+
+export interface UserProfile {
+ id: string;
+ name: string;
+ bio: string;
+ tags: Tag[];
+ updatedAt: number;
+ signature: string;
+}
+
+// === Poll ===
+
+export interface Poll {
+ id: string;
+ ownerId: string;
+ title: string;
+ description: string;
+ anonymous: boolean;
+ status: 'draft' | 'open' | 'closed';
+ visibility: 'private' | 'link' | 'public';
+ createdAt: number;
+ closedAt?: number;
+ options: PollOption[];
+ votes: Vote[];
+ roles: RoleAssignment[];
+}
+
+export interface PollOption {
+ id: string;
+ text: string;
+ addedBy: string;
+ addedAt: number;
+}
+
+export interface Vote {
+ optionId: string;
+ voterId: string | null;
+ timestamp: number;
+}
+
+export type Role = 'viewer' | 'participant' | 'moderator';
+
+export interface RoleAssignment {
+ userId: string;
+ role: Role;
+}
+
+// === Permissions ===
+
+export type PollAction =
+ | 'view'
+ | 'vote'
+ | 'addOption'
+ | 'manageUsers'
+ | 'managePoll'
+ | 'deletePoll';
+
+// === P2P Messages ===
+
+export type Message =
+ | { type: 'poll:state'; commandId?: string; payload: Poll }
+ | { type: 'poll:vote'; commandId: string; payload: Vote & { pollId: string } }
+ | { type: 'poll:option:add'; commandId: string; payload: PollOption & { pollId: string } }
+ | { type: 'poll:role:update'; commandId: string; payload: RoleAssignment & { pollId: string } }
+ | { type: 'poll:status:update'; commandId: string; payload: { pollId: string; status: Poll['status'] } }
+ | { type: 'user:profile'; payload: UserProfile }
+ | { type: 'ack'; payload: { commandId: string; revision: number } }
+ | { type: 'error'; payload: { commandId: string; message: string } }
+ | { type: 'sync:request'; payload: { pollId: string } };
+
+// === Snapshot (server) ===
+
+export interface PollSnapshot {
+ pollId: string;
+ ownerId: string;
+ ownerPeerId: string;
+ title: string;
+ description: string;
+ options: { id: string; text: string }[];
+ voteCounts: Record;
+ totalVotes: number;
+ status: Poll['status'];
+ anonymous: boolean;
+ updatedAt: number;
+}
diff --git a/app/src/routes/+layout.svelte b/app/src/routes/+layout.svelte
new file mode 100644
index 0000000..4a3df1a
--- /dev/null
+++ b/app/src/routes/+layout.svelte
@@ -0,0 +1,9 @@
+
+
+
+ {@render children()}
+
diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte
new file mode 100644
index 0000000..68bd35e
--- /dev/null
+++ b/app/src/routes/+page.svelte
@@ -0,0 +1,23 @@
+
+
+
+ evocracy
+
+
+
+
+
evocracy
+
Decentralized polling, peer-to-peer.
+
+
+
+
diff --git a/app/src/routes/app/+layout.svelte b/app/src/routes/app/+layout.svelte
new file mode 100644
index 0000000..37bb84f
--- /dev/null
+++ b/app/src/routes/app/+layout.svelte
@@ -0,0 +1,57 @@
+
+
+
+ {#if !initialized}
+
+ {:else}
+
+ {@render children()}
+
+ {/if}
+
+
+
+
diff --git a/app/src/routes/app/create/+page.svelte b/app/src/routes/app/create/+page.svelte
new file mode 100644
index 0000000..db02ee1
--- /dev/null
+++ b/app/src/routes/app/create/+page.svelte
@@ -0,0 +1,138 @@
+
+
+
+ Create Poll – evocracy
+
+
+
diff --git a/app/src/routes/app/poll/[id]/+page.svelte b/app/src/routes/app/poll/[id]/+page.svelte
new file mode 100644
index 0000000..1ff077b
--- /dev/null
+++ b/app/src/routes/app/poll/[id]/+page.svelte
@@ -0,0 +1,273 @@
+
+
+
+ {poll?.title ?? 'Poll'} – evocracy
+
+
+
+ {#if !poll}
+
Poll not found
+
← Back to polls
+ {:else}
+
+
+
← Back
+
{poll.title}
+ {#if poll.description}
+
{poll.description}
+ {/if}
+
+
+ {poll.status === 'draft' ? '📝 Draft' : poll.status === 'open' ? '🟢 Open' : '🔴 Closed'}
+
+
+ {poll.anonymous ? '🔒 Anonymous' : '👤 Named'}
+
+
+ {myRole}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Results ({totalVotes} vote{totalVotes !== 1 ? 's' : ''})
+
+ {#each poll.options as option}
+ {@const counts = voteCounts(poll)}
+ {@const count = counts[option.id] || 0}
+ {@const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0}
+ {@const isMyVote = userVote?.optionId === option.id}
+
+
+ {/each}
+
+
+
+ {#if canAddOption(poll, userId)}
+
+ {/if}
+
+
+ {#if canManagePoll(poll, userId)}
+
+
+
+ {#if showManage}
+
+
+
+ {#if poll.status === 'draft'}
+
+ {/if}
+ {#if poll.status === 'open'}
+
+ {/if}
+ {#if poll.status === 'closed'}
+
+ {/if}
+
+
+
+ {#if canManageUsers(poll, userId)}
+
+
Participants ({poll.roles.length})
+ {#each poll.roles as role}
+
+
{role.userId.slice(0, 12)}...
+
+ {role.role}
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+ {#if canDeletePoll(poll, userId)}
+
+ {/if}
+
+ {/if}
+
+ {/if}
+ {/if}
+
diff --git a/app/src/routes/app/polls/+page.svelte b/app/src/routes/app/polls/+page.svelte
new file mode 100644
index 0000000..ed826e8
--- /dev/null
+++ b/app/src/routes/app/polls/+page.svelte
@@ -0,0 +1,73 @@
+
+
+
+ My Polls – evocracy
+
+
+
+
My Polls
+
+ {#if polls.loading}
+
Loading...
+ {:else if polls.all.length === 0}
+
+ {:else}
+ {#if polls.owned.length > 0}
+
Owned
+
+ {/if}
+
+ {#if polls.participating.length > 0}
+
Participating
+
+ {/if}
+ {/if}
+
diff --git a/app/src/routes/app/profile/+page.svelte b/app/src/routes/app/profile/+page.svelte
new file mode 100644
index 0000000..60ac87f
--- /dev/null
+++ b/app/src/routes/app/profile/+page.svelte
@@ -0,0 +1,127 @@
+
+
+
+ Profile – evocracy
+
+
+
+
Profile
+
+ {#if profile.loading}
+
Loading...
+ {:else}
+
+
Your Peer ID
+
{userId}
+
+
+
+
+
+
+
Tags
+
+ {#if profile.current?.tags.length}
+
+ {#each profile.current.tags as tag, i}
+
+ {tag.category}:
+ {tag.value}
+
+
+ {/each}
+
+ {/if}
+
+
+
+ {/if}
+
diff --git a/app/src/routes/embed/[id]/+page.svelte b/app/src/routes/embed/[id]/+page.svelte
new file mode 100644
index 0000000..6bc5b43
--- /dev/null
+++ b/app/src/routes/embed/[id]/+page.svelte
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+ {#if loading}
+
Loading...
+ {:else if !snapshot}
+
Poll unavailable
+ {:else}
+
{snapshot.title}
+
+ {totalVotes} vote{totalVotes !== 1 ? 's' : ''}
+ · {snapshot.status === 'open' ? '🟢 Open' : '🔴 Closed'}
+
+
+ {#each options as option}
+ {@const count = counts[option.id] || 0}
+ {@const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0}
+
+
+ {option.text}
+ {pct.toFixed(0)}%
+
+
+
+ {/each}
+
+
+ {/if}
+
diff --git a/app/src/routes/p/[id]/+page.svelte b/app/src/routes/p/[id]/+page.svelte
new file mode 100644
index 0000000..c5716f2
--- /dev/null
+++ b/app/src/routes/p/[id]/+page.svelte
@@ -0,0 +1,170 @@
+
+
+
+ {title || 'Poll'} – evocracy
+
+
+
+
+
+
+ {#if loading}
+
+ {:else if error && !snapshot && !livePoll}
+
+
{error}
+
The poll owner may need to be online, or the poll may not exist.
+
+ {:else}
+
+
+
+
{title}
+ {#if liveConnected}
+
+ Live
+
+ {/if}
+
+ {#if description}
+
{description}
+ {/if}
+
+
+ {status === 'open' ? '🟢 Open' : status === 'closed' ? '🔴 Closed' : '📝 Draft'}
+
+
+ {anonymous ? '🔒 Anonymous' : '👤 Named'}
+
+
+ {totalVotes} vote{totalVotes !== 1 ? 's' : ''}
+
+
+
+
+
+
+ {#each options as option}
+ {@const count = counts[option.id] || 0}
+ {@const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0}
+
+
+
+ {option.text}
+ {count} ({pct.toFixed(0)}%)
+
+
+
+ {/each}
+
+
+
+
+
+
+ Powered by evocracy
+
+ {/if}
+
diff --git a/app/static/robots.txt b/app/static/robots.txt
new file mode 100644
index 0000000..b6dd667
--- /dev/null
+++ b/app/static/robots.txt
@@ -0,0 +1,3 @@
+# allow crawling everything by default
+User-agent: *
+Disallow:
diff --git a/app/svelte.config.js b/app/svelte.config.js
new file mode 100644
index 0000000..d2dd2e6
--- /dev/null
+++ b/app/svelte.config.js
@@ -0,0 +1,17 @@
+import adapter from '@sveltejs/adapter-auto';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ kit: {
+ // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
+ // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
+ // See https://svelte.dev/docs/kit/adapters for more information about adapters.
+ adapter: adapter()
+ },
+ vitePlugin: {
+ dynamicCompileOptions: ({ filename }) =>
+ filename.includes('node_modules') ? undefined : { runes: true }
+ }
+};
+
+export default config;
diff --git a/app/tsconfig.json b/app/tsconfig.json
new file mode 100644
index 0000000..2c2ed3c
--- /dev/null
+++ b/app/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "rewriteRelativeImportExtensions": true,
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
+ //
+ // To make changes to top-level options such as include and exclude, we recommend extending
+ // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
+}
diff --git a/app/vite.config.ts b/app/vite.config.ts
new file mode 100644
index 0000000..2d35c4f
--- /dev/null
+++ b/app/vite.config.ts
@@ -0,0 +1,7 @@
+import tailwindcss from '@tailwindcss/vite';
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()]
+});
diff --git a/server/index.ts b/server/index.ts
new file mode 100644
index 0000000..b56382d
--- /dev/null
+++ b/server/index.ts
@@ -0,0 +1,146 @@
+import { readFile, writeFile, mkdir } from 'fs/promises';
+import { existsSync } from 'fs';
+import { join } from 'path';
+
+const DATA_DIR = join(import.meta.dir, 'data');
+const PORT = parseInt(process.env.PORT || '3001');
+
+// Ensure data dir exists
+if (!existsSync(DATA_DIR)) await mkdir(DATA_DIR, { recursive: true });
+
+// In-memory binding: pollId → ownerId (first writer wins)
+const ownerBindings = new Map();
+
+// Load existing bindings from disk
+async function loadBindings() {
+ const bindingsFile = join(DATA_DIR, '_bindings.json');
+ if (existsSync(bindingsFile)) {
+ const data = JSON.parse(await readFile(bindingsFile, 'utf-8'));
+ for (const [k, v] of Object.entries(data)) ownerBindings.set(k, v as string);
+ }
+}
+
+async function saveBindings() {
+ const bindingsFile = join(DATA_DIR, '_bindings.json');
+ await writeFile(bindingsFile, JSON.stringify(Object.fromEntries(ownerBindings)));
+}
+
+await loadBindings();
+
+// Rate limiting: simple per-IP counter
+const rateLimits = new Map();
+const RATE_LIMIT = 60; // requests per minute
+
+function checkRateLimit(ip: string): boolean {
+ const now = Date.now();
+ const entry = rateLimits.get(ip);
+
+ if (!entry || now > entry.reset) {
+ rateLimits.set(ip, { count: 1, reset: now + 60_000 });
+ return true;
+ }
+
+ entry.count++;
+ return entry.count <= RATE_LIMIT;
+}
+
+const server = Bun.serve({
+ port: PORT,
+
+ async fetch(req) {
+ const url = new URL(req.url);
+ const path = url.pathname;
+
+ // CORS
+ if (req.method === 'OPTIONS') {
+ return new Response(null, { headers: corsHeaders() });
+ }
+
+ // GET /api/polls/:id/snapshot
+ const getMatch = path.match(/^\/api\/polls\/([^/]+)\/snapshot$/);
+ if (getMatch && req.method === 'GET') {
+ const pollId = getMatch[1];
+ const file = join(DATA_DIR, `${pollId}.json`);
+
+ if (!existsSync(file)) {
+ return json({ error: 'Not found' }, 404);
+ }
+
+ const data = await readFile(file, 'utf-8');
+ return json(JSON.parse(data), 200);
+ }
+
+ // PUT /api/polls/:id/snapshot
+ const putMatch = path.match(/^\/api\/polls\/([^/]+)\/snapshot$/);
+ if (putMatch && req.method === 'PUT') {
+ const ip = req.headers.get('x-forwarded-for') || 'unknown';
+ if (!checkRateLimit(ip)) {
+ return json({ error: 'Rate limit exceeded' }, 429);
+ }
+
+ const pollId = putMatch[1];
+ const body = await req.json();
+
+ // Validate required fields
+ if (!body.ownerId || !body.title || !body.signature) {
+ return json({ error: 'Missing required fields' }, 400);
+ }
+
+ // Check owner binding
+ const existingOwner = ownerBindings.get(pollId);
+ if (existingOwner && existingOwner !== body.ownerId) {
+ return json({ error: 'Unauthorized: owner mismatch' }, 403);
+ }
+
+ // TODO: verify Ed25519 signature against body.ownerId
+ // For now, trust the ownerId binding as basic auth
+
+ // Bind on first write
+ if (!existingOwner) {
+ ownerBindings.set(pollId, body.ownerId);
+ await saveBindings();
+ }
+
+ const snapshot = {
+ pollId,
+ ownerId: body.ownerId,
+ ownerPeerId: body.ownerPeerId,
+ title: body.title,
+ description: body.description || '',
+ options: body.options || [],
+ voteCounts: body.voteCounts || {},
+ totalVotes: body.totalVotes || 0,
+ status: body.status || 'draft',
+ anonymous: body.anonymous ?? false,
+ updatedAt: Date.now()
+ };
+
+ const file = join(DATA_DIR, `${pollId}.json`);
+ await writeFile(file, JSON.stringify(snapshot, null, 2));
+
+ return json({ ok: true }, 200);
+ }
+
+ return json({ error: 'Not found' }, 404);
+ }
+});
+
+function corsHeaders(): Record {
+ return {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, PUT, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type'
+ };
+}
+
+function json(data: unknown, status: number): Response {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...corsHeaders()
+ }
+ });
+}
+
+console.log(`Snapshot server running on http://localhost:${PORT}`);
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..9089478
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "evocracy-server",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "bun run --watch index.ts",
+ "start": "bun run index.ts"
+ }
+}
diff --git a/specs/001-project-setup/README.md b/specs/001-project-setup/README.md
index 939300c..dc20114 100644
--- a/specs/001-project-setup/README.md
+++ b/specs/001-project-setup/README.md
@@ -1,16 +1,24 @@
---
-status: planned
+status: complete
created: '2026-03-16'
tags:
- infra
- setup
priority: high
created_at: '2026-03-16T07:51:47.401Z'
+updated_at: '2026-03-16T10:01:25.930Z'
+transitions:
+ - status: in-progress
+ at: '2026-03-16T09:49:02.744Z'
+ - status: complete
+ at: '2026-03-16T10:01:25.930Z'
+completed_at: '2026-03-16T10:01:25.930Z'
+completed: '2026-03-16'
---
# Project Setup & Architecture
-> **Status**: 📅 Planned · **Priority**: High · **Created**: 2026-03-16
+> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: infra, setup
## Overview
diff --git a/specs/002-p2p-networking/README.md b/specs/002-p2p-networking/README.md
index 39dd04f..5a915ad 100644
--- a/specs/002-p2p-networking/README.md
+++ b/specs/002-p2p-networking/README.md
@@ -1,5 +1,5 @@
---
-status: planned
+status: complete
created: '2026-03-16'
tags:
- p2p
@@ -8,12 +8,17 @@ priority: high
created_at: '2026-03-16T07:51:47.888Z'
depends_on:
- 001-project-setup
-updated_at: '2026-03-16T07:52:03.104Z'
+updated_at: '2026-03-16T10:01:26.362Z'
+completed_at: '2026-03-16T10:01:26.362Z'
+completed: '2026-03-16'
+transitions:
+ - status: complete
+ at: '2026-03-16T10:01:26.362Z'
---
# P2P Networking Layer
-> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: p2p, core
+> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: p2p, core
## Overview
diff --git a/specs/003-user-identity-profiles/README.md b/specs/003-user-identity-profiles/README.md
index 7b662ee..a704c4b 100644
--- a/specs/003-user-identity-profiles/README.md
+++ b/specs/003-user-identity-profiles/README.md
@@ -1,5 +1,5 @@
---
-status: planned
+status: complete
created: '2026-03-16'
tags:
- identity
@@ -8,12 +8,17 @@ priority: high
created_at: '2026-03-16T07:51:48.340Z'
depends_on:
- 001-project-setup
-updated_at: '2026-03-16T07:52:03.509Z'
+updated_at: '2026-03-16T10:01:26.785Z'
+completed_at: '2026-03-16T10:01:26.785Z'
+completed: '2026-03-16'
+transitions:
+ - status: complete
+ at: '2026-03-16T10:01:26.785Z'
---
# User Identity & Profiles
-> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: identity, profiles
+> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: identity, profiles
## Overview
diff --git a/specs/004-poll-data-model/README.md b/specs/004-poll-data-model/README.md
index 3eaef95..7704c55 100644
--- a/specs/004-poll-data-model/README.md
+++ b/specs/004-poll-data-model/README.md
@@ -1,5 +1,5 @@
---
-status: planned
+status: complete
created: '2026-03-16'
tags:
- data
@@ -9,12 +9,17 @@ created_at: '2026-03-16T07:51:48.793Z'
depends_on:
- 002-p2p-networking
- 003-user-identity-profiles
-updated_at: '2026-03-16T07:52:03.943Z'
+updated_at: '2026-03-16T10:01:27.181Z'
+completed_at: '2026-03-16T10:01:27.181Z'
+completed: '2026-03-16'
+transitions:
+ - status: complete
+ at: '2026-03-16T10:01:27.181Z'
---
# Poll Data Model & Sync
-> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: data, core
+> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: data, core
## Overview
diff --git a/specs/005-poll-creation-management/README.md b/specs/005-poll-creation-management/README.md
index ec0f944..45df9eb 100644
--- a/specs/005-poll-creation-management/README.md
+++ b/specs/005-poll-creation-management/README.md
@@ -1,5 +1,5 @@
---
-status: planned
+status: complete
created: '2026-03-16'
tags:
- polls
@@ -8,14 +8,19 @@ priority: high
created_at: '2026-03-16T07:51:49.209Z'
depends_on:
- 004-poll-data-model
-updated_at: '2026-03-16T07:52:06.762Z'
+updated_at: '2026-03-16T10:01:27.560Z'
related:
- 011-mobile-first-ui
+completed_at: '2026-03-16T10:01:27.560Z'
+completed: '2026-03-16'
+transitions:
+ - status: complete
+ at: '2026-03-16T10:01:27.560Z'
---
# Poll Creation & Management
-> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: polls, management
+> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: polls, management
## Overview
diff --git a/specs/008-voting-system/README.md b/specs/008-voting-system/README.md
index 0dfebb8..2e069b5 100644
--- a/specs/008-voting-system/README.md
+++ b/specs/008-voting-system/README.md
@@ -1,5 +1,5 @@
---
-status: planned
+status: complete
created: '2026-03-16'
tags:
- voting
@@ -8,14 +8,19 @@ priority: high
created_at: '2026-03-16T07:51:50.525Z'
depends_on:
- 005-poll-creation-management
-updated_at: '2026-03-16T09:18:58.099Z'
+updated_at: '2026-03-16T10:01:27.962Z'
related:
- 011-mobile-first-ui
+completed_at: '2026-03-16T10:01:27.962Z'
+completed: '2026-03-16'
+transitions:
+ - status: complete
+ at: '2026-03-16T10:01:27.962Z'
---
# Voting System & Anonymity
-> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: voting, core
+> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: voting, core
## Overview
diff --git a/specs/009-public-sharing/README.md b/specs/009-public-sharing/README.md
index 96cbd23..e352119 100644
--- a/specs/009-public-sharing/README.md
+++ b/specs/009-public-sharing/README.md
@@ -1,5 +1,5 @@
---
-status: planned
+status: complete
created: '2026-03-16'
tags:
- sharing
@@ -10,14 +10,19 @@ depends_on:
- 005-poll-creation-management
- 008-voting-system
- 012-lightweight-server
-updated_at: '2026-03-16T07:57:41.176Z'
+updated_at: '2026-03-16T10:01:28.366Z'
related:
- 011-mobile-first-ui
+completed_at: '2026-03-16T10:01:28.366Z'
+completed: '2026-03-16'
+transitions:
+ - status: complete
+ at: '2026-03-16T10:01:28.366Z'
---
# Public Sharing & Read-Only View
-> **Status**: 🗓️ Planned · **Priority**: Medium · **Created**: 2026-03-16 · **Tags**: sharing, web
+> **Status**: ✅ Complete · **Priority**: Medium · **Created**: 2026-03-16 · **Tags**: sharing, web
## Overview
diff --git a/specs/010-embeddable-widget/README.md b/specs/010-embeddable-widget/README.md
index 8286f2c..b929982 100644
--- a/specs/010-embeddable-widget/README.md
+++ b/specs/010-embeddable-widget/README.md
@@ -1,5 +1,5 @@
---
-status: planned
+status: complete
created: '2026-03-16'
tags:
- embed
@@ -8,12 +8,17 @@ priority: low
created_at: '2026-03-16T07:51:51.430Z'
depends_on:
- 009-public-sharing
-updated_at: '2026-03-16T07:52:06.356Z'
+updated_at: '2026-03-16T10:01:28.760Z'
+completed_at: '2026-03-16T10:01:28.760Z'
+completed: '2026-03-16'
+transitions:
+ - status: complete
+ at: '2026-03-16T10:01:28.760Z'
---
# Embeddable Poll Widget
-> **Status**: 🗓️ Planned · **Priority**: Low · **Created**: 2026-03-16 · **Tags**: embed, widget
+> **Status**: ✅ Complete · **Priority**: Low · **Created**: 2026-03-16 · **Tags**: embed, widget
## Overview
diff --git a/specs/012-lightweight-server/README.md b/specs/012-lightweight-server/README.md
index 1052367..3c78209 100644
--- a/specs/012-lightweight-server/README.md
+++ b/specs/012-lightweight-server/README.md
@@ -1,5 +1,5 @@
---
-status: planned
+status: complete
created: '2026-03-16'
tags:
- server
@@ -8,12 +8,17 @@ priority: high
created_at: '2026-03-16T07:57:36.544Z'
depends_on:
- 001-project-setup
-updated_at: '2026-03-16T09:32:10.798Z'
+updated_at: '2026-03-16T10:01:29.167Z'
+completed_at: '2026-03-16T10:01:29.167Z'
+completed: '2026-03-16'
+transitions:
+ - status: complete
+ at: '2026-03-16T10:01:29.167Z'
---
# Poll Snapshot Server
-> **Status**: 🗓️ Planned · **Priority**: High · **Created**: 2026-03-16 · **Tags**: server, infra
+> **Status**: ✅ Complete · **Priority**: High · **Created**: 2026-03-16 · **Tags**: server, infra
## Overview