diff --git a/Dockerfile b/Dockerfile
index 3571d92..30a9b85 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,17 +1,18 @@
# Stage 1: Build
FROM oven/bun:1 AS build
WORKDIR /app
-COPY package.json bun.lock ./
-RUN bun install --frozen-lockfile
-COPY . .
-ARG VITE_NKODE_API_URL=https://api.nkode.donovankelly.xyz
+
+ARG VITE_NKODE_API_URL
ENV VITE_NKODE_API_URL=$VITE_NKODE_API_URL
+
+COPY package.json bun.lock* ./
+RUN bun install --frozen-lockfile || bun install
+COPY . .
RUN bun run build
# Stage 2: Serve
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
-# SPA routing: all paths → index.html
RUN printf 'server {\n listen 80;\n root /usr/share/nginx/html;\n index index.html;\n location / {\n try_files $uri $uri/ /index.html;\n }\n}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
diff --git a/bun.lock b/bun.lock
index b6056aa..42621c2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,11 +7,17 @@
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "react-router-dom": "^6.30.3",
+ "vite-plugin-top-level-await": "^1.6.0",
+ "vite-plugin-wasm": "^3.5.0",
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
- "@types/node": "^24.10.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/node": "^25.1.0",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
@@ -19,15 +25,26 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
- "react-router-dom": "^7.13.0",
+ "jsdom": "^27.4.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
+ "vitest": "^4.0.18",
},
},
},
"packages": {
+ "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
+
+ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
+
+ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.1", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.4" } }, "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ=="],
+
+ "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.6", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.4" } }, "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg=="],
+
+ "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
+
"@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
"@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="],
@@ -60,12 +77,26 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
+ "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
+
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
"@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="],
+ "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
+
+ "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
+
+ "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="],
+
+ "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
+
+ "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.26", "", {}, "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA=="],
+
+ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
+
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
@@ -136,6 +167,8 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
+ "@exodus/bytes": ["@exodus/bytes@1.10.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg=="],
+
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -154,8 +187,12 @@
"@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=="],
+ "@remix-run/router": ["@remix-run/router@1.23.2", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="],
+
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
+ "@rollup/plugin-virtual": ["@rollup/plugin-virtual@3.0.2", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A=="],
+
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg=="],
@@ -206,6 +243,36 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ=="],
+ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
+ "@swc/core": ["@swc/core@1.15.11", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.11", "@swc/core-darwin-x64": "1.15.11", "@swc/core-linux-arm-gnueabihf": "1.15.11", "@swc/core-linux-arm64-gnu": "1.15.11", "@swc/core-linux-arm64-musl": "1.15.11", "@swc/core-linux-x64-gnu": "1.15.11", "@swc/core-linux-x64-musl": "1.15.11", "@swc/core-win32-arm64-msvc": "1.15.11", "@swc/core-win32-ia32-msvc": "1.15.11", "@swc/core-win32-x64-msvc": "1.15.11" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w=="],
+
+ "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg=="],
+
+ "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA=="],
+
+ "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.11", "", { "os": "linux", "cpu": "arm" }, "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg=="],
+
+ "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA=="],
+
+ "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w=="],
+
+ "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.11", "", { "os": "linux", "cpu": "x64" }, "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ=="],
+
+ "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw=="],
+
+ "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA=="],
+
+ "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw=="],
+
+ "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.11", "", { "os": "win32", "cpu": "x64" }, "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw=="],
+
+ "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
+
+ "@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
+
+ "@swc/wasm": ["@swc/wasm@1.15.11", "", {}, "sha512-230rdYZf8ux3nIwISOQNCFrxzxpL/UFY4Khv/3UsvpEdo709j/+Tg80yXWW3DXETeZNPBV72QpvEBhXsl7Lc9g=="],
+
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
@@ -236,6 +303,16 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
+ "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
+
+ "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
+
+ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
+
+ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
+
+ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
+
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -244,11 +321,15 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
+ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+
+ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
- "@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="],
+ "@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
"@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
@@ -276,20 +357,44 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
+ "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
+
+ "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
+
+ "@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="],
+
+ "@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="],
+
+ "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="],
+
+ "@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="],
+
+ "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
+
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
+ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
+
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
+ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
+
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
@@ -298,6 +403,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
+ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -308,22 +415,38 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
- "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
-
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+ "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="],
+
+ "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
+
+ "cssstyle": ["cssstyle@5.3.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="],
+
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+ "data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="],
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
+
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+
"electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
+ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+ "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
+
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -348,8 +471,12 @@
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
+
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -382,16 +509,26 @@
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
+ "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
+
+ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
+
+ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
+
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
+
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -400,6 +537,8 @@
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
+ "jsdom": ["jsdom@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="],
+
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -442,10 +581,16 @@
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
- "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+ "lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="],
+
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+ "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
+
+ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -456,6 +601,8 @@
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
+ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
+
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -464,10 +611,14 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
+ "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
+
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -476,44 +627,76 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
+
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
+ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
- "react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
+ "react-router": ["react-router@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="],
- "react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="],
+ "react-router-dom": ["react-router-dom@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="],
+
+ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
+
+ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
+ "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
-
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
+ "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
+
+ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
+
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
+
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
+ "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
+
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
+
+ "tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="],
+
+ "tldts-core": ["tldts-core@7.0.19", "", {}, "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A=="],
+
+ "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
+
+ "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
+
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -528,12 +711,36 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+ "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
+
"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=="],
+ "vite-plugin-top-level-await": ["vite-plugin-top-level-await@1.6.0", "", { "dependencies": { "@rollup/plugin-virtual": "^3.0.2", "@swc/core": "^1.12.14", "@swc/wasm": "^1.12.14", "uuid": "10.0.0" }, "peerDependencies": { "vite": ">=2.8" } }, "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww=="],
+
+ "vite-plugin-wasm": ["vite-plugin-wasm@3.5.0", "", { "peerDependencies": { "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" } }, "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ=="],
+
+ "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
+
+ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
+
+ "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
+
+ "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
+
+ "whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="],
+
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+ "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
+
+ "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
+
+ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
+
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -542,6 +749,8 @@
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
+ "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
@@ -558,12 +767,20 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+
+ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
+
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
+ "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
+
+ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
+
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
}
}
diff --git a/index.html b/index.html
index 4db77b7..25e430c 100644
--- a/index.html
+++ b/index.html
@@ -2,11 +2,12 @@
+
- nKode — Passwordless Auth
-
-
-
+
+
+
+ nKode
diff --git a/package.json b/package.json
index e5bdc19..c64c65d 100644
--- a/package.json
+++ b/package.json
@@ -1,22 +1,29 @@
{
"name": "nkode-web",
"private": true,
- "version": "0.0.0",
+ "version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run"
},
"dependencies": {
"react": "^19.2.0",
- "react-dom": "^19.2.0"
+ "react-dom": "^19.2.0",
+ "react-router-dom": "^6.30.3",
+ "vite-plugin-top-level-await": "^1.6.0",
+ "vite-plugin-wasm": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
- "@types/node": "^24.10.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/node": "^25.1.0",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
@@ -24,10 +31,11 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
- "react-router-dom": "^7.13.0",
+ "jsdom": "^27.4.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
- "vite": "^7.2.4"
+ "vite": "^7.2.4",
+ "vitest": "^4.0.18"
}
}
diff --git a/public/wasm/nkode_client_wasm_bg.js b/public/wasm/nkode_client_wasm_bg.js
index 9416f3b..3c4a898 100644
--- a/public/wasm/nkode_client_wasm_bg.js
+++ b/public/wasm/nkode_client_wasm_bg.js
@@ -96,6 +96,14 @@ function getStringFromWasm0(ptr, len) {
return decodeText(ptr, len);
}
+let cachedUint32ArrayMemory0 = null;
+function getUint32ArrayMemory0() {
+ if (cachedUint32ArrayMemory0 === null || cachedUint32ArrayMemory0.byteLength === 0) {
+ cachedUint32ArrayMemory0 = new Uint32Array(wasm.memory.buffer);
+ }
+ return cachedUint32ArrayMemory0;
+}
+
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
@@ -145,6 +153,13 @@ function makeMutClosure(arg0, arg1, dtor, f) {
return real;
}
+function passArray32ToWasm0(arg, malloc) {
+ const ptr = malloc(arg.length * 4, 4) >>> 0;
+ getUint32ArrayMemory0().set(arg, ptr / 4);
+ WASM_VECTOR_LEN = arg.length;
+ return ptr;
+}
+
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
@@ -189,6 +204,12 @@ function passStringToWasm0(arg, malloc, realloc) {
return ptr;
}
+function takeFromExternrefTable0(idx) {
+ const value = wasm.__wbindgen_externrefs.get(idx);
+ wasm.__externref_table_dealloc(idx);
+ return value;
+}
+
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
@@ -400,6 +421,56 @@ export class NKodeClient {
const ret = wasm.nkodeclient_updateLoginData(this.__wbg_ptr, ptr0, len0);
return ret;
}
+ /**
+ * Decipher key selections into OPAQUE passcode bytes for code login.
+ * Call this after the user taps their nKode sequence on the keypad.
+ *
+ * @param userId - The user's ID
+ * @param secretKeyHex - The user's secret key as hex
+ * @param loginDataBytes - Raw JSON bytes of login data (from server)
+ * @param keySelections - Array of key indices the user tapped
+ * @returns Uint8Array - The passcode bytes to pass to loginCode()
+ * @param {string} secret_key_hex
+ * @param {string} login_data_json
+ * @param {Uint32Array} key_selections
+ * @returns {Uint8Array}
+ */
+ decipherSelection(secret_key_hex, login_data_json, key_selections) {
+ const ptr0 = passStringToWasm0(secret_key_hex, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
+ const len0 = WASM_VECTOR_LEN;
+ const ptr1 = passStringToWasm0(login_data_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
+ const len1 = WASM_VECTOR_LEN;
+ const ptr2 = passArray32ToWasm0(key_selections, wasm.__wbindgen_malloc);
+ const len2 = WASM_VECTOR_LEN;
+ const ret = wasm.nkodeclient_decipherSelection(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
+ if (ret[3]) {
+ throw takeFromExternrefTable0(ret[2]);
+ }
+ var v4 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
+ wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
+ return v4;
+ }
+ /**
+ * Prepare for code login: fetch login data, reconstruct keypad, and fetch icons.
+ * Returns the keypad configuration with icons for UI display.
+ *
+ * Also stores the raw login data JSON internally for decipherSelection().
+ *
+ * @param userId - The user's ID
+ * @param secretKeyHex - The user's secret key as hex string
+ * @returns Promise - { keypadIndices, propertiesPerKey, numberOfKeys, mask, icons, loginDataJson }
+ * @param {string} user_id
+ * @param {string} secret_key_hex
+ * @returns {Promise}
+ */
+ prepareCodeLogin(user_id, secret_key_hex) {
+ const ptr0 = passStringToWasm0(user_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
+ const len0 = WASM_VECTOR_LEN;
+ const ptr1 = passStringToWasm0(secret_key_hex, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
+ const len1 = WASM_VECTOR_LEN;
+ const ret = wasm.nkodeclient_prepareCodeLogin(this.__wbg_ptr, ptr0, len0, ptr1, len1);
+ return ret;
+ }
/**
* Generate a new random 16-byte secret key, returned as a hex string (32 chars).
* @returns {string}
@@ -416,6 +487,52 @@ export class NKodeClient {
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
}
}
+ /**
+ * Prepare icons for code registration (requires active key session).
+ * Fetches icons from the server and randomizes their names via ChaCha20.
+ * Stores intermediate state internally for completeCodeRegistration().
+ *
+ * @returns Promise - JSON: { icons: [{ file_name, file_type, img_data }] }
+ * @returns {Promise}
+ */
+ prepareCodeRegistration() {
+ const ret = wasm.nkodeclient_prepareCodeRegistration(this.__wbg_ptr);
+ return ret;
+ }
+ /**
+ * Complete code registration after icon selection.
+ * Enciphers the selection, registers OPAQUE code auth, and stores login data.
+ *
+ * @param selectedIndices - Array of icon indices the user selected (global indices, not key indices)
+ * @returns Promise
+ * @param {Uint32Array} selected_indices
+ * @returns {Promise}
+ */
+ completeCodeRegistration(selected_indices) {
+ const ptr0 = passArray32ToWasm0(selected_indices, wasm.__wbindgen_malloc);
+ const len0 = WASM_VECTOR_LEN;
+ const ret = wasm.nkodeclient_completeCodeRegistration(this.__wbg_ptr, ptr0, len0);
+ return ret;
+ }
+ /**
+ * Complete code registration with email (full version).
+ * Enciphers the selection, registers OPAQUE code auth, and stores login data on server.
+ *
+ * @param email - User's email address
+ * @param selectedIndices - Uint32Array of icon indices the user selected
+ * @returns Promise
+ * @param {string} email
+ * @param {Uint32Array} selected_indices
+ * @returns {Promise}
+ */
+ completeCodeRegistrationWithEmail(email, selected_indices) {
+ const ptr0 = passStringToWasm0(email, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
+ const len0 = WASM_VECTOR_LEN;
+ const ptr1 = passArray32ToWasm0(selected_indices, wasm.__wbindgen_malloc);
+ const len1 = WASM_VECTOR_LEN;
+ const ret = wasm.nkodeclient_completeCodeRegistrationWithEmail(this.__wbg_ptr, ptr0, len0, ptr1, len1);
+ return ret;
+ }
/**
* Create a new client pointed at the given nKode server base URL.
* @param {string} base_url
@@ -567,6 +684,10 @@ export function __wbg_fetch_8119fbf8d0e4f4d1(arg0, arg1) {
return ret;
};
+export function __wbg_getRandomValues_1c61fac11405ffdc() { return handleError(function (arg0, arg1) {
+ globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
+}, arguments) };
+
export function __wbg_getRandomValues_b8f5dbd5f3995a9e() { return handleError(function (arg0, arg1) {
arg0.getRandomValues(arg1);
}, arguments) };
@@ -816,12 +937,6 @@ export function __wbg_versions_c01dfd4722a88165(arg0) {
return ret;
};
-export function __wbindgen_cast_151ffb1b798ab8ff(arg0, arg1) {
- // Cast intrinsic for `Closure(Closure { dtor_idx: 76, function: Function { arguments: [Externref], shim_idx: 77, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
- const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h28b97059ae600264, wasm_bindgen__convert__closures_____invoke__h8f97ce5df83102bb);
- return ret;
-};
-
export function __wbindgen_cast_2241b6af4c4b2941(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
@@ -852,6 +967,12 @@ export function __wbindgen_cast_d6cd19b81560fd6e(arg0) {
return ret;
};
+export function __wbindgen_cast_f2cc0f2a96e2ef5b(arg0, arg1) {
+ // Cast intrinsic for `Closure(Closure { dtor_idx: 115, function: Function { arguments: [Externref], shim_idx: 116, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
+ const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h28b97059ae600264, wasm_bindgen__convert__closures_____invoke__h8f97ce5df83102bb);
+ return ret;
+};
+
export function __wbindgen_init_externref_table() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
diff --git a/public/wasm/nkode_client_wasm_bg.wasm b/public/wasm/nkode_client_wasm_bg.wasm
index 4ac5f57..035991b 100644
Binary files a/public/wasm/nkode_client_wasm_bg.wasm and b/public/wasm/nkode_client_wasm_bg.wasm differ
diff --git a/src/App.tsx b/src/App.tsx
index 5935451..85cf8ce 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,41 +1,41 @@
-import { lazy, Suspense } from 'react';
-import { BrowserRouter, Routes, Route } from 'react-router-dom';
-import { Layout } from '@/components/Layout';
-import { AuthContext, useAuthState } from '@/hooks/useAuth';
-import { ROUTES } from '@/lib/types';
+import { Routes, Route, Navigate } from 'react-router-dom'
+import { AuthProvider, useAuth } from './hooks/useAuth'
+import Layout from './components/Layout'
+import SusiPage from './pages/SusiPage'
+import LoginKeypadPage from './pages/LoginKeypadPage'
+import SignupKeypadPage from './pages/SignupKeypadPage'
+import HomePage from './pages/HomePage'
+import NotFoundPage from './pages/NotFoundPage'
-const HomePage = lazy(() => import('@/pages/HomePage').then((m) => ({ default: m.HomePage })));
-const LoginPage = lazy(() => import('@/pages/LoginPage').then((m) => ({ default: m.LoginPage })));
-const SignupPage = lazy(() => import('@/pages/SignupPage').then((m) => ({ default: m.SignupPage })));
-const AdminPage = lazy(() => import('@/pages/AdminPage').then((m) => ({ default: m.AdminPage })));
-const DeveloperPage = lazy(() => import('@/pages/DeveloperPage').then((m) => ({ default: m.DeveloperPage })));
+function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const { isAuthenticated } = useAuth()
+ if (!isAuthenticated) return
+ return <>{children}>
+}
-function Loading() {
- return (
-
- );
+function GuestRoute({ children }: { children: React.ReactNode }) {
+ const { isAuthenticated } = useAuth()
+ if (isAuthenticated) return
+ return <>{children}>
}
export default function App() {
- const auth = useAuthState();
-
return (
-
-
- }>
-
- }>
- } />
- } />
- } />
- } />
- } />
-
-
-
-
-
- );
+
+
+ }>
+ {/* Guest routes */}
+ } />
+ } />
+ } />
+
+ {/* Protected routes */}
+ } />
+
+ {/* 404 */}
+ } />
+
+
+
+ )
}
diff --git a/src/__tests__/AuthFlow.test.tsx b/src/__tests__/AuthFlow.test.tsx
new file mode 100644
index 0000000..729f8dd
--- /dev/null
+++ b/src/__tests__/AuthFlow.test.tsx
@@ -0,0 +1,223 @@
+/**
+ * Auth flow integration tests — verify the sequence of API calls
+ * and navigation for login and signup flows.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import SusiPage from '../pages/SusiPage'
+import * as api from '../services/api'
+
+// Mock the entire api module
+vi.mock('../services/api', () => ({
+ loginKey: vi.fn(),
+ prepareCodeLogin: vi.fn(),
+ generateSecretKey: vi.fn(),
+ registerKey: vi.fn(),
+ prepareCodeRegistration: vi.fn(),
+}))
+
+// Mock useNavigate
+const mockNavigate = vi.fn()
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom')
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ }
+})
+
+function renderSusiPage() {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('Auth Flow Integration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Login flow', () => {
+ it('calls loginKey → prepareCodeLogin → navigates to /login-keypad', async () => {
+ const user = userEvent.setup()
+ const mockSession = {
+ sessionId: 'sess-123',
+ userId: 'user-abc',
+ createdAt: '2025-01-01',
+ expiresAt: '2025-01-02',
+ }
+ const mockCodeLoginData = {
+ keypadIndices: [0, 1, 2],
+ propertiesPerKey: 3,
+ numberOfKeys: 6,
+ mask: [1, 0, 1],
+ icons: [],
+ loginDataJson: '{}',
+ }
+
+ vi.mocked(api.loginKey).mockResolvedValue(mockSession)
+ vi.mocked(api.prepareCodeLogin).mockResolvedValue(mockCodeLoginData)
+
+ renderSusiPage()
+
+ // Fill in email
+ await user.type(screen.getByPlaceholderText('email'), 'test@example.com')
+
+ // Fill in secret key (32 hex chars)
+ await user.type(
+ screen.getByPlaceholderText('secret key (32 hex chars)'),
+ 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
+ )
+
+ // Submit (find the submit button, not the tab)
+ const loginButtons = screen.getAllByRole('button', { name: /login/i })
+ const submitBtn = loginButtons.find(b => b.getAttribute('type') === 'submit')!
+ await user.click(submitBtn)
+
+ await waitFor(() => {
+ // Step 1: loginKey called with email + key
+ expect(api.loginKey).toHaveBeenCalledWith(
+ 'test@example.com',
+ 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
+ )
+ })
+
+ await waitFor(() => {
+ // Step 2: prepareCodeLogin called with userId + key
+ expect(api.prepareCodeLogin).toHaveBeenCalledWith(
+ 'user-abc',
+ 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
+ )
+ })
+
+ await waitFor(() => {
+ // Step 3: navigate to /login-keypad with state
+ expect(mockNavigate).toHaveBeenCalledWith('/login-keypad', {
+ state: {
+ email: 'test@example.com',
+ secretKeyHex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
+ userId: 'user-abc',
+ codeLoginData: mockCodeLoginData,
+ },
+ })
+ })
+ })
+
+ it('shows error on login failure', async () => {
+ const user = userEvent.setup()
+ vi.mocked(api.loginKey).mockRejectedValue(new Error('Invalid credentials'))
+
+ renderSusiPage()
+
+ await user.type(screen.getByPlaceholderText('email'), 'test@example.com')
+ await user.type(
+ screen.getByPlaceholderText('secret key (32 hex chars)'),
+ 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
+ )
+ const loginButtons = screen.getAllByRole('button', { name: /login/i })
+ const submitBtn = loginButtons.find(b => b.getAttribute('type') === 'submit')!
+ await user.click(submitBtn)
+
+ await waitFor(() => {
+ expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Signup flow', () => {
+ it('calls generateSecretKey → registerKey → loginKey → prepareCodeRegistration → navigates to /signup-keypad', async () => {
+ const user = userEvent.setup()
+ const mockSecretKey = 'deadbeef12345678deadbeef12345678'
+ const mockSession = {
+ sessionId: 'sess-456',
+ userId: 'user-xyz',
+ createdAt: '2025-01-01',
+ expiresAt: '2025-01-02',
+ }
+ const mockIcons = {
+ icons: [
+ { file_name: 'icon1.svg', file_type: 'svg', img_data: '' },
+ ],
+ }
+
+ vi.mocked(api.generateSecretKey).mockReturnValue(mockSecretKey)
+ vi.mocked(api.registerKey).mockResolvedValue(undefined)
+ vi.mocked(api.loginKey).mockResolvedValue(mockSession)
+ vi.mocked(api.prepareCodeRegistration).mockResolvedValue(mockIcons)
+
+ renderSusiPage()
+
+ // Switch to signup tab
+ await user.click(screen.getByRole('button', { name: /sign up/i }))
+
+ // Fill in email
+ await user.type(screen.getByPlaceholderText('email'), 'newuser@example.com')
+
+ // Submit — the submit button in signup tab also says "Sign Up"
+ // We need the form submit button, not the tab button
+ const buttons = screen.getAllByRole('button', { name: /sign up/i })
+ // The last one is the form submit button
+ const submitBtn = buttons[buttons.length - 1]
+ await user.click(submitBtn)
+
+ await waitFor(() => {
+ // Step 1: generateSecretKey
+ expect(api.generateSecretKey).toHaveBeenCalled()
+ })
+
+ await waitFor(() => {
+ // Step 2: registerKey
+ expect(api.registerKey).toHaveBeenCalledWith('newuser@example.com', mockSecretKey)
+ })
+
+ await waitFor(() => {
+ // Step 3: loginKey
+ expect(api.loginKey).toHaveBeenCalledWith('newuser@example.com', mockSecretKey)
+ })
+
+ await waitFor(() => {
+ // Step 4: prepareCodeRegistration
+ expect(api.prepareCodeRegistration).toHaveBeenCalled()
+ })
+
+ await waitFor(() => {
+ // Step 5: navigate to /signup-keypad
+ expect(mockNavigate).toHaveBeenCalledWith('/signup-keypad', {
+ state: {
+ email: 'newuser@example.com',
+ secretKeyHex: mockSecretKey,
+ userId: 'user-xyz',
+ icons: mockIcons.icons,
+ },
+ })
+ })
+ })
+ })
+
+ describe('No method selection anywhere', () => {
+ it('login tab has no method selection step', () => {
+ renderSusiPage()
+
+ expect(screen.queryByText(/choose.*method/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
+ expect(screen.queryByRole('radio')).not.toBeInTheDocument()
+ })
+
+ it('signup tab has no method selection step', async () => {
+ const user = userEvent.setup()
+ renderSusiPage()
+
+ await user.click(screen.getByRole('button', { name: /sign up/i }))
+
+ expect(screen.queryByText(/choose.*method/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
+ expect(screen.queryByRole('radio')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/__tests__/Keypad.test.tsx b/src/__tests__/Keypad.test.tsx
new file mode 100644
index 0000000..e6953dc
--- /dev/null
+++ b/src/__tests__/Keypad.test.tsx
@@ -0,0 +1,194 @@
+/**
+ * Keypad component tests — verify rendering, selection, backspace, submit, SVG rendering.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import Keypad from '../components/Keypad'
+
+// Simple SVG test data
+const makeSvg = (id: number) =>
+ ``
+
+function makeSvgs(count: number): string[] {
+ return Array.from({ length: count }, (_, i) => makeSvg(i))
+}
+
+describe('Keypad', () => {
+ let onComplete: (selection: number[]) => void
+
+ beforeEach(() => {
+ onComplete = vi.fn() as unknown as (selection: number[]) => void
+ })
+
+ it('renders correct number of keys based on numbOfKeys prop', () => {
+ const numbOfKeys = 6
+ const attrsPerKey = 3
+ const svgs = makeSvgs(numbOfKeys * attrsPerKey)
+
+ render(
+ ,
+ )
+
+ const keys = screen.getAllByRole('button', { name: /Key \d+/ })
+ expect(keys).toHaveLength(numbOfKeys)
+ })
+
+ it('each key has attrsPerKey icon cells', () => {
+ const numbOfKeys = 4
+ const attrsPerKey = 3
+ const svgs = makeSvgs(numbOfKeys * attrsPerKey)
+
+ render(
+ ,
+ )
+
+ // Each key button should contain attrsPerKey icon cells
+ const keys = screen.getAllByRole('button', { name: /Key \d+/ })
+ keys.forEach((key) => {
+ const cells = key.querySelectorAll('.keypad-icon-cell')
+ expect(cells).toHaveLength(attrsPerKey)
+ })
+ })
+
+ it('pressing a key adds to selection (dot appears)', async () => {
+ const user = userEvent.setup()
+ const numbOfKeys = 4
+ const attrsPerKey = 3
+ const svgs = makeSvgs(numbOfKeys * attrsPerKey)
+
+ render(
+ ,
+ )
+
+ // Initially shows placeholder
+ expect(screen.getByText(/tap icons to enter/i)).toBeInTheDocument()
+
+ // Click first key
+ await user.click(screen.getByRole('button', { name: 'Key 1' }))
+
+ // Placeholder gone, dot appears
+ expect(screen.queryByText(/tap icons to enter/i)).not.toBeInTheDocument()
+ expect(screen.getByText('•')).toBeInTheDocument()
+
+ // Click second key
+ await user.click(screen.getByRole('button', { name: 'Key 2' }))
+ expect(screen.getByText('••')).toBeInTheDocument()
+ })
+
+ it('backspace removes last selection', async () => {
+ const user = userEvent.setup()
+ const numbOfKeys = 4
+ const attrsPerKey = 3
+ const svgs = makeSvgs(numbOfKeys * attrsPerKey)
+
+ render(
+ ,
+ )
+
+ // Add two selections
+ await user.click(screen.getByRole('button', { name: 'Key 1' }))
+ await user.click(screen.getByRole('button', { name: 'Key 2' }))
+ expect(screen.getByText('••')).toBeInTheDocument()
+
+ // Click backspace
+ await user.click(screen.getByRole('button', { name: /delete last selection/i }))
+ expect(screen.getByText('•')).toBeInTheDocument()
+
+ // Click backspace again
+ await user.click(screen.getByRole('button', { name: /delete last selection/i }))
+ expect(screen.getByText(/tap icons to enter/i)).toBeInTheDocument()
+ })
+
+ it('submit calls onComplete with selection array', async () => {
+ const user = userEvent.setup()
+ const numbOfKeys = 4
+ const attrsPerKey = 3
+ const svgs = makeSvgs(numbOfKeys * attrsPerKey)
+
+ render(
+ ,
+ )
+
+ // Select keys 0, 2, 1
+ await user.click(screen.getByRole('button', { name: 'Key 1' }))
+ await user.click(screen.getByRole('button', { name: 'Key 3' }))
+ await user.click(screen.getByRole('button', { name: 'Key 2' }))
+
+ // Click submit
+ await user.click(screen.getByRole('button', { name: /submit/i }))
+
+ expect(onComplete).toHaveBeenCalledOnce()
+ expect(onComplete).toHaveBeenCalledWith([0, 2, 1])
+ })
+
+ it('submit is disabled when no selection', () => {
+ const numbOfKeys = 4
+ const attrsPerKey = 3
+ const svgs = makeSvgs(numbOfKeys * attrsPerKey)
+
+ render(
+ ,
+ )
+
+ const submitBtn = screen.getByRole('button', { name: /submit/i })
+ expect(submitBtn).toBeDisabled()
+ })
+
+ it('renders SVGs as-is (no color manipulation)', () => {
+ const numbOfKeys = 2
+ const attrsPerKey = 1
+ const svgs = [
+ '',
+ '',
+ ]
+
+ render(
+ ,
+ )
+
+ // SVGs should be rendered with original colors intact
+ const circles = document.querySelectorAll('circle')
+ expect(circles).toHaveLength(1)
+ expect(circles[0].getAttribute('fill')).toBe('#ff0000')
+
+ const rects = document.querySelectorAll('rect')
+ expect(rects).toHaveLength(1)
+ expect(rects[0].getAttribute('fill')).toBe('blue')
+ })
+})
diff --git a/src/__tests__/SusiPage.test.tsx b/src/__tests__/SusiPage.test.tsx
new file mode 100644
index 0000000..aec39e3
--- /dev/null
+++ b/src/__tests__/SusiPage.test.tsx
@@ -0,0 +1,103 @@
+/**
+ * SusiPage tests — verify login/signup tabs render correctly
+ * and that NO method selection UI exists.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import SusiPage from '../pages/SusiPage'
+
+// Mock the api module so no WASM/OPAQUE calls are made
+vi.mock('../services/api', () => ({
+ loginKey: vi.fn(),
+ prepareCodeLogin: vi.fn(),
+ generateSecretKey: vi.fn(),
+ registerKey: vi.fn(),
+ prepareCodeRegistration: vi.fn(),
+}))
+
+function renderSusiPage() {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('SusiPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Login tab (default)', () => {
+ it('renders email input, secret key input, and Login button', () => {
+ renderSusiPage()
+
+ expect(screen.getByPlaceholderText('email')).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('secret key (32 hex chars)')).toBeInTheDocument()
+ // There are two "Login" buttons: tab + submit. Verify submit exists.
+ const loginButtons = screen.getAllByRole('button', { name: /login/i })
+ expect(loginButtons.length).toBeGreaterThanOrEqual(1)
+ const submitBtn = loginButtons.find(b => b.getAttribute('type') === 'submit')
+ expect(submitBtn).toBeDefined()
+ })
+
+ it('does NOT render any method selection UI', () => {
+ renderSusiPage()
+
+ expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/choose method/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/select.*method/i)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Signup tab', () => {
+ it('renders email input and Sign Up button (no secret key input)', async () => {
+ const user = userEvent.setup()
+ renderSusiPage()
+
+ // Switch to signup tab
+ await user.click(screen.getByRole('button', { name: /sign up/i }))
+
+ // Signup has only email input
+ expect(screen.getByPlaceholderText('email')).toBeInTheDocument()
+ expect(screen.queryByPlaceholderText('secret key (32 hex chars)')).not.toBeInTheDocument()
+
+ // Submit button says "Sign Up" (tab + form button both say "Sign Up")
+ const signUpButtons = screen.getAllByRole('button', { name: /sign up/i })
+ const submitBtn = signUpButtons.find(b => b.getAttribute('type') === 'submit')
+ expect(submitBtn).toBeDefined()
+ })
+
+ it('does NOT render method selection or key-based/code-based options', async () => {
+ const user = userEvent.setup()
+ renderSusiPage()
+
+ await user.click(screen.getByRole('button', { name: /sign up/i }))
+
+ expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
+ expect(screen.queryByText(/choose method/i)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Tab switching', () => {
+ it('switches between Login and Sign Up tabs', async () => {
+ const user = userEvent.setup()
+ renderSusiPage()
+
+ // Initially on Login tab — secret key field present
+ expect(screen.getByPlaceholderText('secret key (32 hex chars)')).toBeInTheDocument()
+
+ // Click Sign Up
+ await user.click(screen.getByRole('button', { name: /sign up/i }))
+ expect(screen.queryByPlaceholderText('secret key (32 hex chars)')).not.toBeInTheDocument()
+
+ // Click Login to switch back
+ await user.click(screen.getByRole('button', { name: /login/i }))
+ expect(screen.getByPlaceholderText('secret key (32 hex chars)')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts
new file mode 100644
index 0000000..c44951a
--- /dev/null
+++ b/src/__tests__/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom'
diff --git a/src/components/Keypad.tsx b/src/components/Keypad.tsx
index 6177ea7..fc6309d 100644
--- a/src/components/Keypad.tsx
+++ b/src/components/Keypad.tsx
@@ -1,153 +1,301 @@
-import { useState, useCallback, useEffect } from 'react';
+/**
+ * SVG Icon Keypad — matches Flutter keypad.dart + keypad_interface.dart.
+ * SVGs render as-is (no color application per DoDNKode branch).
+ */
+import { useState, useCallback, useEffect, useRef } from 'react'
interface KeypadProps {
- /** Number of digits expected */
- length: number;
- /** Called when all digits entered */
- onComplete: (digits: number[]) => void;
- /** Optional: called on each digit press */
- onDigit?: (digit: number, current: number[]) => void;
- /** Show the entered digits (vs dots) */
- showDigits?: boolean;
- /** Label text above the indicator */
- label?: string;
- /** Disable input */
- disabled?: boolean;
- /** Error message to show */
- error?: string;
- /** Reset trigger — increment to clear */
- resetKey?: number;
+ svgs: string[]
+ attrsPerKey: number
+ numbOfKeys: number
+ onComplete: (selection: number[]) => void
+ loading?: boolean
}
-const KEYS = [
- [1, 2, 3],
- [4, 5, 6],
- [7, 8, 9],
- [null, 0, 'del'],
-] as const;
+function keyAspectRatio(attrsPerKey: number): number {
+ if (attrsPerKey <= 3) return 21 / 7
+ if (attrsPerKey <= 6) return 21 / 15
+ if (attrsPerKey <= 9) return 1
+ if (attrsPerKey <= 12) return 21 / 27.5
+ if (attrsPerKey <= 15) return 21 / 34
+ return 21 / 40.5
+}
-export function Keypad({
- length,
+export default function Keypad({
+ svgs,
+ attrsPerKey,
+ numbOfKeys,
onComplete,
- onDigit,
- showDigits = false,
- label,
- disabled = false,
- error,
- resetKey = 0,
+ loading = false,
}: KeypadProps) {
- const [digits, setDigits] = useState([]);
+ const [selection, setSelection] = useState([])
+ const [pressedKey, setPressedKey] = useState(null)
+ const [columns, setColumns] = useState(2)
+ const pressTimerRef = useRef | null>(null)
- // Reset when resetKey changes
+ // Reset selection when svgs change
+ const svgsRef = useRef(svgs)
useEffect(() => {
- setDigits([]);
- }, [resetKey]);
+ if (svgsRef.current !== svgs) {
+ svgsRef.current = svgs
+ setSelection([])
+ }
+ }, [svgs])
- const handlePress = useCallback(
- (key: number | 'del') => {
- if (disabled) return;
-
- if (key === 'del') {
- setDigits((d) => d.slice(0, -1));
- return;
- }
-
- setDigits((prev) => {
- if (prev.length >= length) return prev;
- const next = [...prev, key];
- onDigit?.(key, next);
- if (next.length === length) {
- // Slight delay so the UI shows the last dot
- setTimeout(() => onComplete(next), 150);
- }
- return next;
- });
- },
- [length, onComplete, onDigit, disabled]
- );
-
- // Keyboard support
+ // Responsive: 2 cols portrait, 3 cols landscape
useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- if (disabled) return;
- const n = parseInt(e.key);
- if (!isNaN(n) && n >= 0 && n <= 9) {
- handlePress(n);
- } else if (e.key === 'Backspace') {
- handlePress('del');
+ const update = () => setColumns(window.innerWidth > window.innerHeight ? 3 : 2)
+ update()
+ window.addEventListener('resize', update)
+ return () => window.removeEventListener('resize', update)
+ }, [])
+
+ useEffect(() => {
+ return () => {
+ if (pressTimerRef.current) clearTimeout(pressTimerRef.current)
+ }
+ }, [])
+
+ const handlePress = useCallback((keyIndex: number) => {
+ setSelection(prev => [...prev, keyIndex])
+ setPressedKey(keyIndex)
+ if (pressTimerRef.current) clearTimeout(pressTimerRef.current)
+ pressTimerRef.current = setTimeout(() => setPressedKey(null), 200)
+ }, [])
+
+ const handleBackspace = useCallback(() => {
+ setSelection(prev => prev.slice(0, -1))
+ }, [])
+
+ const handleSubmit = useCallback(() => {
+ if (selection.length > 0) {
+ onComplete(selection)
+ }
+ }, [selection, onComplete])
+
+ // Keyboard: Backspace to delete, Enter to submit
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Backspace') {
+ e.preventDefault()
+ handleBackspace()
+ } else if (e.key === 'Enter' && selection.length > 0 && !loading) {
+ e.preventDefault()
+ handleSubmit()
}
- };
- window.addEventListener('keydown', handler);
- return () => window.removeEventListener('keydown', handler);
- }, [handlePress, disabled]);
+ }
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [handleBackspace, handleSubmit, selection.length, loading])
+
+ const aspect = keyAspectRatio(attrsPerKey)
+ const iconRows = Math.ceil(attrsPerKey / 3)
return (
-
- {/* Label */}
- {label && (
-
- {label}
-
- )}
-
- {/* Dot indicators */}
-
- {Array.from({ length }).map((_, i) => (
-
- {showDigits && i < digits.length && (
-
- {digits[i]}
+
+ {/* Input display: [• dots + ⌫] [Submit] */}
+
+
+
+ {selection.length === 0 ? (
+ Tap icons to enter your nKode
+ ) : (
+
+ {'•'.repeat(selection.length)}
)}
- ))}
+
+
+
- {/* Error */}
- {error && (
-
{error}
- )}
+ {/* Key tile grid */}
+
+ {Array.from({ length: numbOfKeys }).map((_, keyIndex) => {
+ const isActive = pressedKey === keyIndex
+ const startIdx = keyIndex * attrsPerKey
+ const keyIcons = svgs.slice(startIdx, startIdx + attrsPerKey)
- {/* Keypad grid */}
-
- {KEYS.flat().map((key, i) => {
- if (key === null) {
- return
;
- }
- if (key === 'del') {
- return (
-
- );
- }
return (
- );
+ )
})}
+
+
- );
+ )
}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 3647310..89dad66 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -1,70 +1,53 @@
-import { Link, Outlet, useLocation } from 'react-router-dom';
-import { useAuth } from '@/hooks/useAuth';
-import { useTheme } from '@/hooks/useTheme';
-import { ROUTES } from '@/lib/types';
+/**
+ * App shell layout.
+ */
+import { Outlet, Link } from 'react-router-dom'
+import { useAuth } from '../hooks/useAuth'
-export function Layout() {
- const { isAuthenticated, email, logout } = useAuth();
- const { resolved, setTheme, theme } = useTheme();
- const location = useLocation();
-
- const isAuthPage =
- location.pathname === ROUTES.LOGIN || location.pathname === ROUTES.SIGNUP;
+export default function Layout() {
+ const { isAuthenticated, email, logout } = useAuth()
return (
-
+
{/* Header */}
-
+
- {/* Content */}
-
-
+ {/* Main content */}
+
+
+
+
+
+ {/* Footer */}
+
- );
+ )
}
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
index f571b26..2a7e002 100644
--- a/src/hooks/useAuth.ts
+++ b/src/hooks/useAuth.ts
@@ -1,64 +1,51 @@
-import { useState, useCallback, useEffect, createContext, useContext } from 'react';
-import type { AuthState, NKodeSession } from '@/lib/types';
+/**
+ * Auth context — stores email + secretKey for OPAQUE-based auth.
+ * No tokens needed; the WASM client handles session keys internally.
+ */
+import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
+import { createElement } from 'react'
+import type { AuthState } from '../types'
+import { loadAuth, saveAuth, clearAuth } from '../services/auth'
+import { resetClient } from '../services/api'
-const SESSION_KEY = 'nkode_session';
-const EMAIL_KEY = 'nkode_email';
-
-function loadStoredSession(): AuthState {
- try {
- const raw = localStorage.getItem(SESSION_KEY);
- const email = localStorage.getItem(EMAIL_KEY);
- if (raw) {
- const session: NKodeSession = JSON.parse(raw);
- // Check expiry
- if (new Date(session.expiresAt) > new Date()) {
- return { isAuthenticated: true, session, email };
- }
- localStorage.removeItem(SESSION_KEY);
- }
- } catch {}
- return { isAuthenticated: false, session: null, email: null };
+interface AuthContextValue extends AuthState {
+ /** Call after successful registration + code setup to persist credentials. */
+ login: (email: string, secretKeyHex: string, userId: string) => void
+ /** Clear persisted credentials. */
+ logout: () => void
+ /** True if we have stored credentials (email + secretKey). */
+ isAuthenticated: boolean
}
-export interface AuthContextType extends AuthState {
- login: (email: string, session: NKodeSession) => void;
- logout: () => void;
-}
+const AuthContext = createContext
(null)
-export const AuthContext = createContext(null);
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState(loadAuth)
-export function useAuth(): AuthContextType {
- const ctx = useContext(AuthContext);
- if (!ctx) throw new Error('useAuth must be inside AuthProvider');
- return ctx;
-}
-
-export function useAuthState() {
- const [state, setState] = useState(loadStoredSession);
-
- const login = useCallback((email: string, session: NKodeSession) => {
- localStorage.setItem(SESSION_KEY, JSON.stringify(session));
- localStorage.setItem(EMAIL_KEY, email);
- setState({ isAuthenticated: true, session, email });
- }, []);
+ const login = useCallback((email: string, secretKeyHex: string, userId: string) => {
+ const next: AuthState = { email, secretKeyHex, userId }
+ setState(next)
+ saveAuth(next)
+ }, [])
const logout = useCallback(() => {
- localStorage.removeItem(SESSION_KEY);
- localStorage.removeItem(EMAIL_KEY);
- setState({ isAuthenticated: false, session: null, email: null });
- }, []);
+ setState({ email: null, secretKeyHex: null, userId: null })
+ clearAuth()
+ resetClient()
+ }, [])
- // Auto-logout on expiry
- useEffect(() => {
- if (!state.session) return;
- const ms = new Date(state.session.expiresAt).getTime() - Date.now();
- if (ms <= 0) {
- logout();
- return;
- }
- const timer = setTimeout(logout, ms);
- return () => clearTimeout(timer);
- }, [state.session, logout]);
+ const value: AuthContextValue = {
+ ...state,
+ login,
+ logout,
+ isAuthenticated: !!state.email && !!state.secretKeyHex && !!state.userId,
+ }
- return { ...state, login, logout };
+ return createElement(AuthContext.Provider, { value }, children)
+}
+
+export function useAuth(): AuthContextValue {
+ const ctx = useContext(AuthContext)
+ if (!ctx) throw new Error('useAuth must be used within AuthProvider')
+ return ctx
}
diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts
deleted file mode 100644
index 054cefc..0000000
--- a/src/hooks/useTheme.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { useState, useEffect, useCallback } from 'react';
-
-type Theme = 'light' | 'dark' | 'system';
-
-function getSystemTheme(): 'light' | 'dark' {
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
-}
-
-export function useTheme() {
- const [theme, setThemeState] = useState(() => {
- return (localStorage.getItem('nkode_theme') as Theme) || 'system';
- });
-
- const resolved = theme === 'system' ? getSystemTheme() : theme;
-
- useEffect(() => {
- document.documentElement.classList.toggle('dark', resolved === 'dark');
- }, [resolved]);
-
- useEffect(() => {
- if (theme !== 'system') return;
- const mq = window.matchMedia('(prefers-color-scheme: dark)');
- const handler = () => setThemeState('system'); // re-trigger
- mq.addEventListener('change', handler);
- return () => mq.removeEventListener('change', handler);
- }, [theme]);
-
- const setTheme = useCallback((t: Theme) => {
- localStorage.setItem('nkode_theme', t);
- setThemeState(t);
- }, []);
-
- return { theme, resolved, setTheme };
-}
diff --git a/src/index.css b/src/index.css
index 5b56308..f1d8c73 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,47 +1 @@
@import "tailwindcss";
-
-@custom-variant dark (&:is(.dark *));
-
-:root {
- --nkode-primary: #6366f1;
- --nkode-primary-hover: #4f46e5;
- --nkode-surface: #ffffff;
- --nkode-surface-alt: #f8fafc;
- --nkode-border: #e2e8f0;
- --nkode-text: #0f172a;
- --nkode-text-muted: #64748b;
-}
-
-.dark {
- --nkode-primary: #818cf8;
- --nkode-primary-hover: #6366f1;
- --nkode-surface: #0f172a;
- --nkode-surface-alt: #1e293b;
- --nkode-border: #334155;
- --nkode-text: #f1f5f9;
- --nkode-text-muted: #94a3b8;
-}
-
-body {
- margin: 0;
- font-family: 'Inter', system-ui, -apple-system, sans-serif;
- background: var(--nkode-surface);
- color: var(--nkode-text);
- -webkit-font-smoothing: antialiased;
-}
-
-/* Keypad button styles */
-.keypad-btn {
- @apply w-16 h-16 rounded-full text-2xl font-semibold
- transition-all duration-150 ease-out
- active:scale-95 select-none
- bg-white dark:bg-slate-800
- text-slate-900 dark:text-slate-100
- border border-slate-200 dark:border-slate-700
- hover:bg-slate-50 dark:hover:bg-slate-700
- shadow-sm;
-}
-
-.keypad-btn:active {
- box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
-}
diff --git a/src/lib/nkode-client.ts b/src/lib/nkode-client.ts
deleted file mode 100644
index 7c26a33..0000000
--- a/src/lib/nkode-client.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * nKode WASM client wrapper.
- *
- * This module lazily loads the WASM package and provides a typed API.
- * In development, it falls back to a mock client until the WASM package is linked.
- */
-
-import type { NKodeSession } from './types';
-
-// We'll import the WASM module dynamically
-let wasmModule: any = null;
-let wasmClient: any = null;
-
-const API_BASE = import.meta.env.VITE_NKODE_API_URL || 'http://localhost:3000';
-
-/**
- * Try to load the WASM module from /wasm/ directory.
- * Uses dynamic import with vite-ignore to avoid bundling.
- */
-async function tryLoadWasm(): Promise {
- try {
- // Check if WASM files are available
- const check = await fetch('/wasm/nkode_client_wasm_bg.wasm', { method: 'HEAD' });
- if (!check.ok) return null;
-
- // Dynamic import — vite-ignore prevents bundler from resolving
- const wasmUrl = new URL('/wasm/nkode_client_wasm.js', window.location.origin).href;
- const module = await import(/* @vite-ignore */ wasmUrl);
- await module.default('/wasm/nkode_client_wasm_bg.wasm');
- module.init();
- return module;
- } catch {
- return null;
- }
-}
-
-/**
- * Initialize the WASM module. Call once at app startup.
- */
-export async function initNKode(): Promise {
- try {
- const wasm = await tryLoadWasm();
- if (wasm) {
- wasmModule = wasm;
- wasmClient = new wasm.NKodeClient(API_BASE);
- console.log('[nKode] WASM client initialized');
- } else {
- console.warn('[nKode] WASM not available, using mock client');
- }
- } catch (e) {
- console.warn('[nKode] WASM init error, using mock client:', e);
- }
-}
-
-/**
- * Check if WASM client is loaded.
- */
-export function isWasmReady(): boolean {
- return wasmClient !== null;
-}
-
-/**
- * Generate a random 16-byte secret key (hex string).
- */
-export function generateSecretKey(): string {
- if (wasmModule) {
- return wasmModule.NKodeClient.generateSecretKey();
- }
- // Fallback: browser crypto
- const bytes = new Uint8Array(16);
- crypto.getRandomValues(bytes);
- return Array.from(bytes)
- .map((b) => b.toString(16).padStart(2, '0'))
- .join('');
-}
-
-/**
- * Register with key-based OPAQUE flow.
- */
-export async function registerKey(email: string, secretKeyHex: string): Promise {
- if (wasmClient) {
- await wasmClient.registerKey(email, secretKeyHex);
- return;
- }
- // Mock
- console.log('[nKode mock] registerKey', email);
- await new Promise((r) => setTimeout(r, 500));
-}
-
-/**
- * Register with code-based OPAQUE flow.
- */
-export async function registerCode(email: string, passcode: Uint8Array): Promise {
- if (wasmClient) {
- await wasmClient.registerCode(email, passcode);
- return;
- }
- console.log('[nKode mock] registerCode', email);
- await new Promise((r) => setTimeout(r, 500));
-}
-
-/**
- * Login with key-based OPAQUE flow.
- */
-export async function loginKey(email: string, secretKeyHex: string): Promise {
- if (wasmClient) {
- return await wasmClient.loginKey(email, secretKeyHex);
- }
- console.log('[nKode mock] loginKey', email);
- await new Promise((r) => setTimeout(r, 500));
- return {
- sessionId: 'mock-session',
- userId: 'mock-user',
- createdAt: new Date().toISOString(),
- expiresAt: new Date(Date.now() + 3600000).toISOString(),
- };
-}
-
-/**
- * Login with code-based OPAQUE flow.
- */
-export async function loginCode(email: string, passcode: Uint8Array): Promise {
- if (wasmClient) {
- return await wasmClient.loginCode(email, passcode);
- }
- console.log('[nKode mock] loginCode', email);
- await new Promise((r) => setTimeout(r, 500));
- return {
- sessionId: 'mock-session',
- userId: 'mock-user',
- createdAt: new Date().toISOString(),
- expiresAt: new Date(Date.now() + 3600000).toISOString(),
- };
-}
diff --git a/src/lib/types.ts b/src/lib/types.ts
deleted file mode 100644
index 8376dff..0000000
--- a/src/lib/types.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/** Authentication flow type */
-export type AuthFlow = 'key' | 'code';
-
-/** User session from successful login */
-export interface NKodeSession {
- sessionId: string;
- userId: string;
- createdAt: string;
- expiresAt: string;
-}
-
-/** App-level auth state */
-export interface AuthState {
- isAuthenticated: boolean;
- session: NKodeSession | null;
- email: string | null;
-}
-
-/** Route paths */
-export const ROUTES = {
- LOGIN: '/login',
- SIGNUP: '/signup',
- HOME: '/',
- ADMIN: '/admin',
- DEVELOPER: '/developer',
-} as const;
diff --git a/src/main.tsx b/src/main.tsx
index 98e77aa..4bd3d2a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,14 +1,20 @@
-import { StrictMode } from 'react';
-import { createRoot } from 'react-dom/client';
-import './index.css';
-import App from './App';
-import { initNKode } from '@/lib/nkode-client';
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import './index.css'
+import App from './App'
+import { initClient } from './services/api'
-// Initialize WASM client (non-blocking)
-initNKode();
-
-createRoot(document.getElementById('root')!).render(
-
-
-
-);
+// Initialize WASM client, then render
+initClient().then(() => {
+ createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+ )
+}).catch((err: unknown) => {
+ console.error('Failed to initialize WASM client:', err)
+ document.getElementById('root')!.textContent = 'Failed to load application. Please refresh.'
+})
diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx
deleted file mode 100644
index 0c1c98f..0000000
--- a/src/pages/AdminPage.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { useAuth } from '@/hooks/useAuth';
-import { Navigate } from 'react-router-dom';
-import { ROUTES } from '@/lib/types';
-
-export function AdminPage() {
- const { isAuthenticated } = useAuth();
-
- if (!isAuthenticated) {
- return ;
- }
-
- return (
-
-
Admin Dashboard
-
-
- {[
- { label: 'Total Users', value: '—', icon: '👤' },
- { label: 'Active Sessions', value: '—', icon: '🔐' },
- { label: 'Registered Clients', value: '—', icon: '📱' },
- ].map((stat) => (
-
-
-
{stat.icon}
-
-
{stat.label}
-
{stat.value}
-
-
-
- ))}
-
-
-
-
- Admin features coming soon. This will display user management, session monitoring, and OIDC client configuration.
-
-
-
- );
-}
diff --git a/src/pages/DeveloperPage.tsx b/src/pages/DeveloperPage.tsx
deleted file mode 100644
index 3205595..0000000
--- a/src/pages/DeveloperPage.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { useAuth } from '@/hooks/useAuth';
-import { Navigate } from 'react-router-dom';
-import { ROUTES } from '@/lib/types';
-
-export function DeveloperPage() {
- const { isAuthenticated } = useAuth();
-
- if (!isAuthenticated) {
- return ;
- }
-
- return (
-
-
Developer Dashboard
-
-
-
OIDC Client Setup
-
- Register your application to use nKode as an identity provider. Configure redirect URIs, scopes, and authentication flows.
-
-
-
-
-
- Client registration coming soon. This will generate client_id and client_secret for OAuth2/OIDC integration.
-
-
-
- );
-}
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index f9db0d3..d1b4dc4 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -1,85 +1,46 @@
-import { useAuth } from '@/hooks/useAuth';
-import { Keypad } from '@/components/Keypad';
-import { useState } from 'react';
+/**
+ * Home page — shown after full authentication (key + code login).
+ */
+import { useAuth } from '../hooks/useAuth'
+import { useNavigate } from 'react-router-dom'
-export function HomePage() {
- const { isAuthenticated, session, email } = useAuth();
- const [practiceResult, setPracticeResult] = useState(null);
- const [resetKey, setResetKey] = useState(0);
+export default function HomePage() {
+ const { email, userId, logout } = useAuth()
+ const navigate = useNavigate()
- if (!isAuthenticated) {
- return (
-
-
- nKode
-
-
- Passwordless authentication powered by OPAQUE.
-
-
- Replace passwords with a memorized numeric code or a cryptographic key.
- Zero-knowledge proof means the server never sees your secret.
-
-
- );
+ const handleLogout = () => {
+ logout()
+ navigate('/', { replace: true })
}
- const handlePractice = (digits: number[]) => {
- setPracticeResult(`✅ You entered: ${digits.join('')}`);
- setTimeout(() => {
- setPracticeResult(null);
- setResetKey((k) => k + 1);
- }, 2000);
- };
-
return (
-
- {/* Welcome */}
-
-
- Welcome back
-
-
{email}
+
+
+
+
Welcome
+
{email}
+
User: {userId}
+
+
- {/* Session info */}
-
-
Session
-
-
-
- Session ID
-
-
- {session?.sessionId}
-
+
+
+
-
-
- Expires
- -
- {session?.expiresAt
- ? new Date(session.expiresAt).toLocaleString()
- : '—'}
-
-
-
-
-
- {/* Practice keypad */}
-
-
- Practice your nKode
-
-
- {practiceResult && (
-
- {practiceResult}
-
- )}
+
Authenticated via OPAQUE
+
Key + Code authentication complete
+
- );
+ )
}
diff --git a/src/pages/LoginKeypadPage.tsx b/src/pages/LoginKeypadPage.tsx
new file mode 100644
index 0000000..6df488f
--- /dev/null
+++ b/src/pages/LoginKeypadPage.tsx
@@ -0,0 +1,114 @@
+/**
+ * Login keypad page — code-based OPAQUE login.
+ * Displays keypad from prepareCodeLogin data, user taps their nKode,
+ * then deciphers selection and performs code login.
+ */
+import { useState } from 'react'
+import { useLocation, useNavigate, Navigate } from 'react-router-dom'
+import Keypad from '../components/Keypad'
+import { useAuth } from '../hooks/useAuth'
+import * as api from '../services/api'
+import type { CodeLoginData, NKodeIcon } from '../types'
+
+interface LocationState {
+ email: string
+ secretKeyHex: string
+ userId: string
+ codeLoginData: CodeLoginData
+}
+
+export default function LoginKeypadPage() {
+ const location = useLocation()
+ const navigate = useNavigate()
+ const auth = useAuth()
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const state = location.state as LocationState | null
+ if (!state?.email || !state?.codeLoginData) {
+ return
+ }
+
+ const { email, secretKeyHex, userId, codeLoginData } = state
+
+ // Build SVG strings from icons for the keypad.
+ // Icons are already ordered by keypadIndices from prepareCodeLogin.
+ const svgs = buildSvgsFromCodeLoginData(codeLoginData)
+
+ const handleLogin = async (pressedKeys: number[]) => {
+ setLoading(true)
+ setError(null)
+ try {
+ // Decipher key selections into passcode bytes
+ const passcodeBytes = api.decipherSelection(
+ secretKeyHex,
+ codeLoginData.loginDataJson,
+ pressedKeys,
+ )
+
+ // OPAQUE code login
+ await api.loginCode(email, passcodeBytes)
+
+ // Save credentials and navigate home
+ auth.login(email, secretKeyHex, userId)
+ navigate('/home', { replace: true })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Login failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+
Login
+
+
{email}
+
+
+ {error && (
+
+ )}
+
+
+
+ )
+}
+
+/** Build SVG strings array from CodeLoginData for the Keypad component. */
+function buildSvgsFromCodeLoginData(data: CodeLoginData): string[] {
+ const { keypadIndices, icons } = data
+ // keypadIndices maps each position to an icon index
+ return keypadIndices.map((iconIdx: number) => iconToSvg(icons[iconIdx]))
+}
+
+/** Convert an icon object to an SVG/HTML string for rendering. */
+function iconToSvg(icon: NKodeIcon): string {
+ if (!icon) return ''
+ if (icon.file_type === 'svg') {
+ return icon.img_data
+ }
+ // For non-SVG: render as img with base64
+ const mime = icon.file_type === 'png' ? 'image/png'
+ : icon.file_type === 'webp' ? 'image/webp'
+ : 'image/jpeg'
+ return `
`
+}
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
deleted file mode 100644
index a396bd2..0000000
--- a/src/pages/LoginPage.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { useState, useCallback } from 'react';
-import { useNavigate, Link } from 'react-router-dom';
-import { Keypad } from '@/components/Keypad';
-import { useAuth } from '@/hooks/useAuth';
-import { loginCode } from '@/lib/nkode-client';
-import { ROUTES } from '@/lib/types';
-
-type Step = 'email' | 'keypad';
-
-export function LoginPage() {
- const [step, setStep] = useState('email');
- const [email, setEmail] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState('');
- const [resetKey, setResetKey] = useState(0);
- const { login } = useAuth();
- const navigate = useNavigate();
-
- const handleEmailSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (!email.trim()) return;
- setError('');
- setStep('keypad');
- };
-
- const handleKeypadComplete = useCallback(
- async (digits: number[]) => {
- setLoading(true);
- setError('');
- try {
- const passcode = new Uint8Array(digits);
- const session = await loginCode(email, passcode);
- login(email, session);
- navigate(ROUTES.HOME);
- } catch (err: any) {
- setError(err?.message || 'Authentication failed');
- setResetKey((k) => k + 1);
- } finally {
- setLoading(false);
- }
- },
- [email, login, navigate]
- );
-
- return (
-
-
- {/* Logo */}
-
-
- nKode
-
-
- {step === 'email' ? 'Sign in to your account' : 'Enter your nKode'}
-
-
-
- {step === 'email' ? (
-
- ) : (
-
-
-
- {loading && (
-
- Authenticating…
-
- )}
-
- )}
-
-
- );
-}
diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx
new file mode 100644
index 0000000..679162b
--- /dev/null
+++ b/src/pages/NotFoundPage.tsx
@@ -0,0 +1,13 @@
+import { Link } from 'react-router-dom'
+
+export default function NotFoundPage() {
+ return (
+
+
404
+
Page not found
+
+ ← Back to Home
+
+
+ )
+}
diff --git a/src/pages/SignupKeypadPage.tsx b/src/pages/SignupKeypadPage.tsx
new file mode 100644
index 0000000..bdfb796
--- /dev/null
+++ b/src/pages/SignupKeypadPage.tsx
@@ -0,0 +1,165 @@
+/**
+ * Signup keypad page — code registration flow.
+ * Displays icons for the user to select their nKode sequence.
+ * On submit, completes OPAQUE code registration and stores login data.
+ */
+import { useState, useCallback } from 'react'
+import { useLocation, useNavigate, Navigate } from 'react-router-dom'
+import { useAuth } from '../hooks/useAuth'
+import * as api from '../services/api'
+import type { NKodeIcon } from '../types'
+
+interface LocationState {
+ email: string
+ secretKeyHex: string
+ userId: string
+ icons: NKodeIcon[]
+}
+
+/** Default keypad dimensions (matching Flutter: 9 props/key, 6 keys) */
+const ATTRS_PER_KEY = 9
+const NUM_KEYS = 6
+
+export default function SignupKeypadPage() {
+ const location = useLocation()
+ const navigate = useNavigate()
+ const auth = useAuth()
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [secretKeyCopied, setSecretKeyCopied] = useState(false)
+
+ const state = location.state as LocationState | null
+ if (!state?.email || !state?.icons) {
+ return
+ }
+
+ const { email, secretKeyHex, userId, icons } = state
+
+ // Build SVG strings from icons for the keypad
+ const svgs = icons.map((icon: NKodeIcon) => iconToSvg(icon))
+
+ const handleCopyKey = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(secretKeyHex)
+ setSecretKeyCopied(true)
+ setTimeout(() => setSecretKeyCopied(false), 3000)
+ } catch {
+ // Fallback: select text
+ setSecretKeyCopied(false)
+ }
+ }, [secretKeyHex])
+
+ const handleSetNKode = async (pressedKeys: number[]) => {
+ setLoading(true)
+ setError(null)
+ try {
+ // Convert key presses to global icon indices
+ // Each key press corresponds to keys on the keypad.
+ // The selectedIndices for completeCodeRegistration are the KEY indices pressed.
+ // The WASM SDK handles mapping keys to icon indices internally.
+ await api.completeCodeRegistrationWithEmail(email, pressedKeys)
+
+ // Save credentials and navigate home
+ auth.login(email, secretKeyHex, userId)
+ navigate('/home', { replace: true })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Registration failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+
Set Your nKode
+
+
{email}
+
+ {/* Secret key display — user must save this */}
+
+
+ Save your secret key — you'll need it to log in:
+
+
+
+ {secretKeyHex}
+
+
+
+
+
+
+ Tap keys to create your nKode pattern, then submit.
+
+
+
+ {error && (
+
+ )}
+
+
+
+ )
+}
+
+/** Convert an icon object to an SVG/HTML string. */
+function iconToSvg(icon: NKodeIcon): string {
+ if (!icon) return ''
+ if (icon.file_type === 'svg') {
+ return icon.img_data
+ }
+ const mime = icon.file_type === 'png' ? 'image/png'
+ : icon.file_type === 'webp' ? 'image/webp'
+ : 'image/jpeg'
+ return `
`
+}
+
+/**
+ * Signup keypad — reuses the same keypad UI but allows selecting
+ * icons to define the nKode pattern.
+ */
+import Keypad from '../components/Keypad'
+
+interface SignupKeypadProps {
+ svgs: string[]
+ attrsPerKey: number
+ numbOfKeys: number
+ onComplete: (selection: number[]) => void
+ loading: boolean
+}
+
+function SignupKeypad({ svgs, attrsPerKey, numbOfKeys, onComplete, loading }: SignupKeypadProps) {
+ return (
+
+ )
+}
diff --git a/src/pages/SignupPage.tsx b/src/pages/SignupPage.tsx
deleted file mode 100644
index 7ef9ca3..0000000
--- a/src/pages/SignupPage.tsx
+++ /dev/null
@@ -1,277 +0,0 @@
-import { useState, useCallback } from 'react';
-import { useNavigate, Link } from 'react-router-dom';
-import { Keypad } from '@/components/Keypad';
-import { useAuth } from '@/hooks/useAuth';
-import { registerCode, loginCode, generateSecretKey, registerKey } from '@/lib/nkode-client';
-import { ROUTES } from '@/lib/types';
-
-type Step = 'email' | 'method' | 'keypad' | 'confirm' | 'key-show' | 'done';
-
-export function SignupPage() {
- const [step, setStep] = useState('email');
- const [email, setEmail] = useState('');
- const [_method, setMethod] = useState<'code' | 'key'>('code');
- const [firstCode, setFirstCode] = useState([]);
- const [secretKey, setSecretKey] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState('');
- const [resetKey, setResetKey] = useState(0);
- const { login } = useAuth();
- const navigate = useNavigate();
-
- const handleEmailSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (!email.trim()) return;
- setError('');
- setStep('method');
- };
-
- const handleMethodSelect = (m: 'code' | 'key') => {
- setMethod(m);
- if (m === 'code') {
- setStep('keypad');
- } else {
- // Generate key immediately
- const key = generateSecretKey();
- setSecretKey(key);
- setStep('key-show');
- }
- };
-
- const handleFirstCode = useCallback((digits: number[]) => {
- setFirstCode(digits);
- setResetKey((k) => k + 1);
- setStep('confirm');
- }, []);
-
- const handleConfirmCode = useCallback(
- async (digits: number[]) => {
- // Check codes match
- if (digits.join('') !== firstCode.join('')) {
- setError("Codes don't match. Try again.");
- setResetKey((k) => k + 1);
- return;
- }
-
- setLoading(true);
- setError('');
- try {
- const passcode = new Uint8Array(digits);
- await registerCode(email, passcode);
- // Auto-login after registration
- const session = await loginCode(email, passcode);
- login(email, session);
- navigate(ROUTES.HOME);
- } catch (err: any) {
- setError(err?.message || 'Registration failed');
- setResetKey((k) => k + 1);
- } finally {
- setLoading(false);
- }
- },
- [email, firstCode, login, navigate]
- );
-
- const handleKeyRegister = async () => {
- setLoading(true);
- setError('');
- try {
- await registerKey(email, secretKey);
- setStep('done');
- } catch (err: any) {
- setError(err?.message || 'Registration failed');
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
-
- {/* Logo */}
-
-
- nKode
-
-
- {step === 'email' && 'Create your account'}
- {step === 'method' && 'Choose your authentication method'}
- {step === 'keypad' && 'Create your nKode'}
- {step === 'confirm' && 'Confirm your nKode'}
- {step === 'key-show' && 'Save your secret key'}
- {step === 'done' && 'You\'re all set!'}
-
-
-
- {step === 'email' && (
-
- )}
-
- {step === 'method' && (
-
-
-
-
-
- )}
-
- {step === 'keypad' && (
-
-
-
-
- )}
-
- {step === 'confirm' && (
-
-
-
- {loading && (
-
- Creating account…
-
- )}
-
- )}
-
- {step === 'key-show' && (
-
-
-
-
- ⚠️ Save this key — you won't see it again!
-
-
- {secretKey}
-
-
-
- {error &&
{error}
}
-
-
- )}
-
- {step === 'done' && (
-
-
✅
-
- Your account has been created with key-based auth.
-
-
- Sign in
-
-
- )}
-
-
- );
-}
diff --git a/src/pages/SusiPage.tsx b/src/pages/SusiPage.tsx
new file mode 100644
index 0000000..25828d5
--- /dev/null
+++ b/src/pages/SusiPage.tsx
@@ -0,0 +1,187 @@
+/**
+ * SUSI (Sign Up / Sign In) page.
+ * Login tab: email → OPAQUE key login → navigate to keypad for code login.
+ * Signup tab: email → OPAQUE key register → key login → navigate to keypad for code registration.
+ */
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import * as api from '../services/api'
+
+export default function SusiPage() {
+ const [tab, setTab] = useState<'login' | 'signup'>('login')
+
+ return (
+
+ {/* Logo area */}
+
+
+ nK
+
+
nKode
+
Passwordless Authentication
+
+
+ {/* Tab bar */}
+
+
+
+
+
+ {/* Tab content */}
+ {tab === 'login' ?
:
}
+
+ )
+}
+
+/** Login tab — email + secret key → OPAQUE key login → code login keypad */
+function LoginTab() {
+ const [email, setEmail] = useState('')
+ const [secretKeyHex, setSecretKeyHex] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const navigate = useNavigate()
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ const trimmedEmail = email.trim()
+ const trimmedKey = secretKeyHex.trim()
+ if (!trimmedEmail) { setError('Enter an email'); return }
+ if (!trimmedKey || trimmedKey.length !== 32) {
+ setError('Enter your 32-character secret key (hex)')
+ return
+ }
+
+ setLoading(true)
+ setError(null)
+ try {
+ // Step 1: OPAQUE key login
+ const session = await api.loginKey(trimmedEmail, trimmedKey)
+
+ // Step 2: Prepare code login (fetch login data + reconstruct keypad)
+ const codeLoginData = await api.prepareCodeLogin(session.userId, trimmedKey)
+
+ // Navigate to keypad page for code login
+ navigate('/login-keypad', {
+ state: {
+ email: trimmedEmail,
+ secretKeyHex: trimmedKey,
+ userId: session.userId,
+ codeLoginData,
+ },
+ })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Login failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+/** Sign Up tab — email → generate key → OPAQUE key register + key login → code registration keypad */
+function SignupTab() {
+ const [email, setEmail] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const navigate = useNavigate()
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ const trimmedEmail = email.trim()
+ if (!trimmedEmail) { setError('Enter an email'); return }
+
+ setLoading(true)
+ setError(null)
+ try {
+ // Step 1: Generate secret key
+ const secretKeyHex = api.generateSecretKey()
+
+ // Step 2: OPAQUE key registration
+ await api.registerKey(trimmedEmail, secretKeyHex)
+
+ // Step 3: OPAQUE key login (needed for authenticated endpoints)
+ const session = await api.loginKey(trimmedEmail, secretKeyHex)
+
+ // Step 4: Prepare code registration (fetch icons)
+ const iconsData = await api.prepareCodeRegistration()
+
+ // Navigate to keypad page for code registration
+ navigate('/signup-keypad', {
+ state: {
+ email: trimmedEmail,
+ secretKeyHex,
+ userId: session.userId,
+ icons: iconsData.icons,
+ },
+ })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Registration failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/services/api.ts b/src/services/api.ts
new file mode 100644
index 0000000..b2b3ac8
--- /dev/null
+++ b/src/services/api.ts
@@ -0,0 +1,112 @@
+/**
+ * nKode API service using WASM client SDK for OPAQUE crypto.
+ * All OPAQUE operations are performed client-side via WebAssembly.
+ */
+import { NKodeClient } from 'nkode-client-wasm'
+import type { NKodeSession, IconsResponse, CodeLoginData } from '../types'
+
+const API_BASE = import.meta.env.VITE_API_URL || ''
+
+let client: NKodeClient | null = null
+
+/** Initialize WASM module and create client. Must be called once before use. */
+export async function initClient(): Promise {
+ // With vite-plugin-wasm + top-level-await, WASM is auto-initialized on import.
+ if (!client) {
+ client = new NKodeClient(API_BASE)
+ }
+ return client
+}
+
+/** Get or create a fresh client (call after initClient). */
+export function getClient(): NKodeClient {
+ if (!client) throw new Error('Call initClient() first')
+ return client
+}
+
+/** Reset the client (e.g. on logout). */
+export function resetClient(): void {
+ if (client) {
+ client.clearSession()
+ client.free()
+ }
+ client = null
+}
+
+/** Generate a new random secret key (hex string). */
+export function generateSecretKey(): string {
+ return NKodeClient.generateSecretKey()
+}
+
+// ── Registration Flow ──
+
+/** Register key-based auth via OPAQUE. */
+export async function registerKey(email: string, secretKeyHex: string): Promise {
+ const c = getClient()
+ await c.registerKey(email, secretKeyHex)
+}
+
+/** Login key-based auth via OPAQUE. Returns session info. */
+export async function loginKey(email: string, secretKeyHex: string): Promise {
+ const c = getClient()
+ return await c.loginKey(email, secretKeyHex) as NKodeSession
+}
+
+/** Register code-based auth via OPAQUE. */
+export async function registerCode(email: string, passcodeBytes: Uint8Array): Promise {
+ const c = getClient()
+ await c.registerCode(email, passcodeBytes)
+}
+
+/** Login code-based auth via OPAQUE. Returns session info. */
+export async function loginCode(email: string, passcodeBytes: Uint8Array): Promise {
+ const c = getClient()
+ return await c.loginCode(email, passcodeBytes) as NKodeSession
+}
+
+// ── Icon & Login Data Flows ──
+
+/** Prepare icons for code registration (after key login). */
+export async function prepareCodeRegistration(): Promise {
+ const c = getClient()
+ return await c.prepareCodeRegistration() as IconsResponse
+}
+
+/** Complete code registration with selected icon indices. */
+export async function completeCodeRegistrationWithEmail(
+ email: string,
+ selectedIndices: number[],
+): Promise {
+ const c = getClient()
+ await c.completeCodeRegistrationWithEmail(email, new Uint32Array(selectedIndices))
+}
+
+/** Prepare code login: fetch login data, reconstruct keypad. */
+export async function prepareCodeLogin(
+ userId: string,
+ secretKeyHex: string,
+): Promise {
+ const c = getClient()
+ return await c.prepareCodeLogin(userId, secretKeyHex) as CodeLoginData
+}
+
+/** Decipher key selections into passcode bytes for code login. */
+export function decipherSelection(
+ secretKeyHex: string,
+ loginDataJson: string,
+ keySelections: number[],
+): Uint8Array {
+ const c = getClient()
+ return c.decipherSelection(secretKeyHex, loginDataJson, new Uint32Array(keySelections))
+}
+
+/** Get user ID from current session. */
+export function getUserId(): string | undefined {
+ const c = getClient()
+ return c.getUserId()
+}
+
+/** Check if client has an active session. */
+export function hasSession(): boolean {
+ return client?.hasSession() ?? false
+}
diff --git a/src/services/auth.ts b/src/services/auth.ts
new file mode 100644
index 0000000..cdc79f7
--- /dev/null
+++ b/src/services/auth.ts
@@ -0,0 +1,23 @@
+/**
+ * Auth state management — persists email + secretKey to localStorage.
+ * Uses OPAQUE-based auth via WASM client (no tokens stored).
+ */
+import type { AuthState } from '../types'
+
+const STORAGE_KEY = 'nkode_auth'
+
+export function loadAuth(): AuthState {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (raw) return JSON.parse(raw) as AuthState
+ } catch { /* ignore corrupt data */ }
+ return { email: null, secretKeyHex: null, userId: null }
+}
+
+export function saveAuth(state: AuthState): void {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
+}
+
+export function clearAuth(): void {
+ localStorage.removeItem(STORAGE_KEY)
+}
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..e36226d
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,36 @@
+/** Auth state stored in localStorage */
+export interface AuthState {
+ email: string | null
+ secretKeyHex: string | null
+ userId: string | null
+}
+
+/** Icon from the WASM client */
+export interface NKodeIcon {
+ file_name: string
+ file_type: string
+ img_data: string
+}
+
+/** Response from prepareCodeRegistration */
+export interface IconsResponse {
+ icons: NKodeIcon[]
+}
+
+/** Response from prepareCodeLogin */
+export interface CodeLoginData {
+ keypadIndices: number[]
+ propertiesPerKey: number
+ numberOfKeys: number
+ mask: number[]
+ icons: NKodeIcon[]
+ loginDataJson: string
+}
+
+/** Session returned from loginKey / loginCode */
+export interface NKodeSession {
+ sessionId: string
+ userId: string
+ createdAt: string
+ expiresAt: string
+}
diff --git a/src/types/nkode-client-wasm.d.ts b/src/types/nkode-client-wasm.d.ts
deleted file mode 100644
index cd20b93..0000000
--- a/src/types/nkode-client-wasm.d.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * Type stub for the nKode WASM package.
- * Will be replaced by real types when the package is linked via `bun link`.
- */
-declare module 'nkode-client-wasm' {
- export class NKodeClient {
- constructor(base_url: string);
- loginCode(email: string, passcode_bytes: Uint8Array): Promise;
- loginKey(email: string, secret_key_hex: string): Promise;
- registerCode(email: string, passcode_bytes: Uint8Array): Promise;
- registerKey(email: string, secret_key_hex: string): Promise;
- static generateSecretKey(): string;
- free(): void;
- }
-
- export function init(): void;
-
- export default function __wbg_init(module_or_path?: any): Promise;
-}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..8ab4f00
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,9 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_BASE_URL: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/src/wasm.d.ts b/src/wasm.d.ts
new file mode 100644
index 0000000..052cd36
--- /dev/null
+++ b/src/wasm.d.ts
@@ -0,0 +1,26 @@
+declare module 'nkode-client-wasm' {
+ export class NKodeClient {
+ constructor(base_url: string)
+ static generateSecretKey(): string
+ registerKey(email: string, secret_key_hex: string): Promise
+ loginKey(email: string, secret_key_hex: string): Promise
+ registerCode(email: string, passcode_bytes: Uint8Array): Promise
+ loginCode(email: string, passcode_bytes: Uint8Array): Promise
+ hasSession(): boolean
+ getUserId(): string | undefined
+ clearSession(): void
+ free(): void
+ getNewIcons(count: number): Promise
+ setIcons(icons_json: string): Promise
+ getLoginData(user_id: string): Promise
+ postLoginData(login_data_json: string): Promise
+ updateLoginData(login_data_json: string): Promise
+ prepareCodeRegistration(): Promise
+ completeCodeRegistration(selected_indices: Uint32Array): Promise
+ completeCodeRegistrationWithEmail(email: string, selected_indices: Uint32Array): Promise
+ prepareCodeLogin(user_id: string, secret_key_hex: string): Promise
+ decipherSelection(secret_key_hex: string, login_data_json: string, key_selections: Uint32Array): Uint8Array
+ }
+
+ export function init(): void
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 5db930a..c328724 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -8,7 +8,6 @@
"types": ["vite/client"],
"skipLibCheck": true,
- /* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
@@ -16,17 +15,12 @@
"noEmit": true,
"jsx": "react-jsx",
- /* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
- "noUncheckedSideEffectImports": true,
- "baseUrl": ".",
- "paths": {
- "@/*": ["src/*"]
- }
+ "noUncheckedSideEffectImports": true
},
"include": ["src"]
}
diff --git a/vite.config.ts b/vite.config.ts
index 4d96565..6bed9d1 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,12 +1,36 @@
+///
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
+import wasm from 'vite-plugin-wasm'
+import topLevelAwait from 'vite-plugin-top-level-await'
+import path from 'path'
export default defineConfig({
- plugins: [react(), tailwindcss()],
+ plugins: [react(), tailwindcss(), wasm(), topLevelAwait()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['./src/__tests__/setup.ts'],
+ css: true,
+ },
resolve: {
alias: {
- '@': '/src',
+ 'nkode-client-wasm': path.resolve(__dirname, 'pkg'),
},
},
+ server: {
+ proxy: {
+ '/v1': {
+ target: 'http://localhost:3000',
+ changeOrigin: true,
+ },
+ },
+ fs: {
+ allow: ['..'],
+ },
+ },
+ optimizeDeps: {
+ exclude: ['nkode-client-wasm'],
+ },
})