This commit is contained in:
polwex 2026-03-24 16:12:30 +07:00
commit d42e47b15b
31 changed files with 3045 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
README.md Normal file
View file

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

515
bun.lock Normal file
View file

@ -0,0 +1,515 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "temp-scaffold",
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"recharts": "^3.8.0",
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@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.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
"@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@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=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm" }, "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.11", "", { "os": "none", "cpu": "arm64" }, "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.11", "", { "os": "win32", "cpu": "x64" }, "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@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.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.2", "@typescript-eslint/types": "^8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2" } }, "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.2", "@typescript-eslint/tsconfig-utils": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.14.0", "", { "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-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"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=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ=="],
"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=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.321", "", {}, "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="],
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"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=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"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=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"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=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"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=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"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=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"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@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rolldown": ["rolldown@1.0.0-rc.11", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.11" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-x64": "1.0.0-rc.11", "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw=="],
"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=="],
"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=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"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=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.57.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.2", "@typescript-eslint/parser": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@8.0.2", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@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=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.11", "", {}, "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
}
}

23
eslint.config.js Normal file
View file

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⌨️</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leo's Typing Tutor</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

31
package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "temp-scaffold",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"recharts": "^3.8.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}

76
src/App.tsx Normal file
View file

@ -0,0 +1,76 @@
import { useState, useCallback } from 'react'
import { useStats } from './hooks/useStats'
import { Layout } from './components/Layout'
import { LessonMode } from './components/LessonMode'
import { FreeMode } from './components/FreeMode'
import { GameMode } from './components/GameMode'
import { StatsView } from './components/StatsView'
import type { SessionResult } from './types'
type Tab = 'lessons' | 'free' | 'game' | 'stats'
export default function App() {
const [tab, setTab] = useState<Tab>('lessons')
const { progress, completeLesson, addSession, updateHighScore } = useStats()
const handleLessonComplete = useCallback((
lessonId: number,
wpm: number,
accuracy: number,
keyStats: Record<string, { hits: number; misses: number }>,
) => {
const result: SessionResult = {
mode: `lesson-${lessonId}`,
wpm,
accuracy,
timestamp: Date.now(),
keyStats,
}
addSession(result)
if (accuracy >= 90) {
completeLesson(lessonId)
}
}, [addSession, completeLesson])
const handleFreeComplete = useCallback((
wpm: number,
accuracy: number,
keyStats: Record<string, { hits: number; misses: number }>,
) => {
addSession({
mode: 'free',
wpm,
accuracy,
timestamp: Date.now(),
keyStats,
})
}, [addSession])
const handleGameOver = useCallback((score: number) => {
updateHighScore(score)
addSession({
mode: 'game',
wpm: 0,
accuracy: 0,
timestamp: Date.now(),
keyStats: {},
})
}, [updateHighScore, addSession])
return (
<Layout activeTab={tab} onTabChange={setTab}>
{tab === 'lessons' && (
<LessonMode progress={progress} onComplete={handleLessonComplete} />
)}
{tab === 'free' && (
<FreeMode onComplete={handleFreeComplete} />
)}
{tab === 'game' && (
<GameMode highScore={progress.gameHighScore} onGameOver={handleGameOver} />
)}
{tab === 'stats' && (
<StatsView progress={progress} />
)}
</Layout>
)
}

View file

@ -0,0 +1,69 @@
import { useState, useCallback } from 'react'
import { QUOTES } from '../data/quotes'
import { useTypingEngine } from '../hooks/useTypingEngine'
import { TypingArea } from './TypingArea'
import { Keyboard } from './Keyboard'
import { ResultsModal } from './ResultsModal'
type Props = {
onComplete: (wpm: number, accuracy: number, keyStats: Record<string, { hits: number; misses: number }>) => void
}
function randomQuote() {
return QUOTES[Math.floor(Math.random() * QUOTES.length)]
}
export function FreeMode({ onComplete }: Props) {
const [quote, setQuote] = useState(randomQuote)
const [result, setResult] = useState<{ wpm: number; accuracy: number } | null>(null)
const handleComplete = useCallback((r: { wpm: number; accuracy: number; keyStats: Record<string, { hits: number; misses: number }> }) => {
setResult(r)
onComplete(r.wpm, r.accuracy, r.keyStats)
}, [onComplete])
const engine = useTypingEngine(quote, handleComplete)
const handleRetry = () => {
setResult(null)
engine.reset()
}
const handleNew = () => {
const q = randomQuote()
setQuote(q)
setResult(null)
engine.reset(q)
}
return (
<div className="fade-in">
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Free Typing</h2>
<button onClick={handleNew}>New Quote</button>
</div>
<TypingArea
text={quote}
currentIndex={engine.currentIndex}
errors={engine.errors}
wpm={engine.wpm}
accuracy={engine.accuracy}
onKeyDown={engine.handleKeyDown}
/>
<div style={{ marginTop: 24 }}>
<Keyboard activeKey={quote[engine.currentIndex]} keyStats={engine.keyStats} />
</div>
{result && (
<ResultsModal
wpm={result.wpm}
accuracy={result.accuracy}
onRetry={handleRetry}
onBack={handleNew}
/>
)}
</div>
)
}

396
src/components/GameMode.tsx Normal file
View file

