feat(sdk-android): snapshot property delegate + feature-scoped files + screen fix (#34)

* rename web to inspect ui

* feat(sdk-android): add camelToSnakeCase conversion

* feat(sdk-android): add snapshot() property delegate with tests

* feat(folio): add feature-scoped sanderling snapshot objects

* refactor(folio): slim FolioApplication to snapshot object references

* fix(folio): rename route snapshot to screen, update spec.ts
This commit is contained in:
pj authored and GitHub committed 2026-04-22 19:55:45 +07:00
1 parent d5b6f6ccaf
commit 46abb28ed6
51 files changed
+297 -84

No files matched your search

+256
View File
@@ -0,0 +1,256 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@sanderling/inspect-web",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.6",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@remix-run/router": ["@remix-run/[email protected]", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="],
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="],
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="],
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="],
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="],
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/prop-types": ["@types/[email protected]", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/[email protected]", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["[email protected]", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-refresh": ["[email protected]", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-router": ["[email protected]", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="],
"react-router-dom": ["[email protected]", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="],
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="],
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
}
}
+1
View File
@@ -0,0 +1 @@
@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/JetBrainsMono-Regular.woff2) format("woff2")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/JetBrainsMono-Medium.woff2) format("woff2")}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/JetBrainsMono-Bold.woff2) format("woff2")}body{font-family:var(--font-mono);font-size:13px;line-height:1.5;color:var(--text-primary);background:var(--background);margin:0}:root{--background: #ffffff;--surface: #f7f7f7;--surface-elevated: #ffffff;--border: #e4e4e4;--border-strong: #c8c8c8;--text-primary: #111111;--text-muted: #6b6b6b;--accent-violation: #d32f2f;--accent-positive: #2e7d32;--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}:root[data-theme=dark],:root[data-theme=dark] body{--background: #0e0e0e;--surface: #161616;--surface-elevated: #1d1d1d;--border: #2a2a2a;--border-strong: #3a3a3a;--text-primary: #f0f0f0;--text-muted: #9a9a9a;--accent-violation: #f06b6b;--accent-positive: #7cc47c}@media (prefers-color-scheme: dark){:root:not([data-theme=light]){--background: #0e0e0e;--surface: #161616;--surface-elevated: #1d1d1d;--border: #2a2a2a;--border-strong: #3a3a3a;--text-primary: #f0f0f0;--text-muted: #9a9a9a;--accent-violation: #f06b6b;--accent-positive: #7cc47c}}body{background:var(--background);color:var(--text-primary);margin:0;font-family:var(--font-mono)}*,*:before,*:after{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{overflow:hidden}a{color:inherit;text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;color:inherit;background:var(--surface);border:1px solid var(--border);padding:4px 10px;cursor:pointer}button:hover{border-color:var(--border-strong)}.app-shell{height:100vh;display:flex;flex-direction:column;overflow:hidden}.app-main{flex:1 1 auto;min-height:0;padding:0;display:flex;flex-direction:column}.app-main>*{flex:1 1 auto;min-height:0}.run-table{width:100%;border-collapse:collapse;align-self:flex-start;flex:0 0 auto}.run-table th,.run-table td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}.run-table th{color:var(--text-muted);font-weight:500;font-size:12px;text-transform:uppercase;letter-spacing:.04em}.run-table tr:hover td{background:var(--surface)}.chip{display:inline-block;padding:2px 8px;border:1px solid var(--border-strong);border-radius:999px;font-size:11px}.chip-violation{border-color:var(--accent-violation);color:var(--accent-violation)}.chip-progress{border-color:var(--border-strong);color:var(--text-muted)}.detail-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}.detail-toolbar{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;border-bottom:1px solid var(--border);flex:0 0 auto}.detail-toolbar-meta{display:flex;gap:16px;align-items:baseline;font-size:12px;color:var(--text-muted)}.detail-toolbar-meta strong{color:var(--text-primary);font-weight:500}.theme-toggle{display:inline-flex;align-items:center;justify-content:center;width:28px;height:24px;padding:0;color:var(--text-muted)}.theme-toggle:hover{color:var(--text-primary)}.theme-toggle svg{display:block}.detail-grid{flex:1 1 auto;display:grid;grid-template-columns:340px minmax(0,1fr) minmax(0,1fr);grid-template-rows:minmax(0,1fr) minmax(0,130px);grid-template-areas:"actions before after" "metrics metrics metrics";gap:0;padding:0;min-height:0;overflow:hidden}.detail-grid>.detail-actions{grid-area:actions;min-height:0;overflow:hidden}.detail-grid>.detail-state-before,.detail-grid>.detail-state-after{display:flex;flex-direction:column;gap:0;min-height:0;min-width:0;overflow:hidden}.detail-grid>.detail-state-before{grid-area:before}.detail-grid>.detail-state-after{grid-area:after}.detail-grid>.detail-actions,.detail-grid>.detail-state-before{border-right:1px solid var(--border)}.detail-grid>.detail-state-before .screenshot-frame,.detail-grid>.detail-state-after .screenshot-frame,.detail-grid>.detail-state-before .screenshot-placeholder,.detail-grid>.detail-state-after .screenshot-placeholder{flex:1 1 auto;max-height:100%;max-width:100%;margin:0 auto;min-height:0}.detail-grid>.detail-metrics{grid-area:metrics;overflow:hidden;display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--border)}.detail-grid>.detail-metrics .metrics-chart{flex:1 1 auto;min-height:0;max-height:100%}.detail-metrics .detail-panel-body{overflow:hidden}.detail-metrics-exceptions{padding:4px 8px;border-top:1px solid var(--border);overflow:auto;max-height:80px}.detail-panel{border:none;background:transparent;padding:0;min-height:0;display:flex;flex-direction:column}.detail-panel h2{margin:0;padding:4px 8px;font-size:10px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--border);flex:0 0 auto}.detail-panel-body{flex:1 1 auto;mLine truncated
+67
View File
@@ -0,0 +1,67 @@
function xf(e,t){for(var n=0;n<t.length;n++){const r=t[n];if(typeof r!="string"&&!Array.isArray(r)){for(const l in r)if(l!=="default"&&!(l in e)){const i=Object.getOwnPropertyDescriptor(r,l);i&&Object.defineProperty(e,l,i.get?i:{enumerable:!0,get:()=>r[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();function wf(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Au={exports:{}},yl={},Vu={exports:{}},O={};/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var sr=Symbol.for("react.element"),Sf=Symbol.for("react.portal"),kf=Symbol.for("react.fragment"),Ef=Symbol.for("react.strict_mode"),Cf=Symbol.for("react.profiler"),Nf=Symbol.for("react.provider"),jf=Symbol.for("react.context"),_f=Symbol.for("react.forward_ref"),Pf=Symbol.for("react.suspense"),Tf=Symbol.for("react.memo"),Lf=Symbol.for("react.lazy"),cs=Symbol.iterator;function Rf(e){return e===null||typeof e!="object"?null:(e=cs&&e[cs]||e["@@iterator"],typeof e=="function"?e:null)}var Bu={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Wu=Object.assign,Hu={};function mn(e,t,n){this.props=e,this.context=t,this.refs=Hu,this.updater=n||Bu}mn.prototype.isReactComponent={};mn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};mn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Qu(){}Qu.prototype=mn.prototype;function uo(e,t,n){this.props=e,this.context=t,this.refs=Hu,this.updater=n||Bu}var ao=uo.prototype=new Qu;ao.constructor=uo;Wu(ao,mn.prototype);ao.isPureReactComponent=!0;var fs=Array.isArray,Ku=Object.prototype.hasOwnProperty,co={current:null},Gu={key:!0,ref:!0,__self:!0,__source:!0};function Ju(e,t,n){var r,l={},i=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(i=""+t.key),t)Ku.call(t,r)&&!Gu.hasOwnProperty(r)&&(l[r]=t[r]);var s=arguments.length-2;if(s===1)l.children=n;else if(1<s){for(var u=Array(s),a=0;a<s;a++)u[a]=arguments[a+2];l.children=u}if(e&&e.defaultProps)for(r in s=e.defaultProps,s)l[r]===void 0&&(l[r]=s[r]);return{$$typeof:sr,type:e,key:i,ref:o,props:l,_owner:co.current}}function zf(e,t){return{$$typeof:sr,type:e.type,key:t,ref:e.ref,props:e.props,_owner:e._owner}}function fo(e){return typeof e=="object"&&e!==null&&e.$$typeof===sr}function If(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(n){return t[n]})}var ds=/\/+/g;function $l(e,t){return typeof e=="object"&&e!==null&&e.key!=null?If(""+e.key):t.toString(36)}function Mr(e,t,n,r,l){var i=typeof e;(i==="undefined"||i==="boolean")&&(e=null);var o=!1;if(e===null)o=!0;else switch(i){case"string":case"number":o=!0;break;case"object":switch(e.$$typeof){case sr:case Sf:o=!0}}if(o)return o=e,l=l(o),e=r===""?"."+$l(o,0):r,fs(l)?(n="",e!=null&&(n=e.replace(ds,"$&/")+"/"),Mr(l,t,n,"",function(a){return a})):l!=null&&(fo(l)&&(l=zf(l,n+(!l.key||o&&o.key===l.key?"":(""+l.key).replace(ds,"$&/")+"/")+e)),t.push(l)),1;if(o=0,r=r===""?".":r+":",fs(e))for(var s=0;s<e.length;s++){i=e[s];var u=r+$l(i,s);o+=Mr(i,t,n,u,l)}else if(u=Rf(e),typeof u=="function")for(e=u.call(e),s=0;!(i=e.next()).done;)i=i.value,u=r+$l(i,s++),o+=Mr(i,t,n,u,l);else if(i==="object")throw t=String(e),Error("Objects are not valid as a React child (found: "+(t==="[object Object]"?"object with keys {"+Object.keys(e).join(", ")+"}":t)+"). If you meant to render a collection of children, use an array instead.");return o}function vr(e,t,n){if(e==null)return e;var r=[],l=0;return Mr(e,r,"","",function(i){return t.call(n,i,l++)}),r}function Of(e){if(e._status===-1){var t=e._result;t=t(),t.then(function(n){(e._status===0||e._status===-1)&&(e._status=1,e._result=n)},function(n){(e._status===0||e._status===-1)&&(e._status=2,e._result=n)}),e._status===-1&&(e._status=0,e._result=t)}if(e._status===1)return e._result.default;throw e._result}var de={current:null},Fr={transition:null},Mf={ReactCurrentDispatcher:de,ReactCurrentBatchConfig:Fr,ReactCurrentOwner:co};function Yu(){throw Error("act(...) is not supported in production builds of React.")}O.Children={map:vr,forEach:function(e,t,n){vr(e,function(){t.apply(this,arguments)},n)},count:function(e){var t=0;return vr(e,function(){t++}),t},toArray:function(e){return vr(e,function(t){return t})||[]},only:function(e){if(!fo(e))throw Error("React.Children.only expected to receive a single React element child.");return e}};O.Component=mn;O.Fragment=kf;O.Profiler=Cf;O.PureComponent=uo;O.StrictMode=Ef;O.Suspense=Pf;O.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=Mf;O.act=Yu;O.cloneElement=function(e,t,n){if(e==null)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+e+".");var r=Wu({},e.props),l=e.key,i=e.ref,o=e._owner;if(t!=null){if(t.ref!==void 0&&(i=t.ref,o=co.current),t.key!==void 0&&(l=""+t.key),e.type&&e.type.defaultProps)var s=e.type.defaultProps;for(u in t)Ku.call(t,u)&&!Gu.hasOwnProperty(u)&&(r[u]=t[u]===void 0&&s!==void 0?s[u]:t[u])}var u=arguments.length-2;if(u===1)r.children=n;else if(1<u){s=Array(u);for(var a=0;a<u;a++)s[a]=arguments[a+2];r.children=s}return{$$typeof:sr,type:e.type,key:l,ref:i,props:r,_owner:o}};O.createContext=function(e){return e={$$typeof:jf,_currentValue:e,_currentValue2:e,_threadCount:0,ProviderLine truncated
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Df=S,$f=Symbol.for("react.element"),Uf=Symbol.for("react.fragment"),Af=Object.prototype.hasOwnProperty,Vf=Df.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,Bf={key:!0,ref:!0,__self:!0,__source:!0};function Zu(e,t,n){var r,l={},i=null,o=null;n!==void 0&&(i=""+n),t.key!==void 0&&(i=""+t.key),t.ref!==void 0&&(o=t.ref);for(r in t)Af.call(t,r)&&!Bf.hasOwnProperty(r)&&(l[r]=t[r]);if(e&&e.defaultProps)for(r in t=e.defaultProps,t)l[r]===void 0&&(l[r]=t[r]);return{$$typeof:$f,type:e,key:i,ref:o,props:l,_owner:Vf.current}}yl.Fragment=Uf;yl.jsx=Zu;yl.jsxs=Zu;Au.exports=yl;var p=Au.exports,qu={exports:{}},Ee={},bu={exports:{}},ea={};/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/(function(e){function t(T,z){var I=T.length;T.push(z);e:for(;0<I;){var K=I-1>>>1,ee=T[K];if(0<l(ee,z))T[K]=z,T[I]=ee,I=K;else break e}}function n(T){return T.length===0?null:T[0]}function r(T){if(T.length===0)return null;var z=T[0],I=T.pop();if(I!==z){T[0]=I;e:for(var K=0,ee=T.length,hr=ee>>>1;K<hr;){var Et=2*(K+1)-1,Dl=T[Et],Ct=Et+1,mr=T[Ct];if(0>l(Dl,I))Ct<ee&&0>l(mr,Dl)?(T[K]=mr,T[Ct]=I,K=Ct):(T[K]=Dl,T[Et]=I,K=Et);else if(Ct<ee&&0>l(mr,I))T[K]=mr,T[Ct]=I,K=Ct;else break e}}return z}function l(T,z){var I=T.sortIndex-z.sortIndex;return I!==0?I:T.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var u=[],a=[],h=1,c=null,d=3,y=!1,g=!1,w=!1,N=typeof setTimeout=="function"?setTimeout:null,m=typeof clearTimeout=="function"?clearTimeout:null,f=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function v(T){for(var z=n(a);z!==null;){if(z.callback===null)r(a);else if(z.startTime<=T)r(a),z.sortIndex=z.expirationTime,t(u,z);else break;z=n(a)}}function x(T){if(w=!1,v(T),!g)if(n(u)!==null)g=!0,Ml(E);else{var z=n(a);z!==null&&Fl(x,z.startTime-T)}}function E(T,z){g=!1,w&&(w=!1,m(C),C=-1),y=!0;var I=d;try{for(v(z),c=n(u);c!==null&&(!(c.expirationTime>z)||T&&!X());){var K=c.callback;if(typeof K=="function"){c.callback=null,d=c.priorityLevel;var ee=K(c.expirationTime<=z);z=e.unstable_now(),typeof ee=="function"?c.callback=ee:c===n(u)&&r(u),v(z)}else r(u);c=n(u)}if(c!==null)var hr=!0;else{var Et=n(a);Et!==null&&Fl(x,Et.startTime-z),hr=!1}return hr}finally{c=null,d=I,y=!1}}var _=!1,P=null,C=-1,R=5,L=-1;function X(){return!(e.unstable_now()-L<R)}function Q(){if(P!==null){var T=e.unstable_now();L=T;var z=!0;try{z=P(!0,T)}finally{z?b():(_=!1,P=null)}}else _=!1}var b;if(typeof f=="function")b=function(){f(Q)};else if(typeof MessageChannel<"u"){var pr=new MessageChannel,yf=pr.port2;pr.port1.onmessage=Q,b=function(){yf.postMessage(null)}}else b=function(){N(Q,0)};function Ml(T){P=T,_||(_=!0,b())}function Fl(T,z){C=N(function(){T(e.unstable_now())},z)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(T){T.callback=null},e.unstable_continueExecution=function(){g||y||(g=!0,Ml(E))},e.unstable_forceFrameRate=function(T){0>T||125<T?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):R=0<T?Math.floor(1e3/T):5},e.unstable_getCurrentPriorityLevel=function(){return d},e.unstable_getFirstCallbackNode=function(){return n(u)},e.unstable_next=function(T){switch(d){case 1:case 2:case 3:var z=3;break;default:z=d}var I=d;d=z;try{return T()}finally{d=I}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(T,z){switch(T){case 1:case 2:case 3:case 4:case 5:break;default:T=3}var I=d;d=T;try{return z()}finally{d=I}},e.unstable_scheduleCallback=function(T,z,I){var K=e.unstable_now();switch(typeof I=="object"&&I!==null?(I=I.delay,I=typeof I=="number"&&0<I?K+I:K):I=K,T){case 1:var ee=-1;break;case 2:ee=250;break;case 5:ee=1073741823;break;case 4:ee=1e4;break;default:ee=5e3}return ee=I+ee,T={id:h++,callback:z,priorityLevel:T,startTime:I,expirationTime:ee,sortIndex:-1},I>K?(T.sortIndex=I,t(a,T),n(u)===null&&T===n(a)&&(w?(m(C),C=-1):w=!0,Fl(x,I-K))):(T.sortIndex=ee,t(u,T),g||y||(g=!0,Ml(E))),T},e.unstable_shouldYield=X,e.unstable_wrapCallback=function(T){var z=d;return function(){var I=d;d=z;try{return T.apply(this,arguments)}finally{d=I}}}})(ea);bu.exports=ea;var Wf=bu.exports;/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Hf=S,ke=Wf;function k(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n<arguments.length;n++)t+="&args[]="+encodeURIComponent(arguments[n]);return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}var ta=new Set,Bn={};function Dt(e,t){un(e,t),un(e+"Capture",t)}function un(e,t){for(Bn[e]=t,e=0;e<t.length;e++)ta.add(t[e])}var Je=!(typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),fi=Object.prototype.hasOwnProperty,Qf=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,ps={},hs={};function Kf(e){return fi.call(hs,e)?!0:fi.call(ps,e)?!1:Qf.test(e)?hs[e]=!0:(ps[e]=!0,!1)}function Gf(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Jf(e,t,n,r){if(t===null||typeof t>"u"||Gf(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function pe(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var ie={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ie[e]=new pe(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ie[t]=new pe(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ie[e]=new pe(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ie[e]=new pe(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ie[e]=new pe(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ie[e]=new pe(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ie[e]=new pe(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ie[e]=new pe(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ie[e]=new pe(e,5,!1,e.toLowerCase(),null,!1,!1)});var po=/[\-:]([a-z])/g;function ho(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(po,ho);ie[t]=new pe(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(po,ho);ie[t]=new pe(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(po,ho);ie[t]=new pe(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ie[e]=new pe(e,1,!1,e.toLowerCase(),null,!1,!1)});ie.xlinkHref=new pe("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ie[e]=new pe(e,1,!1,e.toLowerCase(),null,!0,!0)});function mo(e,t,n,r){var l=ie.hasOwnProperty(t)?ie[t]:null;(l!==null?l.type!==0:r||!(2<t.length)||t[0]!=="o"&&t[0]!==" Line truncated
`+Ul+e}var Al=!1;function Vl(e,t){if(!e||Al)return"";Al=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(t)if(t=function(){throw Error()},Object.defineProperty(t.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(t,[])}catch(a){var r=a}Reflect.construct(e,[],t)}else{try{t.call()}catch(a){r=a}e.call(t.prototype)}else{try{throw Error()}catch(a){r=a}e()}}catch(a){if(a&&r&&typeof a.stack=="string"){for(var l=a.stack.split(`
`),i=r.stack.split(`
`),o=l.length-1,s=i.length-1;1<=o&&0<=s&&l[o]!==i[s];)s--;for(;1<=o&&0<=s;o--,s--)if(l[o]!==i[s]){if(o!==1||s!==1)do if(o--,s--,0>s||l[o]!==i[s]){var u=`
`+l[o].replace(" at new "," at ");return e.displayName&&u.includes("<anonymous>")&&(u=u.replace("<anonymous>",e.displayName)),u}while(1<=o&&0<=s);break}}}finally{Al=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Pn(e):""}function Yf(e){switch(e.tag){case 5:return Pn(e.type);case 16:return Pn("Lazy");case 13:return Pn("Suspense");case 19:return Pn("SuspenseList");case 0:case 2:case 15:return e=Vl(e.type,!1),e;case 11:return e=Vl(e.type.render,!1),e;case 1:return e=Vl(e.type,!0),e;default:return""}}function mi(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Wt:return"Fragment";case Bt:return"Portal";case di:return"Profiler";case vo:return"StrictMode";case pi:return"Suspense";case hi:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case ra:return(e.displayName||"Context")+".Consumer";case na:return(e._context.displayName||"Context")+".Provider";case go:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case yo:return t=e.displayName||null,t!==null?t:mi(e.type)||"Memo";case tt:t=e._payload,e=e._init;try{return mi(e(t))}catch{}}return null}function Xf(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return mi(t);case 8:return t===vo?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function gt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ia(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Zf(e){var t=ia(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function yr(e){e._valueTracker||(e._valueTracker=Zf(e))}function oa(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ia(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Gr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function vi(e,t){var n=t.checked;return W({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function vs(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=gt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function sa(e,t){t=t.checked,t!=null&&mo(e,"checked",t,!1)}function gi(e,t){sa(e,t);var n=gt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?yi(e,t.type,n):t.hasOwnProperty("defaultValue")&&yi(e,t.type,gt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function gs(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function yi(e,t,n){(t!=="number"||Gr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Tn=Array.isArray;function tn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l<n.length;l++)t["$"+n[l]]=!0;for(n=0;n<e.length;n++)l=t.hasOwnProperty("$"+e[n].value),e[n].selected!==l&&(e[n].selected=l),l&&r&&(e[n].defaultSelected=!0)}else{for(n=""+gt(n),t=null,l=0;l<e.length;l++){if(e[l].value===n){e[l].selected=!0,r&&(e[l].defaultSelected=!0);return}t!==null||e[l].disabled||(t=e[l])}t!==null&&(t.selected=!0)}}function xi(e,t){if(t.dang Line truncated
`).replace(sp,"")}function jr(e,t,n){if(t=As(t),As(e)!==t&&n)throw Error(k(425))}function tl(){}var zi=null,Ii=null;function Oi(e,t){return e==="textarea"||e==="noscript"||typeof t.children=="string"||typeof t.children=="number"||typeof t.dangerouslySetInnerHTML=="object"&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var Mi=typeof setTimeout=="function"?setTimeout:void 0,up=typeof clearTimeout=="function"?clearTimeout:void 0,Vs=typeof Promise=="function"?Promise:void 0,ap=typeof queueMicrotask=="function"?queueMicrotask:typeof Vs<"u"?function(e){return Vs.resolve(null).then(e).catch(cp)}:Mi;function cp(e){setTimeout(function(){throw e})}function ei(e,t){var n=t,r=0;do{var l=n.nextSibling;if(e.removeChild(n),l&&l.nodeType===8)if(n=l.data,n==="/$"){if(r===0){e.removeChild(l),Gn(t);return}r--}else n!=="$"&&n!=="$?"&&n!=="$!"||r++;n=l}while(n);Gn(t)}function ft(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t==="$"||t==="$!"||t==="$?")break;if(t==="/$")return null}}return e}function Bs(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var n=e.data;if(n==="$"||n==="$!"||n==="$?"){if(t===0)return e;t--}else n==="/$"&&t++}e=e.previousSibling}return null}var gn=Math.random().toString(36).slice(2),Ae="__reactFiber$"+gn,qn="__reactProps$"+gn,Ye="__reactContainer$"+gn,Fi="__reactEvents$"+gn,fp="__reactListeners$"+gn,dp="__reactHandles$"+gn;function _t(e){var t=e[Ae];if(t)return t;for(var n=e.parentNode;n;){if(t=n[Ye]||n[Ae]){if(n=t.alternate,t.child!==null||n!==null&&n.child!==null)for(e=Bs(e);e!==null;){if(n=e[Ae])return n;e=Bs(e)}return t}e=n,n=e.parentNode}return null}function cr(e){return e=e[Ae]||e[Ye],!e||e.tag!==5&&e.tag!==6&&e.tag!==13&&e.tag!==3?null:e}function Gt(e){if(e.tag===5||e.tag===6)return e.stateNode;throw Error(k(33))}function El(e){return e[qn]||null}var Di=[],Jt=-1;function wt(e){return{current:e}}function U(e){0>Jt||(e.current=Di[Jt],Di[Jt]=null,Jt--)}function D(e,t){Jt++,Di[Jt]=e.current,e.current=t}var yt={},ae=wt(yt),ve=wt(!1),zt=yt;function an(e,t){var n=e.type.contextTypes;if(!n)return yt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function ge(e){return e=e.childContextTypes,e!=null}function nl(){U(ve),U(ae)}function Ws(e,t,n){if(ae.current!==yt)throw Error(k(168));D(ae,t),D(ve,n)}function Ya(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(k(108,Xf(e)||"Unknown",l));return W({},n,r)}function rl(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||yt,zt=ae.current,D(ae,e),D(ve,ve.current),!0}function Hs(e,t,n){var r=e.stateNode;if(!r)throw Error(k(169));n?(e=Ya(e,t,zt),r.__reactInternalMemoizedMergedChildContext=e,U(ve),U(ae),D(ae,e)):U(ve),D(ve,n)}var He=null,Cl=!1,ti=!1;function Xa(e){He===null?He=[e]:He.push(e)}function pp(e){Cl=!0,Xa(e)}function St(){if(!ti&&He!==null){ti=!0;var e=0,t=F;try{var n=He;for(F=1;e<n.length;e++){var r=n[e];do r=r(!0);while(r!==null)}He=null,Cl=!1}catch(l){throw He!==null&&(He=He.slice(e+1)),Sa(wo,St),l}finally{F=t,ti=!1}}return null}var Yt=[],Xt=0,ll=null,il=0,Ne=[],je=0,It=null,Qe=1,Ke="";function Nt(e,t){Yt[Xt++]=il,Yt[Xt++]=ll,ll=e,il=t}function Za(e,t,n){Ne[je++]=Qe,Ne[je++]=Ke,Ne[je++]=It,It=e;var r=Qe;e=Ke;var l=32-Me(r)-1;r&=~(1<<l),n+=1;var i=32-Me(t)+l;if(30<i){var o=l-l%5;i=(r&(1<<o)-1).toString(32),r>>=o,l-=o,Qe=1<<32-Me(t)+l|n<<l|r,Ke=i+e}else Qe=1<<i|n<<l|r,Ke=e}function To(e){e.return!==null&&(Nt(e,1),Za(e,1,0))}function Lo(e){for(;e===ll;)ll=Yt[--Xt],Yt[Xt]=null,il=Yt[--Xt],Yt[Xt]=null;for(;e===It;)It=Ne[--je],Ne[je]=null,Ke=Ne[--je],Ne[je]=null,Qe=Ne[--je],Ne[je]=null}var Se=null,we=null,A=!1,Oe=null;function qa(e,t){var n=_e(5,null,null,0);n.elementType="DELETED",n.stateNode=t,n.return=e,t=e.deletions,t===null?(e.deletions=[n],e.flags|=16):t.push(n)}function Qs(e,t){switch(e.tag){case 5:var n=e.type;return t=t.nodeType!==1||n.toLowerCase()!==t.nodeName.toLowerCase()?null:t,t!==null?(e.stateNode=t,Se=e,we=ft(t.firstChild),!0):!1;case 6:return t=e.pendingProps===""||t.nodeType!==3?null:t,t!==null?(e.stateNode=t,Se=e,we=null,!0):!1;case 13:return t=t.nodeType!==8?null:t,t!==null?(n=It!==null?{id:Qe,overflow:Ke}:null,e.memoizedState={dehydrated:t,treeContext:n,retryLane:1073741824},n=_e(18,null,null,0),n.stateNode=t,n.return=e,e.child=n,Se=e,we=null,!0):!1;default:return!1}}function $i(e){return(e.mode&1)!==0&&(e.flags&128)===0}function Ui(e){if(A){var t=we;if(t){var n=t;if(!Qs(e,t)){if($i(e))throw Error(k(418));t=ft(n.nextSibling);var r=Se;t&&Qs(e,t)?qa(r,n):(e.flags=e.flags&-4097|2,A=!1,Se=e)}}else{if($i(e))throw Error(k(418));e.flags=e.flags&-4097|2,A=!1,Se=e}}}function Ks(e){for(e=e.return;e!==null&&e.t Line truncated
Error generating stack: `+i.message+`
`+i.stack}return{value:e,source:t,stack:l,digest:null}}function oi(e,t,n){return{value:e,source:null,stack:n??null,digest:t??null}}function Wi(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var kp=typeof WeakMap=="function"?WeakMap:Map;function Nc(e,t,n){n=Ge(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){pl||(pl=!0,bi=r),Wi(e,t)},n}function jc(e,t,n){n=Ge(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var l=t.value;n.payload=function(){return r(l)},n.callback=function(){Wi(e,t)}}var i=e.stateNode;return i!==null&&typeof i.componentDidCatch=="function"&&(n.callback=function(){Wi(e,t),typeof r!="function"&&(pt===null?pt=new Set([this]):pt.add(this));var o=t.stack;this.componentDidCatch(t.value,{componentStack:o!==null?o:""})}),n}function eu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new kp;var l=new Set;r.set(t,l)}else l=r.get(t),l===void 0&&(l=new Set,r.set(t,l));l.has(n)||(l.add(n),e=Fp.bind(null,e,t,n),t.then(e,e))}function tu(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function nu(e,t,n,r,l){return e.mode&1?(e.flags|=65536,e.lanes=l,e):(e===t?e.flags|=65536:(e.flags|=128,n.flags|=131072,n.flags&=-52805,n.tag===1&&(n.alternate===null?n.tag=17:(t=Ge(-1,1),t.tag=2,dt(n,t,1))),n.lanes|=1),e)}var Ep=qe.ReactCurrentOwner,me=!1;function ce(e,t,n,r){t.child=e===null?tc(t,null,n,r):fn(t,e.child,n,r)}function ru(e,t,n,r,l){n=n.render;var i=t.ref;return on(t,l),r=Vo(e,t,n,r,i,l),n=Bo(),e!==null&&!me?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Ze(e,t,l)):(A&&n&&To(t),t.flags|=1,ce(e,t,r,l),t.child)}function lu(e,t,n,r,l){if(e===null){var i=n.type;return typeof i=="function"&&!qo(i)&&i.defaultProps===void 0&&n.compare===null&&n.defaultProps===void 0?(t.tag=15,t.type=i,_c(e,t,i,r,l)):(e=Kr(n.type,null,r,t,t.mode,l),e.ref=t.ref,e.return=t,t.child=e)}if(i=e.child,!(e.lanes&l)){var o=i.memoizedProps;if(n=n.compare,n=n!==null?n:Yn,n(o,r)&&e.ref===t.ref)return Ze(e,t,l)}return t.flags|=1,e=mt(i,r),e.ref=t.ref,e.return=t,t.child=e}function _c(e,t,n,r,l){if(e!==null){var i=e.memoizedProps;if(Yn(i,r)&&e.ref===t.ref)if(me=!1,t.pendingProps=r=i,(e.lanes&l)!==0)e.flags&131072&&(me=!0);else return t.lanes=e.lanes,Ze(e,t,l)}return Hi(e,t,n,r,l)}function Pc(e,t,n){var r=t.pendingProps,l=r.children,i=e!==null?e.memoizedState:null;if(r.mode==="hidden")if(!(t.mode&1))t.memoizedState={baseLanes:0,cachePool:null,transitions:null},D(bt,xe),xe|=n;else{if(!(n&1073741824))return e=i!==null?i.baseLanes|n:n,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,D(bt,xe),xe|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=i!==null?i.baseLanes:n,D(bt,xe),xe|=r}else i!==null?(r=i.baseLanes|n,t.memoizedState=null):r=n,D(bt,xe),xe|=r;return ce(e,t,l,n),t.child}function Tc(e,t){var n=t.ref;(e===null&&n!==null||e!==null&&e.ref!==n)&&(t.flags|=512,t.flags|=2097152)}function Hi(e,t,n,r,l){var i=ge(n)?zt:ae.current;return i=an(t,i),on(t,l),n=Vo(e,t,n,r,i,l),r=Bo(),e!==null&&!me?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Ze(e,t,l)):(A&&r&&To(t),t.flags|=1,ce(e,t,n,l),t.child)}function iu(e,t,n,r,l){if(ge(n)){var i=!0;rl(t)}else i=!1;if(on(t,l),t.stateNode===null)Wr(e,t),Cc(t,n,r),Bi(t,n,r,l),r=!0;else if(e===null){var o=t.stateNode,s=t.memoizedProps;o.props=s;var u=o.context,a=n.contextType;typeof a=="object"&&a!==null?a=Te(a):(a=ge(n)?zt:ae.current,a=an(t,a));var h=n.getDerivedStateFromProps,c=typeof h=="function"||typeof o.getSnapshotBeforeUpdate=="function";c||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(s!==r||u!==a)&&bs(t,o,r,a),nt=!1;var d=t.memoizedState;o.state=d,ul(t,r,o,l),u=t.memoizedState,s!==r||d!==u||ve.current||nt?(typeof h=="function"&&(Vi(t,n,h,r),u=t.memoizedState),(s=nt||qs(t,n,s,r,d,u,a))?(c||typeof o.UNSAFE_componentWillMount!="function"&&typeof o.componentWillMount!="function"||(typeof o.componentWillMount=="function"&&o.componentWillMount(),typeof o.UNSAFE_componentWillMount=="function"&&o.UNSAFE_componentWillMount()),typeof o.componentDidMount=="function"&&(t.flags|=4194308)):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=u),o.props=r,o.state=u,o.context=a,r=s):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),r=!1)}else{o=t.stateNode,rc(e,t),s=t.memoizedProps,a=t.type===t.elementType?s:ze(t.type,s),o.props=a,c=t.pendingProps,d=o.context,u=n.contextType,typeof u=="object"&&u!==null?u=Te(u):(u=ge(n)?zt:ae.current,u=an(t,u));var y=n.getDerivedStateFromProps;(h=typeof y=="function"||typeof o.getSnapshotBeforeUpdate=="function")||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(s!==c||d!==u)&&bs(t,o,r,u),nt=!1,d=t.memoizedState,o.state=d,ul(t,r,o,l);var g=t.memoizedState;s Line truncated
* @remix-run/router v1.23.2
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function ir(){return ir=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},ir.apply(this,arguments)}var st;(function(e){e.Pop="POP",e.Push="PUSH",e.Replace="REPLACE"})(st||(st={}));const xu="popstate";function Jp(e){e===void 0&&(e={});function t(r,l){let{pathname:i,search:o,hash:s}=r.location;return ro("",{pathname:i,search:o,hash:s},l.state&&l.state.usr||null,l.state&&l.state.key||"default")}function n(r,l){return typeof l=="string"?l:gl(l)}return Xp(t,n,null,e)}function J(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function rs(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function Yp(){return Math.random().toString(36).substr(2,8)}function wu(e,t){return{usr:e.state,key:e.key,idx:t}}function ro(e,t,n,r){return n===void 0&&(n=null),ir({pathname:typeof e=="string"?e:e.pathname,search:"",hash:""},typeof t=="string"?yn(t):t,{state:n,key:t&&t.key||r||Yp()})}function gl(e){let{pathname:t="/",search:n="",hash:r=""}=e;return n&&n!=="?"&&(t+=n.charAt(0)==="?"?n:"?"+n),r&&r!=="#"&&(t+=r.charAt(0)==="#"?r:"#"+r),t}function yn(e){let t={};if(e){let n=e.indexOf("#");n>=0&&(t.hash=e.substr(n),e=e.substr(0,n));let r=e.indexOf("?");r>=0&&(t.search=e.substr(r),e=e.substr(0,r)),e&&(t.pathname=e)}return t}function Xp(e,t,n,r){r===void 0&&(r={});let{window:l=document.defaultView,v5Compat:i=!1}=r,o=l.history,s=st.Pop,u=null,a=h();a==null&&(a=0,o.replaceState(ir({},o.state,{idx:a}),""));function h(){return(o.state||{idx:null}).idx}function c(){s=st.Pop;let N=h(),m=N==null?null:N-a;a=N,u&&u({action:s,location:w.location,delta:m})}function d(N,m){s=st.Push;let f=ro(w.location,N,m);a=h()+1;let v=wu(f,a),x=w.createHref(f);try{o.pushState(v,"",x)}catch(E){if(E instanceof DOMException&&E.name==="DataCloneError")throw E;l.location.assign(x)}i&&u&&u({action:s,location:w.location,delta:1})}function y(N,m){s=st.Replace;let f=ro(w.location,N,m);a=h();let v=wu(f,a),x=w.createHref(f);o.replaceState(v,"",x),i&&u&&u({action:s,location:w.location,delta:0})}function g(N){let m=l.location.origin!=="null"?l.location.origin:l.location.href,f=typeof N=="string"?N:gl(N);return f=f.replace(/ $/,"%20"),J(m,"No window.location.(origin|href) available to create URL for href: "+f),new URL(f,m)}let w={get action(){return s},get location(){return e(l,o)},listen(N){if(u)throw new Error("A history only accepts one active listener");return l.addEventListener(xu,c),u=N,()=>{l.removeEventListener(xu,c),u=null}},createHref(N){return t(l,N)},createURL:g,encodeLocation(N){let m=g(N);return{pathname:m.pathname,search:m.search,hash:m.hash}},push:d,replace:y,go(N){return o.go(N)}};return w}var Su;(function(e){e.data="data",e.deferred="deferred",e.redirect="redirect",e.error="error"})(Su||(Su={}));function Zp(e,t,n){return n===void 0&&(n="/"),qp(e,t,n)}function qp(e,t,n,r){let l=typeof t=="string"?yn(t):t,i=ls(l.pathname||"/",n);if(i==null)return null;let o=ef(e);bp(o);let s=null;for(let u=0;s==null&&u<o.length;++u){let a=fh(i);s=uh(o[u],a)}return s}function ef(e,t,n,r){t===void 0&&(t=[]),n===void 0&&(n=[]),r===void 0&&(r="");let l=(i,o,s)=>{let u={relativePath:s===void 0?i.path||"":s,caseSensitive:i.caseSensitive===!0,childrenIndex:o,route:i};u.relativePath.startsWith("/")&&(J(u.relativePath.startsWith(r),'Absolute route path "'+u.relativePath+'" nested under path '+('"'+r+'" is not valid. An absolute child route path ')+"must start with the combined path of all its parent routes."),u.relativePath=u.relativePath.slice(r.length));let a=vt([r,u.relativePath]),h=n.concat(u);i.children&&i.children.length>0&&(J(i.index!==!0,"Index routes must not have child routes. Please remove "+('all child routes from route path "'+a+'".')),ef(i.children,t,h,a)),!(i.path==null&&!i.index)&&t.push({path:a,score:oh(a,i.index),routesMeta:h})};return e.forEach((i,o)=>{var s;if(i.path===""||!((s=i.path)!=null&&s.includes("?")))l(i,o);else for(let u of tf(i.path))l(i,o,u)}),t}function tf(e){let t=e.split("/");if(t.length===0)return[];let[n,...r]=t,l=n.endsWith("?"),i=n.replace(/\?$/,"");if(r.length===0)return l?[i,""]:[i];let o=tf(r.join("/")),s=[];return s.push(...o.map(u=>u===""?i:[i,u].join("/"))),l&&s.push(...o),s.map(u=>e.startsWith("/")&&u===""?"/":u)}function bp(e){e.sort((t,n)=>t.score!==n.score?n.score-t.score:sh(t.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}const eh=/^:[\w-]+$/,th=3,nh=2,rh=1,lh=10,ih=-2,ku=e=>e==="*";function oh(e,t){let n=e.split("/"),r=n.length;return n.some(ku)&&(r+=ih),t&&(r+=nh),n.filter(l=>!ku(l)).reduce((l,i)=>l+(eh.test(i)?th:i===""?rh:lh),r)}function sh(e,t){return e.length===t.length&&e.slice(0,-1).every((r,l)=>r===t[l])?e[e.length-1]-t[t.length-1]:0}function uh(e,t,n){let{routesMeta:r}=e,l={},i="/",o=[];for(let s=0;s<r.length;++s){let u=r[s],a=s===r.length-1,h=i==="/"?t:t.slice(i.length)||"/",c=ah({path:u.relativePath,caseSensitive:u.caseSensitLine truncated
* React Router v6.30.3
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function or(){return or=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},or.apply(this,arguments)}const ss=S.createContext(null),Sh=S.createContext(null),kt=S.createContext(null),Ol=S.createContext(null),be=S.createContext({outlet:null,matches:[],isDataRoute:!1}),rf=S.createContext(null);function kh(e,t){let{relative:n}=t===void 0?{}:t;xn()||J(!1);let{basename:r,navigator:l}=S.useContext(kt),{hash:i,pathname:o,search:s}=sf(e,{relative:n}),u=o;return r!=="/"&&(u=o==="/"?r:vt([r,o])),l.createHref({pathname:u,search:s,hash:i})}function xn(){return S.useContext(Ol)!=null}function dr(){return xn()||J(!1),S.useContext(Ol).location}function lf(e){S.useContext(kt).static||S.useLayoutEffect(e)}function us(){let{isDataRoute:e}=S.useContext(be);return e?Mh():Eh()}function Eh(){xn()||J(!1);let e=S.useContext(ss),{basename:t,future:n,navigator:r}=S.useContext(kt),{matches:l}=S.useContext(be),{pathname:i}=dr(),o=JSON.stringify(is(l,n.v7_relativeSplatPath)),s=S.useRef(!1);return lf(()=>{s.current=!0}),S.useCallback(function(a,h){if(h===void 0&&(h={}),!s.current)return;if(typeof a=="number"){r.go(a);return}let c=os(a,JSON.parse(o),i,h.relative==="path");e==null&&t!=="/"&&(c.pathname=c.pathname==="/"?t:vt([t,c.pathname])),(h.replace?r.replace:r.push)(c,h.state,h)},[t,r,o,i,e])}function of(){let{matches:e}=S.useContext(be),t=e[e.length-1];return t?t.params:{}}function sf(e,t){let{relative:n}=t===void 0?{}:t,{future:r}=S.useContext(kt),{matches:l}=S.useContext(be),{pathname:i}=dr(),o=JSON.stringify(is(l,r.v7_relativeSplatPath));return S.useMemo(()=>os(e,JSON.parse(o),i,n==="path"),[e,o,i,n])}function Ch(e,t){return Nh(e,t)}function Nh(e,t,n,r){xn()||J(!1);let{navigator:l}=S.useContext(kt),{matches:i}=S.useContext(be),o=i[i.length-1],s=o?o.params:{};o&&o.pathname;let u=o?o.pathnameBase:"/";o&&o.route;let a=dr(),h;if(t){var c;let N=typeof t=="string"?yn(t):t;u==="/"||(c=N.pathname)!=null&&c.startsWith(u)||J(!1),h=N}else h=a;let d=h.pathname||"/",y=d;if(u!=="/"){let N=u.replace(/^\//,"").split("/");y="/"+d.replace(/^\//,"").split("/").slice(N.length).join("/")}let g=Zp(e,{pathname:y}),w=Lh(g&&g.map(N=>Object.assign({},N,{params:Object.assign({},s,N.params),pathname:vt([u,l.encodeLocation?l.encodeLocation(N.pathname).pathname:N.pathname]),pathnameBase:N.pathnameBase==="/"?u:vt([u,l.encodeLocation?l.encodeLocation(N.pathnameBase).pathname:N.pathnameBase])})),i,n,r);return t&&w?S.createElement(Ol.Provider,{value:{location:or({pathname:"/",search:"",hash:"",state:null,key:"default"},h),navigationType:st.Pop}},w):w}function jh(){let e=Oh(),t=xh(e)?e.status+" "+e.statusText:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,l={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return S.createElement(S.Fragment,null,S.createElement("h2",null,"Unexpected Application Error!"),S.createElement("h3",{style:{fontStyle:"italic"}},t),n?S.createElement("pre",{style:l},n):null,null)}const _h=S.createElement(jh,null);class Ph extends S.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,n){return n.location!==t.location||n.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:n.error,location:n.location,revalidation:t.revalidation||n.revalidation}}componentDidCatch(t,n){console.error("React Router caught the following error during render",t,n)}render(){return this.state.error!==void 0?S.createElement(be.Provider,{value:this.props.routeContext},S.createElement(rf.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function Th(e){let{routeContext:t,match:n,children:r}=e,l=S.useContext(ss);return l&&l.static&&l.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(l.staticContext._deepestRenderedBoundaryId=n.route.id),S.createElement(be.Provider,{value:t},r)}function Lh(e,t,n,r){var l;if(t===void 0&&(t=[]),n===void 0&&(n=null),r===void 0&&(r=null),e==null){var i;if(!n)return null;if(n.errors)e=n.matches;else if((i=r)!=null&&i.v7_partialHydration&&t.length===0&&!n.initialized&&n.matches.length>0)e=n.matches;else return null}let o=e,s=(l=n)==null?void 0:l.errors;if(s!=null){let h=o.findIndex(c=>c.route.id&&(s==null?void 0:s[c.route.id])!==void 0);h>=0||J(!1),o=o.slice(0,Math.min(o.length,h+1))}let u=!1,a=-1;if(n&&r&&r.v7_partialHydration)for(let h=0;h<o.length;h++){let c=o[h];if((c.route.HydrateFallback||c.route.hydrateFallbackElement)&&(a=h),c.route.id){let{loaderData:d,errors:y}=n,g=c.route.loader&&d[c.route.id]===void 0&&(!y||y[c.route.id]===void 0);if(c.route.lazy||g){u=!0,a>=0?o=o.slice(0,a+1):o=[o[0]];break}}}return o.reduceRight((h,c,d)=>{let y,g=!1,w=null,N=null;n&&(y=s&&c.route.id?s[c.route.id]:void 0Line truncated
* React Router DOM v6.30.3
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function io(){return io=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},io.apply(this,arguments)}function Vh(e,t){if(e==null)return{};var n={},r=Object.keys(e),l,i;for(i=0;i<r.length;i++)l=r[i],!(t.indexOf(l)>=0)&&(n[l]=e[l]);return n}function Bh(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Wh(e,t){return e.button===0&&(!t||t==="_self")&&!Bh(e)}const Hh=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],Qh="6";try{window.__reactRouterVersion=Qh}catch{}const Kh="startTransition",Nu=Ff[Kh];function Gh(e){let{basename:t,children:n,future:r,window:l}=e,i=S.useRef();i.current==null&&(i.current=Jp({window:l,v5Compat:!0}));let o=i.current,[s,u]=S.useState({action:o.action,location:o.location}),{v7_startTransition:a}=r||{},h=S.useCallback(c=>{a&&Nu?Nu(()=>u(c)):u(c)},[u,a]);return S.useLayoutEffect(()=>o.listen(h),[o,h]),S.useEffect(()=>Dh(r),[r]),S.createElement(Uh,{basename:t,children:n,location:s.location,navigationType:s.action,navigator:o,future:r})}const Jh=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",Yh=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,ff=S.forwardRef(function(t,n){let{onClick:r,relative:l,reloadDocument:i,replace:o,state:s,target:u,to:a,preventScrollReset:h,viewTransition:c}=t,d=Vh(t,Hh),{basename:y}=S.useContext(kt),g,w=!1;if(typeof a=="string"&&Yh.test(a)&&(g=a,Jh))try{let v=new URL(window.location.href),x=a.startsWith("//")?new URL(v.protocol+a):new URL(a),E=ls(x.pathname,y);x.origin===v.origin&&E!=null?a=E+x.search+x.hash:w=!0}catch{}let N=kh(a,{relative:l}),m=Xh(a,{replace:o,state:s,target:u,preventScrollReset:h,relative:l,viewTransition:c});function f(v){r&&r(v),v.defaultPrevented||m(v)}return S.createElement("a",io({},d,{href:g||N,onClick:w||i?r:f,ref:n,target:u}))});var ju;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(ju||(ju={}));var _u;(function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"})(_u||(_u={}));function Xh(e,t){let{target:n,replace:r,state:l,preventScrollReset:i,relative:o,viewTransition:s}=t===void 0?{}:t,u=us(),a=dr(),h=sf(e,{relative:o});return S.useCallback(c=>{if(Wh(c,n)){c.preventDefault();let d=r!==void 0?r:gl(a)===gl(h);u(e,{replace:d,state:l,preventScrollReset:i,relative:o,viewTransition:s})}},[a,u,h,r,l,n,e,i,o,s])}async function as(e){const t=await fetch(e,{headers:{Accept:"application/json"}});if(!t.ok)throw new Error(`request failed: ${t.status} ${t.statusText} (${e})`);return await t.json()}function Zh(){return as("/api/runs")}function qh(e){return as(`/api/runs/${encodeURIComponent(e)}`)}function bh(e,t){return as(`/api/runs/${encodeURIComponent(e)}/steps/${t}`)}function Pu(e,t){return`/api/runs/${encodeURIComponent(e)}/screenshots/${encodeURIComponent(t)}`}function em(e,t){S.useEffect(()=>{if(typeof EventSource>"u")return;const n=new EventSource("/api/events"),r=()=>{t()};return n.addEventListener(e,r),()=>{n.removeEventListener(e,r),n.close()}},[e,t])}function tm(e){if(!e)return"-";if(e<1e3)return`${e}ms`;const t=e/1e3;if(t<60)return`${t.toFixed(1)}s`;const n=Math.floor(t/60),r=Math.round(t-n*60);return`${n}m${r}s`}function nm(e){const t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()}function rm(){const[e,t]=S.useState(null),[n,r]=S.useState(null),l=S.useCallback(()=>{Zh().then(t).catch(i=>{r(i instanceof Error?i.message:String(i))})},[]);return S.useEffect(()=>{l()},[l]),em("runs.changed",l),n?p.jsxs("div",{className:"status-block status-error",children:["failed to load runs: ",n]}):e?e.length===0?p.jsx("div",{className:"status-block",children:"no runs yet. run `sanderling test` to produce some."}):p.jsxs("table",{className:"run-table",children:[p.jsx("thead",{children:p.jsxs("tr",{children:[p.jsx("th",{children:"started"}),p.jsx("th",{children:"spec"}),p.jsx("th",{children:"seed"}),p.jsx("th",{children:"platform"}),p.jsx("th",{children:"duration"}),p.jsx("th",{children:"steps"}),p.jsx("th",{children:"violations"}),p.jsx("th",{})]})}),p.jsx("tbody",{children:e.map(i=>p.jsxs("tr",{children:[p.jsx("td",{children:p.jsx(ff,{to:`/runs/${i.id}`,children:nm(i.started_at)})}),p.jsx("td",{children:i.spec_path}),p.jsx("td",{children:i.seed}),p.jsx("td",{children:i.platform}),p.jsx("td",{children:tm(i.duration_millis)}),p.jsx("td",{children:i.step_count}),p.jsx("td",{children:i.violation_count>0?p.jsx("span",{className:"chip chip-violation",children:i.violation_count}):"0"}),p.jsx("td",{children:i.in_progress?p.jsx("span",{className:"chip chip-progress",children:"in progress"}):null})]},i.id))})]}):p.jsx("div",{className:"status-block",children:"loading runs..."})}const lm=["id","text","textPrefix","dLine truncated
View File
Whitespace-only changes.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sanderling inspect</title>
<script type="module" crossorigin src="/assets/index-DAhEzdrG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BSJUf7yI.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sanderling inspect</title>
<link rel="stylesheet" href="/src/styles/typography.css" />
<link rel="stylesheet" href="/src/styles/tokens.css" />
<link rel="stylesheet" href="/src/styles/app.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@sanderling/inspect-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.6"
}
}
View File
Whitespace-only changes.
+32
View File
@@ -0,0 +1,32 @@
import { BrowserRouter, Navigate, Route, Routes, useParams } from "react-router-dom";
import RunList from "./routes/RunList";
import RunDetail from "./routes/RunDetail";
function RunIndexRedirect() {
const { id } = useParams<{ id: string }>();
if (!id) {
return <NotFound />;
}
return <Navigate to={`/runs/${id}/steps/1`} replace />;
}
function NotFound() {
return <div className="status-block">not found</div>;
}
export default function App() {
return (
<BrowserRouter>
<div className="app-shell">
<main className="app-main">
<Routes>
<Route path="/" element={<RunList />} />
<Route path="/runs/:id" element={<RunIndexRedirect />} />
<Route path="/runs/:id/steps/:step" element={<RunDetail />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Run, RunSummary, Step } from "./types";
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path, { headers: { Accept: "application/json" } });
if (!response.ok) {
throw new Error(`request failed: ${response.status} ${response.statusText} (${path})`);
}
return (await response.json()) as T;
}
export function listRuns(): Promise<RunSummary[]> {
return getJson<RunSummary[]>("/api/runs");
}
export function getRun(runId: string): Promise<Run> {
return getJson<Run>(`/api/runs/${encodeURIComponent(runId)}`);
}
export function getStep(runId: string, index: number): Promise<Step> {
return getJson<Step>(`/api/runs/${encodeURIComponent(runId)}/steps/${index}`);
}
export function screenshotUrl(runId: string, name: string): string {
return `/api/runs/${encodeURIComponent(runId)}/screenshots/${encodeURIComponent(name)}`;
}
@@ -0,0 +1,59 @@
.residual {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
line-height: 1.6;
word-break: break-word;
}
.residual-leaf,
.residual-unary,
.residual-binary,
.residual-group {
display: inline;
}
.residual-unary,
.residual-binary {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px;
}
.op-label {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
padding: 1px 4px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--surface);
}
.residual-bound {
color: var(--text-muted);
font-size: 11px;
}
.residual-error {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
border: 1px solid var(--accent-violation);
border-radius: 3px;
color: var(--accent-violation);
}
.residual-error .op-label {
color: var(--accent-violation);
border-color: var(--accent-violation);
background: transparent;
}
.residual-error-message {
font-size: 12px;
}
@@ -0,0 +1,88 @@
import type { ResidualNode } from "../types";
import "./ResidualNode.css";
export interface ResidualNodeProps {
node: ResidualNode;
}
function OperatorLabel({ children }: { children: string }) {
return <span className="op-label">{children}</span>;
}
function Wrapped({ node }: { node: ResidualNode }) {
return (
<span className="residual-group">
(<ResidualNodeView node={node} />)
</span>
);
}
function ResidualNodeView({ node }: ResidualNodeProps) {
switch (node.op) {
case "true":
return <span className="residual-leaf">true</span>;
case "false":
return <span className="residual-leaf">false</span>;
case "predicate":
return (
<span className="residual-leaf">
<OperatorLabel>pred</OperatorLabel>
{node.name ? <>({node.name})</> : null}
</span>
);
case "error":
return (
<span className="residual-error" role="status">
<OperatorLabel>error</OperatorLabel>
<span className="residual-error-message">{node.message}</span>
</span>
);
case "always":
case "now":
case "next":
case "not":
return (
<span className="residual-unary">
<OperatorLabel>{node.op}</OperatorLabel>
<Wrapped node={node.arg} />
</span>
);
case "eventually":
return (
<span className="residual-unary">
<OperatorLabel>eventually</OperatorLabel>
{node.within ? (
<span className="residual-bound">
within {node.within.amount} {node.within.unit}
</span>
) : null}
<Wrapped node={node.arg} />
</span>
);
case "and":
case "or":
return (
<span className="residual-binary">
<Wrapped node={node.left} />
<OperatorLabel>{node.op}</OperatorLabel>
<Wrapped node={node.right} />
</span>
);
case "implies":
return (
<span className="residual-binary">
<Wrapped node={node.left} />
<OperatorLabel>{"=>"}</OperatorLabel>
<Wrapped node={node.right} />
</span>
);
}
}
export default function ResidualNodeRoot({ node }: ResidualNodeProps) {
return (
<span className="residual">
<ResidualNodeView node={node} />
</span>
);
}
+77
View File
@@ -0,0 +1,77 @@
.tabs {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
font-family: var(--font-mono);
}
.tabs-header {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
padding: 0 4px;
}
.tabs-tab {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 4px 10px;
background: transparent;
border: none;
border-bottom: 1px solid transparent;
color: var(--text-muted);
cursor: pointer;
margin-bottom: -1px;
display: inline-flex;
align-items: center;
gap: 6px;
outline: none;
}
.tabs-tab:focus-visible {
outline: 1px solid var(--text-primary);
outline-offset: -1px;
}
.tabs-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
padding: 0 4px;
border: 1px solid var(--border-strong);
border-radius: 999px;
font-size: 9px;
line-height: 1;
letter-spacing: 0;
}
.tabs-tab[data-active="true"] .tabs-badge {
border-color: var(--text-primary);
}
.tabs-badge[data-kind="violation"] {
border-color: var(--accent-violation);
color: var(--accent-violation);
}
.tabs-tab:hover {
color: var(--text-primary);
}
.tabs-tab[data-active="true"] {
color: var(--text-primary);
border-bottom-color: var(--text-primary);
}
.tabs-panel {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
+110
View File
@@ -0,0 +1,110 @@
import { useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import "./Tabs.css";
export interface TabDefinition {
id: string;
label: string;
content: ReactNode;
badge?: ReactNode;
}
export interface TabsProps {
tabs: TabDefinition[];
defaultTabId?: string;
ariaLabel?: string;
}
export default function Tabs({ tabs, defaultTabId, ariaLabel }: TabsProps) {
const initial = defaultTabId && tabs.some((t) => t.id === defaultTabId) ? defaultTabId : tabs[0]?.id;
const [activeId, setActiveId] = useState<string | undefined>(initial);
const buttonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
if (tabs.length === 0) return null;
const active = tabs.find((t) => t.id === activeId) ?? tabs[0];
const focusTab = (id: string) => {
setActiveId(id);
const node = buttonRefs.current.get(id);
if (node) node.focus();
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabs.findIndex((t) => t.id === active.id);
if (currentIndex < 0) return;
switch (event.key) {
case "ArrowRight":
case "ArrowDown": {
event.preventDefault();
const next = tabs[(currentIndex + 1) % tabs.length];
focusTab(next.id);
return;
}
case "ArrowLeft":
case "ArrowUp": {
event.preventDefault();
const prev = tabs[(currentIndex - 1 + tabs.length) % tabs.length];
focusTab(prev.id);
return;
}
case "Home": {
event.preventDefault();
focusTab(tabs[0].id);
return;
}
case "End": {
event.preventDefault();
focusTab(tabs[tabs.length - 1].id);
return;
}
}
};
const panelId = `${ariaLabel ?? "tabs"}-panel`.replace(/\s+/g, "-");
return (
<div className="tabs">
<div
className="tabs-header"
role="tablist"
aria-label={ariaLabel}
onKeyDown={handleKeyDown}
>
{tabs.map((tab) => {
const isActive = tab.id === active.id;
return (
<button
key={tab.id}
ref={(node) => {
if (node) {
buttonRefs.current.set(tab.id, node);
} else {
buttonRefs.current.delete(tab.id);
}
}}
type="button"
role="tab"
id={`${panelId}-tab-${tab.id}`}
className="tabs-tab"
data-active={isActive ? "true" : "false"}
aria-selected={isActive}
aria-controls={panelId}
tabIndex={isActive ? 0 : -1}
onClick={() => setActiveId(tab.id)}
>
<span>{tab.label}</span>
{tab.badge !== undefined ? <span className="tabs-badge">{tab.badge}</span> : null}
</button>
);
})}
</div>
<div
className="tabs-panel"
role="tabpanel"
id={panelId}
aria-labelledby={`${panelId}-tab-${active.id}`}
>
{active.content}
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect } from "react";
export interface UseKeyboardNavOptions {
onPrev: () => void;
onNext: () => void;
onJumpStart: () => void;
onJumpEnd: () => void;
onJumpPrev10: () => void;
onJumpNext10: () => void;
onJumpNextViolation: () => void;
}
const NAVIGATION_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
const ARROW_OWNING_ROLES = new Set(["tab", "tablist", "option", "listbox", "menuitem", "menu"]);
function targetOwnsArrowKeys(target: HTMLElement | null): boolean {
let node: HTMLElement | null = target;
while (node) {
const role = node.getAttribute?.("role");
if (role && ARROW_OWNING_ROLES.has(role)) return true;
node = node.parentElement;
}
return false;
}
export function useKeyboardNav(options: UseKeyboardNavOptions) {
useEffect(() => {
const handler = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target && (NAVIGATION_TAGS.has(target.tagName) || target.isContentEditable)) {
return;
}
const isArrow = event.key.startsWith("Arrow");
if (isArrow && targetOwnsArrowKeys(target)) {
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return;
}
switch (event.key) {
case "ArrowLeft":
case "k":
if (event.shiftKey) {
options.onJumpPrev10();
} else {
options.onPrev();
}
event.preventDefault();
return;
case "ArrowRight":
case "j":
if (event.shiftKey) {
options.onJumpNext10();
} else {
options.onNext();
}
event.preventDefault();
return;
case "g":
options.onJumpStart();
event.preventDefault();
return;
case "G":
options.onJumpEnd();
event.preventDefault();
return;
case ".":
options.onJumpNextViolation();
event.preventDefault();
return;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [options]);
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect } from "react";
export function useSse(eventName: string, onEvent: () => void) {
useEffect(() => {
if (typeof EventSource === "undefined") {
return;
}
const source = new EventSource("/api/events");
const messageHandler = () => {
onEvent();
};
source.addEventListener(eventName, messageHandler);
return () => {
source.removeEventListener(eventName, messageHandler);
source.close();
};
}, [eventName, onEvent]);
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export interface UseStepResult {
runId: string;
stepIndex: number;
goTo: (index: number) => void;
}
export function useStep(maxIndex: number | undefined): UseStepResult {
const navigate = useNavigate();
const { id, step } = useParams<{ id: string; step: string }>();
const runId = id ?? "";
const parsed = Number(step ?? "1");
const stepIndex = Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
const clamped = clampIndex(stepIndex, maxIndex);
useEffect(() => {
if (clamped !== stepIndex && runId !== "") {
navigate(`/runs/${runId}/steps/${clamped}`, { replace: true });
}
}, [clamped, stepIndex, runId, navigate]);
const goTo = (index: number) => {
const target = clampIndex(index, maxIndex);
if (target !== stepIndex && runId !== "") {
navigate(`/runs/${runId}/steps/${target}`);
}
};
return { runId, stepIndex: clamped, goTo };
}
function clampIndex(index: number, maxIndex: number | undefined): number {
if (index < 1) {
return 1;
}
if (maxIndex !== undefined && index > maxIndex) {
return maxIndex;
}
return index;
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
export type ThemeMode = "light" | "dark";
const STORAGE_KEY = "sanderling-inspect-theme";
function detectInitial(): ThemeMode {
if (typeof window === "undefined") {
return "light";
}
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark") {
return stored;
}
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
export function useTheme(): { theme: ThemeMode; toggle: () => void } {
const [theme, setTheme] = useState<ThemeMode>(() => detectInitial());
useEffect(() => {
if (typeof document === "undefined") {
return;
}
document.documentElement.dataset.theme = theme;
try {
window.localStorage.setItem(STORAGE_KEY, theme);
} catch {
// private mode or storage disabled — theme still applies for the session.
}
}, [theme]);
return {
theme,
toggle: () => setTheme((current) => (current === "dark" ? "light" : "dark")),
};
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
const container = document.getElementById("root");
if (!container) {
throw new Error("missing #root element");
}
createRoot(container).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+132
View File
@@ -0,0 +1,132 @@
.action-list {
list-style: none;
padding: 0;
margin: 0;
font-family: var(--font-mono);
font-size: 11px;
}
.action-list-item {
display: block;
padding: 3px 6px;
border-left: 2px solid transparent;
cursor: pointer;
color: var(--text-primary);
outline: none;
}
.action-list-item:focus-visible {
outline: 1px solid var(--text-primary);
outline-offset: -1px;
}
.action-list-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto auto;
align-items: baseline;
column-gap: 4px;
}
.action-list-index {
text-align: left;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.action-list-body {
display: inline-flex;
gap: 6px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-list-verb {
color: var(--text-primary);
font-weight: 500;
}
.action-list-target {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-list-target-tag {
color: var(--text-primary);
}
.action-list-markers {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.action-list-elapsed {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
text-align: right;
padding-left: 8px;
}
.action-list-item:hover {
background: color-mix(in srgb, currentColor 6%, transparent);
}
.action-list-item:focus-visible {
background: color-mix(in srgb, currentColor 8%, transparent);
}
.action-list-item[data-active="true"] {
background: color-mix(in srgb, currentColor 10%, transparent);
border-left-color: currentColor;
}
.action-list-item[data-active="true"][data-violations="true"] {
border-left-color: var(--accent-violation);
}
.action-list-marker-violation {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-violation);
}
.action-list-marker-exception {
width: 8px;
height: 8px;
border-radius: 50%;
border: 1px solid var(--text-muted);
background: transparent;
}
.action-list-details {
display: grid;
grid-template-columns: 20px 1fr;
column-gap: 4px;
padding: 2px 0;
font-size: 10px;
}
.action-list-detail-row {
grid-column: 2;
display: grid;
grid-template-columns: 70px 1fr;
gap: 6px;
color: var(--text-muted);
}
.action-list-detail-label {
color: var(--text-muted);
}
.action-list-detail-value {
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+299
View File
@@ -0,0 +1,299 @@
import { useEffect, useRef } from "react";
import type { KeyboardEvent } from "react";
import type { Step, StepSummary } from "../types";
import "./ActionList.css";
export interface ActionListProps {
steps: StepSummary[];
selectedIndex: number;
onSelect: (index: number) => void;
runStartMillis?: number;
selectedStep?: Step;
}
interface FormattedRow {
verb: string;
target: string;
targetIsTag: boolean;
}
const SELECTOR_PREFIXES = [
"id",
"text",
"textPrefix",
"desc",
"descPrefix",
"class",
"classPrefix",
"package",
];
function parseSelector(selector: string): { kind: string; value: string } | null {
const colonIndex = selector.indexOf(":");
if (colonIndex <= 0) {
return null;
}
const kind = selector.slice(0, colonIndex);
const value = selector.slice(colonIndex + 1);
if (!SELECTOR_PREFIXES.includes(kind)) {
return null;
}
return { kind, value };
}
function tagFromSelector(selector: string): string {
const parsed = parseSelector(selector);
if (!parsed) {
return selector;
}
if (parsed.kind.endsWith("Prefix")) {
return `${parsed.value}...`;
}
return parsed.value;
}
export function formatActionRow(step: StepSummary): FormattedRow {
const kind = step.action_kind;
const label = step.action_label ?? "";
if (!kind) {
if (step.screen) {
return { verb: "Observe", target: `@ ${step.screen}`, targetIsTag: false };
}
return { verb: "Observe", target: "", targetIsTag: false };
}
switch (kind) {
case "Tap": {
if (!label) {
return { verb: "Click", target: "", targetIsTag: false };
}
if (label.startsWith("(") && label.endsWith(")")) {
return { verb: "Click", target: label, targetIsTag: false };
}
if (parseSelector(label)) {
return { verb: "Click", target: tagFromSelector(label), targetIsTag: true };
}
return { verb: "Click", target: label, targetIsTag: false };
}
case "InputText":
return { verb: "Type", target: label, targetIsTag: false };
case "Swipe":
return { verb: "Swipe", target: label, targetIsTag: true };
case "PressKey":
return { verb: "Press", target: label, targetIsTag: true };
case "Wait":
return { verb: "Wait", target: label, targetIsTag: true };
default:
return { verb: kind, target: label, targetIsTag: false };
}
}
export function formatElapsed(millis: number): string {
const safe = Math.max(0, Math.floor(millis));
const totalSeconds = Math.floor(safe / 1000);
const mm = Math.floor(totalSeconds / 60);
const ss = totalSeconds % 60;
const ms = safe % 1000;
const pad2 = (n: number) => String(n).padStart(2, "0");
const pad3 = (n: number) => String(n).padStart(3, "0");
return `${pad2(mm)}:${pad2(ss)}.${pad3(ms)}`;
}
function renderTarget(target: string, isTag: boolean) {
if (!target) {
return null;
}
if (isTag) {
return <span className="action-list-target action-list-target-tag">{`<${target}/>`}</span>;
}
return <span className="action-list-target">{target}</span>;
}
function contentTextForStep(step: Step): string {
const action = step.action;
if (!action) {
return "";
}
if (typeof action.text === "string") {
return action.text;
}
return "";
}
function positionTextForStep(step: Step): string | null {
const action = step.action;
if (!action) {
return null;
}
if (action.tap_point) {
return `${action.tap_point.x.toFixed(1)}, ${action.tap_point.y.toFixed(1)}`;
}
if (typeof action.x === "number" && typeof action.y === "number") {
return `${action.x.toFixed(1)}, ${action.y.toFixed(1)}`;
}
if (
typeof action.from_x === "number" &&
typeof action.from_y === "number"
) {
return `${action.from_x.toFixed(1)}, ${action.from_y.toFixed(1)}`;
}
return null;
}
export default function ActionList({
steps,
selectedIndex,
onSelect,
runStartMillis,
selectedStep,
}: ActionListProps) {
const itemRefs = useRef<Map<number, HTMLLIElement>>(new Map());
const baseMillis =
runStartMillis ?? (steps[0] ? new Date(steps[0].timestamp).getTime() : 0);
useEffect(() => {
const node = itemRefs.current.get(selectedIndex);
if (node && typeof node.scrollIntoView === "function") {
node.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
const focusIndex = (index: number) => {
const node = itemRefs.current.get(index);
if (node && typeof node.focus === "function") {
node.focus();
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLLIElement>, index: number) => {
const position = steps.findIndex((entry) => entry.index === index);
switch (event.key) {
case "Enter":
case " ":
event.preventDefault();
onSelect(index);
return;
case "ArrowDown": {
if (position < 0 || position >= steps.length - 1) return;
event.preventDefault();
const next = steps[position + 1].index;
onSelect(next);
focusIndex(next);
return;
}
case "ArrowUp": {
if (position <= 0) return;
event.preventDefault();
const prev = steps[position - 1].index;
onSelect(prev);
focusIndex(prev);
return;
}
case "Home": {
event.preventDefault();
const first = steps[0]?.index;
if (first !== undefined) {
onSelect(first);
focusIndex(first);
}
return;
}
case "End": {
event.preventDefault();
const last = steps[steps.length - 1]?.index;
if (last !== undefined) {
onSelect(last);
focusIndex(last);
}
return;
}
}
};
return (
<ol className="action-list" role="listbox" aria-label="Steps">
{steps.map((step) => {
const isActive = step.index === selectedIndex;
const { verb, target, targetIsTag } = formatActionRow(step);
const elapsedMillis = new Date(step.timestamp).getTime() - baseMillis;
const elapsed = formatElapsed(elapsedMillis);
const spokenTarget = target
? targetIsTag
? `<${target}/>`
: target
: "";
const ariaLabel = spokenTarget
? `Step ${step.index} ${verb} ${spokenTarget}`
: `Step ${step.index} ${verb}`;
const showDetails = isActive && selectedStep && selectedStep.step === step.index;
const positionText = showDetails ? positionTextForStep(selectedStep) : null;
const contentText = showDetails ? contentTextForStep(selectedStep) : "";
return (
<li
key={step.index}
ref={(node) => {
if (node) {
itemRefs.current.set(step.index, node);
} else {
itemRefs.current.delete(step.index);
}
}}
className="action-list-item"
role="option"
tabIndex={isActive ? 0 : -1}
aria-selected={isActive}
aria-label={ariaLabel}
data-active={isActive ? "true" : "false"}
data-violations={step.has_violations ? "true" : "false"}
data-exceptions={step.has_exceptions ? "true" : "false"}
onClick={() => onSelect(step.index)}
onKeyDown={(event) => handleKeyDown(event, step.index)}
title={target || undefined}
>
<div className="action-list-row">
<span className="action-list-index">{step.index}.</span>
<span className="action-list-body">
<span className="action-list-verb">{verb}</span>
{renderTarget(target, targetIsTag)}
</span>
<span className="action-list-markers">
{step.has_exceptions ? (
<span
className="action-list-marker-exception"
aria-label="exceptions"
role="img"
/>
) : null}
{step.has_violations ? (
<span
className="action-list-marker-violation"
aria-label="violations"
role="img"
/>
) : null}
</span>
<span className="action-list-elapsed">{elapsed}</span>
</div>
{showDetails ? (
<div className="action-list-details">
<div className="action-list-detail-row">
<span className="action-list-detail-label">Position</span>
<span className="action-list-detail-value">
{positionText ?? ""}
</span>
</div>
<div className="action-list-detail-row">
<span className="action-list-detail-label">Content</span>
<span className="action-list-detail-value">
{contentText ? contentText : `""`}
</span>
</div>
</div>
) : null}
</li>
);
})}
</ol>
);
}
+98
View File
@@ -0,0 +1,98 @@
.exceptions-panel {
display: flex;
flex-direction: column;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
.exceptions-panel-toolbar {
display: flex;
justify-content: flex-end;
}
.exceptions-panel-jump {
font-family: var(--font-mono);
font-size: 11px;
}
.exceptions-panel-jump:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.exceptions-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.exceptions-row details {
border: 1px dashed var(--border-strong);
background: var(--surface);
padding: 6px 8px;
}
.exceptions-row details[open] {
border-color: var(--text-primary);
}
.exceptions-summary {
display: grid;
grid-template-columns: 8px auto auto minmax(0, 1fr) auto;
align-items: baseline;
gap: 6px;
cursor: pointer;
list-style: none;
outline: none;
}
.exceptions-summary::-webkit-details-marker {
display: none;
}
.exceptions-marker {
width: 8px;
height: 8px;
border: 1px dashed var(--text-primary);
background: transparent;
align-self: center;
}
.exceptions-class {
font-weight: 700;
white-space: nowrap;
}
.exceptions-colon {
color: var(--text-muted);
}
.exceptions-message {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exceptions-time {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
margin-left: auto;
}
.exceptions-stack {
margin: 8px 0 0;
padding: 8px;
font-family: var(--font-mono);
font-size: 11px;
background: var(--background);
border: 1px solid var(--border);
max-height: 220px;
overflow-y: auto;
white-space: pre;
}
+68
View File
@@ -0,0 +1,68 @@
import type { Exception } from "../types";
import "./ExceptionsPanel.css";
export interface ExceptionsPanelProps {
exceptions?: Exception[];
onJumpToFirstException: () => void;
hasFirstException: boolean;
}
function formatTimestamp(unixMillis: number): string {
const date = new Date(unixMillis);
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
const ss = String(date.getSeconds()).padStart(2, "0");
const ms = String(date.getMilliseconds()).padStart(3, "0");
return `${hh}:${mm}:${ss}.${ms}`;
}
export default function ExceptionsPanel({
exceptions,
onJumpToFirstException,
hasFirstException,
}: ExceptionsPanelProps) {
return (
<div className="exceptions-panel">
<div className="exceptions-panel-toolbar">
<button
type="button"
className="exceptions-panel-jump"
onClick={onJumpToFirstException}
disabled={!hasFirstException}
>
jump to first exception
</button>
</div>
{!exceptions || exceptions.length === 0 ? (
<div className="status-block">no exceptions</div>
) : (
<ol className="exceptions-list">
{exceptions.map((exception, index) => (
<li key={index} className="exceptions-row">
<details open={index === 0}>
<summary className="exceptions-summary">
<span className="exceptions-marker" aria-hidden="true" />
<span className="exceptions-class">{exception.class}</span>
{exception.message ? (
<>
<span className="exceptions-colon">:</span>
<span className="exceptions-message">{exception.message}</span>
</>
) : null}
{exception.unix_millis !== undefined ? (
<span className="exceptions-time">
{formatTimestamp(exception.unix_millis)}
</span>
) : null}
</summary>
{exception.stack_trace ? (
<pre className="exceptions-stack">{exception.stack_trace}</pre>
) : null}
</details>
</li>
))}
</ol>
)}
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
.metrics-chart {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-muted);
}
.metrics-lanes {
flex: 1 1 auto;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
}
.metrics-lane {
flex: 1 1 0;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 32px;
align-items: stretch;
min-height: 0;
border-bottom: 1px solid var(--border);
}
.metrics-lane:last-of-type {
border-bottom: none;
}
.metrics-lane-label {
display: flex;
align-items: center;
justify-content: center;
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 8px;
font-weight: 600;
letter-spacing: 0.12em;
border-right: 1px solid var(--border);
}
.metrics-lane-plot {
position: relative;
min-width: 0;
min-height: 0;
}
.metrics-lane-canvas {
position: absolute;
inset: 4px 0;
}
.metrics-lane-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.metrics-lane-path {
fill: none;
stroke: var(--text-primary);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.metrics-lane-dot {
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--text-primary);
transform: translate(-50%, -50%);
pointer-events: none;
}
.metrics-lane-dot[data-active="true"] {
width: 7px;
height: 7px;
}
.metrics-lane-tooltip {
position: absolute;
padding: 2px 6px;
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--text-primary);
font-variant-numeric: tabular-nums;
font-size: 10px;
white-space: nowrap;
pointer-events: none;
z-index: 2;
}
.metrics-lane-tooltip[data-placement="above"] {
transform: translate(-50%, calc(-100% - 6px));
}
.metrics-lane-tooltip[data-placement="below"] {
transform: translate(-50%, 6px);
}
.metrics-lane-highlight {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--text-primary);
opacity: 0.7;
pointer-events: none;
}
.metrics-lane-ticks {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
padding: 2px 6px;
border-left: 1px solid var(--border);
font-variant-numeric: tabular-nums;
font-size: 9px;
}
.metrics-hits {
position: absolute;
inset: 0 32px 0 18px;
display: block;
pointer-events: none;
}
.metrics-hit {
position: absolute;
top: 0;
bottom: 0;
background: transparent;
border: 0;
padding: 0;
margin: 0;
cursor: pointer;
pointer-events: auto;
}
.metrics-hit:hover {
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
}
.metrics-axis {
flex: 0 0 auto;
position: relative;
height: 20px;
margin: 0 32px 0 18px;
border-top: 1px solid var(--border);
}
.metrics-axis-label {
position: absolute;
top: 2px;
transform: translateX(-50%);
font-variant-numeric: tabular-nums;
font-size: 9px;
white-space: nowrap;
}
+293
View File
@@ -0,0 +1,293 @@
import { useState } from "react";
import type { Metrics } from "../types";
import "./MetricsChart.css";
export interface MetricsSample {
stepIndex: number;
timestamp: string;
metrics?: Metrics;
}
export interface MetricsChartProps {
samples: MetricsSample[];
selectedIndex: number;
onSelect: (stepIndex: number) => void;
runStartMillis?: number;
}
const MB = 1024 * 1024;
function formatHeap(bytes: number): string {
if (bytes <= 0) return "0B";
if (bytes < MB) return `${Math.round(bytes / 1024)}K`;
if (bytes < 1024 * MB) return `${Math.round(bytes / MB)}M`;
return `${(bytes / (1024 * MB)).toFixed(1)}G`;
}
function formatTime(millis: number): string {
const safe = Math.max(0, Math.floor(millis));
const seconds = Math.floor(safe / 1000);
const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
const ss = String(seconds % 60).padStart(2, "0");
return `${mm}:${ss}`;
}
function fractionFor(index: number, count: number): number {
if (count <= 1) return 0.5;
return index / (count - 1);
}
function buildPath(
samples: MetricsSample[],
getValue: (sample: MetricsSample) => number | undefined,
ceiling: number,
): string {
const segments: string[] = [];
let pendingCommand = "M";
samples.forEach((sample, index) => {
const value = getValue(sample);
if (value === undefined) {
pendingCommand = "M";
return;
}
const x = fractionFor(index, samples.length);
const ratio = ceiling === 0 ? 0 : Math.min(value / ceiling, 1);
const y = 1 - ratio;
segments.push(`${pendingCommand}${x.toFixed(4)},${y.toFixed(4)}`);
pendingCommand = "L";
});
return segments.join(" ");
}
interface LaneDot {
key: number | string;
x: number;
y: number;
}
interface LaneHover {
fraction: number;
y: number;
label: string;
}
interface LaneProps {
label: string;
path: string;
dots: LaneDot[];
topTickLabel: string;
bottomTickLabel: string;
selectedFraction: number | null;
hover: LaneHover | null;
}
function Lane({
label,
path,
dots,
topTickLabel,
bottomTickLabel,
selectedFraction,
hover,
}: LaneProps) {
return (
<div className="metrics-lane">
<div className="metrics-lane-label">{label}</div>
<div className="metrics-lane-plot">
{selectedFraction !== null ? (
<div
className="metrics-lane-highlight"
style={{ left: `${selectedFraction * 100}%` }}
/>
) : null}
<div className="metrics-lane-canvas">
<svg
className="metrics-lane-svg"
viewBox="0 0 1 1"
preserveAspectRatio="none"
aria-hidden="true"
>
{path ? <path d={path} className="metrics-lane-path" /> : null}
</svg>
{dots.map((dot) => (
<div
key={dot.key}
className="metrics-lane-dot"
style={{ left: `${dot.x * 100}%`, top: `${dot.y * 100}%` }}
data-active={hover !== null && Math.abs(hover.fraction - dot.x) < 1e-6 ? "true" : undefined}
/>
))}
{hover ? (
<div
className="metrics-lane-tooltip"
data-placement={hover.y < 0.35 ? "below" : "above"}
style={{ left: `${hover.fraction * 100}%`, top: `${hover.y * 100}%` }}
>
{hover.label}
</div>
) : null}
</div>
</div>
<div className="metrics-lane-ticks">
<span>{topTickLabel}</span>
<span>{bottomTickLabel}</span>
</div>
</div>
);
}
function buildDots(
samples: MetricsSample[],
getValue: (sample: MetricsSample) => number | undefined,
ceiling: number,
): LaneDot[] {
const dots: LaneDot[] = [];
samples.forEach((sample, index) => {
const value = getValue(sample);
if (value === undefined) return;
const ratio = ceiling === 0 ? 0 : Math.min(value / ceiling, 1);
dots.push({
key: sample.stepIndex,
x: fractionFor(index, samples.length),
y: 1 - ratio,
});
});
return dots;
}
export default function MetricsChart({
samples,
selectedIndex,
onSelect,
runStartMillis,
}: MetricsChartProps) {
const [hoveredColumn, setHoveredColumn] = useState<number | null>(null);
if (samples.length === 0 || !samples.some((sample) => sample.metrics !== undefined)) {
return <div className="status-block">no metrics</div>;
}
const heapTop = samples.reduce(
(max, sample) => Math.max(max, sample.metrics?.heap_bytes ?? 0),
0,
);
const cpuObservedMax = samples.reduce(
(max, sample) => Math.max(max, sample.metrics?.cpu_percent ?? 0),
0,
);
const cpuTop = Math.max(100, cpuObservedMax);
const heapPath = buildPath(samples, (sample) => sample.metrics?.heap_bytes, heapTop);
const cpuPath = buildPath(samples, (sample) => sample.metrics?.cpu_percent, cpuTop);
const heapDots = buildDots(samples, (sample) => sample.metrics?.heap_bytes, heapTop);
const cpuDots = buildDots(samples, (sample) => sample.metrics?.cpu_percent, cpuTop);
const baseMillis = runStartMillis ?? new Date(samples[0].timestamp).getTime();
const axisCount = Math.min(5, samples.length);
const axisTicks = Array.from({ length: axisCount }, (_, i) => {
const idx =
axisCount === 1
? 0
: Math.round((i / (axisCount - 1)) * (samples.length - 1));
const sample = samples[idx];
const sampleMillis = new Date(sample.timestamp).getTime();
const elapsed =
Number.isFinite(sampleMillis) && Number.isFinite(baseMillis)
? sampleMillis - baseMillis
: NaN;
return {
key: sample.stepIndex,
fraction: fractionFor(idx, samples.length),
label: Number.isFinite(elapsed) ? formatTime(elapsed) : String(sample.stepIndex),
};
});
const selectedColumn = samples.findIndex((sample) => sample.stepIndex === selectedIndex);
const selectedFraction =
selectedColumn >= 0 ? fractionFor(selectedColumn, samples.length) : null;
const hoveredSample =
hoveredColumn !== null && hoveredColumn >= 0 && hoveredColumn < samples.length
? samples[hoveredColumn]
: null;
const hoverFraction =
hoveredColumn !== null ? fractionFor(hoveredColumn, samples.length) : null;
const heapHover: LaneHover | null =
hoveredSample && hoverFraction !== null && hoveredSample.metrics?.heap_bytes !== undefined
? {
fraction: hoverFraction,
y: 1 - (heapTop === 0 ? 0 : Math.min(hoveredSample.metrics.heap_bytes / heapTop, 1)),
label: formatHeap(hoveredSample.metrics.heap_bytes),
}
: null;
const cpuHover: LaneHover | null =
hoveredSample && hoverFraction !== null && hoveredSample.metrics?.cpu_percent !== undefined
? {
fraction: hoverFraction,
y: 1 - (cpuTop === 0 ? 0 : Math.min(hoveredSample.metrics.cpu_percent / cpuTop, 1)),
label: `${hoveredSample.metrics.cpu_percent.toFixed(1)}%`,
}
: null;
return (
<div className="metrics-chart">
<div className="metrics-lanes">
<Lane
label="HEAP"
path={heapPath}
dots={heapDots}
topTickLabel={formatHeap(heapTop)}
bottomTickLabel="0B"
selectedFraction={selectedFraction}
hover={heapHover}
/>
<Lane
label="CPU"
path={cpuPath}
dots={cpuDots}
topTickLabel={`${Math.round(cpuTop)}%`}
bottomTickLabel="0%"
selectedFraction={selectedFraction}
hover={cpuHover}
/>
<div className="metrics-hits" role="presentation">
{samples.map((sample, index) => {
const left = (() => {
if (samples.length === 1) return 0;
const half = 0.5 / (samples.length - 1);
return Math.max(0, fractionFor(index, samples.length) - half);
})();
const width = samples.length === 1 ? 1 : 1 / (samples.length - 1);
return (
<button
key={sample.stepIndex}
type="button"
className="metrics-hit"
style={{ left: `${left * 100}%`, width: `${width * 100}%` }}
onClick={() => onSelect(sample.stepIndex)}
onPointerEnter={() => setHoveredColumn(index)}
onPointerLeave={() =>
setHoveredColumn((current) => (current === index ? null : current))
}
aria-label={`select step ${sample.stepIndex}`}
/>
);
})}
</div>
</div>
<div className="metrics-axis">
{axisTicks.map((tick) => (
<span
key={tick.key}
className="metrics-axis-label"
style={{ left: `${tick.fraction * 100}%` }}
>
{tick.label}
</span>
))}
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
.screenshot-frame {
position: relative;
width: 100%;
max-width: 100%;
max-height: 100%;
margin: 0 auto;
background: var(--surface);
overflow: hidden;
}
.screenshot-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.screenshot-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.screenshot-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 240px;
background: var(--surface);
border: 1px dashed var(--border-strong);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useId, useRef, useState } from "react";
import type { Action } from "../types";
import "./Screenshot.css";
export interface ScreenshotProps {
src?: string;
action?: Action;
deviceWidth?: number;
deviceHeight?: number;
}
const DEFAULT_WIDTH = 1080;
const DEFAULT_HEIGHT = 1920;
export default function Screenshot({ src, action, deviceWidth, deviceHeight }: ScreenshotProps) {
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const previousSrc = useRef<string | undefined>(src);
const reactId = useId();
const maskId = `screenshot-spotlight-${reactId.replace(/:/g, "")}`;
useEffect(() => {
if (previousSrc.current !== src) {
previousSrc.current = src;
setLoadFailed(false);
setNaturalSize(null);
}
}, [src]);
if (!src || loadFailed) {
return (
<div className="screenshot-placeholder" data-testid="screenshot-placeholder">
no screenshot
</div>
);
}
const width = deviceWidth ?? naturalSize?.width ?? DEFAULT_WIDTH;
const height = deviceHeight ?? naturalSize?.height ?? DEFAULT_HEIGHT;
const stroke = Math.max(2, width / 200);
const tapRadius = Math.max(12, width / 60);
const arrowHeadSize = Math.max(18, width / 40);
const spotlightRadius = tapRadius * 3;
const bounds = action?.resolved_bounds;
const tap = action?.tap_point;
const isSwipe =
action?.kind === "Swipe" &&
action.from_x !== undefined &&
action.from_y !== undefined &&
action.to_x !== undefined &&
action.to_y !== undefined;
return (
<div
className="screenshot-frame"
style={{ aspectRatio: `${width} / ${height}` }}
>
<img
className="screenshot-image"
src={src}
alt="device screenshot"
onLoad={(event) => {
const img = event.currentTarget;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setNaturalSize({ width: img.naturalWidth, height: img.naturalHeight });
}
}}
onError={() => setLoadFailed(true)}
/>
<svg
className="screenshot-overlay"
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
>
{tap && (
<>
<defs>
<radialGradient
id={`${maskId}-gradient`}
cx={tap.x}
cy={tap.y}
r={spotlightRadius}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="black" />
<stop offset="55%" stopColor="black" />
<stop offset="100%" stopColor="white" />
</radialGradient>
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width={width} height={height}>
<rect x="0" y="0" width={width} height={height} fill={`url(#${maskId}-gradient)`} />
</mask>
</defs>
<rect
data-testid="screenshot-spotlight"
x="0"
y="0"
width={width}
height={height}
fill="black"
opacity="0.5"
mask={`url(#${maskId})`}
/>
</>
)}
{bounds && (
<rect
x={bounds.x}
y={bounds.y}
width={bounds.width}
height={bounds.height}
fill="none"
stroke="var(--accent-violation)"
strokeWidth={stroke}
/>
)}
{isSwipe && (
<SwipeArrow
fromX={action!.from_x!}
fromY={action!.from_y!}
toX={action!.to_x!}
toY={action!.to_y!}
stroke={stroke}
headSize={arrowHeadSize}
/>
)}
</svg>
</div>
);
}
interface SwipeArrowProps {
fromX: number;
fromY: number;
toX: number;
toY: number;
stroke: number;
headSize: number;
}
function SwipeArrow({ fromX, fromY, toX, toY, stroke, headSize }: SwipeArrowProps) {
const angle = (Math.atan2(toY - fromY, toX - fromX) * 180) / Math.PI;
const half = headSize / 2;
const points = `${toX},${toY} ${toX - headSize},${toY - half} ${toX - headSize},${toY + half}`;
return (
<g>
<line
x1={fromX}
y1={fromY}
x2={toX}
y2={toY}
stroke="var(--text-muted)"
strokeWidth={stroke}
strokeLinecap="round"
/>
<polygon
points={points}
fill="var(--text-muted)"
transform={`rotate(${angle} ${toX} ${toY})`}
/>
</g>
);
}
+44
View File
@@ -0,0 +1,44 @@
.snapshot-table {
margin: 0;
padding: 0;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
.snapshot-row {
display: grid;
grid-template-columns: minmax(0, 160px) minmax(0, 1fr);
gap: 12px;
align-items: baseline;
padding: 4px 8px;
border-left: 2px solid transparent;
}
.snapshot-row[data-changed="true"] {
border-left-color: var(--border-strong);
}
.snapshot-path {
margin: 0;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.snapshot-value {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
font-variant-numeric: tabular-nums;
}
.snapshot-row[data-long-value="true"] .snapshot-value {
white-space: pre-wrap;
max-height: 6em;
overflow-y: auto;
text-align: left;
}
+164
View File
@@ -0,0 +1,164 @@
import "./SnapshotTable.css";
export interface SnapshotTableProps {
snapshots?: Record<string, unknown>;
previousSnapshots?: Record<string, unknown>;
}
interface Row {
path: string;
value: unknown;
}
const INLINE_ARRAY_LIMIT = 2;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}
function flatten(input: Record<string, unknown>): Row[] {
const rows: Row[] = [];
const walk = (value: unknown, path: string) => {
if (isPlainObject(value)) {
const keys = Object.keys(value).sort();
if (keys.length === 0) {
rows.push({ path, value: {} });
return;
}
for (const key of keys) {
const nextPath = path === "" ? key : `${path}.${key}`;
walk(value[key], nextPath);
}
return;
}
if (Array.isArray(value)) {
if (value.length <= INLINE_ARRAY_LIMIT) {
rows.push({ path, value });
return;
}
for (let i = 0; i < value.length; i++) {
walk(value[i], `${path}[${i}]`);
}
return;
}
rows.push({ path, value });
};
for (const key of Object.keys(input).sort()) {
walk(input[key], key);
}
return rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
}
function getAtPath(source: Record<string, unknown> | undefined, path: string): unknown {
if (!source) {
return undefined;
}
if (Object.prototype.hasOwnProperty.call(source, path)) {
return source[path];
}
const segments = path.split(/\.|\[(\d+)\]/).filter((segment) => segment !== undefined && segment !== "");
let current: unknown = source;
for (const segment of segments) {
if (current === null || current === undefined) {
return undefined;
}
if (Array.isArray(current)) {
const index = Number(segment);
if (Number.isNaN(index)) {
return undefined;
}
current = current[index];
continue;
}
if (typeof current === "object") {
current = (current as Record<string, unknown>)[segment];
continue;
}
return undefined;
}
return current;
}
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalize);
}
if (isPlainObject(value)) {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
out[key] = canonicalize(value[key]);
}
return out;
}
return value;
}
function stableStringify(value: unknown): string {
return JSON.stringify(canonicalize(value));
}
function formatValue(value: unknown): string {
if (value === null) {
return "null";
}
if (value === undefined) {
return "undefined";
}
if (typeof value === "string") {
return JSON.stringify(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return stableStringify(value);
}
export default function SnapshotTable({ snapshots, previousSnapshots }: SnapshotTableProps) {
if (!snapshots || Object.keys(snapshots).length === 0) {
return <div className="status-block">no snapshots</div>;
}
const rows = flatten(snapshots);
const hasPrevious = previousSnapshots !== undefined;
const previousRows = hasPrevious ? flatten(previousSnapshots) : [];
const previousByPath = new Map(previousRows.map((row) => [row.path, row.value]));
return (
<dl className="snapshot-table">
{rows.map((row) => {
const formatted = formatValue(row.value);
let changed = false;
let previousFormatted: string | undefined;
if (hasPrevious) {
const prevValue = previousByPath.has(row.path)
? previousByPath.get(row.path)
: getAtPath(previousSnapshots, row.path);
if (stableStringify(prevValue) !== stableStringify(row.value)) {
changed = true;
previousFormatted = formatValue(prevValue);
}
}
const rowProps: Record<string, string> = {};
if (changed) {
rowProps["data-changed"] = "true";
rowProps.title = `was: ${previousFormatted}`;
}
return (
<div key={row.path} className="snapshot-row" {...rowProps}>
<dt className="snapshot-path" title={row.path}>
{row.path}
</dt>
<dd className="snapshot-value" title={formatted}>
{formatted}
</dd>
</div>
);
})}
</dl>
);
}
+40
View File
@@ -0,0 +1,40 @@
.timeline {
display: block;
width: 100%;
height: auto;
font-family: var(--font-mono);
}
.timeline-label {
font-size: 11px;
fill: var(--text-muted);
}
.timeline-cell {
cursor: pointer;
outline: none;
}
.timeline-cell:hover {
stroke: var(--text-primary);
stroke-width: 1;
}
.timeline-cell:focus-visible {
stroke: var(--text-primary);
stroke-width: 1;
}
.timeline-action {
cursor: pointer;
outline: none;
}
.timeline-action:hover,
.timeline-action:focus-visible {
fill: var(--text-primary);
}
.timeline-highlight {
pointer-events: none;
}
+142
View File
@@ -0,0 +1,142 @@
import type { KeyboardEvent } from "react";
import type { StepSummary } from "../types";
import "./Timeline.css";
export type LaneStatus = "violated" | "pending" | "holds";
export interface PropertyLane {
name: string;
statuses: LaneStatus[];
}
export interface TimelineProps {
steps: StepSummary[];
lanes: PropertyLane[];
selectedIndex: number;
onSelect: (index: number) => void;
}
const LANE_HEIGHT = 22;
const LABEL_WIDTH = 140;
const CELL_GAP = 2;
const CHART_WIDTH = 1000;
const STATUS_FILL: Record<LaneStatus, string> = {
violated: "var(--accent-violation)",
pending: "color-mix(in srgb, var(--text-primary) 30%, transparent)",
holds: "color-mix(in srgb, var(--text-primary) 10%, transparent)",
};
export default function Timeline({ steps, lanes, selectedIndex, onSelect }: TimelineProps) {
if (steps.length === 0 || lanes.length === 0) {
return <div className="status-block">no timeline data</div>;
}
const cellAreaWidth = CHART_WIDTH - LABEL_WIDTH;
const cellWidth = cellAreaWidth / steps.length;
const totalRows = lanes.length + 1;
const height = totalRows * LANE_HEIGHT;
const handleKeyDown = (event: KeyboardEvent<SVGRectElement>, stepIndex: number) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(stepIndex);
}
};
const selectedColumn = steps.findIndex((step) => step.index === selectedIndex);
const highlightX =
selectedColumn >= 0 ? LABEL_WIDTH + selectedColumn * cellWidth + cellWidth / 2 : null;
return (
<svg
className="timeline"
viewBox={`0 0 ${CHART_WIDTH} ${height}`}
preserveAspectRatio="none"
role="img"
aria-label="property timeline"
>
{lanes.map((lane, laneIndex) => {
const rowY = laneIndex * LANE_HEIGHT;
return (
<g key={lane.name} className="timeline-lane" data-lane={lane.name}>
<text
className="timeline-label"
x={LABEL_WIDTH - 8}
y={rowY + LANE_HEIGHT / 2}
dominantBaseline="middle"
textAnchor="end"
>
{lane.name}
</text>
{steps.map((step, stepCol) => {
const status = lane.statuses[stepCol] ?? "pending";
const x = LABEL_WIDTH + stepCol * cellWidth + CELL_GAP / 2;
const w = Math.max(cellWidth - CELL_GAP, 1);
const y = rowY + 3;
const h = LANE_HEIGHT - 6;
return (
<rect
key={step.index}
className="timeline-cell"
data-status={status}
data-step={step.index}
x={x}
y={y}
width={w}
height={h}
fill={STATUS_FILL[status]}
tabIndex={0}
onClick={() => onSelect(step.index)}
onKeyDown={(event) => handleKeyDown(event, step.index)}
>
<title>{`step ${step.index}: ${status}`}</title>
</rect>
);
})}
</g>
);
})}
<g className="timeline-actions" data-row="actions">
{steps.map((step, stepCol) => {
const cx = LABEL_WIDTH + stepCol * cellWidth + cellWidth / 2;
const cy = lanes.length * LANE_HEIGHT + LANE_HEIGHT / 2;
return (
<circle
key={step.index}
className="timeline-action"
data-step={step.index}
cx={cx}
cy={cy}
r={3}
fill="var(--text-muted)"
tabIndex={0}
onClick={() => onSelect(step.index)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(step.index);
}
}}
>
<title>{`step ${step.index}`}</title>
</circle>
);
})}
</g>
{highlightX !== null ? (
<line
className="timeline-highlight"
data-selected="true"
x1={highlightX}
x2={highlightX}
y1={0}
y2={height}
stroke="var(--text-primary)"
strokeWidth={2}
pointerEvents="none"
/>
) : null}
</svg>
);
}
+117
View File
@@ -0,0 +1,117 @@
.violations-panel {
display: flex;
flex-direction: column;
gap: 8px;
font-family: var(--font-mono);
color: var(--text-primary);
}
.violations-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.violations-panel-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin: 0;
}
.violations-panel-jump {
font-family: var(--font-mono);
font-size: 11px;
text-transform: lowercase;
padding: 4px 8px;
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
}
.violations-panel-jump:hover:not(:disabled) {
border-color: var(--border-strong);
}
.violations-panel-jump:disabled {
color: var(--text-muted);
cursor: not-allowed;
opacity: 0.6;
}
.violations-panel-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.violations-panel-row {
border: 1px solid var(--border);
border-radius: 3px;
padding: 8px;
background: var(--surface-elevated);
}
.violations-panel-row[data-status="violated"] {
border-color: var(--accent-violation);
}
.violations-panel-row-head {
display: flex;
align-items: center;
gap: 8px;
}
.violations-panel-badge {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 6px;
border-radius: 3px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-muted);
}
.violations-panel-badge[data-status="violated"] {
color: var(--accent-violation);
border-color: var(--accent-violation);
background: transparent;
}
.violations-panel-badge[data-status="holds"] {
color: var(--text-muted);
opacity: 0.7;
}
.violations-panel-name {
font-weight: 700;
font-size: 13px;
}
.violations-panel-residual {
margin-top: 6px;
font-size: 12px;
}
.violations-panel-residual summary {
cursor: pointer;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 0;
user-select: none;
}
.violations-panel-residual[open] summary {
margin-bottom: 6px;
}
+111
View File
@@ -0,0 +1,111 @@
import { useMemo } from "react";
import type { ResidualNode } from "../types";
import ResidualNodeView from "../components/ResidualNode";
import "./ViolationsPanel.css";
export interface ViolationsPanelProps {
propertyNames: string[];
violations: string[];
residuals?: Record<string, ResidualNode>;
onJumpToFirstViolation: () => void;
hasFirstViolation: boolean;
/** When true, only render violated rows and hide the header button row. */
violationsOnly?: boolean;
}
type Status = "violated" | "pending" | "holds";
const STATUS_ORDER: Record<Status, number> = {
violated: 0,
pending: 1,
holds: 2,
};
function statusFor(
name: string,
violations: Set<string>,
residuals?: Record<string, ResidualNode>,
): Status {
if (violations.has(name)) {
return "violated";
}
const residual = residuals?.[name];
if (residual && residual.op === "true") {
return "holds";
}
return "pending";
}
export default function ViolationsPanel({
propertyNames,
violations,
residuals,
onJumpToFirstViolation,
hasFirstViolation,
violationsOnly = false,
}: ViolationsPanelProps) {
const violationSet = useMemo(() => new Set(violations), [violations]);
const rows = useMemo(() => {
const sorted = [...propertyNames].sort((a, b) => a.localeCompare(b));
const all = sorted.map((name) => ({
name,
status: statusFor(name, violationSet, residuals),
}));
const filtered = violationsOnly ? all.filter((row) => row.status === "violated") : all;
return filtered.sort((a, b) => {
const groupDelta = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
if (groupDelta !== 0) {
return groupDelta;
}
return a.name.localeCompare(b.name);
});
}, [propertyNames, violationSet, residuals, violationsOnly]);
if (violationsOnly && rows.length === 0) {
return <div className="status-block">no violations</div>;
}
return (
<section className="violations-panel">
{violationsOnly ? null : (
<header className="violations-panel-header">
<h2 className="violations-panel-title">properties</h2>
<button
type="button"
className="violations-panel-jump"
onClick={onJumpToFirstViolation}
disabled={!hasFirstViolation}
>
jump to first violation
</button>
</header>
)}
<ul className="violations-panel-list">
{rows.map(({ name, status }) => {
const residual = residuals?.[name];
return (
<li key={name} className="violations-panel-row" data-status={status}>
<div className="violations-panel-row-head">
<span
className="violations-panel-badge"
data-status={status}
aria-label={`status ${status}`}
>
{status}
</span>
<span className="violations-panel-name">{name}</span>
</div>
{residual ? (
<details className="violations-panel-residual">
<summary>residual</summary>
<ResidualNodeView node={residual} />
</details>
) : null}
</li>
);
})}
</ul>
</section>
);
}
+389
View File
@@ -0,0 +1,389 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { getRun, getStep, screenshotUrl } from "../api";
import type { Run, Step } from "../types";
import ActionList from "../panels/ActionList";
import Screenshot from "../panels/Screenshot";
import SnapshotTable from "../panels/SnapshotTable";
import ViolationsPanel from "../panels/ViolationsPanel";
import ExceptionsPanel from "../panels/ExceptionsPanel";
import type { LaneStatus, PropertyLane } from "../panels/Timeline";
import MetricsChart, { type MetricsSample } from "../panels/MetricsChart";
import Tabs, { type TabDefinition } from "../components/Tabs";
import { useStep } from "../hooks/useStep";
import { useKeyboardNav } from "../hooks/useKeyboardNav";
import { useTheme } from "../hooks/useTheme";
interface RunHistory {
names: string[];
lanes: PropertyLane[];
firstViolationStep?: number;
firstExceptionStep?: number;
exceptionStepIndices: number[];
violationStepIndices: number[];
metricsSamples: MetricsSample[];
steps: (Step | null)[];
}
export default function RunDetail() {
const [run, setRun] = useState<Run | null>(null);
const [history, setHistory] = useState<RunHistory | null>(null);
const [error, setError] = useState<string | null>(null);
const { theme, toggle } = useTheme();
const stepCount = run?.steps.length;
const { runId, stepIndex, goTo } = useStep(stepCount);
useEffect(() => {
if (!runId) return;
let cancelled = false;
setRun(null);
setHistory(null);
setError(null);
getRun(runId)
.then(async (loaded) => {
if (cancelled) return;
setRun(loaded);
const computed = await loadHistory(loaded);
if (!cancelled) {
setHistory(computed);
}
})
.catch((failure: unknown) => {
if (!cancelled) {
setError(failure instanceof Error ? failure.message : String(failure));
}
});
return () => {
cancelled = true;
};
}, [runId]);
const currentStep = history?.steps[stepIndex - 1] ?? null;
const previousStep = stepIndex > 1 ? history?.steps[stepIndex - 2] ?? null : null;
const nextStep = history && stepIndex < history.steps.length ? history.steps[stepIndex] ?? null : null;
const jumpToFirstViolation = useCallback(() => {
if (history?.firstViolationStep) {
goTo(history.firstViolationStep);
}
}, [history, goTo]);
const jumpToFirstException = useCallback(() => {
if (history?.firstExceptionStep) {
goTo(history.firstExceptionStep);
}
}, [history, goTo]);
const jumpToNextViolation = useCallback(() => {
if (!run) return;
const next = run.steps.find((entry) => entry.index > stepIndex && entry.has_violations);
if (next) goTo(next.index);
}, [run, stepIndex, goTo]);
useKeyboardNav({
onPrev: () => goTo(stepIndex - 1),
onNext: () => goTo(stepIndex + 1),
onJumpStart: () => goTo(1),
onJumpEnd: () => stepCount && goTo(stepCount),
onJumpPrev10: () => goTo(stepIndex - 10),
onJumpNext10: () => goTo(stepIndex + 10),
onJumpNextViolation: jumpToNextViolation,
});
const beforeScreenshot = useMemo(() => {
if (!runId || !currentStep) return undefined;
return screenshotUrl(runId, `step-${String(currentStep.step).padStart(5, "0")}.png`);
}, [runId, currentStep]);
const afterScreenshot = useMemo(() => {
if (!runId || !currentStep) return undefined;
return screenshotUrl(runId, `step-${String(currentStep.step).padStart(5, "0")}-after.png`);
}, [runId, currentStep]);
const runStartMillis = useMemo(() => {
if (!run?.steps.length) return 0;
return new Date(run.steps[0].timestamp).getTime();
}, [run]);
if (error) {
return <div className="status-block status-error">failed: {error}</div>;
}
if (!run) {
return <div className="status-block">loading run...</div>;
}
const violationsBefore = currentStep?.violations ?? [];
const violationsAfter = nextStep?.violations ?? violationsBefore;
const residualsBefore = currentStep?.residuals;
const residualsAfter = nextStep?.residuals ?? residualsBefore;
const exceptionsForStep = currentStep?.exceptions;
const beforeTabs: TabDefinition[] = [
{
id: "screenshot",
label: "Screenshot",
content: <Screenshot src={beforeScreenshot} action={currentStep?.action} />,
},
{
id: "snapshots",
label: "Snapshots",
content: (
<SnapshotTable
snapshots={currentStep?.snapshots}
previousSnapshots={previousStep?.snapshots ?? undefined}
/>
),
},
{
id: "properties",
label: "Properties",
content: (
<ViolationsPanel
propertyNames={history?.names ?? []}
violations={violationsBefore}
residuals={residualsBefore}
onJumpToFirstViolation={jumpToFirstViolation}
hasFirstViolation={history?.firstViolationStep !== undefined}
/>
),
},
{
id: "violations",
label: "Violations",
badge:
violationsBefore.length > 0 ? (
<span className="tabs-badge" data-kind="violation">
{violationsBefore.length}
</span>
) : undefined,
content: (
<ViolationsPanel
propertyNames={history?.names ?? []}
violations={violationsBefore}
residuals={residualsBefore}
onJumpToFirstViolation={jumpToFirstViolation}
hasFirstViolation={history?.firstViolationStep !== undefined}
violationsOnly
/>
),
},
];
const afterTabs: TabDefinition[] = [
{
id: "screenshot",
label: "Screenshot",
content: <Screenshot src={afterScreenshot} action={undefined} />,
},
{
id: "snapshots",
label: "Snapshots",
content: (
<SnapshotTable
snapshots={nextStep?.snapshots ?? currentStep?.snapshots}
previousSnapshots={currentStep?.snapshots ?? undefined}
/>
),
},
{
id: "properties",
label: "Properties",
content: (
<ViolationsPanel
propertyNames={history?.names ?? []}
violations={violationsAfter}
residuals={residualsAfter}
onJumpToFirstViolation={jumpToFirstViolation}
hasFirstViolation={history?.firstViolationStep !== undefined}
/>
),
},
{
id: "violations",
label: "Violations",
badge:
violationsAfter.length > 0 ? (
<span className="tabs-badge" data-kind="violation">
{violationsAfter.length}
</span>
) : undefined,
content: (
<ViolationsPanel
propertyNames={history?.names ?? []}
violations={violationsAfter}
residuals={residualsAfter}
onJumpToFirstViolation={jumpToFirstViolation}
hasFirstViolation={history?.firstViolationStep !== undefined}
violationsOnly
/>
),
},
];
return (
<div className="detail-root">
<div className="detail-toolbar">
<div className="detail-toolbar-meta">
<Link to="/">runs</Link>
<span>{run.id}</span>
<span>
<strong>{run.spec_path}</strong> seed={run.seed}
</span>
<span>
step {stepIndex} / {stepCount ?? 0}
</span>
</div>
<button
type="button"
className="theme-toggle"
onClick={toggle}
aria-label={theme === "dark" ? "switch to light theme" : "switch to dark theme"}
title={theme === "dark" ? "switch to light theme" : "switch to dark theme"}
>
{theme === "dark" ? (
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
<circle cx="8" cy="8" r="3" fill="none" stroke="currentColor" strokeWidth="1.25" />
<g stroke="currentColor" strokeWidth="1.25" strokeLinecap="round">
<line x1="8" y1="1.5" x2="8" y2="3.5" />
<line x1="8" y1="12.5" x2="8" y2="14.5" />
<line x1="1.5" y1="8" x2="3.5" y2="8" />
<line x1="12.5" y1="8" x2="14.5" y2="8" />
<line x1="3.4" y1="3.4" x2="4.8" y2="4.8" />
<line x1="11.2" y1="11.2" x2="12.6" y2="12.6" />
<line x1="3.4" y1="12.6" x2="4.8" y2="11.2" />
<line x1="11.2" y1="4.8" x2="12.6" y2="3.4" />
</g>
</svg>
) : (
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
<path
d="M13.2 9.8A5.2 5.2 0 0 1 6.2 2.8a5.2 5.2 0 1 0 7 7Z"
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeLinejoin="round"
/>
</svg>
)}
</button>
</div>
<div className="detail-grid">
<aside className="detail-actions detail-panel">
<h2>actions</h2>
<div className="detail-panel-body">
<ActionList
steps={run.steps}
selectedIndex={stepIndex}
onSelect={goTo}
runStartMillis={runStartMillis}
selectedStep={currentStep ?? undefined}
/>
</div>
</aside>
<section className="detail-state-before detail-panel">
<h2>state before</h2>
<div className="detail-panel-body">
<Tabs tabs={beforeTabs} defaultTabId="screenshot" ariaLabel="state before" />
</div>
</section>
<section className="detail-state-after detail-panel">
<h2>state after</h2>
<div className="detail-panel-body">
<Tabs tabs={afterTabs} defaultTabId="screenshot" ariaLabel="state after" />
</div>
</section>
<section className="detail-metrics detail-panel">
<div className="detail-panel-body">
<MetricsChart
samples={history?.metricsSamples ?? []}
selectedIndex={stepIndex}
onSelect={goTo}
runStartMillis={runStartMillis}
/>
{(exceptionsForStep && exceptionsForStep.length > 0) ||
history?.firstExceptionStep !== undefined ? (
<div className="detail-metrics-exceptions">
<ExceptionsPanel
exceptions={exceptionsForStep}
onJumpToFirstException={jumpToFirstException}
hasFirstException={history?.firstExceptionStep !== undefined}
/>
</div>
) : null}
</div>
</section>
</div>
</div>
);
}
async function loadHistory(run: Run): Promise<RunHistory> {
const responses = await Promise.all(
run.steps.map((entry) => getStep(run.id, entry.index).catch(() => null)),
);
const propertyNames = collectPropertyNames(responses);
const lanes: PropertyLane[] = propertyNames.map((name) => ({
name,
statuses: responses.map((step) => statusForProperty(name, step)),
}));
const firstViolationStep = run.steps.find((entry) => entry.has_violations)?.index;
const firstExceptionStep = run.steps.find((entry) => entry.has_exceptions)?.index;
const exceptionStepIndices = run.steps
.filter((entry) => entry.has_exceptions)
.map((entry) => entry.index);
const violationStepIndices = run.steps
.filter((entry) => entry.has_violations)
.map((entry) => entry.index);
const metricsSamples: MetricsSample[] = run.steps.map((entry, position) => ({
stepIndex: entry.index,
timestamp: entry.timestamp,
metrics: responses[position]?.metrics,
}));
return {
names: propertyNames,
lanes: sortLanes(lanes),
firstViolationStep,
firstExceptionStep,
exceptionStepIndices,
violationStepIndices,
metricsSamples,
steps: responses,
};
}
function collectPropertyNames(steps: (Step | null)[]): string[] {
const names = new Set<string>();
for (const step of steps) {
if (!step?.residuals) continue;
for (const name of Object.keys(step.residuals)) {
names.add(name);
}
}
return [...names].sort();
}
function statusForProperty(name: string, step: Step | null): LaneStatus {
if (!step) return "pending";
if (step.violations?.includes(name)) return "violated";
const residual = step.residuals?.[name];
if (residual && residual.op === "true") return "holds";
return "pending";
}
function sortLanes(lanes: PropertyLane[]): PropertyLane[] {
const rank = (lane: PropertyLane): number => {
const last = lane.statuses[lane.statuses.length - 1];
if (lane.statuses.includes("violated")) return 0;
if (last === "pending") return 1;
return 2;
};
return [...lanes].sort((a, b) => {
const delta = rank(a) - rank(b);
if (delta !== 0) return delta;
return a.name.localeCompare(b.name);
});
}
+97
View File
@@ -0,0 +1,97 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { listRuns } from "../api";
import type { RunSummary } from "../types";
import { useSse } from "../hooks/useSse";
function formatDuration(milliseconds?: number): string {
if (!milliseconds) {
return "-";
}
if (milliseconds < 1000) {
return `${milliseconds}ms`;
}
const seconds = milliseconds / 1000;
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds - minutes * 60);
return `${minutes}m${remainingSeconds}s`;
}
function formatStartedAt(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
export default function RunList() {
const [runs, setRuns] = useState<RunSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
listRuns()
.then(setRuns)
.catch((failure: unknown) => {
setError(failure instanceof Error ? failure.message : String(failure));
});
}, []);
useEffect(() => {
refresh();
}, [refresh]);
useSse("runs.changed", refresh);
if (error) {
return <div className="status-block status-error">failed to load runs: {error}</div>;
}
if (!runs) {
return <div className="status-block">loading runs...</div>;
}
if (runs.length === 0) {
return <div className="status-block">no runs yet. run `sanderling test` to produce some.</div>;
}
return (
<table className="run-table">
<thead>
<tr>
<th>started</th>
<th>spec</th>
<th>seed</th>
<th>platform</th>
<th>duration</th>
<th>steps</th>
<th>violations</th>
<th></th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id}>
<td>
<Link to={`/runs/${run.id}`}>{formatStartedAt(run.started_at)}</Link>
</td>
<td>{run.spec_path}</td>
<td>{run.seed}</td>
<td>{run.platform}</td>
<td>{formatDuration(run.duration_millis)}</td>
<td>{run.step_count}</td>
<td>
{run.violation_count > 0 ? (
<span className="chip chip-violation">{run.violation_count}</span>
) : (
"0"
)}
</td>
<td>{run.in_progress ? <span className="chip chip-progress">in progress</span> : null}</td>
</tr>
))}
</tbody>
</table>
);
}
+272
View File
@@ -0,0 +1,272 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
overflow: hidden;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
font: inherit;
color: inherit;
background: var(--surface);
border: 1px solid var(--border);
padding: 4px 10px;
cursor: pointer;
}
button:hover {
border-color: var(--border-strong);
}
.app-shell {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-main {
flex: 1 1 auto;
min-height: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.app-main > * {
flex: 1 1 auto;
min-height: 0;
}
.run-table {
width: 100%;
border-collapse: collapse;
align-self: flex-start;
flex: 0 0 auto;
}
.run-table th,
.run-table td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.run-table th {
color: var(--text-muted);
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.run-table tr:hover td {
background: var(--surface);
}
.chip {
display: inline-block;
padding: 2px 8px;
border: 1px solid var(--border-strong);
border-radius: 999px;
font-size: 11px;
}
.chip-violation {
border-color: var(--accent-violation);
color: var(--accent-violation);
}
.chip-progress {
border-color: var(--border-strong);
color: var(--text-muted);
}
.detail-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.detail-toolbar-meta {
display: flex;
gap: 16px;
align-items: baseline;
font-size: 12px;
color: var(--text-muted);
}
.detail-toolbar-meta strong {
color: var(--text-primary);
font-weight: 500;
}
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 24px;
padding: 0;
color: var(--text-muted);
}
.theme-toggle:hover {
color: var(--text-primary);
}
.theme-toggle svg {
display: block;
}
.detail-grid {
flex: 1 1 auto;
display: grid;
grid-template-columns: 340px minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(0, 130px);
grid-template-areas:
"actions before after"
"metrics metrics metrics";
gap: 0;
padding: 0;
min-height: 0;
overflow: hidden;
}
.detail-grid > .detail-actions {
grid-area: actions;
min-height: 0;
overflow: hidden;
}
.detail-grid > .detail-state-before,
.detail-grid > .detail-state-after {
display: flex;
flex-direction: column;
gap: 0;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.detail-grid > .detail-state-before {
grid-area: before;
}
.detail-grid > .detail-state-after {
grid-area: after;
}
.detail-grid > .detail-actions,
.detail-grid > .detail-state-before {
border-right: 1px solid var(--border);
}
.detail-grid > .detail-state-before .screenshot-frame,
.detail-grid > .detail-state-after .screenshot-frame,
.detail-grid > .detail-state-before .screenshot-placeholder,
.detail-grid > .detail-state-after .screenshot-placeholder {
flex: 1 1 auto;
max-height: 100%;
max-width: 100%;
margin: 0 auto;
min-height: 0;
}
.detail-grid > .detail-metrics {
grid-area: metrics;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
border-top: 1px solid var(--border);
}
.detail-grid > .detail-metrics .metrics-chart {
flex: 1 1 auto;
min-height: 0;
max-height: 100%;
}
.detail-metrics .detail-panel-body {
overflow: hidden;
}
.detail-metrics-exceptions {
padding: 4px 8px;
border-top: 1px solid var(--border);
overflow: auto;
max-height: 80px;
}
.detail-panel {
border: none;
background: transparent;
padding: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.detail-panel h2 {
margin: 0;
padding: 4px 8px;
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.detail-panel-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
.detail-actions .detail-panel-body {
padding: 0;
}
.status-block {
padding: 12px;
color: var(--text-muted);
}
.status-error {
color: var(--accent-violation);
}
+46
View File
@@ -0,0 +1,46 @@
:root {
--background: #ffffff;
--surface: #f7f7f7;
--surface-elevated: #ffffff;
--border: #e4e4e4;
--border-strong: #c8c8c8;
--text-primary: #111111;
--text-muted: #6b6b6b;
--accent-violation: #d32f2f;
--accent-positive: #2e7d32;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
:root[data-theme="dark"],
:root[data-theme="dark"] body {
--background: #0e0e0e;
--surface: #161616;
--surface-elevated: #1d1d1d;
--border: #2a2a2a;
--border-strong: #3a3a3a;
--text-primary: #f0f0f0;
--text-muted: #9a9a9a;
--accent-violation: #f06b6b;
--accent-positive: #7cc47c;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--background: #0e0e0e;
--surface: #161616;
--surface-elevated: #1d1d1d;
--border: #2a2a2a;
--border-strong: #3a3a3a;
--text-primary: #f0f0f0;
--text-muted: #9a9a9a;
--accent-violation: #f06b6b;
--accent-positive: #7cc47c;
}
}
body {
background: var(--background);
color: var(--text-primary);
margin: 0;
font-family: var(--font-mono);
}
+32
View File
@@ -0,0 +1,32 @@
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/JetBrainsMono-Regular.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/JetBrainsMono-Medium.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/JetBrainsMono-Bold.woff2") format("woff2");
}
body {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
background: var(--background);
margin: 0;
}
+127
View File
@@ -0,0 +1,127 @@
export interface Meta {
seed: number;
spec_path: string;
bundle_sha256: string;
platform: string;
bundle_id: string;
started_at: string;
ended_at?: string;
sanderling_version: string;
}
export interface RunSummary {
id: string;
started_at: string;
ended_at?: string;
spec_path: string;
seed: number;
platform: string;
bundle_id: string;
duration_millis: number;
step_count: number;
violation_count: number;
in_progress: boolean;
}
export interface StepSummary {
index: number;
timestamp: string;
screen?: string;
action_kind?: string;
action_label?: string;
has_violations: boolean;
has_exceptions: boolean;
}
export interface Run extends RunSummary {
meta: Meta;
steps: StepSummary[];
}
export interface BoundsRect {
left: number;
top: number;
right: number;
bottom: number;
}
export interface HierarchyElement {
resourceId?: string;
text?: string;
description?: string;
class?: string;
package?: string;
clickable?: boolean;
enabled?: boolean;
checked?: boolean;
focused?: boolean;
selected?: boolean;
bounds: BoundsRect;
}
export interface Hierarchy {
elements: HierarchyElement[];
}
export interface BoundsRecord {
x: number;
y: number;
width: number;
height: number;
}
export interface PointRecord {
x: number;
y: number;
}
export interface Action {
kind: string;
x?: number;
y?: number;
from_x?: number;
from_y?: number;
to_x?: number;
to_y?: number;
key?: string;
text?: string;
duration_millis?: number;
selector?: string;
resolved_bounds?: BoundsRecord;
tap_point?: PointRecord;
}
export interface Exception {
class: string;
message?: string;
stack_trace?: string;
unix_millis?: number;
}
export type ResidualNode =
| { op: "true" }
| { op: "false" }
| { op: "always" | "now" | "next" | "not"; arg: ResidualNode }
| { op: "eventually"; arg: ResidualNode; within?: { amount: number; unit: string } }
| { op: "and" | "or" | "implies"; left: ResidualNode; right: ResidualNode }
| { op: "predicate"; name?: string }
| { op: "error"; message: string };
export interface Metrics {
cpu_percent?: number;
heap_bytes?: number;
total_memory_bytes?: number;
}
export interface Step {
step: number;
timestamp: string;
screen?: string;
snapshots?: Record<string, unknown>;
action?: Action;
exceptions?: Exception[];
violations?: string[];
hierarchy?: Hierarchy;
residuals?: Record<string, ResidualNode>;
metrics?: Metrics;
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:8765",
changeOrigin: false,
},
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});