mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
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:
31 files changed
+2970
-74
No files matched your search
+8
-1
@@ -48,6 +48,11 @@ inspect-ui/node_modules/
|
||||
inspect-ui/dist/
|
||||
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
|
||||
/docs/_diagrams/*.svg
|
||||
/docs/_diagrams/*.png
|
||||
@@ -57,4 +62,6 @@ inspect-ui/.vite/
|
||||
.claude/*
|
||||
|
||||
# Personal research notes
|
||||
/research/
|
||||
/research/
|
||||
|
||||
internal/inspect/dist/*
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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}}"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
@@ -92,7 +92,25 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool) e
|
||||
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 {
|
||||
@@ -116,7 +134,15 @@ func (d *Driver) TapSelector(_ context.Context, selector string) error {
|
||||
func (d *Driver) InputText(_ context.Context, text string) error {
|
||||
return chromedp.Run(d.tabCtx,
|
||||
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) {
|
||||
script := `
|
||||
(function buildTree(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const attrs = {};
|
||||
const bounds = '[' + Math.round(rect.left) + ',' + Math.round(rect.top) + ',' +
|
||||
Math.round(rect.right) + ',' + Math.round(rect.bottom) + ']';
|
||||
if (rect.width > 0 || rect.height > 0) attrs.bounds = bounds;
|
||||
const text = (el.textContent || '').trim().slice(0, 200);
|
||||
if (text) attrs.text = text;
|
||||
if (el.id) attrs['resource-id'] = el.id;
|
||||
const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || '';
|
||||
if (label) attrs['content-desc'] = label;
|
||||
if (el.tagName) attrs['class'] = el.tagName.toLowerCase();
|
||||
const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' ||
|
||||
el.tagName === 'INPUT' || el.tagName === 'SELECT' ||
|
||||
el.getAttribute('role') === 'button' || el.getAttribute('onclick'));
|
||||
const children = [];
|
||||
for (const child of el.children) {
|
||||
children.push(buildTree(child));
|
||||
(function() {
|
||||
const route = window.location.hash.replace(/^#/, '').split('?')[0] || '/';
|
||||
function buildTree(el, isRoot) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const attrs = {};
|
||||
const bounds = '[' + Math.round(rect.left) + ',' + Math.round(rect.top) + ',' +
|
||||
Math.round(rect.right) + ',' + Math.round(rect.bottom) + ']';
|
||||
if (rect.width > 0 || rect.height > 0) attrs.bounds = bounds;
|
||||
const text = (el.textContent || '').trim().slice(0, 200);
|
||||
if (text) attrs.text = text;
|
||||
if (el.id) attrs['resource-id'] = el.id;
|
||||
const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || '';
|
||||
if (label) attrs['content-desc'] = label;
|
||||
if (el.tagName) attrs['class'] = el.tagName.toLowerCase();
|
||||
if (isRoot) attrs['sanderling-screen'] = route;
|
||||
const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' ||
|
||||
el.tagName === 'INPUT' || el.tagName === 'SELECT' ||
|
||||
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 {
|
||||
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);`
|
||||
return buildTree(document.body, true);
|
||||
})()`
|
||||
|
||||
var result any
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil {
|
||||
|
||||
@@ -43,12 +43,15 @@ type Element struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Package string `json:"package,omitempty"`
|
||||
Clickable bool `json:"clickable,omitempty"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Focused bool `json:"focused,omitempty"`
|
||||
Selected bool `json:"selected,omitempty"`
|
||||
Bounds Bounds `json:"bounds"`
|
||||
// Screen holds the current route/screen name when set by the driver on the
|
||||
// root element (web platform only; empty for native platforms).
|
||||
Screen string `json:"screen,omitempty"`
|
||||
Clickable bool `json:"clickable,omitempty"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
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.
|
||||
@@ -105,6 +108,7 @@ func elementFromNode(node *treeNodeJSON) *Element {
|
||||
}
|
||||
element.Class = attrs["class"]
|
||||
element.Package = attrs["package"]
|
||||
element.Screen = attrs["sanderling-screen"]
|
||||
|
||||
if node.Clickable != nil {
|
||||
element.Clickable = *node.Clickable
|
||||
|
||||
@@ -135,6 +135,9 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
if screenErr != nil {
|
||||
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)
|
||||
verdicts := options.Verifier.EvaluateProperties()
|
||||
violations := violationNames(verdicts)
|
||||
@@ -181,8 +184,10 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
})
|
||||
}
|
||||
|
||||
if err := options.Connection.Release(ctx); err != nil {
|
||||
return summary, fmt.Errorf("step %d release: %w", stepIndex, err)
|
||||
if options.Connection != nil {
|
||||
if err := options.Connection.Release(ctx); err != nil {
|
||||
return summary, fmt.Errorf("step %d release: %w", stepIndex, err)
|
||||
}
|
||||
}
|
||||
|
||||
if nextErr == nil {
|
||||
@@ -216,9 +221,6 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
}
|
||||
|
||||
func validate(options Options) error {
|
||||
if options.Connection == nil {
|
||||
return errors.New("runner: Connection is required")
|
||||
}
|
||||
if options.Driver == nil {
|
||||
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) {
|
||||
if options.Connection == nil {
|
||||
return agent.Message{}, nil
|
||||
}
|
||||
snapshotTimeout := options.SnapshotTimeout
|
||||
if snapshotTimeout <= 0 {
|
||||
snapshotTimeout = 5 * time.Second
|
||||
|
||||
@@ -233,8 +233,8 @@ globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:
|
||||
|
||||
func TestRunner_RejectsMissingFields(t *testing.T) {
|
||||
_, err := Run(context.Background(), Options{Duration: time.Second})
|
||||
if err == nil || !strings.Contains(err.Error(), "Connection") {
|
||||
t.Errorf("expected Connection-required error, got %v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "Driver") {
|
||||
t.Errorf("expected Driver-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
-30
@@ -67,14 +67,16 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
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
|
||||
var connection *agent.Conn
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
agentServer := agent.NewServer(listener)
|
||||
agentServer := agent.NewServer(listener)
|
||||
|
||||
type acceptResult struct {
|
||||
connection *agent.Conn
|
||||
err error
|
||||
}
|
||||
acceptChannel := make(chan acceptResult, 1)
|
||||
go func() {
|
||||
acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
|
||||
defer cancel()
|
||||
connection, acceptErr := agentServer.Accept(acceptCtx)
|
||||
acceptChannel <- acceptResult{connection: connection, err: acceptErr}
|
||||
}()
|
||||
type acceptResult struct {
|
||||
conn *agent.Conn
|
||||
err error
|
||||
}
|
||||
acceptChannel := make(chan acceptResult, 1)
|
||||
go func() {
|
||||
acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
|
||||
defer cancel()
|
||||
conn, acceptErr := agentServer.Accept(acceptCtx)
|
||||
acceptChannel <- acceptResult{conn: conn, err: acceptErr}
|
||||
}()
|
||||
|
||||
if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil {
|
||||
return fmt.Errorf("launch app: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.BundleID, sdkAcceptTimeout.Seconds())
|
||||
if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil {
|
||||
return fmt.Errorf("launch app: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.BundleID, sdkAcceptTimeout.Seconds())
|
||||
|
||||
result := <-acceptChannel
|
||||
if result.err != nil {
|
||||
return fmt.Errorf("accept SDK: %w", result.err)
|
||||
result := <-acceptChannel
|
||||
if result.err != nil {
|
||||
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
|
||||
if seed == 0 {
|
||||
|
||||
Reference in new issue
Block a user