@ -0,0 +1,396 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import styles from '../styles/game.module.css'
const WORD_POOL = [
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'her',
'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how',
'its', 'may', 'new', 'now', 'old', 'see', 'way', 'who', 'did', 'got',
'let', 'say', 'she', 'too', 'use', 'big', 'cat', 'dog', 'fun', 'hat',
'jump', 'kick', 'love', 'make', 'nice', 'open', 'play', 'quiz', 'run',
'star', 'time', 'up', 'very', 'walk', 'xray', 'year', 'zero', 'code',
'type', 'fast', 'hero', 'epic', 'cool', 'fire', 'bolt', 'zoom', 'dash',
'pixel', 'blaze', 'storm', 'power', 'quest', 'magic', 'super', 'turbo',
'cyber', 'ultra', 'royal', 'brave', 'swift', 'light', 'dream', 'shine',
]
type FallingWord = {
id: number
text: string
x: number
y: number
speed: number
matched: number // how many chars matched
}
type Explosion = {
id: number
text: string
x: number
y: number
points: number
}
type Particle = {
id: number
x: number
y: number
tx: number
ty: number
color: string
duration: number
}
const PARTICLE_COLORS = ['#2ecc71', '#27ae60', '#7c6ff7', '#f1c40f', '#e84393', '#3498db', '#00d2d3']
function spawnParticles(x: number, y: number, count: number): Particle[] {
return Array.from({ length: count }, (_, i) => {
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5
const dist = 40 + Math.random() * 80
return {
id: Date.now() + i,
x,
y,
tx: Math.cos(angle) * dist,
ty: Math.sin(angle) * dist,
color: PARTICLE_COLORS[Math.floor(Math.random() * PARTICLE_COLORS.length)],
duration: 0.4 + Math.random() * 0.3,
}
})
}
type Props = {
highScore: number
onGameOver: (score: number) => void
}
export function GameMode({ highScore, onGameOver }: Props) {
const [words, setWords] = useState<FallingWord[]>([])
const [input, setInput] = useState('')
const inputRef = useRef('')
const [score, setScore] = useState(0)
const [lives, setLives] = useState(3)
const [gameOver, setGameOver] = useState(false)
const [started, setStarted] = useState(false)
const [focused, setFocused] = useState(false)
const [explosions, setExplosions] = useState<Explosion[]>([])
const [particles, setParticles] = useState<Particle[]>([])
const [flash, setFlash] = useState(0)
const [combo, setCombo] = useState(0)
const [showCombo, setShowCombo] = useState(0)
const comboTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const containerRef = useRef<HTMLDivElement>(null)
const nextIdRef = useRef(0)
const frameRef = useRef<number>(0)
const lastSpawnRef = useRef(0)
const difficultyRef = useRef(1)
const livesRef = useRef(3)
const gameOverRef = useRef(false)
livesRef.current = lives
gameOverRef.current = gameOver
const spawnWord = useCallback(() => {
const difficulty = difficultyRef.current
const maxLen = Math.min(3 + Math.floor(difficulty / 3), 7)
const pool = WORD_POOL.filter(w => w.length <= maxLen)
const text = pool[Math.floor(Math.random() * pool.length)]
const word: FallingWord = {
id: nextIdRef.current++,
text,
x: Math.random() * 70 + 5, // 5%-75% from left
y: -5,
speed: 0.3 + difficulty * 0.08,
matched: 0,
}
setWords(prev => [...prev, word])
}, [])
const startGame = () => {
setWords([])
updateInput('')
setScore(0)
setLives(3)
setGameOver(false)
setStarted(true)
setExplosions([])
setParticles([])
setCombo(0)
difficultyRef.current = 1
lastSpawnRef.current = 0
nextIdRef.current = 0
containerRef.current?.focus()
}
// Game loop
useEffect(() => {
if (!started || gameOver) return
let lastTime = performance.now()
const tick = (now: number) => {
if (gameOverRef.current) return
const dt = (now - lastTime) / 16.67 // normalize to ~60fps
lastTime = now
// Spawn
const spawnInterval = Math.max(1500 - difficultyRef.current * 80, 600)
if (now - lastSpawnRef.current > spawnInterval) {
spawnWord()
lastSpawnRef.current = now
}
// Move words
setWords(prev => {
const next: FallingWord[] = []
let lostLife = false
for (const w of prev) {
const ny = w.y + w.speed * dt
if (ny > 100) {
lostLife = true
} else {
next.push({ ...w, y: ny })
}
}
if (lostLife) {
setLives(l => {
const nl = l - 1
if (nl <= 0) {
setGameOver(true)
}
return nl
})
}
return next
})
// Increase difficulty
difficultyRef.current = 1 + score / 50
frameRef.current = requestAnimationFrame(tick)
}
frameRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frameRef.current)
}, [started, gameOver, spawnWord, score])
// Fire onGameOver
useEffect(() => {
if (gameOver && started) {
onGameOver(score)
}
}, [gameOver, started, score, onGameOver])
const updateInput = (val: string) => {
inputRef.current = val
setInput(val)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (gameOver) return
if (!started) {
startGame()
return
}
if (e.key === 'Backspace') {
const newInput = inputRef.current.slice(0, -1)
updateInput(newInput)
setWords(prev => prev.map(w => ({
...w,
matched: w.text.startsWith(newInput) && newInput.length > 0 ? newInput.length : 0,
})))
return
}
if (e.key.length !== 1) return
e.preventDefault()
const newInput = inputRef.current + e.key
// Do matching + removal in one atomic setWords call
setWords(prev => {
const completed = prev.find(w => w.text === newInput)
if (completed) {
const points = completed.text.length * 10
setScore(s => s + points)
updateInput('')
// Spawn explosion at word position
const now = Date.now()
setExplosions(ex => [...ex, {
id: now,
text: completed.text,
x: completed.x,
y: completed.y,
points,
}])
// Spawn particles
setParticles(p => [...p, ...spawnParticles(completed.x, completed.y, 12)])
// Screen flash
setFlash(now)
// Combo tracking
setCombo(c => {
const next = c + 1
if (next >= 3) setShowCombo(now)
return next
})
clearTimeout(comboTimerRef.current)
comboTimerRef.current = setTimeout(() => setCombo(0), 2000)
// Auto-clean effects after animation
setTimeout(() => {
setExplosions(ex => ex.filter(e => e.id !== now))
}, 500)
setTimeout(() => {
setParticles(p => p.slice(-24))
}, 800)
return prev.filter(w => w.id !== completed.id)
}
// No completion — update partial matches
updateInput(newInput)
return prev.map(w => ({
...w,
matched: w.text.startsWith(newInput) ? newInput.length : 0,
}))
})
}
return (
<div className="fade-in">
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Falling Words</h2>
<span style={{ color: 'var(--text-dim)', fontSize: 14 }}>
High Score: {highScore}
</span>
{started && !gameOver && (
<button onClick={startGame} style={{ marginLeft: 'auto' }}>Restart</button>
)}
</div>
<div
ref={containerRef}
className={`${styles.gameContainer} ${!focused ? styles.blurred : ''}`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>
<div className={styles.gameHud}>
<div className={styles.hudItem}>
<span className={styles.hudLabel}>Score</span>
<span className={styles.hudValue}>{score}</span>
</div>
<div className={styles.hudItem}>
<span className={styles.hudLabel}>Lives</span>
<span className={`${styles.hudValue} ${styles.lives}`}>
{'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 3 - lives))}
</span>
</div>
</div>
{words.map(w => (
<div
key={w.id}
className={`${styles.word} ${w.matched > 0 ? styles.wordMatched : ''}`}
style={{ left: `${w.x}%`, top: `${w.y}%` }}
>
{w.text.split('').map((ch, i) => (
<span key={i} className={i < w.matched ? styles.wordMatchedChar : ''}>
{ch}
</span>
))}
</div>
))}
{/* Exploding words */}
{explosions.map(ex => (
<div
key={ex.id}
className={`${styles.word} ${styles.wordExploding}`}
style={{ left: `${ex.x}%`, top: `${ex.y}%` }}
>
{ex.text}
</div>
))}
{/* Particles */}
{particles.map(p => (
<div
key={p.id}
className={styles.particle}
style={{
left: `${p.x}%`,
top: `${p.y}%`,
background: p.color,
boxShadow: `0 0 6px ${p.color}, 0 0 12px ${p.color}`,
'--tx': `${p.tx}px`,
'--ty': `${p.ty}px`,
'--duration': `${p.duration}s`,
} as React.CSSProperties}
/>
))}
{/* Score popups */}
{explosions.map(ex => (
<div
key={`score-${ex.id}`}
className={styles.scorePopup}
style={{ left: `${ex.x + 3}%`, top: `${ex.y - 2}%` }}
>
+{ex.points}
</div>
))}
{/* Screen flash */}
{flash > 0 && <div key={flash} className={styles.screenFlash} />}
{/* Combo text */}
{showCombo > 0 && combo >= 3 && (
<div key={showCombo} className={styles.comboText}>
{combo}x COMBO!
</div>
)}
<div className={styles.dangerZone} />
{started && !gameOver && (
<div className={styles.inputDisplay}>
{input || <span style={{ opacity: 0.3 }}>type a word...</span>}
</div>
)}
{!started && (
<div className={styles.gameOver}>
<div className={styles.gameOverTitle}>Falling Words</div>
<div className={styles.gameOverScore}>
Type the words before they fall!
</div>
<button className={styles.gameOverButtons} onClick={startGame}
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 18, padding: '14px 36px' }}>
Start Game
</button>
</div>
)}
{gameOver && (
<div className={styles.gameOver}>
<div className={styles.gameOverTitle}>Game Over!</div>
<div className={styles.gameOverScore}>Score: {score}</div>
{score > highScore && score > 0 && (
<div className={styles.highScore}>New High Score!</div>
)}
<div className={styles.gameOverButtons}>
<button
onClick={startGame}
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 16, padding: '12px 28px' }}
>
Play Again
</button>
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,69 @@
import { useState, useEffect } from 'react'
import { KEYBOARD_LAYOUT, FINGER_COLORS, FINGER_LABELS } from '../data/keyboard'
import type { KeyInfo } from '../types'
import styles from '../styles/keyboard.module.css'
type Props = {
activeKey?: string
keyStats?: Record<string, { hits: number; misses: number }>
}
export function Keyboard({ activeKey, keyStats }: Props) {
const [pressedKey, setPressedKey] = useState<string | null>(null)
useEffect(() => {
const down = (e: KeyboardEvent) => setPressedKey(e.key.toLowerCase())
const up = () => setPressedKey(null)
window.addEventListener('keydown', down)
window.addEventListener('keyup', up)
return () => {
window.removeEventListener('keydown', down)
window.removeEventListener('keyup', up)
}
}, [])
const getKeyBg = (ki: KeyInfo) => {
const base = FINGER_COLORS[ki.finger]
if (keyStats) {
const stat = keyStats[ki.key]
if (stat) {
const total = stat.hits + stat.misses
if (total > 0) {
const acc = stat.hits / total
// Blend toward red for low accuracy
if (acc < 0.7) return `color-mix(in srgb, ${base}, var(--error) 40%)`
}
}
}
return base
}
return (
<div className={styles.keyboard}>
{KEYBOARD_LAYOUT.map((row, ri) => (
<div className={styles.row} key={ri}>
{row.map(ki => {
const isActive = activeKey !== undefined &&
ki.key === activeKey.toLowerCase()
const isPressed = pressedKey === ki.key
const label = ki.key === ' ' ? 'Space' : ki.key
return (
<div
key={ki.key}
className={`${styles.key} ${ki.key === ' ' ? styles.space : ''} ${isActive ? styles.active : ''} ${isPressed ? styles.pressed : ''}`}
style={{
background: getKeyBg(ki),
color: '#fff',
}}
>
{label}
<span className={styles.fingerLabel}>{FINGER_LABELS[ki.finger]}</span>
</div>
)
})}
</div>
))}
</div>
)
}

58
src/components/Layout.tsx Normal file
View file

@ -0,0 +1,58 @@
import { type ReactNode } from 'react'
type Tab = 'lessons' | 'free' | 'game' | 'stats'
type Props = {
activeTab: Tab
onTabChange: (tab: Tab) => void
children: ReactNode
}
const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: 'lessons', label: 'Lessons', icon: '📚' },
{ id: 'free', label: 'Free Type', icon: '⌨️' },
{ id: 'game', label: 'Game', icon: '🎮' },
{ id: 'stats', label: 'Stats', icon: '📊' },
]
export function Layout({ activeTab, onTabChange, children }: Props) {
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '20px 24px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 32,
}}>
<h1 style={{
fontSize: 26,
fontWeight: 800,
background: 'linear-gradient(135deg, var(--accent), #e84393)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}>
Leo's Typing Tutor
</h1>
<nav style={{ display: 'flex', gap: 4 }}>
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
style={{
background: activeTab === tab.id ? 'var(--accent)' : 'var(--bg-card)',
color: activeTab === tab.id ? '#fff' : 'var(--text)',
fontWeight: activeTab === tab.id ? 600 : 400,
padding: '10px 18px',
borderRadius: 'var(--radius)',
fontSize: 14,
}}
>
{tab.icon} {tab.label}
</button>
))}
</nav>
</header>
<main>{children}</main>
</div>
)
}

