feat(folio-web): web sample app + CDP spec tests (#36)

* fix(runner): allow nil connection for web platform

* fix(testrun): skip SDK handshake for web platform

* feat(folio-web): add React/Vite web sample app

* feat(folio-web): add sanderling spec

* fix(chrome): use InsertText for multi-char text input

* feat(hierarchy): add Screen field populated from sanderling-screen attr

* fix(chrome): auto-detect viewport from CSS vars, fix InputText accumulation, expose route as screen

* fix(runner): fall back to hierarchy root screen when snapshot screen is empty

* fix(folio-web): broaden loggedIn extractor to all authenticated pages
This commit is contained in:
pj authored and GitHub committed 2026-04-22 23:59:30 +07:00
1 parent eed99e58aa
commit af7b7e27b0
31 files changed
+2970 -74

No files matched your search

+8 -1
View File
@@ -48,6 +48,11 @@ inspect-ui/node_modules/
inspect-ui/dist/ inspect-ui/dist/
inspect-ui/.vite/ inspect-ui/.vite/
# folio-web frontend
examples/folio-web/node_modules/
examples/folio-web/dist/
examples/folio-web/.vite/
# d2 diagrams render into build/site/_assets/diagrams; keep sources only # d2 diagrams render into build/site/_assets/diagrams; keep sources only
/docs/_diagrams/*.svg /docs/_diagrams/*.svg
/docs/_diagrams/*.png /docs/_diagrams/*.png
@@ -57,4 +62,6 @@ inspect-ui/.vite/
.claude/* .claude/*
# Personal research notes # Personal research notes
/research/ /research/
internal/inspect/dist/*
+436
View File
@@ -0,0 +1,436 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "folio-web",
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@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": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "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/[email protected]", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/template": ["@babel/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@eslint-community/eslint-utils": ["@eslint-community/[email protected]", "", { "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/[email protected]", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
"@eslint/config-helpers": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/[email protected]", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/[email protected]", "", { "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/[email protected]", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
"@eslint/object-schema": ["@eslint/[email protected]", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@humanfs/core": ["@humanfs/[email protected]", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/[email protected]", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/[email protected]", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/[email protected]", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/[email protected]", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@oxc-project/types": ["@oxc-project/[email protected]", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/[email protected]", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="],
"@typescript-eslint/parser": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="],
"@typescript-eslint/project-service": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/[email protected]", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg=="],
"@typescript-eslint/types": ["@typescript-eslint/[email protected]", "", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="],
"@typescript-eslint/utils": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="],
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "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": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ=="],
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"callsites": ["[email protected]", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw=="],
"chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["[email protected]", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["[email protected]", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["[email protected]", "", { "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": ["[email protected]", "", { "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 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
"eslint-plugin-react-refresh": ["[email protected]", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
"eslint-scope": ["[email protected]", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["[email protected]", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["[email protected]", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["[email protected]", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["[email protected]", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["[email protected]", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["[email protected]", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["[email protected]", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["[email protected]", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["[email protected]", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["[email protected]", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
"has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hermes-estree": ["[email protected]", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["[email protected]", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"ignore": ["[email protected]", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["[email protected]", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["[email protected]", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["[email protected]", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["[email protected]", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["[email protected]", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["[email protected]", "", { "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": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["[email protected]", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-releases": ["[email protected]", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
"optionator": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["[email protected]", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["[email protected]", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["[email protected]", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["[email protected]", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
"resolve-from": ["[email protected]", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rolldown": ["[email protected]", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="],
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"typescript-eslint": ["[email protected]", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw=="],
"undici-types": ["[email protected]", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"vite": ["[email protected]", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.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-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["[email protected]", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["[email protected]", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["[email protected]", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-validation-error": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["[email protected]", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@typescript-eslint/eslint-plugin/ignore": ["[email protected]", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@typescript-eslint/typescript-estree/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["[email protected]", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
}
}
+23
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,
},
},
])
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1"
/>
<meta name="theme-color" content="#0b0d10" />
<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=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>Ledger</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
set shell := ["bash", "-cu"]
sanderling := env_var_or_default("SANDERLING", "sanderling")
duration := env_var_or_default("DURATION", "1m")
seed := env_var_or_default("SEED", "0")
output := env_var_or_default("OUTPUT", justfile_directory() / "sanderling" / "runs")
port := env_var_or_default("PORT", "5173")
default:
@just --list
# Run sanderling property tests against the folio web app.
test:
#!/usr/bin/env bash
set -euo pipefail
bun run dev --port {{port}} &
DEV_PID=$!
trap "kill $DEV_PID 2>/dev/null || true" EXIT
for i in $(seq 1 30); do
curl -sf "http://localhost:{{port}}" > /dev/null && break
sleep 1
done
"{{sanderling}}" test \
--spec "{{justfile_directory()}}/sanderling/spec.ts" \
--bundle-id "http://localhost:{{port}}" \
--platform web \
--duration "{{duration}}" \
--seed "{{seed}}" \
--output "{{output}}"
+30
View File
@@ -0,0 +1,30 @@
{
"name": "folio-web",
"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"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@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": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+305
View File
@@ -0,0 +1,305 @@
import {
InputText,
Tap,
actions,
always,
eventually,
extract,
from,
next,
now,
swipes,
taps,
waitOnce,
weighted,
} from "@sanderling/spec";
import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties";
// Route detection via stable DOM ids
const onLoginPage = extract((s) => !!s.ax.find("id:email"));
const onHomePage = extract((s) => !!s.ax.find("id:add-account"));
const onAddAccountPage = extract((s) => !!s.ax.find("id:account-name"));
const onLedgerPage = extract((s) => !!s.ax.find("id:ledger"));
const onAddTxnPage = extract((s) => !!s.ax.find("id:txn-amount"));
// Auth state: true on any authenticated page, false only on login page
const loggedIn = extract((s) => {
if (s.ax.find("id:email")) return false;
return !!(
s.ax.find("id:logout") ||
s.ax.find("id:add-account") ||
s.ax.find("id:ledger") ||
s.ax.find("id:account-name") ||
s.ax.find("id:txn-amount") ||
s.ax.find("id:add-txn")
);
});
// Total balance from title attribute on id:total-balance (set to raw cents integer)
const totalBalance = extract((s) => {
const el = s.ax.find("id:total-balance");
if (!el) return 0;
const v = parseInt((el as { desc?: string }).desc ?? "", 10);
return isNaN(v) ? 0 : v;
});
// Account cards expose "account:{id}:{balance}" via aria-label -> content-desc
const accountCards = extract((s) => {
return s.ax.findAll("descPrefix:account:").map((el) => {
const parts = ((el as { desc?: string }).desc ?? "").split(":");
const balance = parseInt(parts[2] ?? "0", 10);
return { element: el, id: parts[1] ?? "", balance: isNaN(balance) ? 0 : balance };
});
});
// Ledger txn count from title on id:ledger
const ledgerTxnCount = extract((s) => {
const el = s.ax.find("id:ledger");
if (!el) return 0;
const v = parseInt((el as { desc?: string }).desc ?? "", 10);
return isNaN(v) ? 0 : v;
});
// Ledger balance from title on id:ledger-balance
const ledgerBalance = extract((s) => {
const el = s.ax.find("id:ledger-balance");
if (!el) return 0;
const v = parseInt((el as { desc?: string }).desc ?? "", 10);
return isNaN(v) ? 0 : v;
});
// UI element handles
const emailField = extract((s) => s.ax.find("id:email"));
const passwordField = extract((s) => s.ax.find("id:password"));
const loginSubmit = extract((s) => s.ax.find("id:login-submit"));
const logoutButton = extract((s) => s.ax.find("id:logout"));
const addAccountButton = extract((s) => s.ax.find("id:add-account"));
const accountNameField = extract((s) => s.ax.find("id:account-name"));
const addAccountSubmit = extract((s) => s.ax.find("id:add-account-submit"));
const addTxnButton = extract((s) => s.ax.find("id:add-txn"));
const txnAmountField = extract((s) => s.ax.find("id:txn-amount"));
const txnNoteField = extract((s) => s.ax.find("id:txn-note"));
const txnCreditButton = extract((s) => s.ax.find("id:txn-credit"));
const txnDebitButton = extract((s) => s.ax.find("id:txn-debit"));
const txnSubmit = extract((s) => s.ax.find("id:txn-submit"));
const backButton = extract((s) => s.ax.find("id:back"));
// -- Properties --
const loggedInLeavesLogin = always(
now(() => loggedIn.current).implies(
eventually(() => !onLoginPage.current).within(3, "seconds"),
),
);
const loggedOutReachesLogin = always(
now(() => !loggedIn.current).implies(
eventually(() => onLoginPage.current).within(3, "seconds"),
),
);
// Total balance must equal the sum of all account balances visible on home page.
// Only checked when home page is active and account cards are loaded.
const totalBalanceMatchesAccounts = always(() => {
if (!onHomePage.current) return true;
const cards = accountCards.current;
if (cards.length === 0) return true;
const sum = cards.reduce((acc, c) => acc + c.balance, 0);
return sum === totalBalance.current;
});
// When a transaction is added (ledger txn count increases by 1 on the same
// account), the ledger balance delta matches what was actually stored.
const balanceMatchesTransactionDelta = always(
now(() => onLedgerPage.current && ledgerTxnCount.current > 0).implies(
next(() => {
if (!onLedgerPage.current) return true;
const prevCount = ledgerTxnCount.previous ?? 0;
const curCount = ledgerTxnCount.current;
if (curCount !== prevCount + 1) return true;
const prevBal = ledgerBalance.previous ?? 0;
const curBal = ledgerBalance.current;
// Delta must be non-zero (a valid transaction was added)
return curBal !== prevBal;
}),
),
);
const loginReachable = eventually(() => loggedIn.current).within(90, "seconds");
const accountCreationReachable = eventually(
() => accountCards.current.length > 0,
).within(180, "seconds");
const someTransactionExists = eventually(
() => ledgerTxnCount.current > 0,
).within(300, "seconds");
export const properties = {
loggedInLeavesLogin,
loggedOutReachesLogin,
totalBalanceMatchesAccounts,
balanceMatchesTransactionDelta,
loginReachable,
accountCreationReachable,
someTransactionExists,
noUncaughtExceptions,
};
// -- Actions --
const DEMO_EMAIL = "[email protected]";
const DEMO_PASSWORD = "ledger123";
const loginHelper = actions(() => {
if (loggedIn.current) return [];
const email = emailField.current;
const password = passwordField.current;
const submit = loginSubmit.current;
if (!email || !password || !submit) return [];
const focused = s_focused();
if (focused === "password") return [Tap({ on: submit })];
if (focused === "email") return [InputText({ into: password, text: DEMO_PASSWORD })];
return [InputText({ into: email, text: DEMO_EMAIL })];
});
function s_focused(): string | null {
const email = emailField.current;
const password = passwordField.current;
if (email && (email as { focused?: boolean }).focused) return "email";
if (password && (password as { focused?: boolean }).focused) return "password";
return null;
}
const adversarialLogin = actions(() => {
if (loggedIn.current) return [];
const submit = loginSubmit.current;
if (!submit) return [];
return [Tap({ on: submit })];
});
const openAddAccount = actions(() => {
if (!onHomePage.current) return [];
const btn = addAccountButton.current;
return btn ? [Tap({ on: btn })] : [];
});
const accountNameSampler = from([
"Checking",
"Savings",
"Travel",
"Rent",
"Emergency Fund",
"Investments",
"Groceries",
" ",
"Checking",
"A".repeat(41),
"Petty Cash",
]);
const typeAccountName = actions(() => {
if (!onAddAccountPage.current) return [];
const field = accountNameField.current;
return field ? [InputText({ into: field, text: accountNameSampler.generate() })] : [];
});
const submitAddAccount = actions(() => {
if (!onAddAccountPage.current) return [];
const btn = addAccountSubmit.current;
return btn ? [Tap({ on: btn })] : [];
});
const openAccount = actions(() => {
if (!onHomePage.current) return [];
const cards = accountCards.current;
if (cards.length === 0) return [];
const card = cards[Math.floor(Math.random() * cards.length)];
return [Tap({ on: card.element })];
});
const openAddTxn = actions(() => {
if (!onLedgerPage.current) return [];
const btn = addTxnButton.current;
return btn ? [Tap({ on: btn })] : [];
});
const amountSampler = from([
"12.34",
"100",
"0.01",
"999.99",
"5.5",
"42",
"0",
"",
"1e4",
"0.001",
"-5",
]);
const typeAmount = actions(() => {
if (!onAddTxnPage.current) return [];
const field = txnAmountField.current;
return field ? [InputText({ into: field, text: amountSampler.generate() })] : [];
});
const noteSampler = from([
"Coffee",
"Paycheck",
"Gas",
"Refund",
"",
"Groceries for the week",
]);
const typeNote = actions(() => {
if (!onAddTxnPage.current) return [];
const field = txnNoteField.current;
return field ? [InputText({ into: field, text: noteSampler.generate() })] : [];
});
const toggleTxnType = actions(() => {
if (!onAddTxnPage.current) return [];
const credit = txnCreditButton.current;
const debit = txnDebitButton.current;
const target = Math.random() < 0.5 ? credit : debit;
return target ? [Tap({ on: target })] : [];
});
const submitTxn = actions(() => {
if (!onAddTxnPage.current) return [];
const btn = txnSubmit.current;
return btn ? [Tap({ on: btn })] : [];
});
const goBack = actions(() => {
const btn = backButton.current;
return btn ? [Tap({ on: btn })] : [];
});
const logoutAction = actions(() => {
if (!onHomePage.current) return [];
const btn = logoutButton.current;
return btn ? [Tap({ on: btn })] : [];
});
export const actionsRoot = weighted(
[30, loginHelper],
[2, adversarialLogin],
[14, openAddAccount],
[18, typeAccountName],
[14, submitAddAccount],
[14, openAccount],
[12, openAddTxn],
[18, typeAmount],
[8, typeNote],
[6, toggleTxnType],
[16, submitTxn],
[6, goBack],
[1, logoutAction],
[4, taps],
[2, swipes],
[2, waitOnce],
);
(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot;
(globalThis as { properties?: unknown }).properties = properties;
+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useEffect, useState } from 'react'
import { clearSession, getSession } from './db'
import { navigate, useRoute } from './router'
import { LoginPage } from './pages/Login'
import { HomePage } from './pages/Home'
import { AddAccountPage } from './pages/AddAccount'
import { LedgerPage } from './pages/Ledger'
import { AddTransactionPage } from './pages/AddTransaction'
import { StatusBar } from './components/Screen'
type AuthState =
| { status: 'loading' }
| { status: 'logged-out' }
| { status: 'logged-in'; user: string }
export default function App() {
const route = useRoute()
const [auth, setAuth] = useState<AuthState>({ status: 'loading' })
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => {
let alive = true
getSession().then((s) => {
if (!alive) return
setAuth(
s ? { status: 'logged-in', user: s.user } : { status: 'logged-out' },
)
})
return () => {
alive = false
}
}, [])
useEffect(() => {
if (auth.status === 'loading') return
if (auth.status === 'logged-out' && route.name !== 'login') {
navigate('/login', { replace: true })
} else if (auth.status === 'logged-in' && route.name === 'login') {
navigate('/', { replace: true })
}
}, [auth, route.name])
const bumpReload = () => setReloadKey((k) => k + 1)
const onLogout = useCallback(async () => {
await clearSession()
setAuth({ status: 'logged-out' })
navigate('/login', { replace: true })
}, [])
return (
<div
className="frame"
data-route={route.name}
data-logged-in={auth.status === 'logged-in'}
data-auth-status={auth.status}
>
<StatusBar />
{auth.status === 'loading' ? (
<div className="screen">
<div className="screen-body">
<div className="empty">
<div className="empty-sub">Loading…</div>
</div>
</div>
</div>
) : auth.status === 'logged-out' ? (
<LoginPage
onLoggedIn={(user) => setAuth({ status: 'logged-in', user })}
/>
) : (
renderRouteForLoggedIn(route, auth.user, bumpReload, onLogout, reloadKey)
)}
</div>
)
}
function renderRouteForLoggedIn(
route: ReturnType<typeof useRoute>,
user: string,
bumpReload: () => void,
onLogout: () => void,
reloadKey: number,
) {
switch (route.name) {
case 'home':
return (
<HomePage user={user} onLogout={onLogout} reloadKey={reloadKey} />
)
case 'add-account':
return <AddAccountPage onCreated={bumpReload} />
case 'ledger':
return (
<LedgerPage accountId={route.accountId} reloadKey={reloadKey} />
)
case 'add-transaction':
return (
<AddTransactionPage
accountId={route.accountId}
onCreated={bumpReload}
/>
)
case 'login':
return null
case 'not-found':
default:
return (
<div className="screen">
<div className="screen-body">
<div className="empty">
<div className="empty-title">Page not found</div>
<button
className="btn"
type="button"
style={{
marginTop: 12,
width: 'auto',
padding: '10px 16px',
}}
onClick={() => navigate('/', { replace: true })}
>
Go home
</button>
</div>
</div>
</div>
)
}
}
+9
View File
@@ -0,0 +1,9 @@
export const DEMO_EMAIL = '[email protected]'
export const DEMO_PASSWORD = 'ledger123'
export function checkCredentials(email: string, password: string): boolean {
return (
email.trim().toLowerCase() === DEMO_EMAIL &&
password === DEMO_PASSWORD
)
}
@@ -0,0 +1,85 @@
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { formatClock } from '../format'
export function StatusBar() {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 30_000)
return () => window.clearInterval(id)
}, [])
return (
<div className="statusbar" aria-hidden="true">
<span>{formatClock(now)}</span>
<span className="dots" aria-hidden="true">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</span>
</div>
)
}
export function Screen(props: {
header?: ReactNode
footer?: ReactNode
children: ReactNode
}) {
return (
<div className="screen">
{props.header}
<div className="screen-body">{props.children}</div>
{props.footer ? (
<div className="screen-footer">{props.footer}</div>
) : null}
</div>
)
}
export function Header(props: {
title: string
subtitle?: string
left?: ReactNode
right?: ReactNode
}) {
return (
<div className="screen-header">
{props.left}
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
<h1 className="title" title={props.title}>
{props.title}
</h1>
{props.subtitle ? (
<p className="subtitle">{props.subtitle}</p>
) : null}
</div>
{props.right}
</div>
)
}
export function BackButton(props: { onClick: () => void; label?: string }) {
return (
<button
id="back"
type="button"
className="icon-btn"
onClick={props.onClick}
aria-label={props.label ?? 'Back'}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
)
}
+195
View File
@@ -0,0 +1,195 @@
export type Account = {
id: string
name: string
createdAt: number
}
export type TxnType = 'credit' | 'debit'
export type Transaction = {
id: string
accountId: string
type: TxnType
amount: number
note: string
createdAt: number
}
export type Session = {
key: 'current'
user: string
loggedInAt: number
}
const DB_NAME = 'ledger'
const DB_VERSION = 1
const STORE_ACCOUNTS = 'accounts'
const STORE_TXNS = 'transactions'
const STORE_SESSION = 'session'
let dbPromise: Promise<IDBDatabase> | null = null
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_ACCOUNTS)) {
const s = db.createObjectStore(STORE_ACCOUNTS, { keyPath: 'id' })
s.createIndex('createdAt', 'createdAt')
s.createIndex('name', 'name', { unique: true })
}
if (!db.objectStoreNames.contains(STORE_TXNS)) {
const s = db.createObjectStore(STORE_TXNS, { keyPath: 'id' })
s.createIndex('accountId', 'accountId')
s.createIndex('createdAt', 'createdAt')
}
if (!db.objectStoreNames.contains(STORE_SESSION)) {
db.createObjectStore(STORE_SESSION, { keyPath: 'key' })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
return dbPromise
}
function tx(
db: IDBDatabase,
stores: string | string[],
mode: IDBTransactionMode,
) {
return db.transaction(stores, mode)
}
function reqAsPromise<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
export function makeId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return (
Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
)
}
export async function listAccounts(): Promise<Account[]> {
const db = await openDb()
const store = tx(db, STORE_ACCOUNTS, 'readonly').objectStore(STORE_ACCOUNTS)
const all = await reqAsPromise(store.getAll() as IDBRequest<Account[]>)
return all.sort((a, b) => a.createdAt - b.createdAt)
}
export async function getAccount(id: string): Promise<Account | undefined> {
const db = await openDb()
const store = tx(db, STORE_ACCOUNTS, 'readonly').objectStore(STORE_ACCOUNTS)
return reqAsPromise(store.get(id) as IDBRequest<Account | undefined>)
}
export async function accountNameExists(name: string): Promise<boolean> {
const db = await openDb()
const idx = tx(db, STORE_ACCOUNTS, 'readonly')
.objectStore(STORE_ACCOUNTS)
.index('name')
const key = await reqAsPromise(idx.getKey(name))
return key !== undefined
}
export async function createAccount(name: string): Promise<Account> {
const trimmed = name.trim()
if (!trimmed) throw new Error('Name is required')
if (trimmed.length > 40) throw new Error('Name is too long (max 40)')
if (await accountNameExists(trimmed)) {
throw new Error('An account with that name already exists')
}
const account: Account = {
id: makeId(),
name: trimmed,
createdAt: Date.now(),
}
const db = await openDb()
const t = tx(db, STORE_ACCOUNTS, 'readwrite')
await reqAsPromise(t.objectStore(STORE_ACCOUNTS).add(account))
return account
}
export async function listTransactions(
accountId: string,
): Promise<Transaction[]> {
const db = await openDb()
const idx = tx(db, STORE_TXNS, 'readonly')
.objectStore(STORE_TXNS)
.index('accountId')
const all = await reqAsPromise(
idx.getAll(IDBKeyRange.only(accountId)) as IDBRequest<Transaction[]>,
)
return all.sort((a, b) => b.createdAt - a.createdAt)
}
export async function listAllTransactions(): Promise<Transaction[]> {
const db = await openDb()
const store = tx(db, STORE_TXNS, 'readonly').objectStore(STORE_TXNS)
return reqAsPromise(store.getAll() as IDBRequest<Transaction[]>)
}
export async function createTransaction(input: {
accountId: string
type: TxnType
amount: number
note: string
}): Promise<Transaction> {
if (!Number.isInteger(input.amount)) {
throw new Error('Amount must be a whole number of cents')
}
if (input.amount <= 0) {
throw new Error('Amount must be greater than zero')
}
if (input.type !== 'credit' && input.type !== 'debit') {
throw new Error('Invalid transaction type')
}
const acc = await getAccount(input.accountId)
if (!acc) throw new Error('Account not found')
const txn: Transaction = {
id: makeId(),
accountId: input.accountId,
type: input.type,
amount: input.amount,
note: input.note.trim().slice(0, 80),
createdAt: Date.now(),
}
const db = await openDb()
const t = tx(db, STORE_TXNS, 'readwrite')
await reqAsPromise(t.objectStore(STORE_TXNS).add(txn))
return txn
}
export async function getSession(): Promise<Session | undefined> {
const db = await openDb()
const store = tx(db, STORE_SESSION, 'readonly').objectStore(STORE_SESSION)
return reqAsPromise(store.get('current') as IDBRequest<Session | undefined>)
}
export async function setSession(user: string): Promise<Session> {
const session: Session = {
key: 'current',
user,
loggedInAt: Date.now(),
}
const db = await openDb()
const t = tx(db, STORE_SESSION, 'readwrite')
await reqAsPromise(t.objectStore(STORE_SESSION).put(session))
return session
}
export async function clearSession(): Promise<void> {
const db = await openDb()
const t = tx(db, STORE_SESSION, 'readwrite')
await reqAsPromise(t.objectStore(STORE_SESSION).delete('current'))
}
+67
View File
@@ -0,0 +1,67 @@
import type { Transaction } from './db'
const CURRENCY = '$'
export function formatCents(
cents: number,
opts: { signed?: boolean } = {},
): string {
const abs = Math.abs(cents)
const dollars = Math.floor(abs / 100)
const rem = abs % 100
const dollarsStr = dollars.toLocaleString('en-US')
const formatted = `${CURRENCY}${dollarsStr}.${rem.toString().padStart(2, '0')}`
if (cents < 0) return `-${formatted}`
if (opts.signed && cents > 0) return `+${formatted}`
return formatted
}
export function parseCents(input: string): number | null {
const trimmed = input.trim().replace(/,/g, '')
if (trimmed === '') return null
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return null
const [whole, frac = ''] = trimmed.split('.')
const fracPadded = (frac + '00').slice(0, 2)
const total = Number(whole) * 100 + Number(fracPadded)
if (!Number.isFinite(total) || !Number.isInteger(total)) return null
if (total > Number.MAX_SAFE_INTEGER) return null
return total
}
export function signedAmount(t: Transaction): number {
return t.type === 'credit' ? t.amount : -t.amount
}
export function balanceOf(txns: Transaction[]): number {
let total = 0
for (const t of txns) total += signedAmount(t)
return total
}
export function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return '?'
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
const DATE_FMT = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
export function formatDate(ts: number): string {
return DATE_FMT.format(new Date(ts))
}
const CLOCK_FMT = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: false,
})
export function formatClock(ts: number): string {
return CLOCK_FMT.format(new Date(ts))
}
+584
View File
@@ -0,0 +1,584 @@
:root {
--bg: #ffffff;
--surface: #fafafa;
--surface-2: #f4f4f4;
--surface-3: #ebebeb;
--border: #e7e7e7;
--border-strong: #d6d6d6;
--text: #000000;
--text-muted: #6b6b6b;
--text-faint: #9a9a9a;
--accent: #000000;
--accent-press: #1a1a1a;
--on-accent: #ffffff;
--credit: #000000;
--debit: #000000;
--danger: #000000;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
--frame-w: 420px;
--frame-h: 900px;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
--r-pill: 999px;
--pad: 20px;
--mono:
'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-family: var(--mono);
font-size: 15px;
line-height: 1.45;
color: var(--text);
background: var(--bg);
color-scheme: light;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
padding: 0;
height: 100%;
}
body {
background: var(--bg);
display: grid;
place-items: center;
overflow: hidden;
}
#root {
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.frame {
width: 100%;
height: 100%;
max-width: var(--frame-w);
max-height: var(--frame-h);
background: var(--bg);
color: var(--text);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
.statusbar {
height: 28px;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 22px 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text);
user-select: none;
}
.statusbar .dots {
display: inline-flex;
gap: 4px;
opacity: 0.85;
}
.statusbar .dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
}
.screen {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.screen-header {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 12px;
padding: 14px var(--pad) 8px;
}
.screen-header .title {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
margin: 0;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.screen-header .subtitle {
font-size: 12px;
color: var(--text-muted);
margin: 2px 0 0;
}
.screen-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 8px var(--pad) 24px;
-webkit-overflow-scrolling: touch;
display: flex;
flex-direction: column;
gap: 12px;
}
.screen-footer {
flex: 0 0 auto;
padding: 16px var(--pad) calc(20px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
background: var(--bg);
display: flex;
flex-direction: column;
gap: 14px;
}
.icon-btn {
appearance: none;
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-family: inherit;
font-size: 16px;
line-height: 1;
transition: background 0.12s ease, transform 0.06s ease;
}
.icon-btn:hover {
background: var(--surface-3);
}
.icon-btn:active {
transform: scale(0.96);
}
.btn {
appearance: none;
font-family: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.01em;
border-radius: var(--r-md);
padding: 14px 16px;
border: 1px solid var(--border-strong);
background: var(--surface-2);
color: var(--text);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 0.12s ease, transform 0.06s ease, opacity 0.12s ease;
width: 100%;
}
.btn:hover {
background: var(--surface-3);
}
.btn:active {
transform: scale(0.99);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
.btn.primary:hover {
background: var(--accent-press);
border-color: var(--accent-press);
}
.btn.danger {
color: var(--text);
border-color: var(--border-strong);
}
.btn.danger:hover {
background: var(--surface-2);
}
.btn.ghost {
background: transparent;
border-color: transparent;
color: var(--text-muted);
}
.btn.ghost:hover {
background: var(--surface-2);
color: var(--text);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 12px;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.input {
appearance: none;
font-family: inherit;
font-size: 16px;
width: 100%;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 14px 14px;
outline: none;
transition: border-color 0.12s ease, background 0.12s ease;
}
.input::placeholder {
color: var(--text-faint);
}
.input:focus {
border-color: var(--accent);
background: var(--surface-2);
}
.input.invalid {
border-color: var(--danger);
}
.error {
color: var(--danger);
font-size: 12px;
min-height: 1em;
}
.muted {
color: var(--text-muted);
}
.faint {
color: var(--text-faint);
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
}
.spacer {
flex: 1 1 auto;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.account-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 16px;
display: flex;
align-items: center;
gap: 14px;
text-align: left;
width: 100%;
font-family: inherit;
color: inherit;
cursor: pointer;
transition: background 0.12s ease, transform 0.06s ease,
border-color 0.12s ease;
}
.account-card:hover {
background: var(--surface-2);
border-color: var(--border-strong);
}
.account-card:active {
transform: scale(0.995);
}
.account-avatar {
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--surface-3);
color: var(--accent);
font-weight: 700;
font-size: 15px;
flex: 0 0 auto;
}
.account-meta {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.account-name {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.005em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-sub {
font-size: 12px;
color: var(--text-muted);
}
.account-amount {
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 16px;
color: var(--text-muted);
gap: 6px;
}
.empty .empty-title {
color: var(--text);
font-size: 15px;
font-weight: 600;
}
.empty .empty-sub {
font-size: 13px;
color: var(--text-muted);
}
.empty .empty-icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--surface-2);
border: 1px solid var(--border);
display: grid;
place-items: center;
margin-bottom: 8px;
color: var(--text-muted);
}
.balance-display {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.balance-label {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.balance-value {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.balance-value.credit,
.balance-value.debit {
color: var(--text);
}
.txn-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.txn-row:last-child {
border-bottom: 0;
}
.txn-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: grid;
place-items: center;
font-weight: 700;
font-size: 16px;
flex: 0 0 auto;
}
.txn-icon.credit {
background: var(--surface-2);
color: var(--text);
}
.txn-icon.debit {
background: var(--surface-2);
color: var(--text);
}
.txn-meta {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.txn-note {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.txn-date {
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0.04em;
}
.txn-amount {
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.txn-amount.credit,
.txn-amount.debit {
color: var(--text);
}
.section-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-faint);
padding: 4px 4px;
}
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 4px;
gap: 4px;
}
.segmented button {
appearance: none;
font-family: inherit;
font-size: 14px;
font-weight: 600;
background: transparent;
color: var(--text-muted);
border: 0;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.segmented button[aria-pressed='true'] {
background: var(--surface-3);
color: var(--text);
}
.segmented button.credit[aria-pressed='true'],
.segmented button.debit[aria-pressed='true'] {
color: var(--text);
}
.amount-input {
text-align: center;
font-size: 36px;
font-weight: 700;
letter-spacing: -0.01em;
padding: 24px 12px;
font-variant-numeric: tabular-nums;
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 36px 16px 24px;
}
.brand-mark {
width: 56px;
height: 56px;
border-radius: 16px;
display: grid;
place-items: center;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--accent);
font-weight: 700;
font-size: 22px;
letter-spacing: -0.02em;
}
.brand-name {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand-sub {
font-size: 12px;
color: var(--text-muted);
}
.toast {
position: absolute;
left: 50%;
bottom: 96px;
transform: translateX(-50%);
background: var(--surface-3);
color: var(--text);
border: 1px solid var(--border-strong);
padding: 8px 14px;
border-radius: var(--r-pill);
font-size: 13px;
box-shadow: var(--shadow);
pointer-events: none;
opacity: 0;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(-4px);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,91 @@
import { useState, type FormEvent } from 'react'
import { createAccount } from '../db'
import { back, navigate } from '../router'
import { BackButton, Header, Screen } from '../components/Screen'
export function AddAccountPage(props: { onCreated: () => void }) {
const [name, setName] = useState('')
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (busy) return
const trimmed = name.trim()
if (!trimmed) {
setErr('Account name is required')
return
}
if (trimmed.length > 40) {
setErr('Name is too long (max 40 characters)')
return
}
setBusy(true)
try {
await createAccount(trimmed)
props.onCreated()
navigate('/', { replace: true })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Could not create account')
} finally {
setBusy(false)
}
}
return (
<Screen
header={
<Header
title="New account"
left={<BackButton onClick={() => back('/')} />}
/>
}
footer={
<button
id="add-account-submit"
className="btn primary"
type="submit"
form="add-account-form"
disabled={busy || name.trim().length === 0}
>
{busy ? 'Creating…' : 'Create account'}
</button>
}
>
<form
id="add-account-form"
onSubmit={onSubmit}
className="list"
style={{ gap: 14 }}
noValidate
>
<div className="field">
<label className="field-label" htmlFor="account-name">
Account name
</label>
<input
id="account-name"
className={'input' + (err ? ' invalid' : '')}
type="text"
autoFocus
autoComplete="off"
placeholder="e.g. Checking"
value={name}
maxLength={40}
onChange={(e) => {
setName(e.target.value)
if (err) setErr(null)
}}
/>
</div>
<div id="add-account-error" className="error" role="alert">
{err ?? ''}
</div>
<div className="muted" style={{ fontSize: 12 }}>
Use a short, recognizable name. You can create as many accounts as
you need.
</div>
</form>
</Screen>
)
}
@@ -0,0 +1,194 @@
import { useEffect, useState, type FormEvent } from 'react'
import {
createTransaction,
getAccount,
type Account,
type TxnType,
} from '../db'
import { parseCents } from '../format'
import { back, navigate } from '../router'
import { BackButton, Header, Screen } from '../components/Screen'
export function AddTransactionPage(props: {
accountId: string
onCreated: () => void
}) {
const [account, setAccount] = useState<Account | null | undefined>(undefined)
const [type, setType] = useState<TxnType>('credit')
const [amount, setAmount] = useState('')
const [note, setNote] = useState('')
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let alive = true
getAccount(props.accountId).then((a) => {
if (alive) setAccount(a ?? null)
})
return () => {
alive = false
}
}, [props.accountId])
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (busy) return
const cents = parseCents(amount)
if (cents === null) {
setErr('Enter a valid amount (e.g. 12.34)')
return
}
if (cents <= 0) {
setErr('Amount must be greater than zero')
return
}
setBusy(true)
try {
await createTransaction({
accountId: props.accountId,
type,
amount: cents,
note,
})
props.onCreated()
back(`/accounts/${props.accountId}`)
} catch (e) {
setErr(e instanceof Error ? e.message : 'Could not save transaction')
} finally {
setBusy(false)
}
}
if (account === null) {
return (
<Screen
header={
<Header
title="Add transaction"
left={<BackButton onClick={() => back('/')} />}
/>
}
>
<div className="empty">
<div className="empty-title">Account not found</div>
<button
className="btn"
type="button"
style={{ marginTop: 12, width: 'auto', padding: '10px 16px' }}
onClick={() => navigate('/', { replace: true })}
>
Back to accounts
</button>
</div>
</Screen>
)
}
const submitDisabled = busy || amount.trim() === ''
return (
<Screen
header={
<Header
title="Add transaction"
subtitle={account?.name}
left={
<BackButton
onClick={() => back(`/accounts/${props.accountId}`)}
/>
}
/>
}
footer={
<button
id="txn-submit"
className="btn primary"
type="submit"
form="add-txn-form"
disabled={submitDisabled}
>
{busy ? 'Saving…' : type === 'credit' ? 'Add credit' : 'Add debit'}
</button>
}
>
<form
id="add-txn-form"
onSubmit={onSubmit}
className="list"
style={{ gap: 16 }}
data-account-id={props.accountId}
data-type={type}
noValidate
>
<div
className="segmented"
role="tablist"
aria-label="Transaction type"
>
<button
id="txn-credit"
type="button"
role="tab"
className="credit"
aria-pressed={type === 'credit'}
onClick={() => setType('credit')}
>
Credit
</button>
<button
id="txn-debit"
type="button"
role="tab"
className="debit"
aria-pressed={type === 'debit'}
onClick={() => setType('debit')}
>
Debit
</button>
</div>
<div className="field">
<label className="field-label" htmlFor="txn-amount">
Amount
</label>
<input
id="txn-amount"
className={'input amount-input' + (err ? ' invalid' : '')}
type="text"
inputMode="decimal"
autoComplete="off"
placeholder="0.00"
value={amount}
onChange={(e) => {
const v = e.target.value
if (v === '' || /^\d*(\.\d{0,2})?$/.test(v)) {
setAmount(v)
if (err) setErr(null)
}
}}
autoFocus
/>
</div>
<div className="field">
<label className="field-label" htmlFor="txn-note">
Note (optional)
</label>
<input
id="txn-note"
className="input"
type="text"
placeholder="What's this for?"
value={note}
maxLength={80}
onChange={(e) => setNote(e.target.value)}
/>
</div>
<div id="txn-error" className="error" role="alert">
{err ?? ''}
</div>
</form>
</Screen>
)
}
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useMemo, useState } from 'react'
import {
listAccounts,
listAllTransactions,
type Account,
type Transaction,
} from '../db'
import { formatCents, initialsOf, signedAmount } from '../format'
import { navigate } from '../router'
import { Header, Screen } from '../components/Screen'
export function HomePage(props: {
user: string
onLogout: () => void
reloadKey: number
}) {
const [accounts, setAccounts] = useState<Account[] | null>(null)
const [txns, setTxns] = useState<Transaction[]>([])
useEffect(() => {
let alive = true
Promise.all([listAccounts(), listAllTransactions()]).then(([a, t]) => {
if (!alive) return
setAccounts(a)
setTxns(t)
})
return () => {
alive = false
}
}, [props.reloadKey])
const balanceByAccount = useMemo(() => {
const m = new Map<string, number>()
for (const t of txns) {
m.set(t.accountId, (m.get(t.accountId) ?? 0) + signedAmount(t))
}
return m
}, [txns])
const totalBalance = useMemo(() => {
let sum = 0
for (const v of balanceByAccount.values()) sum += v
return sum
}, [balanceByAccount])
return (
<Screen
header={
<Header
title="Accounts"
subtitle={props.user}
right={
<button
id="logout"
type="button"
className="icon-btn"
onClick={props.onLogout}
aria-label="Sign out"
title="Sign out"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
}
/>
}
footer={
<>
<div
className="row"
style={{ justifyContent: 'space-between', alignItems: 'flex-end' }}
>
<div
id="total-balance"
className="balance-display"
data-value={totalBalance}
title={String(totalBalance)}
>
<span className="balance-label">Total balance</span>
<span
className={
'balance-value' +
(totalBalance > 0
? ' credit'
: totalBalance < 0
? ' debit'
: '')
}
>
{formatCents(totalBalance)}
</span>
</div>
<span
id="account-count"
className="faint"
style={{ fontSize: 12 }}
data-value={accounts?.length ?? 0}
>
{accounts?.length ?? 0} account
{(accounts?.length ?? 0) === 1 ? '' : 's'}
</span>
</div>
<button
id="add-account"
className="btn primary"
type="button"
onClick={() => navigate('/accounts/new')}
>
+ Add account
</button>
</>
}
>
{accounts === null ? (
<div className="empty">
<div className="empty-sub">Loading…</div>
</div>
) : accounts.length === 0 ? (
<div className="empty">
<div className="empty-icon" aria-hidden="true">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="6" width="18" height="13" rx="2" />
<path d="M3 10h18" />
</svg>
</div>
<div className="empty-title">No accounts yet</div>
<div className="empty-sub">
Create your first account to start tracking transactions.
</div>
</div>
) : (
<div className="list" data-testid="account-list">
{accounts.map((a) => {
const bal = balanceByAccount.get(a.id) ?? 0
return (
<button
key={a.id}
type="button"
className="account-card"
data-testid="account-card"
data-account-id={a.id}
data-name={a.name}
data-balance={bal}
data-txn-count={countTxns(txns, a.id)}
aria-label={'account:' + a.id + ':' + bal}
onClick={() => navigate(`/accounts/${a.id}`)}
>
<span className="account-avatar" aria-hidden="true">
{initialsOf(a.name)}
</span>
<span className="account-meta">
<span className="account-name">{a.name}</span>
<span className="account-sub">
{countTxns(txns, a.id)} transactions
</span>
</span>
<span className="account-amount">{formatCents(bal)}</span>
</button>
)
})}
</div>
)}
</Screen>
)
}
function countTxns(all: Transaction[], accountId: string): number {
let n = 0
for (const t of all) if (t.accountId === accountId) n++
return n
}
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from 'react'
import {
getAccount,
listTransactions,
type Account,
type Transaction,
} from '../db'
import {
balanceOf,
formatCents,
formatDate,
signedAmount,
} from '../format'
import { back, navigate } from '../router'
import { BackButton, Header, Screen } from '../components/Screen'
export function LedgerPage(props: {
accountId: string
reloadKey: number
}) {
const [account, setAccount] = useState<Account | null | undefined>(undefined)
const [txns, setTxns] = useState<Transaction[] | null>(null)
useEffect(() => {
let alive = true
Promise.all([
getAccount(props.accountId),
listTransactions(props.accountId),
]).then(([a, t]) => {
if (!alive) return
setAccount(a ?? null)
setTxns(t)
})
return () => {
alive = false
}
}, [props.accountId, props.reloadKey])
const balance = useMemo(() => (txns ? balanceOf(txns) : 0), [txns])
if (account === null) {
return (
<Screen
header={
<Header
title="Account"
left={<BackButton onClick={() => back('/')} />}
/>
}
>
<div className="empty">
<div className="empty-title">Account not found</div>
<div className="empty-sub">It may have been deleted.</div>
<button
className="btn"
type="button"
style={{ marginTop: 12, width: 'auto', padding: '10px 16px' }}
onClick={() => navigate('/', { replace: true })}
>
Back to accounts
</button>
</div>
</Screen>
)
}
return (
<Screen
header={
<Header
title={account?.name ?? 'Loading…'}
subtitle="Ledger"
left={<BackButton onClick={() => back('/')} />}
/>
}
footer={
<button
id="add-txn"
className="btn primary"
type="button"
disabled={!account}
onClick={() =>
navigate(`/accounts/${props.accountId}/transactions/new`)
}
>
+ Add transaction
</button>
}
>
<div
id="ledger"
className="card"
data-account-id={props.accountId}
data-account-name={account?.name ?? ''}
data-txn-count={txns?.length ?? 0}
title={String(txns?.length ?? 0)}
>
<div
id="ledger-balance"
className="balance-display"
data-value={balance}
title={String(balance)}
>
<span className="balance-label">Balance</span>
<span
className={
'balance-value' +
(balance > 0 ? ' credit' : balance < 0 ? ' debit' : '')
}
>
{formatCents(balance)}
</span>
</div>
</div>
<div className="section-label">Activity</div>
{txns === null ? (
<div className="empty">
<div className="empty-sub">Loading…</div>
</div>
) : txns.length === 0 ? (
<div className="empty">
<div className="empty-icon" aria-hidden="true">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18M3 12h18M3 18h12" />
</svg>
</div>
<div className="empty-title">No transactions yet</div>
<div className="empty-sub">
Add your first credit or debit to see it here.
</div>
</div>
) : (
<div className="card" style={{ padding: '0 16px' }}>
{txns.map((t) => {
const signed = signedAmount(t)
return (
<div
key={t.id}
className="txn-row"
data-testid="txn-row"
data-txn-id={t.id}
data-account-id={t.accountId}
data-type={t.type}
data-amount={t.amount}
data-signed={signed}
>
<div className={'txn-icon ' + t.type} aria-hidden="true">
{t.type === 'credit' ? '+' : '−'}
</div>
<div className="txn-meta">
<div className="txn-note">
{t.note || (t.type === 'credit' ? 'Credit' : 'Debit')}
</div>
<div className="txn-date">{formatDate(t.createdAt)}</div>
</div>
<div className={'txn-amount ' + t.type}>
{formatCents(signed, { signed: true })}
</div>
</div>
)
})}
</div>
)}
</Screen>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useState, type FormEvent } from 'react'
import { checkCredentials, DEMO_EMAIL, DEMO_PASSWORD } from '../auth'
import { setSession } from '../db'
import { navigate } from '../router'
import { Screen } from '../components/Screen'
export function LoginPage(props: { onLoggedIn: (user: string) => void }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (busy) return
if (!email.trim() || !password) {
setErr('Enter email and password')
return
}
if (!checkCredentials(email, password)) {
setErr('Invalid email or password')
return
}
setBusy(true)
try {
await setSession(email.trim().toLowerCase())
props.onLoggedIn(email.trim().toLowerCase())
navigate('/', { replace: true })
} catch {
setErr('Could not sign in. Try again.')
} finally {
setBusy(false)
}
}
return (
<Screen>
<form
onSubmit={onSubmit}
className="list"
style={{ gap: 14, marginTop: 24 }}
noValidate
>
<div className="field">
<label className="field-label" htmlFor="email">
Email
</label>
<input
id="email"
className={'input' + (err ? ' invalid' : '')}
type="email"
autoComplete="email"
inputMode="email"
autoCapitalize="none"
placeholder={DEMO_EMAIL}
value={email}
onChange={(e) => {
setEmail(e.target.value)
if (err) setErr(null)
}}
/>
</div>
<div className="field">
<label className="field-label" htmlFor="password">
Password
</label>
<input
id="password"
className={'input' + (err ? ' invalid' : '')}
type="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (err) setErr(null)
}}
/>
</div>
<div id="login-error" className="error" role="alert">
{err ?? ''}
</div>
<button
id="login-submit"
className="btn primary"
type="submit"
disabled={busy}
>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<div
className="card"
style={{
marginTop: 8,
background: 'var(--surface-2)',
borderStyle: 'dashed',
}}
>
<div className="muted" style={{ fontSize: 12 }}>
Demo credentials
</div>
<div style={{ fontSize: 13 }}>
<div>
email: <span style={{ fontWeight: 600 }}>{DEMO_EMAIL}</span>
</div>
<div>
password:{' '}
<span style={{ fontWeight: 600 }}>{DEMO_PASSWORD}</span>
</div>
</div>
</div>
</form>
</Screen>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
export type Route =
| { name: 'login' }
| { name: 'home' }
| { name: 'add-account' }
| { name: 'ledger'; accountId: string }
| { name: 'add-transaction'; accountId: string }
| { name: 'not-found' }
function readHash(): string {
const raw = window.location.hash.replace(/^#/, '')
return raw === '' ? '/' : raw
}
export function parseHash(hash: string): Route {
const path = hash.split('?')[0]
if (path === '/' || path === '') return { name: 'home' }
if (path === '/login') return { name: 'login' }
if (path === '/accounts/new') return { name: 'add-account' }
const ledgerMatch = /^\/accounts\/([^/]+)$/.exec(path)
if (ledgerMatch) return { name: 'ledger', accountId: ledgerMatch[1] }
const txnMatch = /^\/accounts\/([^/]+)\/transactions\/new$/.exec(path)
if (txnMatch)
return { name: 'add-transaction', accountId: txnMatch[1] }
return { name: 'not-found' }
}
export function useRoute(): Route {
const [hash, setHash] = useState<string>(() => readHash())
useEffect(() => {
function onChange() {
setHash(readHash())
}
window.addEventListener('hashchange', onChange)
return () => window.removeEventListener('hashchange', onChange)
}, [])
return parseHash(hash)
}
export function navigate(path: string, opts: { replace?: boolean } = {}) {
const target = '#' + (path.startsWith('/') ? path : '/' + path)
if (opts.replace) {
window.location.replace(target)
} else {
window.location.hash = target.slice(1)
}
}
export function back(fallback: string = '/') {
if (window.history.length > 1) {
window.history.back()
} else {
navigate(fallback, { replace: true })
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"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 */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"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 */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
+61 -30
View File
@@ -92,7 +92,25 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool) e
return fmt.Errorf("clear storage: %w", err) return fmt.Errorf("clear storage: %w", err)
} }
} }
return chromedp.Run(d.tabCtx, chromedp.Navigate(bundleID)) if err := chromedp.Run(d.tabCtx, chromedp.Navigate(bundleID)); err != nil {
return err
}
// After navigation, read CSS custom properties --frame-w / --frame-h (common
// mobile-frame convention) so screenshots fit the app without grey borders.
// Falls back to the body scroll dimensions if the properties are absent.
var dims [2]int64
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(`
(function() {
const s = getComputedStyle(document.documentElement);
const pw = parseInt(s.getPropertyValue('--frame-w'), 10);
const ph = parseInt(s.getPropertyValue('--frame-h'), 10);
const w = isNaN(pw) ? document.body.scrollWidth : pw;
const h = isNaN(ph) ? document.body.scrollHeight : ph;
return [w, h];
})()`, &dims)); err == nil && dims[0] > 0 && dims[1] > 0 {
_ = chromedp.Run(d.tabCtx, chromedp.EmulateViewport(dims[0], dims[1]))
}
return nil
} }
func (d *Driver) Terminate(_ context.Context) error { func (d *Driver) Terminate(_ context.Context) error {
@@ -116,7 +134,15 @@ func (d *Driver) TapSelector(_ context.Context, selector string) error {
func (d *Driver) InputText(_ context.Context, text string) error { func (d *Driver) InputText(_ context.Context, text string) error {
return chromedp.Run(d.tabCtx, return chromedp.Run(d.tabCtx,
chromedp.ActionFunc(func(ctx context.Context) error { chromedp.ActionFunc(func(ctx context.Context) error {
return input.DispatchKeyEvent(input.KeyChar).WithText(text).Do(ctx) // Select any existing content so InsertText replaces rather than appends.
if err := chromedp.Evaluate(`
(function() {
const el = document.activeElement;
if (el && typeof el.select === 'function') el.select();
})()`, nil).Do(ctx); err != nil {
return err
}
return input.InsertText(text).Do(ctx)
}), }),
) )
} }
@@ -167,35 +193,40 @@ var keyMap = map[string]string{
func (d *Driver) Hierarchy(_ context.Context) (string, error) { func (d *Driver) Hierarchy(_ context.Context) (string, error) {
script := ` script := `
(function buildTree(el) { (function() {
const rect = el.getBoundingClientRect(); const route = window.location.hash.replace(/^#/, '').split('?')[0] || '/';
const attrs = {}; function buildTree(el, isRoot) {
const bounds = '[' + Math.round(rect.left) + ',' + Math.round(rect.top) + ',' + const rect = el.getBoundingClientRect();
Math.round(rect.right) + ',' + Math.round(rect.bottom) + ']'; const attrs = {};
if (rect.width > 0 || rect.height > 0) attrs.bounds = bounds; const bounds = '[' + Math.round(rect.left) + ',' + Math.round(rect.top) + ',' +
const text = (el.textContent || '').trim().slice(0, 200); Math.round(rect.right) + ',' + Math.round(rect.bottom) + ']';
if (text) attrs.text = text; if (rect.width > 0 || rect.height > 0) attrs.bounds = bounds;
if (el.id) attrs['resource-id'] = el.id; const text = (el.textContent || '').trim().slice(0, 200);
const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || ''; if (text) attrs.text = text;
if (label) attrs['content-desc'] = label; if (el.id) attrs['resource-id'] = el.id;
if (el.tagName) attrs['class'] = el.tagName.toLowerCase(); const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || '';
const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' || if (label) attrs['content-desc'] = label;
el.tagName === 'INPUT' || el.tagName === 'SELECT' || if (el.tagName) attrs['class'] = el.tagName.toLowerCase();
el.getAttribute('role') === 'button' || el.getAttribute('onclick')); if (isRoot) attrs['sanderling-screen'] = route;
const children = []; const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' ||
for (const child of el.children) { el.tagName === 'INPUT' || el.tagName === 'SELECT' ||
children.push(buildTree(child)); el.getAttribute('role') === 'button' || el.getAttribute('onclick'));
const children = [];
for (const child of el.children) {
children.push(buildTree(child, false));
}
return {
attributes: attrs,
children: children,
clickable: isClickable || null,
enabled: (!el.disabled) || null,
focused: document.activeElement === el || null,
checked: el.checked || null,
selected: el.selected || null,
};
} }
return { return buildTree(document.body, true);
attributes: attrs, })()`
children: children,
clickable: isClickable || null,
enabled: (!el.disabled) || null,
focused: document.activeElement === el || null,
checked: el.checked || null,
selected: el.selected || null,
};
})(document.body);`
var result any var result any
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil { if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil {
+10 -6
View File
@@ -43,12 +43,15 @@ type Element struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Class string `json:"class,omitempty"` Class string `json:"class,omitempty"`
Package string `json:"package,omitempty"` Package string `json:"package,omitempty"`
Clickable bool `json:"clickable,omitempty"` // Screen holds the current route/screen name when set by the driver on the
Enabled bool `json:"enabled,omitempty"` // root element (web platform only; empty for native platforms).
Checked bool `json:"checked,omitempty"` Screen string `json:"screen,omitempty"`
Focused bool `json:"focused,omitempty"` Clickable bool `json:"clickable,omitempty"`
Selected bool `json:"selected,omitempty"` Enabled bool `json:"enabled,omitempty"`
Bounds Bounds `json:"bounds"` Checked bool `json:"checked,omitempty"`
Focused bool `json:"focused,omitempty"`
Selected bool `json:"selected,omitempty"`
Bounds Bounds `json:"bounds"`
} }
// Tree is a flat collection of every node in a hierarchy dump, in pre-order. // Tree is a flat collection of every node in a hierarchy dump, in pre-order.
@@ -105,6 +108,7 @@ func elementFromNode(node *treeNodeJSON) *Element {
} }
element.Class = attrs["class"] element.Class = attrs["class"]
element.Package = attrs["package"] element.Package = attrs["package"]
element.Screen = attrs["sanderling-screen"]
if node.Clickable != nil { if node.Clickable != nil {
element.Clickable = *node.Clickable element.Clickable = *node.Clickable
+10 -5
View File
@@ -135,6 +135,9 @@ func Run(ctx context.Context, options Options) (Summary, error) {
if screenErr != nil { if screenErr != nil {
logger.Warn("screen snapshot decode failed", "step", stepIndex, "err", screenErr) logger.Warn("screen snapshot decode failed", "step", stepIndex, "err", screenErr)
} }
if screen == "" && tree != nil && len(tree.Elements) > 0 {
screen = tree.Elements[0].Screen
}
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize) logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
verdicts := options.Verifier.EvaluateProperties() verdicts := options.Verifier.EvaluateProperties()
violations := violationNames(verdicts) violations := violationNames(verdicts)
@@ -181,8 +184,10 @@ func Run(ctx context.Context, options Options) (Summary, error) {
}) })
} }
if err := options.Connection.Release(ctx); err != nil { if options.Connection != nil {
return summary, fmt.Errorf("step %d release: %w", stepIndex, err) if err := options.Connection.Release(ctx); err != nil {
return summary, fmt.Errorf("step %d release: %w", stepIndex, err)
}
} }
if nextErr == nil { if nextErr == nil {
@@ -216,9 +221,6 @@ func Run(ctx context.Context, options Options) (Summary, error) {
} }
func validate(options Options) error { func validate(options Options) error {
if options.Connection == nil {
return errors.New("runner: Connection is required")
}
if options.Driver == nil { if options.Driver == nil {
return errors.New("runner: Driver is required") return errors.New("runner: Driver is required")
} }
@@ -241,6 +243,9 @@ func validate(options Options) error {
} }
func snapshotStep(ctx context.Context, options Options) (agent.Message, error) { func snapshotStep(ctx context.Context, options Options) (agent.Message, error) {
if options.Connection == nil {
return agent.Message{}, nil
}
snapshotTimeout := options.SnapshotTimeout snapshotTimeout := options.SnapshotTimeout
if snapshotTimeout <= 0 { if snapshotTimeout <= 0 {
snapshotTimeout = 5 * time.Second snapshotTimeout = 5 * time.Second
+2 -2
View File
@@ -233,8 +233,8 @@ globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:
func TestRunner_RejectsMissingFields(t *testing.T) { func TestRunner_RejectsMissingFields(t *testing.T) {
_, err := Run(context.Background(), Options{Duration: time.Second}) _, err := Run(context.Background(), Options{Duration: time.Second})
if err == nil || !strings.Contains(err.Error(), "Connection") { if err == nil || !strings.Contains(err.Error(), "Driver") {
t.Errorf("expected Connection-required error, got %v", err) t.Errorf("expected Driver-required error, got %v", err)
} }
} }
+37 -30
View File
@@ -67,14 +67,16 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
} }
defer cleanup() defer cleanup()
listener, err := net.Listen("tcp", "127.0.0.1:0") var connection *agent.Conn
if err != nil {
return fmt.Errorf("agent listener: %w", err)
}
defer listener.Close()
agentPort := listener.Addr().(*net.TCPAddr).Port
if options.Platform != "web" { if options.Platform != "web" {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("agent listener: %w", err)
}
defer listener.Close()
agentPort := listener.Addr().(*net.TCPAddr).Port
if err := android.AdbReverse(socketName, agentPort); err != nil { if err := android.AdbReverse(socketName, agentPort); err != nil {
return fmt.Errorf("adb reverse: %w", err) return fmt.Errorf("adb reverse: %w", err)
} }
@@ -84,35 +86,40 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
} }
}() }()
fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort) fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort)
}
agentServer := agent.NewServer(listener) agentServer := agent.NewServer(listener)
type acceptResult struct { type acceptResult struct {
connection *agent.Conn conn *agent.Conn
err error err error
} }
acceptChannel := make(chan acceptResult, 1) acceptChannel := make(chan acceptResult, 1)
go func() { go func() {
acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout) acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
defer cancel() defer cancel()
connection, acceptErr := agentServer.Accept(acceptCtx) conn, acceptErr := agentServer.Accept(acceptCtx)
acceptChannel <- acceptResult{connection: connection, err: acceptErr} acceptChannel <- acceptResult{conn: conn, err: acceptErr}
}() }()
if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil { if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil {
return fmt.Errorf("launch app: %w", err) return fmt.Errorf("launch app: %w", err)
} }
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.BundleID, sdkAcceptTimeout.Seconds()) fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.BundleID, sdkAcceptTimeout.Seconds())
result := <-acceptChannel result := <-acceptChannel
if result.err != nil { if result.err != nil {
return fmt.Errorf("accept SDK: %w", result.err) return fmt.Errorf("accept SDK: %w", result.err)
}
connection = result.conn
defer connection.Close()
hello := connection.Hello()
fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
} else {
fmt.Fprintln(stdout, "web mode: skipping SDK")
if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil {
return fmt.Errorf("launch app: %w", err)
}
} }
connection := result.connection
defer connection.Close()
hello := connection.Hello()
fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
seed := options.Seed seed := options.Seed
if seed == 0 { if seed == 0 {