diff --git a/.gitignore b/.gitignore
index 5826a8a..a53317d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
\ No newline at end of file
+/research/
+
+internal/inspect/dist/*
\ No newline at end of file
diff --git a/examples/folio-web/bun.lock b/examples/folio-web/bun.lock
new file mode 100644
index 0000000..c20b23f
--- /dev/null
+++ b/examples/folio-web/bun.lock
@@ -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/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
+
+ "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
+
+ "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
+
+ "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
+
+ "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
+
+ "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
+
+ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
+
+ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
+
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
+
+ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
+
+ "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
+
+ "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
+
+ "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
+
+ "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
+
+ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
+
+ "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
+
+ "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
+
+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
+
+ "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
+
+ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
+
+ "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
+
+ "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
+
+ "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
+
+ "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
+
+ "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
+
+ "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
+
+ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
+
+ "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
+
+ "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
+
+ "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
+
+ "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
+
+ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "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/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
+
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
+
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
+
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
+
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
+
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
+
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
+
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
+
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
+
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
+
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
+
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
+
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
+
+ "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "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/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
+
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
+
+ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
+ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+
+ "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
+
+ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
+
+ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+
+ "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "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/parser@8.59.0", "", { "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/project-service@8.59.0", "", { "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/scope-manager@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="],
+
+ "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="],
+
+ "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.0", "", { "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/types@8.59.0", "", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="],
+
+ "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "", { "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/utils@8.59.0", "", { "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/visitor-keys@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="],
+
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
+
+ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
+
+ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+
+ "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
+
+ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
+ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+
+ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ=="],
+
+ "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
+
+ "browserslist": ["browserslist@4.28.2", "", { "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": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001790", "", {}, "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw=="],
+
+ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
+ "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
+
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "electron-to-chromium": ["electron-to-chromium@1.5.343", "", {}, "sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
+
+ "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
+
+ "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
+
+ "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
+
+ "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
+
+ "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
+
+ "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
+
+ "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
+
+ "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
+
+ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+
+ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
+
+ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
+
+ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
+
+ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
+
+ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+
+ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
+
+ "globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
+
+ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+
+ "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
+
+ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
+
+ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
+
+ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
+
+ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
+
+ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+
+ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
+
+ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+
+ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
+
+ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+
+ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+
+ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
+
+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+
+ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+
+ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
+
+ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+
+ "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+
+ "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
+
+ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
+
+ "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+
+ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+
+ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
+
+ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
+
+ "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
+
+ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+
+ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+
+ "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
+
+ "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
+
+ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
+
+ "rolldown": ["rolldown@1.0.0-rc.16", "", { "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": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+
+ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
+
+ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
+ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
+
+ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
+
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
+
+ "typescript-eslint": ["typescript-eslint@8.59.0", "", { "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": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+
+ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+
+ "vite": ["vite@8.0.9", "", { "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": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+
+ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+
+ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+
+ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+
+ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
+
+ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
+
+ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
+
+ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
+
+ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
+
+ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+
+ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
+
+ "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
+
+ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
+
+ "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+ }
+}
diff --git a/examples/folio-web/eslint.config.js b/examples/folio-web/eslint.config.js
new file mode 100644
index 0000000..5e6b472
--- /dev/null
+++ b/examples/folio-web/eslint.config.js
@@ -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,
+ },
+ },
+])
diff --git a/examples/folio-web/index.html b/examples/folio-web/index.html
new file mode 100644
index 0000000..8b98ba0
--- /dev/null
+++ b/examples/folio-web/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+ Ledger
+
+
+
+
+
+
diff --git a/examples/folio-web/justfile b/examples/folio-web/justfile
new file mode 100644
index 0000000..8c74f49
--- /dev/null
+++ b/examples/folio-web/justfile
@@ -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}}"
diff --git a/examples/folio-web/package.json b/examples/folio-web/package.json
new file mode 100644
index 0000000..e0d552d
--- /dev/null
+++ b/examples/folio-web/package.json
@@ -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"
+ }
+}
diff --git a/examples/folio-web/public/favicon.svg b/examples/folio-web/public/favicon.svg
new file mode 100644
index 0000000..6893eb1
--- /dev/null
+++ b/examples/folio-web/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/folio-web/public/icons.svg b/examples/folio-web/public/icons.svg
new file mode 100644
index 0000000..e952219
--- /dev/null
+++ b/examples/folio-web/public/icons.svg
@@ -0,0 +1,24 @@
+
diff --git a/examples/folio-web/sanderling/spec.ts b/examples/folio-web/sanderling/spec.ts
new file mode 100644
index 0000000..cf3ba9e
--- /dev/null
+++ b/examples/folio-web/sanderling/spec.ts
@@ -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 = "demo@ledger.app";
+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;
diff --git a/examples/folio-web/src/App.tsx b/examples/folio-web/src/App.tsx
new file mode 100644
index 0000000..4ba5682
--- /dev/null
+++ b/examples/folio-web/src/App.tsx
@@ -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({ 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 (
+
+
+ {auth.status === 'loading' ? (
+
+ ) : auth.status === 'logged-out' ? (
+
setAuth({ status: 'logged-in', user })}
+ />
+ ) : (
+ renderRouteForLoggedIn(route, auth.user, bumpReload, onLogout, reloadKey)
+ )}
+
+ )
+}
+
+function renderRouteForLoggedIn(
+ route: ReturnType,
+ user: string,
+ bumpReload: () => void,
+ onLogout: () => void,
+ reloadKey: number,
+) {
+ switch (route.name) {
+ case 'home':
+ return (
+
+ )
+ case 'add-account':
+ return
+ case 'ledger':
+ return (
+
+ )
+ case 'add-transaction':
+ return (
+
+ )
+ case 'login':
+ return null
+ case 'not-found':
+ default:
+ return (
+
+
+
+
Page not found
+
+
+
+
+ )
+ }
+}
diff --git a/examples/folio-web/src/auth.ts b/examples/folio-web/src/auth.ts
new file mode 100644
index 0000000..1773640
--- /dev/null
+++ b/examples/folio-web/src/auth.ts
@@ -0,0 +1,9 @@
+export const DEMO_EMAIL = 'demo@ledger.app'
+export const DEMO_PASSWORD = 'ledger123'
+
+export function checkCredentials(email: string, password: string): boolean {
+ return (
+ email.trim().toLowerCase() === DEMO_EMAIL &&
+ password === DEMO_PASSWORD
+ )
+}
diff --git a/examples/folio-web/src/components/Screen.tsx b/examples/folio-web/src/components/Screen.tsx
new file mode 100644
index 0000000..0bea15b
--- /dev/null
+++ b/examples/folio-web/src/components/Screen.tsx
@@ -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 (
+
+ {formatClock(now)}
+
+
+
+
+
+
+ )
+}
+
+export function Screen(props: {
+ header?: ReactNode
+ footer?: ReactNode
+ children: ReactNode
+}) {
+ return (
+
+ {props.header}
+
{props.children}
+ {props.footer ? (
+
{props.footer}
+ ) : null}
+
+ )
+}
+
+export function Header(props: {
+ title: string
+ subtitle?: string
+ left?: ReactNode
+ right?: ReactNode
+}) {
+ return (
+
+ {props.left}
+
+
+ {props.title}
+
+ {props.subtitle ? (
+
{props.subtitle}
+ ) : null}
+
+ {props.right}
+
+ )
+}
+
+export function BackButton(props: { onClick: () => void; label?: string }) {
+ return (
+
+ )
+}
diff --git a/examples/folio-web/src/db.ts b/examples/folio-web/src/db.ts
new file mode 100644
index 0000000..5e5afc4
--- /dev/null
+++ b/examples/folio-web/src/db.ts
@@ -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 | null = null
+
+function openDb(): Promise {
+ 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(req: IDBRequest): Promise {
+ 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 {
+ const db = await openDb()
+ const store = tx(db, STORE_ACCOUNTS, 'readonly').objectStore(STORE_ACCOUNTS)
+ const all = await reqAsPromise(store.getAll() as IDBRequest)
+ return all.sort((a, b) => a.createdAt - b.createdAt)
+}
+
+export async function getAccount(id: string): Promise {
+ const db = await openDb()
+ const store = tx(db, STORE_ACCOUNTS, 'readonly').objectStore(STORE_ACCOUNTS)
+ return reqAsPromise(store.get(id) as IDBRequest)
+}
+
+export async function accountNameExists(name: string): Promise {
+ 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 {
+ 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 {
+ 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,
+ )
+ return all.sort((a, b) => b.createdAt - a.createdAt)
+}
+
+export async function listAllTransactions(): Promise {
+ const db = await openDb()
+ const store = tx(db, STORE_TXNS, 'readonly').objectStore(STORE_TXNS)
+ return reqAsPromise(store.getAll() as IDBRequest)
+}
+
+export async function createTransaction(input: {
+ accountId: string
+ type: TxnType
+ amount: number
+ note: string
+}): Promise {
+ 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 {
+ const db = await openDb()
+ const store = tx(db, STORE_SESSION, 'readonly').objectStore(STORE_SESSION)
+ return reqAsPromise(store.get('current') as IDBRequest)
+}
+
+export async function setSession(user: string): Promise {
+ 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 {
+ const db = await openDb()
+ const t = tx(db, STORE_SESSION, 'readwrite')
+ await reqAsPromise(t.objectStore(STORE_SESSION).delete('current'))
+}
diff --git a/examples/folio-web/src/format.ts b/examples/folio-web/src/format.ts
new file mode 100644
index 0000000..f9a34fb
--- /dev/null
+++ b/examples/folio-web/src/format.ts
@@ -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))
+}
diff --git a/examples/folio-web/src/index.css b/examples/folio-web/src/index.css
new file mode 100644
index 0000000..85f7fae
--- /dev/null
+++ b/examples/folio-web/src/index.css
@@ -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);
+}
diff --git a/examples/folio-web/src/main.tsx b/examples/folio-web/src/main.tsx
new file mode 100644
index 0000000..bef5202
--- /dev/null
+++ b/examples/folio-web/src/main.tsx
@@ -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(
+
+
+ ,
+)
diff --git a/examples/folio-web/src/pages/AddAccount.tsx b/examples/folio-web/src/pages/AddAccount.tsx
new file mode 100644
index 0000000..bb11f8d
--- /dev/null
+++ b/examples/folio-web/src/pages/AddAccount.tsx
@@ -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(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 (
+ back('/')} />}
+ />
+ }
+ footer={
+
+ }
+ >
+
+
+ )
+}
diff --git a/examples/folio-web/src/pages/AddTransaction.tsx b/examples/folio-web/src/pages/AddTransaction.tsx
new file mode 100644
index 0000000..de612e5
--- /dev/null
+++ b/examples/folio-web/src/pages/AddTransaction.tsx
@@ -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(undefined)
+ const [type, setType] = useState('credit')
+ const [amount, setAmount] = useState('')
+ const [note, setNote] = useState('')
+ const [err, setErr] = useState(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 (
+ back('/')} />}
+ />
+ }
+ >
+
+
Account not found
+
+
+
+ )
+ }
+
+ const submitDisabled = busy || amount.trim() === ''
+
+ return (
+ back(`/accounts/${props.accountId}`)}
+ />
+ }
+ />
+ }
+ footer={
+
+ }
+ >
+
+
+ )
+}
diff --git a/examples/folio-web/src/pages/Home.tsx b/examples/folio-web/src/pages/Home.tsx
new file mode 100644
index 0000000..0ce6a06
--- /dev/null
+++ b/examples/folio-web/src/pages/Home.tsx
@@ -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(null)
+ const [txns, setTxns] = useState([])
+
+ 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()
+ 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 (
+
+
+
+ }
+ />
+ }
+ footer={
+ <>
+
+
+ Total balance
+ 0
+ ? ' credit'
+ : totalBalance < 0
+ ? ' debit'
+ : '')
+ }
+ >
+ {formatCents(totalBalance)}
+
+
+
+ {accounts?.length ?? 0} account
+ {(accounts?.length ?? 0) === 1 ? '' : 's'}
+
+
+
+ >
+ }
+ >
+ {accounts === null ? (
+
+ ) : accounts.length === 0 ? (
+
+
+
No accounts yet
+
+ Create your first account to start tracking transactions.
+
+
+ ) : (
+
+ {accounts.map((a) => {
+ const bal = balanceByAccount.get(a.id) ?? 0
+ return (
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+function countTxns(all: Transaction[], accountId: string): number {
+ let n = 0
+ for (const t of all) if (t.accountId === accountId) n++
+ return n
+}
diff --git a/examples/folio-web/src/pages/Ledger.tsx b/examples/folio-web/src/pages/Ledger.tsx
new file mode 100644
index 0000000..1e155fb
--- /dev/null
+++ b/examples/folio-web/src/pages/Ledger.tsx
@@ -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(undefined)
+ const [txns, setTxns] = useState(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 (
+ back('/')} />}
+ />
+ }
+ >
+
+
Account not found
+
It may have been deleted.
+
+
+
+ )
+ }
+
+ return (
+ back('/')} />}
+ />
+ }
+ footer={
+
+ }
+ >
+
+
+ Balance
+ 0 ? ' credit' : balance < 0 ? ' debit' : '')
+ }
+ >
+ {formatCents(balance)}
+
+
+
+
+ Activity
+
+ {txns === null ? (
+
+ ) : txns.length === 0 ? (
+
+
+
No transactions yet
+
+ Add your first credit or debit to see it here.
+
+
+ ) : (
+
+ {txns.map((t) => {
+ const signed = signedAmount(t)
+ return (
+
+
+ {t.type === 'credit' ? '+' : '−'}
+
+
+
+ {t.note || (t.type === 'credit' ? 'Credit' : 'Debit')}
+
+
{formatDate(t.createdAt)}
+
+
+ {formatCents(signed, { signed: true })}
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/examples/folio-web/src/pages/Login.tsx b/examples/folio-web/src/pages/Login.tsx
new file mode 100644
index 0000000..a3d8136
--- /dev/null
+++ b/examples/folio-web/src/pages/Login.tsx
@@ -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(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 (
+
+
+
+ )
+}
diff --git a/examples/folio-web/src/router.ts b/examples/folio-web/src/router.ts
new file mode 100644
index 0000000..329233b
--- /dev/null
+++ b/examples/folio-web/src/router.ts
@@ -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(() => 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 })
+ }
+}
diff --git a/examples/folio-web/tsconfig.app.json b/examples/folio-web/tsconfig.app.json
new file mode 100644
index 0000000..1d29c88
--- /dev/null
+++ b/examples/folio-web/tsconfig.app.json
@@ -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"]
+}
diff --git a/examples/folio-web/tsconfig.json b/examples/folio-web/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/examples/folio-web/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/examples/folio-web/tsconfig.node.json b/examples/folio-web/tsconfig.node.json
new file mode 100644
index 0000000..d3c52ea
--- /dev/null
+++ b/examples/folio-web/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"]
+}
diff --git a/examples/folio-web/vite.config.ts b/examples/folio-web/vite.config.ts
new file mode 100644
index 0000000..9ffcc67
--- /dev/null
+++ b/examples/folio-web/vite.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go
index 5cfe22f..d96a8e8 100644
--- a/internal/driver/chrome/driver.go
+++ b/internal/driver/chrome/driver.go
@@ -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 {
diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go
index 7ce3613..fbadd25 100644
--- a/internal/hierarchy/hierarchy.go
+++ b/internal/hierarchy/hierarchy.go
@@ -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
diff --git a/internal/runner/runner.go b/internal/runner/runner.go
index 2968020..3d606cc 100644
--- a/internal/runner/runner.go
+++ b/internal/runner/runner.go
@@ -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
diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go
index 034b074..b11b7e8 100644
--- a/internal/runner/runner_test.go
+++ b/internal/runner/runner_test.go
@@ -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)
}
}
diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go
index 27c6d01..a605b47 100644
--- a/internal/testrun/testrun.go
+++ b/internal/testrun/testrun.go
@@ -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 {