View file

@ -0,0 +1,97 @@
import { useState, useCallback } from 'react'
import { LESSONS } from '../data/lessons'
import { useTypingEngine } from '../hooks/useTypingEngine'
import type { UserProgress } from '../types'
import { LessonSelect } from './LessonSelect'
import { TypingArea } from './TypingArea'
import { Keyboard } from './Keyboard'
import { ResultsModal } from './ResultsModal'
type Props = {
progress: UserProgress
onComplete: (lessonId: number, wpm: number, accuracy: number, keyStats: Record<string, { hits: number; misses: number }>) => void
}
export function LessonMode({ progress, onComplete }: Props) {
const [activeLessonId, setActiveLessonId] = useState<number | null>(null)
const [result, setResult] = useState<{ wpm: number; accuracy: number } | null>(null)
const lesson = activeLessonId ? LESSONS.find(l => l.id === activeLessonId) : null
const text = lesson ? lesson.words.join(' ') : ''
const handleComplete = useCallback((r: { wpm: number; accuracy: number; keyStats: Record<string, { hits: number; misses: number }> }) => {
setResult(r)
if (activeLessonId) {
onComplete(activeLessonId, r.wpm, r.accuracy, r.keyStats)
}
}, [activeLessonId, onComplete])
const engine = useTypingEngine(text, handleComplete)
const handleSelect = (id: number) => {
const l = LESSONS.find(l => l.id === id)
if (!l) return
setActiveLessonId(id)
setResult(null)
engine.reset(l.words.join(' '))
}
const handleRetry = () => {
setResult(null)
engine.reset()
}
const handleBack = () => {
setActiveLessonId(null)
setResult(null)
}
if (!lesson) {
return <LessonSelect progress={progress} onSelect={handleSelect} />
}
// Find next lesson name for unlock message
const nextLesson = LESSONS.find(l => l.unlockAfter === lesson.id)
const unlockText = result && result.accuracy >= 90 && nextLesson
? nextLesson.name
: undefined
return (
<div className="fade-in">
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
<button onClick={handleBack}>Back</button>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700 }}>{lesson.name}</h2>
{lesson.newKeys.length > 0 && (
<span style={{ color: 'var(--text-dim)', fontSize: 14 }}>
New keys: <span style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{lesson.newKeys.join(' ')}</span>
</span>
)}
</div>
</div>
<TypingArea
text={text}
currentIndex={engine.currentIndex}
errors={engine.errors}
wpm={engine.wpm}
accuracy={engine.accuracy}
onKeyDown={engine.handleKeyDown}
/>
<div style={{ marginTop: 24 }}>
<Keyboard activeKey={text[engine.currentIndex]} keyStats={engine.keyStats} />
</div>
{result && (
<ResultsModal
wpm={result.wpm}
accuracy={result.accuracy}
unlocked={unlockText}
onRetry={handleRetry}
onBack={handleBack}
/>
)}
</div>
)
}

View file

@ -0,0 +1,52 @@
import { LESSONS } from '../data/lessons'
import type { UserProgress } from '../types'
import styles from '../styles/typing.module.css'
type Props = {
progress: UserProgress
onSelect: (lessonId: number) => void
}
export function LessonSelect({ progress, onSelect }: Props) {
const isUnlocked = (lessonId: number) => {
const lesson = LESSONS.find(l => l.id === lessonId)
if (!lesson?.unlockAfter) return true
return progress.completedLessons.includes(lesson.unlockAfter)
}
return (
<div>
<div className={styles.modeHeader}>
<h2 className={styles.modeTitle}>Lessons</h2>
<p className={styles.modeSubtitle}>
Master each lesson to unlock the next ({progress.completedLessons.length}/{LESSONS.length} completed)
</p>
</div>
<div className={styles.lessonSelect}>
{LESSONS.map(lesson => {
const unlocked = isUnlocked(lesson.id)
const completed = progress.completedLessons.includes(lesson.id)
return (
<div
key={lesson.id}
className={`${styles.lessonCard} ${!unlocked ? styles.locked : ''} ${completed ? styles.completed : ''}`}
onClick={() => unlocked && onSelect(lesson.id)}
>
<div className={styles.lessonId}>Lesson {lesson.id}</div>
<div className={styles.lessonName}>{lesson.name}</div>
{lesson.newKeys.length > 0 && (
<div className={styles.lessonKeys}>
New: {lesson.newKeys.join(' ')}
</div>
)}
<div className={styles.lessonStatus}>
{completed ? 'Completed' : unlocked ? 'Ready' : 'Locked'}
</div>
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,45 @@
import styles from '../styles/typing.module.css'
type Props = {
wpm: number
accuracy: number
unlocked?: string
onRetry: () => void
onBack: () => void
}
export function ResultsModal({ wpm, accuracy, unlocked, onRetry, onBack }: Props) {
return (
<div className={styles.modal} onClick={onBack}>
<div className={styles.modalContent} onClick={e => e.stopPropagation()}>
<div className={styles.modalTitle}>
{accuracy >= 90 ? 'Great Job!' : 'Keep Practicing!'}
</div>
<div className={styles.modalStats}>
<div className={styles.stat}>
<span className={styles.statValue}>{wpm}</span>
<span className={styles.statLabel}>WPM</span>
</div>
<div className={styles.stat}>
<span className={styles.statValue}>{accuracy}%</span>
<span className={styles.statLabel}>Accuracy</span>
</div>
</div>
{unlocked && (
<div className={styles.unlockText}>
Unlocked: {unlocked}
</div>
)}
{accuracy < 90 && (
<div style={{ color: 'var(--text-dim)', fontSize: 14 }}>
Need 90% accuracy to unlock the next lesson
</div>
)}
<div className={styles.modalButtons}>
<button className={styles.secondaryBtn} onClick={onBack}>Back</button>
<button className={styles.primaryBtn} onClick={onRetry}>Try Again</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,164 @@
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { LESSONS } from '../data/lessons'
import { KEYBOARD_LAYOUT, FINGER_COLORS } from '../data/keyboard'
import type { UserProgress } from '../types'
import styles from '../styles/game.module.css'
import kbStyles from '../styles/keyboard.module.css'
type Props = {
progress: UserProgress
}
export function StatsView({ progress }: Props) {
const { sessions, completedLessons, gameHighScore } = progress
// Build chart data from sessions (last 50)
const recentSessions = sessions.slice(-50)
const chartData = recentSessions.map((s, i) => ({
session: i + 1,
wpm: s.wpm,
accuracy: s.accuracy,
}))
// Aggregate key stats across all sessions
const aggKeyStats: Record<string, { hits: number; misses: number }> = {}
for (const s of sessions) {
for (const [key, stat] of Object.entries(s.keyStats)) {
if (!aggKeyStats[key]) aggKeyStats[key] = { hits: 0, misses: 0 }
aggKeyStats[key].hits += stat.hits
aggKeyStats[key].misses += stat.misses
}
}
const lessonProgress = Math.round((completedLessons.length / LESSONS.length) * 100)
const avgWpm = sessions.length > 0
? Math.round(sessions.reduce((s, r) => s + r.wpm, 0) / sessions.length)
: 0
const avgAccuracy = sessions.length > 0
? Math.round(sessions.reduce((s, r) => s + r.accuracy, 0) / sessions.length)
: 0
return (
<div className="fade-in">
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 20 }}>Stats & Progress</h2>
<div className={styles.statsGrid}>
{/* Summary */}
<div className={styles.statsCard}>
<div className={styles.statsCardTitle}>Overview</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div>
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{avgWpm}</div>
<div style={{ fontSize: 12, color: 'var(--text-dim)' }}>Avg WPM</div>
</div>
<div>
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{avgAccuracy}%</div>
<div style={{ fontSize: 12, color: 'var(--text-dim)' }}>Avg Accuracy</div>
</div>
<div>
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{sessions.length}</div>
<div style={{ fontSize: 12, color: 'var(--text-dim)' }}>Sessions</div>
</div>
<div>
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{gameHighScore}</div>
<div style={{ fontSize: 12, color: 'var(--text-dim)' }}>Game High Score</div>
</div>
</div>
</div>
{/* Lesson Progress */}
<div className={styles.statsCard}>
<div className={styles.statsCardTitle}>Lesson Progress</div>
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', marginBottom: 12, fontFamily: 'var(--font-mono)' }}>
{completedLessons.length} / {LESSONS.length}
</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{ width: `${lessonProgress}%` }} />
</div>
</div>
{/* WPM Chart */}
<div className={styles.statsCard}>
<div className={styles.statsCardTitle}>WPM Over Time</div>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="session" stroke="#666" />
<YAxis stroke="#666" />
<Tooltip
contentStyle={{ background: 'var(--bg-card)', border: '1px solid var(--accent)', borderRadius: 8 }}
/>
<Line type="monotone" dataKey="wpm" stroke="var(--accent)" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<div style={{ color: 'var(--text-dim)', padding: 40, textAlign: 'center' }}>
Complete some sessions to see your progress!
</div>
)}
</div>
{/* Accuracy Chart */}
<div className={styles.statsCard}>
<div className={styles.statsCardTitle}>Accuracy Over Time</div>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="session" stroke="#666" />
<YAxis stroke="#666" domain={[0, 100]} />
<Tooltip
contentStyle={{ background: 'var(--bg-card)', border: '1px solid var(--accent)', borderRadius: 8 }}
/>
<Line type="monotone" dataKey="accuracy" stroke="var(--success)" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<div style={{ color: 'var(--text-dim)', padding: 40, textAlign: 'center' }}>
Complete some sessions to see your progress!
</div>
)}
</div>
{/* Key Accuracy Heatmap */}
<div className={styles.statsCard} style={{ gridColumn: '1 / -1' }}>
<div className={styles.statsCardTitle}>Key Accuracy Heatmap</div>
<div className={kbStyles.keyboard}>
{KEYBOARD_LAYOUT.map((row, ri) => (
<div className={kbStyles.row} key={ri}>
{row.map(ki => {
const stat = aggKeyStats[ki.key]
let bg = FINGER_COLORS[ki.finger]
let opacity = 0.3
if (stat) {
const total = stat.hits + stat.misses
if (total > 0) {
const acc = stat.hits / total
opacity = 0.4 + acc * 0.6
if (acc < 0.7) bg = 'var(--error)'
else if (acc < 0.9) bg = 'var(--warning)'
else bg = 'var(--success)'
}
}
const label = ki.key === ' ' ? 'Space' : ki.key
return (
<div
key={ki.key}
className={`${kbStyles.key} ${ki.key === ' ' ? kbStyles.space : ''}`}
style={{ background: bg, opacity, color: '#fff' }}
title={stat ? `${ki.key}: ${Math.round((stat.hits / (stat.hits + stat.misses)) * 100)}% accuracy` : `${ki.key}: no data`}
>
{label}
</div>
)
})}
</div>
))}
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,57 @@
import { useRef, useState, useEffect } from 'react'
import styles from '../styles/typing.module.css'
type Props = {
text: string
currentIndex: number
errors: Set<number>
wpm: number
accuracy: number
onKeyDown: (e: React.KeyboardEvent) => void
}
export function TypingArea({ text, currentIndex, errors, wpm, accuracy, onKeyDown }: Props) {
const ref = useRef<HTMLDivElement>(null)
const [focused, setFocused] = useState(false)
useEffect(() => {
ref.current?.focus()
}, [])
return (
<div>
<div className={styles.stats}>
<div className={styles.stat}>
<span className={styles.statValue}>{wpm}</span>
<span className={styles.statLabel}>WPM</span>
</div>
<div className={styles.stat}>
<span className={styles.statValue}>{accuracy}%</span>
<span className={styles.statLabel}>Accuracy</span>
</div>
</div>
<div
ref={ref}
className={`${styles.typingArea} ${!focused ? styles.blurred : ''}`}
tabIndex={0}
onKeyDown={onKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>
{text.split('').map((char, i) => {
let cls = styles.pending
if (i < currentIndex) {
cls = errors.has(i) ? styles.incorrect : styles.correct
} else if (i === currentIndex) {
cls = styles.current
}
return (
<span key={i} className={`${styles.char} ${cls}`}>
{char}
</span>
)
})}
</div>
</div>
)
}

100
src/data/keyboard.ts Normal file
View file

@ -0,0 +1,100 @@
import type { Finger, KeyInfo } from '../types'
export const FINGER_COLORS: Record<Finger, string> = {
lpinky: '#e74c3c',
lring: '#e67e22',
lmiddle: '#f1c40f',
lindex: '#2ecc71',
rindex: '#27ae60',
rmiddle: '#3498db',
rring: '#9b59b6',
rpinky: '#e84393',
thumb: '#95a5a6',
}
export const FINGER_LABELS: Record<Finger, string> = {
lpinky: 'Left Pinky',
lring: 'Left Ring',
lmiddle: 'Left Middle',
lindex: 'Left Index',
rindex: 'Right Index',
rmiddle: 'Right Middle',
rring: 'Right Ring',
rpinky: 'Right Pinky',
thumb: 'Thumb',
}
// Row 0 = number row, Row 1 = top, Row 2 = home, Row 3 = bottom, Row 4 = space
export const KEYBOARD_LAYOUT: KeyInfo[][] = [
// Number row
[
{ key: '`', finger: 'lpinky', row: 0, col: 0 },
{ key: '1', finger: 'lpinky', row: 0, col: 1 },
{ key: '2', finger: 'lring', row: 0, col: 2 },
{ key: '3', finger: 'lmiddle', row: 0, col: 3 },
{ key: '4', finger: 'lindex', row: 0, col: 4 },
{ key: '5', finger: 'lindex', row: 0, col: 5 },
{ key: '6', finger: 'rindex', row: 0, col: 6 },
{ key: '7', finger: 'rindex', row: 0, col: 7 },
{ key: '8', finger: 'rmiddle', row: 0, col: 8 },
{ key: '9', finger: 'rring', row: 0, col: 9 },
{ key: '0', finger: 'rpinky', row: 0, col: 10 },
{ key: '-', finger: 'rpinky', row: 0, col: 11 },
{ key: '=', finger: 'rpinky', row: 0, col: 12 },
],
// Top row
[
{ key: 'q', finger: 'lpinky', row: 1, col: 0 },
{ key: 'w', finger: 'lring', row: 1, col: 1 },
{ key: 'e', finger: 'lmiddle', row: 1, col: 2 },
{ key: 'r', finger: 'lindex', row: 1, col: 3 },
{ key: 't', finger: 'lindex', row: 1, col: 4 },
{ key: 'y', finger: 'rindex', row: 1, col: 5 },
{ key: 'u', finger: 'rindex', row: 1, col: 6 },
{ key: 'i', finger: 'rmiddle', row: 1, col: 7 },
{ key: 'o', finger: 'rring', row: 1, col: 8 },
{ key: 'p', finger: 'rpinky', row: 1, col: 9 },
{ key: '[', finger: 'rpinky', row: 1, col: 10 },
{ key: ']', finger: 'rpinky', row: 1, col: 11 },
{ key: '\\', finger: 'rpinky', row: 1, col: 12 },
],
// Home row
[
{ key: 'a', finger: 'lpinky', row: 2, col: 0 },
{ key: 's', finger: 'lring', row: 2, col: 1 },
{ key: 'd', finger: 'lmiddle', row: 2, col: 2 },
{ key: 'f', finger: 'lindex', row: 2, col: 3 },
{ key: 'g', finger: 'lindex', row: 2, col: 4 },
{ key: 'h', finger: 'rindex', row: 2, col: 5 },
{ key: 'j', finger: 'rindex', row: 2, col: 6 },
{ key: 'k', finger: 'rmiddle', row: 2, col: 7 },
{ key: 'l', finger: 'rring', row: 2, col: 8 },
{ key: ';', finger: 'rpinky', row: 2, col: 9 },
{ key: "'", finger: 'rpinky', row: 2, col: 10 },
],
// Bottom row
[
{ key: 'z', finger: 'lpinky', row: 3, col: 0 },
{ key: 'x', finger: 'lring', row: 3, col: 1 },
{ key: 'c', finger: 'lmiddle', row: 3, col: 2 },
{ key: 'v', finger: 'lindex', row: 3, col: 3 },
{ key: 'b', finger: 'lindex', row: 3, col: 4 },
{ key: 'n', finger: 'rindex', row: 3, col: 5 },
{ key: 'm', finger: 'rmiddle', row: 3, col: 6 },
{ key: ',', finger: 'rmiddle', row: 3, col: 7 },
{ key: '.', finger: 'rring', row: 3, col: 8 },
{ key: '/', finger: 'rpinky', row: 3, col: 9 },
],
// Space row
[
{ key: ' ', finger: 'thumb', row: 4, col: 0 },
],
]
// Flat lookup: key → KeyInfo
export const KEY_MAP: Record<string, KeyInfo> = {}
for (const row of KEYBOARD_LAYOUT) {
for (const ki of row) {
KEY_MAP[ki.key] = ki
}
}

171
src/data/lessons.ts Normal file
View file

@ -0,0 +1,171 @@
import type { Lesson } from '../types'
export const LESSONS: Lesson[] = [
{
id: 1,
name: 'Home Row: Left Hand',
newKeys: ['a', 's', 'd', 'f'],
words: [
'add', 'sad', 'dad', 'fad', 'ads', 'fas', 'das',
'sass', 'fads', 'adds', 'dads', 'asdf', 'fdsa',
'aff', 'sadf', 'dafs', 'fads', 'sass', 'adds',
],
},
{
id: 2,
name: 'Home Row: Right Hand',
newKeys: ['j', 'k', 'l', ';'],
unlockAfter: 1,
words: [
'all', 'fall', 'jail', 'kale', 'lake', 'fake',
'flask', 'falls', 'jacks', 'slak', 'flak', 'alf',
'lads', 'salad', 'flask', 'shall', 'djall', 'alks',
],
},
{
id: 3,
name: 'Home Row: G and H',
newKeys: ['g', 'h'],
unlockAfter: 2,
words: [
'had', 'has', 'gal', 'lag', 'hag', 'gash', 'lash',
'half', 'glad', 'flash', 'gag', 'hash', 'sash',
'shall', 'shag', 'ghalf', 'flags', 'glass', 'halls',
],
},
{
id: 4,
name: 'Top Row: Left Hand',
newKeys: ['q', 'w', 'e', 'r', 't'],
unlockAfter: 3,
words: [
'the', 'were', 'tree', 'read', 'tear', 'rest',
'west', 'test', 'quest', 'wheat', 'street', 'great',
'stew', 'tread', 'sweat', 'wreath', 'wet', 'set',
],
},
{
id: 5,
name: 'Top Row: Right Hand',
newKeys: ['y', 'u', 'i', 'o', 'p'],
unlockAfter: 4,
words: [
'you', 'your', 'type', 'youthful', 'quiet', 'equip',
'ripe', 'trip', 'pour', 'our', 'proud', 'youth',
'quite', 'quote', 'opaque', 'tulip', 'quip', 'outpour',
],
},
{
id: 6,
name: 'Home + Top Row Practice',
newKeys: [],
unlockAfter: 5,
words: [
'the quick', 'just right', 'play together', 'light house',
'quiet whisper', 'write daily', 'type faster', 'your style',
'keep it up', 'forge ahead', 'super power', 'world hero',
],
},
{
id: 7,
name: 'Bottom Row: Left Hand',
newKeys: ['z', 'x', 'c', 'v', 'b'],
unlockAfter: 6,
words: [
'box', 'vex', 'cab', 'back', 'cave', 'brave',
'exact', 'verb', 'crisp', 'black', 'beach', 'voice',
'brace', 'civic', 'blaze', 'crux', 'vibe', 'cube',
],
},
{
id: 8,
name: 'Bottom Row: Right Hand',
newKeys: ['n', 'm', ',', '.', '/'],
unlockAfter: 7,
words: [
'man', 'name', 'mine', 'moon', 'noon', 'main',
'manner', 'common', 'command', 'mention', 'moment',
'minion', 'melon', 'nominate', 'minimum', 'memo',
],
},
{
id: 9,
name: 'Full Keyboard Practice',
newKeys: [],
unlockAfter: 8,
words: [
'the lazy fox', 'jumped over', 'brown quick', 'from every angle',
'maximize your', 'best typing', 'skills now', 'extra credit',
'puzzle solved', 'victory dance', 'zero mistakes', 'nice job',
],
},
{
id: 10,
name: 'Capital Letters',
newKeys: ['Shift'],
unlockAfter: 9,
words: [
'Hello World', 'Good Morning', 'Dear Friend', 'New York',
'San Diego', 'Leo Rules', 'Amazing Job', 'Keep Going',
'Great Work', 'High Five', 'The Best', 'Super Star',
],
},
{
id: 11,
name: 'Numbers Row',
newKeys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
unlockAfter: 10,
words: [
'123', '456', '789', '100 cats', '250 dogs',
'42 is the answer', '365 days', '12 months',
'7 wonders', '50 states', '3 wishes', '99 stars',
],
},
{
id: 12,
name: 'Common Punctuation',
newKeys: ['.', ',', '!', '?', "'"],
unlockAfter: 11,
words: [
"Hello, world!", "What's up?", "I can't wait!",
"Great job, Leo!", "Ready? Set. Go!", "Yes, please.",
"Wow, that's cool!", "Don't stop now!",
"Nice work, champ!", "Is it fun? Yes!",
],
},
{
id: 13,
name: 'Special Symbols',
newKeys: ['-', '=', '[', ']', '/', '\\'],
unlockAfter: 12,
words: [
'a-b-c', 'x=y', '[hello]', 'left/right', 'win-win',
'self-made', 'all=equal', '[done]', 'yes/no', 'up-down',
'hi-five', 're=set', '[start]', 'and/or', 'check-in',
],
},
{
id: 14,
name: 'Speed Challenge: Common Words',
newKeys: [],
unlockAfter: 13,
words: [
'the and for are but not you all any can had her',
'was one our out day get has him his how its may',
'new now old see way who did got let say she too use',
],
},
{
id: 15,
name: 'Speed Challenge: Sentences',
newKeys: [],
unlockAfter: 14,
words: [
'The quick brown fox jumps over the lazy dog.',
'Pack my box with five dozen liquor jugs.',
'How vexingly quick daft zebras jump!',
'The five boxing wizards jump quickly.',
'Sphinx of black quartz, judge my vow.',
],
},
]

37
src/data/quotes.ts Normal file
View file

@ -0,0 +1,37 @@
export const QUOTES: string[] = [
"The only way to do great work is to love what you do.",
"In the middle of every difficulty lies opportunity.",
"It does not matter how slowly you go as long as you do not stop.",
"The future belongs to those who believe in the beauty of their dreams.",
"Believe you can and you are halfway there.",
"The best time to plant a tree was twenty years ago. The second best time is now.",
"You miss one hundred percent of the shots you never take.",
"Be yourself; everyone else is already taken.",
"Two things are infinite: the universe and human stupidity.",
"A room without books is like a body without a soul.",
"You only live once, but if you do it right, once is enough.",
"Be the change that you wish to see in the world.",
"In three words I can sum up everything I learned about life: it goes on.",
"If you tell the truth, you do not have to remember anything.",
"Life is what happens when you are busy making other plans.",
"To be yourself in a world that is constantly trying to make you something else is the greatest accomplishment.",
"The secret of getting ahead is getting started.",
"It is never too late to be what you might have been.",
"Everything you can imagine is real.",
"Do what you can, with what you have, where you are.",
"The journey of a thousand miles begins with a single step.",
"Not all those who wander are lost.",
"What we think, we become.",
"The only impossible journey is the one you never begin.",
"Turn your wounds into wisdom.",
"Stars cannot shine without darkness.",
"Dream big and dare to fail.",
"Happiness is not something ready made. It comes from your own actions.",
"The best revenge is massive success.",
"Every moment is a fresh beginning.",
"If opportunity does not knock, build a door.",
"Nothing is impossible. The word itself says I am possible.",
"Keep your face always toward the sunshine and shadows will fall behind you.",
"What you do today can improve all your tomorrows.",
"Quality is not an act, it is a habit.",
]

68
src/hooks/useStats.ts Normal file
View file

@ -0,0 +1,68 @@
import { useState, useCallback } from 'react'
import type { UserProgress, SessionResult } from '../types'
const STORAGE_KEY = 'leo-typing-progress'
const DEFAULT_PROGRESS: UserProgress = {
completedLessons: [],
sessions: [],
gameHighScore: 0,
}
function loadProgress(): UserProgress {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_PROGRESS
return JSON.parse(raw)
} catch {
return DEFAULT_PROGRESS
}
}
function saveProgress(p: UserProgress) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(p))
}
export function useStats() {
const [progress, setProgress] = useState<UserProgress>(loadProgress)
const completeLesson = useCallback((lessonId: number) => {
setProgress(prev => {
const next = {
...prev,
completedLessons: prev.completedLessons.includes(lessonId)
? prev.completedLessons
: [...prev.completedLessons, lessonId],
}
saveProgress(next)
return next
})
}, [])
const addSession = useCallback((result: SessionResult) => {
setProgress(prev => {
const next = {
...prev,
sessions: [...prev.sessions, result],
}
saveProgress(next)
return next
})
}, [])
const updateHighScore = useCallback((score: number) => {
setProgress(prev => {
if (score <= prev.gameHighScore) return prev
const next = { ...prev, gameHighScore: score }
saveProgress(next)
return next
})
}, [])
const resetProgress = useCallback(() => {
setProgress(DEFAULT_PROGRESS)
saveProgress(DEFAULT_PROGRESS)
}, [])
return { progress, completeLesson, addSession, updateHighScore, resetProgress }
}

View file

@ -0,0 +1,122 @@
import { useState, useCallback, useRef, useEffect } from 'react'
export type TypingEngineResult = {
currentIndex: number
errors: Set<number>
wpm: number
accuracy: number
isComplete: boolean
keyStats: Record<string, { hits: number; misses: number }>
handleKeyDown: (e: React.KeyboardEvent) => void
reset: (newText?: string) => void
}
export function useTypingEngine(
initialText: string,
onComplete?: (result: { wpm: number; accuracy: number; keyStats: Record<string, { hits: number; misses: number }> }) => void,
): TypingEngineResult {
const [text, setText] = useState(initialText)
const [currentIndex, setCurrentIndex] = useState(0)
const [errors, setErrors] = useState<Set<number>>(new Set())
const [totalKeystrokes, setTotalKeystrokes] = useState(0)
const [correctKeystrokes, setCorrectKeystrokes] = useState(0)
const [isComplete, setIsComplete] = useState(false)
const [keyStats, setKeyStats] = useState<Record<string, { hits: number; misses: number }>>({})
const startTimeRef = useRef<number | null>(null)
const [wpm, setWpm] = useState(0)
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
const accuracy = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100
// Update WPM periodically
useEffect(() => {
if (isComplete || currentIndex === 0) return
const interval = setInterval(() => {
if (startTimeRef.current) {
const minutes = (Date.now() - startTimeRef.current) / 60000
if (minutes > 0) {
setWpm(Math.round((currentIndex / 5) / minutes))
}
}
}, 500)
return () => clearInterval(interval)
}, [currentIndex, isComplete])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isComplete) return
// Ignore modifier keys, function keys, etc.
if (e.key.length > 1 && e.key !== 'Backspace') return
e.preventDefault()
if (e.key === 'Backspace') {
setCurrentIndex(prev => Math.max(0, prev - 1))
setErrors(prev => {
const next = new Set(prev)
next.delete(currentIndex - 1)
return next
})
return
}
if (!startTimeRef.current) {
startTimeRef.current = Date.now()
}
const expected = text[currentIndex]
const typed = e.key
const charKey = expected?.toLowerCase() ?? typed.toLowerCase()
setTotalKeystrokes(prev => prev + 1)
if (typed === expected) {
setCorrectKeystrokes(prev => prev + 1)
setKeyStats(prev => ({
...prev,
[charKey]: { hits: (prev[charKey]?.hits ?? 0) + 1, misses: prev[charKey]?.misses ?? 0 },
}))
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
if (nextIndex >= text.length) {
setIsComplete(true)
const minutes = (Date.now() - (startTimeRef.current ?? Date.now())) / 60000
const finalWpm = minutes > 0 ? Math.round((text.length / 5) / minutes) : 0
setWpm(finalWpm)
const finalAccuracy = (totalKeystrokes + 1) > 0
? Math.round(((correctKeystrokes + 1) / (totalKeystrokes + 1)) * 100)
: 100
// Build final keyStats including this last keystroke
setKeyStats(prev => {
const final = {
...prev,
[charKey]: { hits: (prev[charKey]?.hits ?? 0) + 1, misses: prev[charKey]?.misses ?? 0 },
}
onCompleteRef.current?.({ wpm: finalWpm, accuracy: finalAccuracy, keyStats: final })
return prev // Don't double-count, already set above
})
}
} else {
setErrors(prev => new Set(prev).add(currentIndex))
setKeyStats(prev => ({
...prev,
[charKey]: { hits: prev[charKey]?.hits ?? 0, misses: (prev[charKey]?.misses ?? 0) + 1 },
}))
}
}, [currentIndex, text, isComplete, totalKeystrokes, correctKeystrokes])
const reset = useCallback((newText?: string) => {
if (newText !== undefined) setText(newText)
setCurrentIndex(0)
setErrors(new Set())
setTotalKeystrokes(0)
setCorrectKeystrokes(0)
setIsComplete(false)
setKeyStats({})
setWpm(0)
startTimeRef.current = null
}, [])
return { currentIndex, errors, wpm, accuracy, isComplete, keyStats, handleKeyDown, reset }
}

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/global.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

310
src/styles/game.module.css Normal file
View file

@ -0,0 +1,310 @@
.gameContainer {
position: relative;
background: var(--bg-card);
border-radius: var(--radius);
height: 500px;
overflow: hidden;
outline: none;
border: 2px solid transparent;
transition: border-color 0.2s;
}
.gameContainer:focus {
border-color: var(--accent);
}
.gameContainer.blurred::after {
content: 'Click to play!';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(26, 27, 46, 0.8);
border-radius: var(--radius);
color: var(--text-dim);
font-size: 18px;
z-index: 5;
}
.gameHud {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(to bottom, rgba(26, 27, 46, 0.9), transparent);
z-index: 2;
font-family: var(--font-mono);
}
.hudItem {
display: flex;
flex-direction: column;
align-items: center;
}
.hudLabel {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
}
.hudValue {
font-size: 22px;
font-weight: 700;
color: var(--accent);
}
.dangerZone {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: var(--error);
opacity: 0.6;
}
.word {
position: absolute;
font-family: var(--font-mono);
font-size: 20px;
font-weight: 600;
padding: 6px 14px;
border-radius: 8px;
background: var(--bg-hover);
border: 2px solid var(--accent);
color: var(--text);
transition: transform 0.05s linear;
white-space: nowrap;
}
.wordMatched {
border-color: var(--success);
}
.wordMatchedChar {
color: var(--success);
}
.inputDisplay {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--bg);
padding: 10px 24px;
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 20px;
color: var(--accent);
border: 2px solid var(--accent);
min-width: 200px;
text-align: center;
z-index: 3;
}
.lives {
font-size: 20px;
letter-spacing: 4px;
}
.gameOver {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(26, 27, 46, 0.95);
z-index: 10;
animation: fadeIn 0.3s ease;
}
.gameOverTitle {
font-size: 36px;
font-weight: 700;
margin-bottom: 16px;
}
.gameOverScore {
font-size: 20px;
color: var(--text-dim);
margin-bottom: 8px;
}
.highScore {
color: var(--warning);
font-weight: 600;
}
.gameOverButtons {
display: flex;
gap: 12px;
margin-top: 24px;
}
.statsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.statsCard {
background: var(--bg-card);
border-radius: var(--radius);
padding: 24px;
}
.statsCardTitle {
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
margin-bottom: 16px;
}
.progressBar {
height: 12px;
background: var(--bg);
border-radius: 6px;
overflow: hidden;
}
.progressFill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--success));
border-radius: 6px;
transition: width 0.5s ease;
}
/* === Word destroy effects === */
.wordExploding {
animation: wordExplode 0.45s ease-out forwards;
pointer-events: none;
border-color: var(--success) !important;
background: var(--success) !important;
color: #fff !important;
}
@keyframes wordExplode {
0% {
transform: scale(1);
opacity: 1;
filter: brightness(1);
}
30% {
transform: scale(1.3);
opacity: 1;
filter: brightness(2);
}
100% {
transform: scale(0.2);
opacity: 0;
filter: brightness(3) blur(4px);
}
}
.particle {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
pointer-events: none;
animation: particleFly var(--duration) ease-out forwards;
z-index: 20;
}
@keyframes particleFly {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
100% {
transform: translate(var(--tx), var(--ty)) scale(0);
opacity: 0;
}
}
.scorePopup {
position: absolute;
font-family: var(--font-mono);
font-size: 24px;
font-weight: 800;
color: var(--success);
pointer-events: none;
z-index: 20;
text-shadow: 0 0 12px var(--success), 0 0 24px rgba(46, 204, 113, 0.4);
animation: scoreFloat 0.8s ease-out forwards;
}
@keyframes scoreFloat {
0% {
transform: translateY(0) scale(0.5);
opacity: 0;
}
20% {
transform: translateY(-10px) scale(1.2);
opacity: 1;
}
100% {
transform: translateY(-60px) scale(0.8);
opacity: 0;
}
}
.screenFlash {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 15;
animation: flash 0.3s ease-out forwards;
border-radius: var(--radius);
}
@keyframes flash {
0% {
background: rgba(46, 204, 113, 0.2);
}
100% {
background: transparent;
}
}
.comboText {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: var(--font-mono);
font-size: 48px;
font-weight: 800;
pointer-events: none;
z-index: 20;
animation: comboPop 0.6s ease-out forwards;
background: linear-gradient(135deg, var(--accent), #e84393, var(--warning));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: none;
filter: drop-shadow(0 0 20px var(--accent));
}
@keyframes comboPop {
0% {
transform: translate(-50%, -50%) scale(0.3) rotate(-10deg);
opacity: 0;
}
30% {
transform: translate(-50%, -50%) scale(1.3) rotate(3deg);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(0.8) rotate(0deg);
opacity: 0;
}
}

69
src/styles/global.css Normal file
View file

@ -0,0 +1,69 @@
:root {
--bg: #1a1b2e;
--bg-card: #242640;
--bg-hover: #2d2f52;
--text: #e8e8f0;
--text-dim: #8888a8;
--accent: #7c6ff7;
--accent-glow: rgba(124, 111, 247, 0.4);
--success: #2ecc71;
--error: #e74c3c;
--warning: #f1c40f;
--radius: 12px;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: var(--bg-card);
color: var(--text);
padding: 10px 20px;
border-radius: var(--radius);
font-size: 14px;
transition: all 0.15s ease;
}
button:hover {
background: var(--bg-hover);
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 8px var(--accent-glow); }
50% { box-shadow: 0 0 20px var(--accent-glow), 0 0 40px var(--accent-glow); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.3s ease;
}

View file

@ -0,0 +1,71 @@
.keyboard {
display: flex;
flex-direction: column;
gap: 6px;
padding: 20px;
background: var(--bg-card);
border-radius: var(--radius);
align-items: center;
user-select: none;
}
.row {
display: flex;
gap: 5px;
}
.row:nth-child(2) { margin-left: 20px; }
.row:nth-child(3) { margin-left: 35px; }
.row:nth-child(4) { margin-left: 55px; }
.key {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
position: relative;
transition: all 0.15s ease;
border: 2px solid transparent;
text-transform: uppercase;
}
.key:hover {
transform: translateY(-2px);
filter: brightness(1.2);
}
.space {
width: 280px;
}
.active {
animation: pulse 1s ease-in-out infinite;
transform: translateY(-2px);
filter: brightness(1.3);
z-index: 1;
}
.pressed {
transform: translateY(2px) !important;
filter: brightness(0.8) !important;
transition: all 0.05s ease;
}
.fingerLabel {
position: absolute;
bottom: -18px;
font-size: 9px;
color: var(--text-dim);
white-space: nowrap;
opacity: 0;
transition: opacity 0.15s ease;
}
.key:hover .fingerLabel {
opacity: 1;
}

View file

@ -0,0 +1,221 @@
.typingArea {
background: var(--bg-card);
border-radius: var(--radius);
padding: 30px;
font-family: var(--font-mono);
font-size: 22px;
line-height: 1.8;
min-height: 120px;
cursor: text;
outline: none;
position: relative;
border: 2px solid transparent;
transition: border-color 0.2s ease;
}
.typingArea:focus {
border-color: var(--accent);
}
.typingArea.blurred::after {
content: 'Click here to start typing...';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(26, 27, 46, 0.8);
border-radius: var(--radius);
color: var(--text-dim);
font-size: 16px;
}
.char {
position: relative;
letter-spacing: 1px;
}
.correct {
color: var(--success);
}
.incorrect {
color: var(--error);
background: rgba(231, 76, 60, 0.15);
border-radius: 3px;
}
.current {
border-left: 2px solid var(--accent);
animation: blink 1s step-end infinite;
margin-left: -1px;
padding-left: 1px;
}
.pending {
color: var(--text-dim);
}
@keyframes blink {
50% { border-color: transparent; }
}
.stats {
display: flex;
gap: 30px;
padding: 16px 0;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.statValue {
font-size: 28px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--accent);
}
.statLabel {
font-size: 12px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
.lessonSelect {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.lessonCard {
background: var(--bg-card);
border-radius: var(--radius);
padding: 20px;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
}
.lessonCard:hover:not(.locked) {
border-color: var(--accent);
transform: translateY(-2px);
}
.locked {
opacity: 0.4;
cursor: not-allowed;
}
.completed {
border-color: var(--success);
}
.lessonId {
font-size: 12px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
.lessonName {
font-size: 18px;
font-weight: 600;
margin: 6px 0;
}
.lessonKeys {
font-family: var(--font-mono);
color: var(--accent);
font-size: 14px;
}
.lessonStatus {
font-size: 12px;
margin-top: 8px;
color: var(--text-dim);
}
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn 0.2s ease;
}
.modalContent {
background: var(--bg-card);
border-radius: var(--radius);
padding: 40px;
max-width: 440px;
width: 90%;
text-align: center;
animation: fadeIn 0.3s ease;
}
.modalTitle {
font-size: 28px;
font-weight: 700;
margin-bottom: 20px;
}
.modalStats {
display: flex;
justify-content: center;
gap: 40px;
margin: 24px 0;
}
.unlockText {
color: var(--success);
font-size: 16px;
margin: 16px 0;
}
.modalButtons {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 24px;
}
.primaryBtn {
background: var(--accent);
color: white;
font-weight: 600;
padding: 12px 28px;
font-size: 16px;
}
.primaryBtn:hover {
filter: brightness(1.1);
background: var(--accent);
}
.secondaryBtn {
background: var(--bg-hover);
padding: 12px 28px;
font-size: 16px;
}
.modeHeader {
margin-bottom: 24px;
}
.modeTitle {
font-size: 24px;
font-weight: 700;
}
.modeSubtitle {
color: var(--text-dim);
margin-top: 4px;
}

33
src/types.ts Normal file
View file

@ -0,0 +1,33 @@
export type Finger =
| 'lpinky' | 'lring' | 'lmiddle' | 'lindex'
| 'rindex' | 'rmiddle' | 'rring' | 'rpinky'
| 'thumb'
export type KeyInfo = {
key: string
finger: Finger
row: number
col: number
}
export type Lesson = {
id: number
name: string
newKeys: string[]
words: string[]
unlockAfter?: number
}
export type SessionResult = {
mode: string
wpm: number
accuracy: number
timestamp: number
keyStats: Record<string, { hits: number; misses: number }>
}
export type UserProgress = {
completedLessons: number[]
sessions: SessionResult[]
gameHighScore: number
}

28
tsconfig.app.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})