General
diff --git a/Assets/StreamingAssets/TabUI/node_modules.meta b/Assets/StreamingAssets/TabUI/node_modules.meta
new file mode 100644
index 00000000..718cebec
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/node_modules.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 8de58a2d65ff2e34aa2139938b07bf65
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/package-lock.json b/Assets/StreamingAssets/TabUI/package-lock.json
new file mode 100644
index 00000000..98275386
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/package-lock.json
@@ -0,0 +1,1790 @@
+{
+ "name": "tabui",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tabui",
+ "version": "1.0.0",
+ "license": "ISC",
+ "devDependencies": {
+ "jsdom": "^29.0.2",
+ "vitest": "^4.1.4"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.10",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz",
+ "integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.1.1",
+ "@csstools/css-color-parser": "^4.0.2",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.0.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz",
+ "integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
+ "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
+ "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
+ "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+ "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
+ "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
+ "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
+ "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.124.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
+ "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
+ "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
+ "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
+ "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
+ "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.9.2",
+ "@emnapi/runtime": "1.9.2",
+ "@napi-rs/wasm-runtime": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
+ "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
+ "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
+ "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.4",
+ "@vitest/utils": "4.1.4",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
+ "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
+ "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
+ "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.4",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
+ "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.4",
+ "@vitest/utils": "4.1.4",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
+ "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
+ "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.4",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "29.0.2",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
+ "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.5",
+ "@asamuzakjp/dom-selector": "^7.0.6",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.1",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.7",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.24.5",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "11.3.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz",
+ "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/parse5": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.9",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
+ "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.15",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
+ "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.124.0",
+ "@rolldown/pluginutils": "1.0.0-rc.15"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.15",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
+ "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
+ "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.0.28",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz",
+ "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.28"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.28",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz",
+ "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/undici": {
+ "version": "7.24.8",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz",
+ "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.0.8",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
+ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.15",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
+ "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.4",
+ "@vitest/mocker": "4.1.4",
+ "@vitest/pretty-format": "4.1.4",
+ "@vitest/runner": "4.1.4",
+ "@vitest/snapshot": "4.1.4",
+ "@vitest/spy": "4.1.4",
+ "@vitest/utils": "4.1.4",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.4",
+ "@vitest/browser-preview": "4.1.4",
+ "@vitest/browser-webdriverio": "4.1.4",
+ "@vitest/coverage-istanbul": "4.1.4",
+ "@vitest/coverage-v8": "4.1.4",
+ "@vitest/ui": "4.1.4",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/Assets/StreamingAssets/TabUI/package-lock.json.meta b/Assets/StreamingAssets/TabUI/package-lock.json.meta
new file mode 100644
index 00000000..b01e177f
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/package-lock.json.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: a1d147f459c9d0b45908171ee6f9610c
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/package.json b/Assets/StreamingAssets/TabUI/package.json
new file mode 100644
index 00000000..4572f927
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "tabui",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "commonjs",
+ "devDependencies": {
+ "jsdom": "^29.0.2",
+ "vitest": "^4.1.4"
+ }
+}
diff --git a/Assets/StreamingAssets/TabUI/package.json.meta b/Assets/StreamingAssets/TabUI/package.json.meta
new file mode 100644
index 00000000..7a538e81
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/package.json.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 41db37b43c90aa948ab2ef143b12d072
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/scripts/bridge.js b/Assets/StreamingAssets/TabUI/scripts/bridge.js
index 5bd095c1..4fa0b354 100644
--- a/Assets/StreamingAssets/TabUI/scripts/bridge.js
+++ b/Assets/StreamingAssets/TabUI/scripts/bridge.js
@@ -47,6 +47,46 @@
window.tabUI?.setMode(data.mode);
break;
+ case 'setSafeArea':
+ window.tabUI?.setSafeArea(data);
+ break;
+
+ case 'setChromePosition':
+ window.tabUI?.setChromePosition(data.position);
+ break;
+
+ case 'setOrientation':
+ window.tabUI?.setOrientation(data.orientation);
+ break;
+
+ case 'setKeyboardState':
+ window.tabUI?.setKeyboardState({ visible: data.visible, height: data.height });
+ break;
+
+ case 'startAutoHide':
+ window.tabUI?.startAutoHideTimer();
+ break;
+
+ case 'stopAutoHide':
+ window.tabUI?.stopAutoHideTimer();
+ break;
+
+ case 'edgeTap':
+ window.tabUI?.handleEdgeTap(data.y, data.screenHeight);
+ break;
+
+ case 'platformBack':
+ window.tabUI?.handlePlatformBack();
+ break;
+
+ case 'setPlatform':
+ window.tabUI?.setPlatform(data.platform);
+ break;
+
+ case 'setMobileTabLimit':
+ window.tabUI?.setMobileTabLimit(data.limit);
+ break;
+
case 'showChrome':
window.tabUI?.showChrome();
break;
@@ -131,6 +171,19 @@
window.tabUI?.updateTabThumbnail(data.tabId, data.thumbnail);
break;
+ // Session restore
+ case 'restoreSession':
+ window.tabUI?.restoreSession(data);
+ break;
+
+ case 'showRestorePrompt':
+ window.tabUI?.showRestorePrompt();
+ break;
+
+ case 'showReloadingToast':
+ window.tabUI?.showReloadingToast();
+ break;
+
default:
console.warn('[Bridge] Unknown message type:', data.type);
}
@@ -261,6 +314,11 @@
sendToUnity({ type: 'requestHideChrome' });
},
+ // Exit dialog (Android back navigation)
+ showExitDialog: function() {
+ sendToUnity({ type: 'showExitDialog' });
+ },
+
// Theme
notifyThemeChange: function(theme) {
sendToUnity({ type: 'themeChanged', theme: theme });
@@ -290,6 +348,14 @@
sendToUnity({ type: 'hudBounds', visible: false });
},
+ // Session restore
+ acceptSessionRestore: function() {
+ sendToUnity({ type: 'acceptSessionRestore' });
+ },
+ declineSessionRestore: function() {
+ sendToUnity({ type: 'declineSessionRestore' });
+ },
+
// Ready notification
notifyReady: function() {
sendToUnity({ type: 'ready' });
diff --git a/Assets/StreamingAssets/TabUI/scripts/ui.js b/Assets/StreamingAssets/TabUI/scripts/ui.js
index 1fc5f40b..eb628c57 100644
--- a/Assets/StreamingAssets/TabUI/scripts/ui.js
+++ b/Assets/StreamingAssets/TabUI/scripts/ui.js
@@ -10,7 +10,14 @@
const state = {
tabs: [],
activeTabId: null,
- mode: 'desktop', // 'desktop' or 'vr'
+ mode: 'desktop', // 'desktop', 'vr', 'mobile', or 'tablet'
+ chromePosition: 'bottom', // 'top' or 'bottom' (mobile only)
+ orientation: 'portrait', // 'portrait' or 'landscape' (mobile only)
+ keyboardVisible: false,
+ keyboardHeight: 0,
+ autoHideTimerId: null,
+ platform: 'desktop', // 'android', 'ios', 'desktop'
+ mobileTabLimit: 5, // max tabs in mobile mode (default 5)
chromeVisible: true,
tabDropdownOpen: false,
menuDropdownOpen: false,
@@ -49,6 +56,10 @@
let forwardButtonPressTimer = null;
let backButtonPressed = false;
let forwardButtonPressed = false;
+ let tabLongPressTimer = null;
+
+ // Swipe-to-close threshold
+ const TAB_SWIPE_DISMISS_THRESHOLD = 80; // px
// DOM Elements
let elements = {};
@@ -541,6 +552,49 @@
// Close dropdowns on outside click
document.addEventListener('click', handleOutsideClick);
+
+ // Swipe detection on chrome element (mobile/tablet only)
+ bindChromeSwipeEvents();
+ }
+
+ // Swipe tracking state
+ var swipeStartX = 0;
+ var swipeStartY = 0;
+
+ /**
+ * Bind touch events on the chrome element for swipe-based tab switching.
+ * Only active in mobile/tablet mode.
+ */
+ function bindChromeSwipeEvents() {
+ var chrome = document.querySelector('.chrome');
+ if (!chrome) return;
+
+ chrome.addEventListener('touchstart', function(e) {
+ if (state.mode !== 'mobile' && state.mode !== 'tablet') return;
+ var touch = e.touches[0];
+ swipeStartX = touch.clientX;
+ swipeStartY = touch.clientY;
+ });
+
+ chrome.addEventListener('touchend', function(e) {
+ if (state.mode !== 'mobile' && state.mode !== 'tablet') return;
+ var touch = e.changedTouches[0];
+ var result = evaluateSwipe({
+ startX: swipeStartX,
+ startY: swipeStartY,
+ endX: touch.clientX,
+ endY: touch.clientY,
+ screenWidth: window.innerWidth
+ });
+ if (result.action === 'switch-tab') {
+ handleSwipeTabSwitch(result.direction);
+ }
+ });
+
+ chrome.addEventListener('touchcancel', function() {
+ swipeStartX = 0;
+ swipeStartY = 0;
+ });
}
/**
@@ -564,6 +618,8 @@
*/
function handleUrlFocus() {
elements.urlBar.select();
+ // Notify Unity so it can ensure the WebView has keyboard focus
+ try { window.vuplex?.postMessage(JSON.stringify({ type: 'urlBarFocused' })); } catch(e) {}
}
/**
@@ -1050,6 +1106,10 @@
* Handle new tab click
*/
function handleNewTab() {
+ if (!canOpenNewTab()) {
+ showToast('Tab limit reached', 'warning');
+ return;
+ }
closeTabDropdown();
window.bridge.newTab();
}
@@ -1143,14 +1203,377 @@
}
/**
- * Set mode (desktop or vr)
+ * Set mode (desktop, vr, mobile, or tablet)
*/
function setMode(mode) {
state.mode = mode;
- if (mode === 'vr') {
- document.body.classList.add('vr-mode');
+ // Remove all mode and position classes first
+ document.body.classList.remove('vr-mode', 'mobile-mode', 'tablet-mode', 'chrome-top', 'chrome-bottom');
+ // Apply mode-specific classes
+ switch (mode) {
+ case 'vr':
+ document.body.classList.add('vr-mode');
+ break;
+ case 'mobile':
+ document.body.classList.add('mobile-mode');
+ applyChromePositionClass();
+ break;
+ case 'tablet':
+ document.body.classList.add('mobile-mode', 'tablet-mode');
+ applyChromePositionClass();
+ break;
+ // 'desktop' and default: no mode classes needed
+ }
+ }
+
+ /**
+ * Apply the current chrome position class to body.
+ * Only meaningful in mobile/tablet mode.
+ */
+ function applyChromePositionClass() {
+ document.body.classList.remove('chrome-top', 'chrome-bottom');
+ document.body.classList.add(state.chromePosition === 'top' ? 'chrome-top' : 'chrome-bottom');
+ }
+
+ /**
+ * Set safe area insets (pixel values from Unity's Screen.safeArea).
+ * Applied as CSS custom properties on :root for layout calculations.
+ */
+ function setSafeArea(insets) {
+ const top = (insets && insets.top) || 0;
+ const bottom = (insets && insets.bottom) || 0;
+ const left = (insets && insets.left) || 0;
+ const right = (insets && insets.right) || 0;
+ const root = document.documentElement;
+ root.style.setProperty('--safe-area-top', top + 'px');
+ root.style.setProperty('--safe-area-bottom', bottom + 'px');
+ root.style.setProperty('--safe-area-left', left + 'px');
+ root.style.setProperty('--safe-area-right', right + 'px');
+ }
+
+ /**
+ * Set chrome bar position ('top' or 'bottom').
+ * Updates body classes and persists in state.
+ */
+ function setChromePosition(position) {
+ state.chromePosition = (position === 'top') ? 'top' : 'bottom';
+ // Only apply class if currently in mobile/tablet mode
+ if (state.mode === 'mobile' || state.mode === 'tablet') {
+ applyChromePositionClass();
+ }
+ }
+
+ /**
+ * Set device orientation ('portrait' or 'landscape').
+ * State tracking only — layout updates happen via setSafeArea.
+ */
+ function setOrientation(orientation) {
+ state.orientation = (orientation === 'landscape') ? 'landscape' : 'portrait';
+ }
+
+ // Swipe detection constants
+ var SWIPE_THRESHOLD = 40; // minimum horizontal travel (px)
+ var SWIPE_MAX_ANGLE = 30; // maximum angle deviation from horizontal (degrees)
+ var EDGE_ZONE = 20; // edge exclusion zone for iOS system gestures (px)
+
+ // Auto-hide constants
+ var AUTO_HIDE_DELAY = 3000; // ms before chrome auto-hides
+
+ /**
+ * Evaluate a swipe gesture and return the intended action.
+ * Pure function — no side effects.
+ * @param {Object} opts - { startX, startY, endX, endY, screenWidth }
+ * @returns {{ action: string, direction?: string }}
+ */
+ function evaluateSwipe(opts) {
+ if (!opts || opts.startX == null || opts.endX == null || opts.screenWidth == null) {
+ return { action: 'none' };
+ }
+ var startX = opts.startX;
+ var startY = opts.startY || 0;
+ var endX = opts.endX;
+ var endY = opts.endY || 0;
+ var screenWidth = opts.screenWidth;
+
+ // Edge zone exclusion (iOS system gestures)
+ if (startX < EDGE_ZONE || startX > (screenWidth - EDGE_ZONE)) {
+ return { action: 'none' };
+ }
+
+ var dx = endX - startX;
+ var dy = endY - startY;
+
+ // Minimum threshold check
+ if (Math.abs(dx) < SWIPE_THRESHOLD) {
+ return { action: 'none' };
+ }
+
+ // Angle check — must be primarily horizontal
+ var angle = Math.atan2(Math.abs(dy), Math.abs(dx)) * 180 / Math.PI;
+ if (angle > SWIPE_MAX_ANGLE) {
+ return { action: 'none' };
+ }
+
+ // dx < 0 = swipe left = next tab; dx > 0 = swipe right = previous tab
+ return { action: 'switch-tab', direction: dx < 0 ? 'next' : 'previous' };
+ }
+
+ /**
+ * Handle swipe-based tab switching.
+ * @param {string} direction - 'next' or 'previous'
+ */
+ function handleSwipeTabSwitch(direction) {
+ if (!state.tabs || state.tabs.length === 0) return;
+ var activeIndex = state.tabs.findIndex(function(t) { return t.id === state.activeTabId; });
+ if (activeIndex < 0) return;
+
+ if (direction === 'next' && activeIndex < state.tabs.length - 1) {
+ window.bridge.switchTab(state.tabs[activeIndex + 1].id);
+ } else if (direction === 'previous' && activeIndex > 0) {
+ window.bridge.switchTab(state.tabs[activeIndex - 1].id);
+ }
+ }
+
+ /**
+ * Start the auto-hide timer. After AUTO_HIDE_DELAY ms, hides the chrome.
+ * Does not start if keyboard is open.
+ */
+ function startAutoHideTimer() {
+ if (state.keyboardVisible) return;
+ stopAutoHideTimer();
+ state.autoHideTimerId = setTimeout(function() {
+ hideChrome();
+ }, AUTO_HIDE_DELAY);
+ }
+
+ /**
+ * Reset the auto-hide timer — cancels existing and starts new one.
+ */
+ function resetAutoHideTimer() {
+ stopAutoHideTimer();
+ startAutoHideTimer();
+ }
+
+ /**
+ * Stop (cancel) the auto-hide timer without restarting.
+ */
+ function stopAutoHideTimer() {
+ if (state.autoHideTimerId != null) {
+ clearTimeout(state.autoHideTimerId);
+ state.autoHideTimerId = null;
+ }
+ }
+
+ /**
+ * Check if a tap is within the edge zone for chrome reactivation.
+ * Pure function.
+ * @param {number} tapY - Y coordinate of tap
+ * @param {number} screenHeight - Total screen height
+ * @param {string} chromePosition - 'top' or 'bottom'
+ * @returns {boolean}
+ */
+ function isEdgeTap(tapY, screenHeight, chromePosition) {
+ if (chromePosition === 'top' && tapY < EDGE_ZONE) return true;
+ if (chromePosition === 'bottom' && tapY > (screenHeight - EDGE_ZONE)) return true;
+ return false;
+ }
+
+ /**
+ * Handle an edge tap — shows chrome if it's hidden and tap is at the correct edge.
+ * @param {number} tapY - Y coordinate of tap
+ * @param {number} screenHeight - Total screen height
+ */
+ function handleEdgeTap(tapY, screenHeight) {
+ if (!state.chromeVisible && isEdgeTap(tapY, screenHeight, state.chromePosition)) {
+ showChrome();
+ resetAutoHideTimer();
+ }
+ }
+
+ /**
+ * Set platform identifier.
+ * Valid values: 'android', 'ios', 'desktop'
+ */
+ function setPlatform(platform) {
+ if (platform === 'android' || platform === 'ios' || platform === 'desktop') {
+ state.platform = platform;
+ }
+ }
+
+ /**
+ * Evaluate back action — pure function.
+ * Priority: close overlay → navigate back → hide chrome → exit dialog (Android) / none (iOS/desktop)
+ */
+ function evaluateBackAction(opts) {
+ if (!opts || typeof opts !== 'object') {
+ return { action: 'none' };
+ }
+ var platform = opts.platform;
+ if (platform !== 'android' && platform !== 'ios') {
+ return { action: 'none' };
+ }
+ if (opts.hasOverlayOpen) {
+ return { action: 'close-overlay' };
+ }
+ if (opts.canGoBack) {
+ return { action: 'navigate-back' };
+ }
+ if (opts.chromeVisible) {
+ return { action: 'hide-chrome' };
+ }
+ if (platform === 'android') {
+ return { action: 'show-exit-dialog' };
+ }
+ return { action: 'none' };
+ }
+
+ /**
+ * Handle platform back button/gesture.
+ * Reads current state, evaluates action, dispatches.
+ */
+ function handlePlatformBack() {
+ var result = evaluateBackAction({
+ canGoBack: state.canGoBack,
+ chromeVisible: state.chromeVisible,
+ hasOverlayOpen: hasAnyOverlayOpen(),
+ platform: state.platform
+ });
+ switch (result.action) {
+ case 'close-overlay':
+ closeAllDropdowns();
+ break;
+ case 'navigate-back':
+ if (window.bridge && window.bridge.goBack) {
+ window.bridge.goBack();
+ }
+ break;
+ case 'hide-chrome':
+ hideChrome();
+ break;
+ case 'show-exit-dialog':
+ if (window.bridge && window.bridge.showExitDialog) {
+ window.bridge.showExitDialog();
+ }
+ break;
+ }
+ }
+
+ // ---- Mobile Tab Limit API ----
+
+ /**
+ * Set the mobile tab limit.
+ * @param {number} limit - Max number of tabs in mobile mode. Must be > 0, defaults to 5.
+ */
+ function setMobileTabLimit(limit) {
+ if (typeof limit !== 'number' || limit <= 0 || !isFinite(limit)) {
+ state.mobileTabLimit = 5;
} else {
- document.body.classList.remove('vr-mode');
+ state.mobileTabLimit = Math.floor(limit);
+ }
+ }
+
+ /**
+ * Get the current mobile tab limit.
+ * @returns {number}
+ */
+ function getMobileTabLimit() {
+ return state.mobileTabLimit;
+ }
+
+ /**
+ * Check if a new tab can be opened.
+ * Desktop/VR modes have no limit. Mobile/tablet enforces mobileTabLimit.
+ * @returns {boolean}
+ */
+ function canOpenNewTab() {
+ if (state.mode !== 'mobile' && state.mode !== 'tablet') {
+ return true;
+ }
+ return state.tabs.length < state.mobileTabLimit;
+ }
+
+ // ---- Swipe-to-Close API ----
+
+ /**
+ * Evaluate tab swipe dismiss — pure function.
+ * @param {Object} opts - { startX, endX, threshold }
+ * @returns {{ action: 'dismiss' | 'none' }}
+ */
+ function evaluateTabSwipeDismiss(opts) {
+ if (!opts || typeof opts !== 'object') {
+ return { action: 'none' };
+ }
+ var startX = opts.startX;
+ var endX = opts.endX;
+ if (typeof startX !== 'number' || typeof endX !== 'number') {
+ return { action: 'none' };
+ }
+ var dx = Math.abs(endX - startX);
+ var threshold = (typeof opts.threshold === 'number') ? opts.threshold : TAB_SWIPE_DISMISS_THRESHOLD;
+
+ // Angle check when Y coordinates provided (scroll vs swipe conflict prevention)
+ if (typeof opts.startY === 'number' && typeof opts.endY === 'number') {
+ var dy = Math.abs(opts.endY - opts.startY);
+ var angle = Math.atan2(dy, dx) * 180 / Math.PI;
+ if (angle > SWIPE_MAX_ANGLE) {
+ return { action: 'none' };
+ }
+ }
+
+ if (dx >= threshold) {
+ return { action: 'dismiss' };
+ }
+ return { action: 'none' };
+ }
+
+ /**
+ * Handle tab swipe dismiss — calls bridge.closeTab.
+ * @param {string} tabId - The tab ID to close
+ */
+ function handleTabSwipeDismiss(tabId) {
+ if (window.bridge && window.bridge.closeTab) {
+ window.bridge.closeTab(tabId);
+ }
+ }
+
+ // ---- Long-Press Thumbnail API ----
+
+ /**
+ * Handle tab long-press — shows thumbnail preview after LONG_PRESS_DELAY.
+ * @param {string} tabId
+ * @param {HTMLElement} anchor
+ */
+ function handleTabLongPress(tabId, anchor) {
+ cancelTabLongPress();
+ tabLongPressTimer = setTimeout(function() {
+ showThumbnailPreview(tabId, anchor);
+ }, LONG_PRESS_DELAY);
+ }
+
+ /**
+ * Cancel a pending tab long-press timer.
+ */
+ function cancelTabLongPress() {
+ if (tabLongPressTimer) {
+ clearTimeout(tabLongPressTimer);
+ tabLongPressTimer = null;
+ }
+ }
+
+ /**
+ * Set on-screen keyboard state.
+ * Updates CSS variable and body class for chrome repositioning.
+ */
+ function setKeyboardState(opts) {
+ const visible = !!(opts && opts.visible);
+ const height = (visible && opts && opts.height) ? opts.height : 0;
+ state.keyboardVisible = visible;
+ state.keyboardHeight = height;
+ document.documentElement.style.setProperty('--keyboard-height', height + 'px');
+ if (visible) {
+ document.body.classList.add('keyboard-open');
+ } else {
+ document.body.classList.remove('keyboard-open');
}
}
@@ -1936,6 +2359,7 @@
// Update each setting field
const fieldMappings = {
+ defaultAvatar: 'setting-default-avatar',
homeURL: 'setting-home-url',
worldLoadTimeout: 'setting-world-load-timeout',
storageMode: 'setting-storage-mode',
@@ -1968,6 +2392,10 @@
// Theme
values.theme = state.theme;
+ // Avatar
+ const defaultAvatar = document.getElementById('setting-default-avatar');
+ if (defaultAvatar) values.defaultAvatar = defaultAvatar.value;
+
// Text and number inputs
const homeUrl = document.getElementById('setting-home-url');
if (homeUrl) values.homeURL = homeUrl.value;
@@ -2058,9 +2486,15 @@
div.classList.add('tab-item--loading');
} else if (tab.loadState === 'error') {
div.classList.add('tab-item--error');
+ } else if (tab.loadState === 'suspended') {
+ div.classList.add('tab-item--suspended');
}
- div.setAttribute('aria-label', tab.displayName || 'Tab');
+ var ariaLabel = tab.displayName || 'Tab';
+ if (tab.loadState === 'suspended') {
+ ariaLabel += ' (suspended)';
+ }
+ div.setAttribute('aria-label', ariaLabel);
if (tab.id === state.activeTabId) {
div.setAttribute('aria-current', 'true');
}
@@ -2093,6 +2527,95 @@
});
}
+ // Mobile touch events: swipe-to-close and long-press
+ if (state.mode === 'mobile' || state.mode === 'tablet') {
+ var touchStartX = 0;
+ var touchStartY = 0;
+ var isSwiping = false;
+ var gestureDecided = false;
+
+ div.addEventListener('touchstart', function(e) {
+ var touch = e.touches[0];
+ touchStartX = touch.clientX;
+ touchStartY = touch.clientY;
+ isSwiping = false;
+ gestureDecided = false;
+ div.classList.remove('tab-item--snapping', 'tab-item--dismissing');
+ div.classList.add('tab-item--swiping');
+ handleTabLongPress(tab.id, div);
+ });
+
+ div.addEventListener('touchmove', function(e) {
+ var touch = e.touches[0];
+ var dx = touch.clientX - touchStartX;
+ var dy = touch.clientY - touchStartY;
+ var absDx = Math.abs(dx);
+ var absDy = Math.abs(dy);
+
+ // Once gesture type is decided, only update visuals if swiping
+ if (gestureDecided) {
+ if (isSwiping) {
+ div.style.transform = 'translateX(' + dx + 'px)';
+ div.style.opacity = String(Math.max(0, 1 - absDx / TAB_SWIPE_DISMISS_THRESHOLD));
+ e.preventDefault();
+ }
+ return;
+ }
+
+ // Determine gesture type once movement exceeds 10px dead zone
+ if (absDx > 10 || absDy > 10) {
+ gestureDecided = true;
+ cancelTabLongPress();
+ var angle = Math.atan2(absDy, absDx) * 180 / Math.PI;
+ if (angle < SWIPE_MAX_ANGLE && absDx > 10) {
+ // Horizontal swipe mode — locked
+ isSwiping = true;
+ div.style.transform = 'translateX(' + dx + 'px)';
+ div.style.opacity = String(Math.max(0, 1 - absDx / TAB_SWIPE_DISMISS_THRESHOLD));
+ e.preventDefault();
+ }
+ // Vertical or diagonal — locked to scroll, don't start swipe
+ }
+ }, { passive: false });
+
+ div.addEventListener('touchend', function(e) {
+ cancelTabLongPress();
+ div.classList.remove('tab-item--swiping');
+ if (isSwiping) {
+ var touch = e.changedTouches[0];
+ var result = evaluateTabSwipeDismiss({
+ startX: touchStartX,
+ endX: touch.clientX,
+ startY: touchStartY,
+ endY: touch.clientY
+ });
+ if (result.action === 'dismiss') {
+ div.classList.add('tab-item--dismissing');
+ var direction = touch.clientX > touchStartX ? 1 : -1;
+ div.style.transform = 'translateX(' + (direction * 300) + 'px)';
+ div.style.opacity = '0';
+ handleTabSwipeDismiss(tab.id);
+ } else {
+ // Snap back
+ div.classList.add('tab-item--snapping');
+ div.style.transform = '';
+ div.style.opacity = '';
+ }
+ }
+ isSwiping = false;
+ });
+
+ div.addEventListener('touchcancel', function() {
+ cancelTabLongPress();
+ isSwiping = false;
+ gestureDecided = false;
+ div.classList.remove('tab-item--swiping');
+ div.classList.add('tab-item--snapping');
+ div.style.transform = '';
+ div.style.opacity = '';
+ });
+ }
+
return div;
}
@@ -2136,6 +2659,71 @@
// Utilities
// ===================
+ // ===================
+ // Session Restore
+ // ===================
+
+ /**
+ * Restore a previous session — updates tabs and active tab from serialized data.
+ */
+ function restoreSession(data) {
+ if (!data) return;
+ var tabs = data.tabs || [];
+ updateTabs(tabs);
+ if (data.activeTabId && tabs.length > 0) {
+ setActiveTab(data.activeTabId);
+ }
+ if (data.hasReloadingTab && tabs.length > 0) {
+ showReloadingToast();
+ }
+ }
+
+ /**
+ * Show a prompt asking the user whether to restore their previous session.
+ */
+ function showRestorePrompt() {
+ // Remove any existing restore prompt
+ var existing = document.getElementById('restore-prompt');
+ if (existing) existing.remove();
+
+ var modal = document.createElement('div');
+ modal.id = 'restore-prompt';
+ modal.setAttribute('role', 'dialog');
+ modal.setAttribute('aria-label', 'Restore session prompt');
+ modal.className = 'restore-prompt-overlay';
+ modal.innerHTML =
+ '
' +
+ '
Restore session?
' +
+ '
Your previous tabs can be restored.
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
';
+
+ document.body.appendChild(modal);
+
+ var acceptBtn = modal.querySelector('[data-action="accept"]');
+ var declineBtn = modal.querySelector('[data-action="decline"]');
+
+ acceptBtn.addEventListener('click', function() {
+ window.bridge?.acceptSessionRestore();
+ modal.remove();
+ });
+
+ declineBtn.addEventListener('click', function() {
+ window.bridge?.declineSessionRestore();
+ modal.remove();
+ });
+ }
+
+ /**
+ * Show a toast indicating a world is being reloaded after memory reclamation.
+ */
+ function showReloadingToast() {
+ showToast('Reloading world...', 'info', 5000);
+ }
+
/**
* Escape HTML to prevent XSS
*/
@@ -2210,7 +2798,39 @@
updateConsole,
addConsoleLine,
updateSettings,
- updateAboutInfo
+ updateAboutInfo,
+ // Mobile API
+ setSafeArea,
+ setChromePosition,
+ setOrientation,
+ setKeyboardState,
+ // Swipe & auto-hide API
+ evaluateSwipe,
+ handleSwipeTabSwitch,
+ startAutoHideTimer,
+ resetAutoHideTimer,
+ stopAutoHideTimer,
+ isEdgeTap,
+ handleEdgeTap,
+ // Back navigation API
+ setPlatform,
+ evaluateBackAction,
+ handlePlatformBack,
+ // Tab limit API
+ setMobileTabLimit,
+ getMobileTabLimit,
+ canOpenNewTab,
+ handleNewTab,
+ // Swipe-to-close API
+ evaluateTabSwipeDismiss,
+ handleTabSwipeDismiss,
+ // Long-press thumbnail API
+ handleTabLongPress,
+ cancelTabLongPress,
+ // Session restore API
+ restoreSession,
+ showRestorePrompt,
+ showReloadingToast
};
// Initialize when DOM is ready
diff --git a/Assets/StreamingAssets/TabUI/styles/components.css b/Assets/StreamingAssets/TabUI/styles/components.css
index 54d8880e..889f4824 100644
--- a/Assets/StreamingAssets/TabUI/styles/components.css
+++ b/Assets/StreamingAssets/TabUI/styles/components.css
@@ -75,6 +75,125 @@
right: var(--spacing-lg);
}
+/* Mobile mode - shared chrome layout */
+.mobile-mode .chrome {
+ left: 2vw;
+ right: 2vw;
+ padding-left: calc(var(--bar-padding-h) + var(--safe-area-left));
+ padding-right: calc(var(--bar-padding-h) + var(--safe-area-right));
+ transition: top 200ms ease, bottom 200ms ease, padding 200ms ease, opacity 200ms ease;
+}
+
+/* Mobile mode - content frame transitions for orientation changes */
+.mobile-mode .content-frame {
+ transition: top 200ms ease, bottom 200ms ease;
+}
+
+/* Mobile mode - bottom-anchored chrome (default) */
+.mobile-mode.chrome-bottom .chrome {
+ top: auto;
+ bottom: var(--safe-area-bottom);
+}
+
+/* Mobile mode - top-anchored chrome */
+.mobile-mode.chrome-top .chrome {
+ bottom: auto;
+ top: var(--safe-area-top);
+}
+
+/* Mobile mode - content frame (bottom chrome) */
+.mobile-mode.chrome-bottom .content-frame {
+ top: 0;
+ bottom: calc(var(--bar-height) + var(--safe-area-bottom));
+}
+
+/* Mobile mode - content frame (top chrome) */
+.mobile-mode.chrome-top .content-frame {
+ top: calc(var(--bar-height) + var(--safe-area-top));
+ bottom: 0;
+}
+
+/* Mobile mode - keyboard open: reposition bottom chrome above keyboard */
+.mobile-mode.chrome-bottom.keyboard-open .chrome {
+ bottom: var(--keyboard-height);
+}
+
+/* Mobile mode - hide desktop-only elements and nav back/forward */
+.mobile-mode #btn-fullscreen,
+.mobile-mode #btn-vr {
+ display: none;
+}
+.mobile-mode .nav-btn-wrapper {
+ display: none;
+}
+
+/* Mobile mode - URL bar doesn't need min-width on narrow screens */
+.mobile-mode .url-bar-container {
+ min-width: 0;
+}
+
+/* Mobile mode - touch target minimums */
+.mobile-mode .nav-btn,
+.mobile-mode .tabs-button,
+.mobile-mode .menu-btn {
+ min-width: var(--touch-target-min);
+ min-height: var(--touch-target-min);
+}
+
+/* Mobile mode - scale SVG icons to fill buttons properly */
+.mobile-mode .nav-btn svg {
+ width: max(3vh, 36px);
+ height: max(3vh, 36px);
+}
+.mobile-mode .tabs-button__icon svg {
+ width: max(3.5vh, 40px);
+ height: max(3.5vh, 40px);
+}
+
+/* Mobile mode - URL bar sizing */
+.mobile-mode .url-bar {
+ font-size: max(1.6vh, 16px);
+ height: max(5vh, 44px);
+ border-radius: max(2.5vh, 22px);
+ padding: 0 max(1.5vw, 12px);
+}
+
+/* Mobile mode - chrome bar inset from screen edges */
+.mobile-mode .chrome {
+ left: 2vw;
+ right: 2vw;
+}
+
+/* Mobile mode - tab item 56px height (MFR10) */
+.mobile-mode .tab-item {
+ min-width: var(--touch-target-min);
+ min-height: 56px;
+}
+
+/* Mobile mode - always-visible close button (no hover on touch) */
+.mobile-mode .tab-item__close {
+ opacity: 1;
+}
+
+/* Mobile mode - smaller thumbnail preview (MNFR11) */
+.mobile-mode .thumbnail-preview {
+ width: 128px;
+ height: 72px;
+}
+
+/* Mobile mode - swipe-to-close animation states */
+.tab-item--swiping {
+ transition: none;
+}
+
+.tab-item--snapping {
+ transition: transform 200ms ease, opacity 200ms ease;
+}
+
+.tab-item--dismissing {
+ transition: transform 200ms ease, opacity 200ms ease;
+}
+
/* Chrome visibility states */
.chrome--hidden {
opacity: 0;
@@ -329,6 +448,18 @@
bottom: calc(100% + var(--spacing-sm));
}
+/* Mobile mode - dropdowns expand upward (bottom-anchored chrome) */
+.mobile-mode.chrome-bottom .dropdown {
+ top: auto;
+ bottom: calc(100% + var(--spacing-sm));
+}
+
+/* Mobile mode - dropdowns expand downward (top-anchored chrome) */
+.mobile-mode.chrome-top .dropdown {
+ bottom: auto;
+ top: calc(100% + var(--spacing-sm));
+}
+
/* Tab Dropdown */
.tab-dropdown {
left: 0;
@@ -457,6 +588,11 @@
color: var(--color-error);
}
+/* Tab suspended state (memory evicted) */
+.tab-item--suspended {
+ opacity: 0.6;
+}
+
/* New Tab Button */
.tab-item--new {
width: 100%;
@@ -531,6 +667,34 @@
top: var(--spacing-lg);
}
+/* Mobile mode - toast transition for orientation changes */
+.mobile-mode .toast-container {
+ transition: top 200ms ease, bottom 200ms ease;
+}
+
+/* Mobile mode - toasts above chrome bar (bottom chrome) */
+.mobile-mode.chrome-bottom .toast-container {
+ bottom: calc(var(--bar-height) + var(--safe-area-bottom) + var(--spacing-md));
+ top: auto;
+ left: var(--spacing-md);
+ right: var(--spacing-md);
+ transform: none;
+}
+
+/* Mobile mode - toasts above chrome bar when keyboard is open (bottom chrome) */
+.mobile-mode.chrome-bottom.keyboard-open .toast-container {
+ bottom: calc(var(--bar-height) + var(--keyboard-height) + var(--spacing-md));
+}
+
+/* Mobile mode - toasts below chrome bar (top chrome) */
+.mobile-mode.chrome-top .toast-container {
+ top: calc(var(--bar-height) + var(--safe-area-top) + var(--spacing-md));
+ bottom: auto;
+ left: var(--spacing-md);
+ right: var(--spacing-md);
+ transform: none;
+}
+
.toast {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-background);
@@ -1204,6 +1368,11 @@
bottom: auto;
}
+.mobile-mode .stats-hud {
+ top: var(--spacing-md);
+ bottom: auto;
+}
+
.stats-hud__header {
display: flex;
align-items: center;
@@ -1577,3 +1746,77 @@
width: 24px;
height: 24px;
}
+
+/* Session Restore Prompt */
+.restore-prompt-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+}
+
+.restore-prompt-content {
+ background: var(--color-background);
+ backdrop-filter: blur(var(--glass-blur));
+ -webkit-backdrop-filter: blur(var(--glass-blur));
+ border: var(--glass-border);
+ border-radius: 12px;
+ padding: 24px;
+ min-width: 280px;
+ max-width: 360px;
+ box-shadow: var(--glass-shadow);
+ text-align: center;
+}
+
+.restore-prompt-content h3 {
+ margin: 0 0 8px 0;
+ color: var(--color-text-primary);
+ font-size: var(--font-size-lg);
+ font-family: var(--font-family);
+}
+
+.restore-prompt-content p {
+ margin: 0 0 20px 0;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-base);
+ font-family: var(--font-family);
+}
+
+.restore-prompt-actions {
+ display: flex;
+ gap: 12px;
+ justify-content: center;
+}
+
+.restore-prompt-actions .btn {
+ padding: 8px 20px;
+ border-radius: 8px;
+ font-size: var(--font-size-base);
+ font-family: var(--font-family);
+ cursor: pointer;
+ border: none;
+ transition: opacity 150ms ease;
+}
+
+.restore-prompt-actions .btn:hover {
+ opacity: 0.85;
+}
+
+.restore-prompt-actions .btn--primary {
+ background: var(--color-accent);
+ color: #fff;
+}
+
+.restore-prompt-actions .btn--secondary {
+ background: var(--color-surface);
+ color: var(--color-text-primary);
+ border: var(--glass-border);
+}
diff --git a/Assets/StreamingAssets/TabUI/styles/tokens.css b/Assets/StreamingAssets/TabUI/styles/tokens.css
index 7c3d6806..851a072b 100644
--- a/Assets/StreamingAssets/TabUI/styles/tokens.css
+++ b/Assets/StreamingAssets/TabUI/styles/tokens.css
@@ -85,6 +85,39 @@
--spacing-md: 24px;
}
+/* Mobile Mode - viewport-relative for correct sizing regardless of DPR.
+ max() ensures values never shrink below sensible px minimums in Editor simulators. */
+.mobile-mode {
+ --bar-height: max(7vh, 80px);
+ --bar-padding-h: max(1.5vw, 12px);
+ --bar-padding-v: max(1vh, 8px);
+ --bar-radius: max(3.5vh, 40px);
+ --tabs-button-size: max(6.5vh, 64px);
+ --nav-btn-size: max(4.5vh, 48px);
+ --tab-icon-size: max(3vh, 32px);
+ --font-size-md: max(1.6vh, 16px);
+ --font-size-lg: max(1.8vh, 18px);
+ --spacing-md: max(0.8vh, 8px);
+ --touch-target-min: max(4.5vh, 48px);
+ --gap-button-bar: max(0.5vh, 4px);
+ --safe-area-top: 0px;
+ --safe-area-bottom: 0px;
+ --safe-area-left: 0px;
+ --safe-area-right: 0px;
+ --keyboard-height: 0px;
+}
+
+/* Tablet Mode - Wider spacing on larger screens */
+.mobile-mode.tablet-mode {
+ --bar-height: 56px;
+ --bar-padding-h: 24px;
+ --bar-padding-v: 10px;
+ --tabs-button-size: 52px;
+ --nav-btn-size: 48px;
+ --spacing-md: 16px;
+ --touch-target-min: 48px;
+}
+
/* Light Mode */
.light-mode {
--color-background: rgba(255, 255, 255, 0.85);
diff --git a/Assets/StreamingAssets/TabUI/tests.meta b/Assets/StreamingAssets/TabUI/tests.meta
new file mode 100644
index 00000000..2de983a4
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: c29d5c91d74cfb34b9cab69f56fa7cdb
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/auto-hide.test.js b/Assets/StreamingAssets/TabUI/tests/auto-hide.test.js
new file mode 100644
index 00000000..ee55fb6c
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/auto-hide.test.js
@@ -0,0 +1,126 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { loadUI, cleanupUI } from './setup.js';
+
+describe('auto-hide timer', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ vi.useRealTimers();
+ });
+
+ it('should hide chrome after 3000ms when startAutoHideTimer is called', () => {
+ tabUI.showChrome();
+ const chrome = document.querySelector('.chrome');
+ expect(chrome.classList.contains('chrome--visible')).toBe(true);
+ tabUI.startAutoHideTimer();
+ vi.advanceTimersByTime(2999);
+ expect(chrome.classList.contains('chrome--visible')).toBe(true);
+ vi.advanceTimersByTime(1);
+ expect(chrome.classList.contains('chrome--hidden')).toBe(true);
+ });
+
+ it('should reset timer when resetAutoHideTimer is called', () => {
+ tabUI.showChrome();
+ const chrome = document.querySelector('.chrome');
+ tabUI.startAutoHideTimer();
+ vi.advanceTimersByTime(2000);
+ tabUI.resetAutoHideTimer();
+ vi.advanceTimersByTime(2000);
+ // Should still be visible (timer was reset, only 2s into new 3s timer)
+ expect(chrome.classList.contains('chrome--visible')).toBe(true);
+ vi.advanceTimersByTime(1000);
+ expect(chrome.classList.contains('chrome--hidden')).toBe(true);
+ });
+
+ it('should cancel timer without restarting when stopAutoHideTimer is called', () => {
+ tabUI.showChrome();
+ const chrome = document.querySelector('.chrome');
+ tabUI.startAutoHideTimer();
+ vi.advanceTimersByTime(1000);
+ tabUI.stopAutoHideTimer();
+ vi.advanceTimersByTime(5000);
+ // Chrome should still be visible — timer was stopped
+ expect(chrome.classList.contains('chrome--visible')).toBe(true);
+ });
+
+ it('should NOT start timer when keyboard is open', () => {
+ tabUI.showChrome();
+ const chrome = document.querySelector('.chrome');
+ tabUI.setKeyboardState({ visible: true, height: 300 });
+ tabUI.startAutoHideTimer();
+ vi.advanceTimersByTime(5000);
+ // Chrome should still be visible — keyboard suppresses auto-hide
+ expect(chrome.classList.contains('chrome--visible')).toBe(true);
+ });
+});
+
+describe('isEdgeTap pure function', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should return true for tapY < 20 when chromePosition is top', () => {
+ expect(tabUI.isEdgeTap(10, 800, 'top')).toBe(true);
+ });
+
+ it('should return true for tapY > (screenHeight - 20) when chromePosition is bottom', () => {
+ expect(tabUI.isEdgeTap(790, 800, 'bottom')).toBe(true);
+ });
+
+ it('should return false for center-screen tap', () => {
+ expect(tabUI.isEdgeTap(400, 800, 'bottom')).toBe(false);
+ expect(tabUI.isEdgeTap(400, 800, 'top')).toBe(false);
+ });
+
+ it('should return false for tapY < 20 when chromePosition is bottom', () => {
+ expect(tabUI.isEdgeTap(10, 800, 'bottom')).toBe(false);
+ });
+
+ it('should return false for tapY > (screenHeight - 20) when chromePosition is top', () => {
+ expect(tabUI.isEdgeTap(790, 800, 'top')).toBe(false);
+ });
+});
+
+describe('handleEdgeTap', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should show chrome when hidden and edge tap detected at matching position', () => {
+ tabUI.setChromePosition('bottom');
+ tabUI.hideChrome();
+ tabUI.handleEdgeTap(790, 800);
+ // Chrome should be visible again
+ const chrome = document.querySelector('.chrome');
+ expect(chrome.classList.contains('chrome--visible')).toBe(true);
+ });
+
+ it('should NOT show chrome when tap is not at edge', () => {
+ tabUI.setChromePosition('bottom');
+ tabUI.hideChrome();
+ tabUI.handleEdgeTap(400, 800);
+ const chrome = document.querySelector('.chrome');
+ expect(chrome.classList.contains('chrome--hidden')).toBe(true);
+ });
+});
diff --git a/Assets/StreamingAssets/TabUI/tests/auto-hide.test.js.meta b/Assets/StreamingAssets/TabUI/tests/auto-hide.test.js.meta
new file mode 100644
index 00000000..3d3d3961
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/auto-hide.test.js.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 392d5a61b1d37114bb721c6b36486f57
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/back-navigation.test.js b/Assets/StreamingAssets/TabUI/tests/back-navigation.test.js
new file mode 100644
index 00000000..b4891ad8
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/back-navigation.test.js
@@ -0,0 +1,163 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { loadUI, cleanupUI } from './setup.js';
+
+describe('evaluateBackAction pure function', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should return navigate-back when Android has back history', () => {
+ const result = tabUI.evaluateBackAction({ canGoBack: true, chromeVisible: true, hasOverlayOpen: false, platform: 'android' });
+ expect(result).toEqual({ action: 'navigate-back' });
+ });
+
+ it('should return hide-chrome when Android has no history but chrome visible', () => {
+ const result = tabUI.evaluateBackAction({ canGoBack: false, chromeVisible: true, hasOverlayOpen: false, platform: 'android' });
+ expect(result).toEqual({ action: 'hide-chrome' });
+ });
+
+ it('should return show-exit-dialog when Android has no history and chrome hidden', () => {
+ const result = tabUI.evaluateBackAction({ canGoBack: false, chromeVisible: false, hasOverlayOpen: false, platform: 'android' });
+ expect(result).toEqual({ action: 'show-exit-dialog' });
+ });
+
+ it('should return navigate-back when iOS has back history', () => {
+ const result = tabUI.evaluateBackAction({ canGoBack: true, chromeVisible: true, hasOverlayOpen: false, platform: 'ios' });
+ expect(result).toEqual({ action: 'navigate-back' });
+ });
+
+ it('should return hide-chrome when iOS has no history but chrome visible', () => {
+ const result = tabUI.evaluateBackAction({ canGoBack: false, chromeVisible: true, hasOverlayOpen: false, platform: 'ios' });
+ expect(result).toEqual({ action: 'hide-chrome' });
+ });
+
+ it('should return none when iOS has no history and chrome hidden (iOS handles exit)', () => {
+ const result = tabUI.evaluateBackAction({ canGoBack: false, chromeVisible: false, hasOverlayOpen: false, platform: 'ios' });
+ expect(result).toEqual({ action: 'none' });
+ });
+
+ it('should return close-overlay when any overlay is open (highest priority)', () => {
+ const result = tabUI.evaluateBackAction({ canGoBack: true, chromeVisible: true, hasOverlayOpen: true, platform: 'android' });
+ expect(result).toEqual({ action: 'close-overlay' });
+ });
+
+ it('should return close-overlay even with no history when overlay open', () => {
+ const result = tabUI.evaluateBackAction({ canGoBack: false, chromeVisible: false, hasOverlayOpen: true, platform: 'ios' });
+ expect(result).toEqual({ action: 'close-overlay' });
+ });
+
+ it('should return none for null/undefined/empty input (defensive)', () => {
+ expect(tabUI.evaluateBackAction(null)).toEqual({ action: 'none' });
+ expect(tabUI.evaluateBackAction(undefined)).toEqual({ action: 'none' });
+ expect(tabUI.evaluateBackAction({})).toEqual({ action: 'none' });
+ });
+
+ it('should return none for desktop platform', () => {
+ const result = tabUI.evaluateBackAction({ canGoBack: true, chromeVisible: true, hasOverlayOpen: false, platform: 'desktop' });
+ expect(result).toEqual({ action: 'none' });
+ });
+
+ it('should ignore invalid platform in setPlatform and keep previous value', () => {
+ tabUI.setPlatform('android');
+ // Verify android is set by checking evaluateBackAction behavior
+ const androidResult = tabUI.evaluateBackAction({ canGoBack: false, chromeVisible: false, hasOverlayOpen: false, platform: 'android' });
+ expect(androidResult).toEqual({ action: 'show-exit-dialog' });
+ // Now set invalid — should be ignored
+ tabUI.setPlatform('invalid');
+ tabUI.setPlatform(null);
+ tabUI.setPlatform(undefined);
+ // Platform should still be android — verify via handlePlatformBack triggering exit dialog
+ tabUI.updateNavState(false, false, false);
+ tabUI.hideChrome();
+ window.bridge = { goBack: vi.fn(), showExitDialog: vi.fn(), notifyThemeChange: vi.fn(), switchTab: vi.fn(), notifyOverlayOpened: vi.fn(), notifyOverlayClosed: vi.fn() };
+ tabUI.handlePlatformBack();
+ expect(window.bridge.showExitDialog).toHaveBeenCalled();
+ delete window.bridge;
+ });
+});
+
+describe('handlePlatformBack integration', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ // Set up mock bridge (loadUI doesn't load bridge.js)
+ window.bridge = {
+ goBack: vi.fn(),
+ showExitDialog: vi.fn(),
+ notifyThemeChange: vi.fn(),
+ switchTab: vi.fn(),
+ notifyOverlayOpened: vi.fn(),
+ notifyOverlayClosed: vi.fn()
+ };
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ delete window.bridge;
+ });
+
+ it('should call bridge.goBack when canGoBack is true', () => {
+ tabUI.setPlatform('android');
+ tabUI.updateNavState(true, false, false);
+ tabUI.showChrome();
+ tabUI.handlePlatformBack();
+ expect(window.bridge.goBack).toHaveBeenCalled();
+ });
+
+ it('should hide chrome when canGoBack is false and chrome is visible', () => {
+ tabUI.setPlatform('android');
+ tabUI.updateNavState(false, false, false);
+ tabUI.showChrome();
+ tabUI.handlePlatformBack();
+ const chrome = document.querySelector('.chrome');
+ expect(chrome.classList.contains('chrome--hidden')).toBe(true);
+ });
+
+ it('should call bridge.showExitDialog when Android, no history, chrome hidden', () => {
+ tabUI.setPlatform('android');
+ tabUI.updateNavState(false, false, false);
+ tabUI.hideChrome();
+ tabUI.handlePlatformBack();
+ expect(window.bridge.showExitDialog).toHaveBeenCalled();
+ });
+
+ it('should do nothing on iOS when no history and chrome hidden (iOS handles exit)', () => {
+ tabUI.setPlatform('ios');
+ tabUI.updateNavState(false, false, false);
+ tabUI.hideChrome();
+ tabUI.handlePlatformBack();
+ // Chrome should stay hidden (no exit dialog on iOS)
+ const chrome = document.querySelector('.chrome');
+ expect(chrome.classList.contains('chrome--hidden')).toBe(true);
+ expect(window.bridge.showExitDialog).not.toHaveBeenCalled();
+ expect(window.bridge.goBack).not.toHaveBeenCalled();
+ });
+
+ it('should close overlay when dropdown is open', () => {
+ tabUI.setPlatform('android');
+ tabUI.updateNavState(true, false, false);
+ tabUI.showChrome();
+ // Open tab dropdown — click triggers toggleTabDropdown which sets
+ // state.tabDropdownOpen=true and display='block' synchronously
+ const tabsButton = document.querySelector('.tabs-button');
+ expect(tabsButton).not.toBeNull();
+ tabsButton.click();
+ // Verify dropdown opened (display set synchronously, class via rAF)
+ const tabDropdown = document.getElementById('tab-dropdown');
+ expect(tabDropdown.style.display).toBe('block');
+ expect(tabsButton.getAttribute('aria-expanded')).toBe('true');
+ tabUI.handlePlatformBack();
+ // closeAllDropdowns → closeTabDropdown sets aria-expanded=false synchronously
+ // (style.display='none' is deferred via setTimeout(200), so check aria instead)
+ expect(tabsButton.getAttribute('aria-expanded')).toBe('false');
+ });
+});
diff --git a/Assets/StreamingAssets/TabUI/tests/back-navigation.test.js.meta b/Assets/StreamingAssets/TabUI/tests/back-navigation.test.js.meta
new file mode 100644
index 00000000..4c0d8c61
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/back-navigation.test.js.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 60724613a286ae742a95d4eb63a9c891
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/bridge.test.js b/Assets/StreamingAssets/TabUI/tests/bridge.test.js
new file mode 100644
index 00000000..cec58f14
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/bridge.test.js
@@ -0,0 +1,199 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { loadUI, cleanupUI } from './setup.js';
+
+const bridgeJsPath = resolve(__dirname, '../scripts/bridge.js');
+const bridgeJsSource = readFileSync(bridgeJsPath, 'utf-8');
+
+/**
+ * Load bridge.js after ui.js is loaded.
+ * Bridge expects window.tabUI to exist.
+ */
+function loadBridge() {
+ const fn = new Function(bridgeJsSource);
+ fn.call(window);
+}
+
+/**
+ * Simulate a message from Unity to the bridge.
+ */
+function simulateUnityMessage(data) {
+ if (window.vuplex && window.vuplex.simulateMessage) {
+ window.vuplex.simulateMessage(data);
+ } else {
+ // Dispatch message event on vuplex
+ const handlers = window.vuplex?._listeners?.message || [];
+ const event = { data: JSON.stringify(data) };
+ handlers.forEach(h => h(event));
+ }
+}
+
+describe('bridge setMode message handling', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ loadBridge();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should call setMode("mobile") when bridge receives setMode message', () => {
+ const spy = vi.spyOn(tabUI, 'setMode');
+ simulateUnityMessage({ type: 'setMode', mode: 'mobile' });
+ expect(spy).toHaveBeenCalledWith('mobile');
+ spy.mockRestore();
+ });
+
+ it('should call setMode("tablet") when bridge receives setMode message', () => {
+ const spy = vi.spyOn(tabUI, 'setMode');
+ simulateUnityMessage({ type: 'setMode', mode: 'tablet' });
+ expect(spy).toHaveBeenCalledWith('tablet');
+ spy.mockRestore();
+ });
+
+ it('should handle unknown mode gracefully without crashing', () => {
+ expect(() => {
+ simulateUnityMessage({ type: 'setMode', mode: 'unknown_mode' });
+ }).not.toThrow();
+ });
+});
+
+describe('bridge setSafeArea message handling', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ loadBridge();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should call setSafeArea with insets when bridge receives setSafeArea message', () => {
+ const spy = vi.spyOn(tabUI, 'setSafeArea');
+ const insets = { top: 44, bottom: 34, left: 0, right: 0 };
+ simulateUnityMessage({ type: 'setSafeArea', ...insets });
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining(insets));
+ spy.mockRestore();
+ });
+
+ it('should call setChromePosition when bridge receives setChromePosition message', () => {
+ const spy = vi.spyOn(tabUI, 'setChromePosition');
+ simulateUnityMessage({ type: 'setChromePosition', position: 'top' });
+ expect(spy).toHaveBeenCalledWith('top');
+ spy.mockRestore();
+ });
+
+ it('should call setOrientation when bridge receives setOrientation message', () => {
+ const spy = vi.spyOn(tabUI, 'setOrientation');
+ simulateUnityMessage({ type: 'setOrientation', orientation: 'landscape' });
+ expect(spy).toHaveBeenCalledWith('landscape');
+ spy.mockRestore();
+ });
+
+ it('should call setKeyboardState when bridge receives setKeyboardState message', () => {
+ const spy = vi.spyOn(tabUI, 'setKeyboardState');
+ simulateUnityMessage({ type: 'setKeyboardState', visible: true, height: 300 });
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining({ visible: true, height: 300 }));
+ spy.mockRestore();
+ });
+});
+
+describe('bridge auto-hide message handling', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ loadBridge();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should call startAutoHideTimer when bridge receives startAutoHide message', () => {
+ const spy = vi.spyOn(tabUI, 'startAutoHideTimer');
+ simulateUnityMessage({ type: 'startAutoHide' });
+ expect(spy).toHaveBeenCalled();
+ spy.mockRestore();
+ });
+
+ it('should call stopAutoHideTimer when bridge receives stopAutoHide message', () => {
+ const spy = vi.spyOn(tabUI, 'stopAutoHideTimer');
+ simulateUnityMessage({ type: 'stopAutoHide' });
+ expect(spy).toHaveBeenCalled();
+ spy.mockRestore();
+ });
+
+ it('should call handleEdgeTap when bridge receives edgeTap message', () => {
+ const spy = vi.spyOn(tabUI, 'handleEdgeTap');
+ simulateUnityMessage({ type: 'edgeTap', y: 10, screenHeight: 800 });
+ expect(spy).toHaveBeenCalledWith(10, 800);
+ spy.mockRestore();
+ });
+
+ it('should call handlePlatformBack when bridge receives platformBack message', () => {
+ const spy = vi.spyOn(tabUI, 'handlePlatformBack');
+ simulateUnityMessage({ type: 'platformBack' });
+ expect(spy).toHaveBeenCalled();
+ spy.mockRestore();
+ });
+
+ it('should call setPlatform when bridge receives setPlatform message', () => {
+ const spy = vi.spyOn(tabUI, 'setPlatform');
+ simulateUnityMessage({ type: 'setPlatform', platform: 'android' });
+ expect(spy).toHaveBeenCalledWith('android');
+ spy.mockRestore();
+ });
+});
+
+describe('bridge setMobileTabLimit message handling', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ loadBridge();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should call setMobileTabLimit when bridge receives setMobileTabLimit message', () => {
+ const spy = vi.spyOn(tabUI, 'setMobileTabLimit');
+ simulateUnityMessage({ type: 'setMobileTabLimit', limit: 5 });
+ expect(spy).toHaveBeenCalledWith(5);
+ spy.mockRestore();
+ });
+
+ it('should not force-close tabs when limit is set below current tab count', () => {
+ tabUI.updateTabs([
+ { id: 't1', title: 'Tab 1', url: 'https://a.com' },
+ { id: 't2', title: 'Tab 2', url: 'https://b.com' },
+ { id: 't3', title: 'Tab 3', url: 'https://c.com' },
+ { id: 't4', title: 'Tab 4', url: 'https://d.com' },
+ { id: 't5', title: 'Tab 5', url: 'https://e.com' }
+ ]);
+ tabUI.setActiveTab('t1');
+ window.bridge = {
+ closeTab: vi.fn(),
+ switchTab: vi.fn(),
+ newTab: vi.fn(),
+ notifyThemeChange: vi.fn(),
+ notifyOverlayOpened: vi.fn(),
+ notifyOverlayClosed: vi.fn()
+ };
+ simulateUnityMessage({ type: 'setMobileTabLimit', limit: 3 });
+ expect(window.bridge.closeTab).not.toHaveBeenCalled();
+ // All 5 tabs still present — limit only prevents new tabs
+ expect(tabUI.canOpenNewTab()).toBe(false);
+ delete window.bridge;
+ });
+});
diff --git a/Assets/StreamingAssets/TabUI/tests/bridge.test.js.meta b/Assets/StreamingAssets/TabUI/tests/bridge.test.js.meta
new file mode 100644
index 00000000..1f9f46c0
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/bridge.test.js.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: cbe32aa4377e4594abff06873565eb06
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/chrome-position.test.js b/Assets/StreamingAssets/TabUI/tests/chrome-position.test.js
new file mode 100644
index 00000000..72abb0bd
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/chrome-position.test.js
@@ -0,0 +1,92 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { loadUI, cleanupUI } from './setup.js';
+
+const componentsCss = readFileSync(resolve(__dirname, '../styles/components.css'), 'utf-8');
+
+/**
+ * Extract the content of a CSS rule block by selector.
+ */
+function extractBlock(css, selector) {
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const regex = new RegExp(escaped + '\\s*\\{([^}]*)\\}', 's');
+ const match = css.match(regex);
+ return match ? match[1] : null;
+}
+
+describe('setChromePosition', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ // Enter mobile mode first since chrome-position is mobile-only
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should default to chrome-bottom class in mobile mode', () => {
+ expect(document.body.classList.contains('chrome-bottom')).toBe(true);
+ expect(document.body.classList.contains('chrome-top')).toBe(false);
+ });
+
+ it('should add chrome-top class and remove chrome-bottom when set to top', () => {
+ tabUI.setChromePosition('top');
+ expect(document.body.classList.contains('chrome-top')).toBe(true);
+ expect(document.body.classList.contains('chrome-bottom')).toBe(false);
+ });
+
+ it('should add chrome-bottom class and remove chrome-top when set to bottom', () => {
+ tabUI.setChromePosition('top');
+ tabUI.setChromePosition('bottom');
+ expect(document.body.classList.contains('chrome-bottom')).toBe(true);
+ expect(document.body.classList.contains('chrome-top')).toBe(false);
+ });
+
+ it('should have CSS rule for bottom-positioned chrome with safe area offset', () => {
+ const block = extractBlock(componentsCss, '.mobile-mode.chrome-bottom .chrome');
+ expect(block).not.toBeNull();
+ expect(block).toContain('--safe-area-bottom');
+ });
+
+ it('should have CSS rule for top-positioned chrome with safe area offset', () => {
+ const block = extractBlock(componentsCss, '.mobile-mode.chrome-top .chrome');
+ expect(block).not.toBeNull();
+ expect(block).toContain('--safe-area-top');
+ });
+
+ it('should update state.chromePosition property', () => {
+ tabUI.setChromePosition('top');
+ // Verify by switching back and checking classes are consistent
+ tabUI.setChromePosition('bottom');
+ expect(document.body.classList.contains('chrome-bottom')).toBe(true);
+ });
+
+ it('should default to bottom when given invalid value', () => {
+ tabUI.setChromePosition('invalid');
+ expect(document.body.classList.contains('chrome-bottom')).toBe(true);
+ expect(document.body.classList.contains('chrome-top')).toBe(false);
+ });
+
+ it('should persist chrome position across mode switches', () => {
+ tabUI.setChromePosition('top');
+ expect(document.body.classList.contains('chrome-top')).toBe(true);
+
+ // Switch to desktop — position classes should be removed
+ tabUI.setMode('desktop');
+ expect(document.body.classList.contains('chrome-top')).toBe(false);
+ expect(document.body.classList.contains('chrome-bottom')).toBe(false);
+
+ // Switch back to mobile — 'top' should be re-applied from state
+ tabUI.setMode('mobile');
+ expect(document.body.classList.contains('chrome-top')).toBe(true);
+ expect(document.body.classList.contains('chrome-bottom')).toBe(false);
+ });
+
+ it('should be exposed on window.tabUI', () => {
+ expect(typeof tabUI.setChromePosition).toBe('function');
+ });
+});
diff --git a/Assets/StreamingAssets/TabUI/tests/chrome-position.test.js.meta b/Assets/StreamingAssets/TabUI/tests/chrome-position.test.js.meta
new file mode 100644
index 00000000..c045cfa9
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/chrome-position.test.js.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 83f1606791aa4024db0bc84000775ed0
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/gesture-conflict.test.js b/Assets/StreamingAssets/TabUI/tests/gesture-conflict.test.js
new file mode 100644
index 00000000..a4a620f2
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/gesture-conflict.test.js
@@ -0,0 +1,255 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { loadUI, cleanupUI } from './setup.js';
+
+// Phase 1: Scroll vs Swipe-to-Close Conflict Prevention (AC: #4)
+describe('evaluateTabSwipeDismiss with vertical component', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should return none when vertical motion dominates (scroll gesture)', () => {
+ // 85px horizontal, 100px vertical — angle ~49.6° (scroll dominates)
+ const result = tabUI.evaluateTabSwipeDismiss({ startX: 100, endX: 185, startY: 100, endY: 200 });
+ expect(result).toEqual({ action: 'none' });
+ });
+
+ it('should return dismiss when horizontal motion dominates', () => {
+ // 85px horizontal, 10px vertical — angle ~6.7° (swipe dominates)
+ const result = tabUI.evaluateTabSwipeDismiss({ startX: 100, endX: 185, startY: 100, endY: 110 });
+ expect(result).toEqual({ action: 'dismiss' });
+ });
+
+ it('should return none when angle exceeds 30 degrees', () => {
+ // 80px horizontal, 50px vertical — angle ~32° (diagonal, rejected)
+ const result = tabUI.evaluateTabSwipeDismiss({ startX: 100, endX: 180, startY: 100, endY: 150 });
+ expect(result).toEqual({ action: 'none' });
+ });
+
+ it('should return dismiss when angle is under 30 degrees and dx >= threshold', () => {
+ // 100px horizontal, 50px vertical — angle ~26.6° (just under 30°, valid)
+ const result = tabUI.evaluateTabSwipeDismiss({ startX: 100, endX: 200, startY: 100, endY: 150 });
+ expect(result).toEqual({ action: 'dismiss' });
+ });
+
+ it('should return none at exactly 30 degree boundary', () => {
+ // tan(30°) = 0.577, so for dx=80 → dy=46.2 → round to 47 for > 30°
+ // atan2(47, 80) ≈ 30.4° → should reject
+ const result = tabUI.evaluateTabSwipeDismiss({ startX: 100, endX: 180, startY: 100, endY: 147 });
+ expect(result).toEqual({ action: 'none' });
+ });
+
+ it('should still work without Y coordinates (backward compatible)', () => {
+ // No startY/endY — existing behavior preserved
+ const result = tabUI.evaluateTabSwipeDismiss({ startX: 100, endX: 180 });
+ expect(result).toEqual({ action: 'dismiss' });
+ });
+
+ it('should return none for pure vertical swipe (0 horizontal, 100 vertical)', () => {
+ const result = tabUI.evaluateTabSwipeDismiss({ startX: 100, endX: 100, startY: 100, endY: 200 });
+ expect(result).toEqual({ action: 'none' });
+ });
+});
+
+// Phase 2: Edge Zone and Existing Gesture Validation Regression Guards (AC: #1, #2, #3, #5)
+describe('gesture conflict regression guards', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ // AC1: iOS edge zone exclusion for evaluateSwipe
+ it('evaluateSwipe: should suppress swipe starting inside left edge zone (x=19)', () => {
+ const result = tabUI.evaluateSwipe({ startX: 19, startY: 200, endX: 119, endY: 200, screenWidth: 390 });
+ expect(result).toEqual({ action: 'none' });
+ });
+
+ it('evaluateSwipe: should suppress swipe starting at edge boundary (x=20, edge zone is < 20)', () => {
+ // EDGE_ZONE = 20, check is startX < EDGE_ZONE, so x=20 is allowed
+ const result = tabUI.evaluateSwipe({ startX: 20, startY: 200, endX: 120, endY: 200, screenWidth: 390 });
+ expect(result).toEqual({ action: 'switch-tab', direction: 'previous' });
+ });
+
+ it('evaluateSwipe: should allow swipe starting just outside edge zone (x=21)', () => {
+ const result = tabUI.evaluateSwipe({ startX: 21, startY: 200, endX: 121, endY: 200, screenWidth: 390 });
+ expect(result).toEqual({ action: 'switch-tab', direction: 'previous' });
+ });
+
+ // AC1: iOS right edge zone exclusion
+ it('evaluateSwipe: should suppress swipe starting inside right edge zone (x=371, screenWidth=390)', () => {
+ const result = tabUI.evaluateSwipe({ startX: 371, startY: 200, endX: 271, endY: 200, screenWidth: 390 });
+ expect(result).toEqual({ action: 'none' });
+ });
+
+ it('evaluateSwipe: should allow swipe starting at right edge boundary (x=370, screenWidth=390)', () => {
+ // screenWidth - EDGE_ZONE = 370, check is startX > 370, so x=370 is allowed
+ const result = tabUI.evaluateSwipe({ startX: 370, startY: 200, endX: 270, endY: 200, screenWidth: 390 });
+ expect(result).toEqual({ action: 'switch-tab', direction: 'next' });
+ });
+
+ // AC2: Threshold regression
+ it('evaluateSwipe: should return none for 39px swipe (below 40px threshold)', () => {
+ const result = tabUI.evaluateSwipe({ startX: 100, startY: 200, endX: 139, endY: 200, screenWidth: 390 });
+ expect(result).toEqual({ action: 'none' });
+ });
+
+ // AC3: Angle regression
+ it('evaluateSwipe: should return none for angle > 30 degrees', () => {
+ // dx=50, dy=42 → angle ~40° → rejected
+ const result = tabUI.evaluateSwipe({ startX: 100, startY: 200, endX: 150, endY: 242, screenWidth: 390 });
+ expect(result).toEqual({ action: 'none' });
+ });
+
+ // AC5: Center-screen tap doesn't reactivate chrome
+ it('isEdgeTap: center-screen tap should not activate chrome (bottom position)', () => {
+ expect(tabUI.isEdgeTap(400, 800, 'bottom')).toBe(false);
+ });
+
+ it('isEdgeTap: near-bottom-edge tap should activate chrome', () => {
+ expect(tabUI.isEdgeTap(790, 800, 'bottom')).toBe(true);
+ });
+
+ it('isEdgeTap: near-top-edge tap should activate chrome (top position)', () => {
+ expect(tabUI.isEdgeTap(5, 800, 'top')).toBe(true);
+ });
+
+ it('isEdgeTap: center-screen tap should not activate chrome (top position)', () => {
+ expect(tabUI.isEdgeTap(400, 800, 'top')).toBe(false);
+ });
+});
+
+// Phase 3: createTabElement Touch Event Wiring (AC: #4)
+describe('tab item touch event wiring', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ tabUI.updateTabs([
+ { id: 'tab1', title: 'Tab 1', url: 'https://one.com' },
+ { id: 'tab2', title: 'Tab 2', url: 'https://two.com' }
+ ]);
+ tabUI.setActiveTab('tab1');
+ window.bridge = {
+ closeTab: vi.fn(),
+ switchTab: vi.fn(),
+ newTab: vi.fn(),
+ requestThumbnail: vi.fn(),
+ notifyThemeChange: vi.fn(),
+ notifyOverlayOpened: vi.fn(),
+ notifyOverlayClosed: vi.fn()
+ };
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ delete window.bridge;
+ });
+
+ function getTabItem(tabId) {
+ // Tab items are rendered in the dropdown; open it first
+ const tabsButton = document.querySelector('.tabs-button');
+ if (tabsButton) tabsButton.click();
+ const items = document.querySelectorAll('.tab-item');
+ for (const item of items) {
+ if (item.getAttribute('aria-label') === tabId || item.textContent.includes(tabId)) {
+ return item;
+ }
+ }
+ // Fallback: return first non-active item
+ return items.length > 1 ? items[1] : items[0];
+ }
+
+ function createTouchEvent(type, clientX, clientY) {
+ return new TouchEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ touches: type === 'touchend' ? [] : [{ clientX, clientY, identifier: 0 }],
+ changedTouches: [{ clientX, clientY, identifier: 0 }]
+ });
+ }
+
+ it('should add tab-item--swiping class on touchstart', () => {
+ const item = getTabItem('tab2');
+ expect(item).toBeTruthy();
+ item.dispatchEvent(createTouchEvent('touchstart', 100, 200));
+ expect(item.classList.contains('tab-item--swiping')).toBe(true);
+ });
+
+ it('should apply translateX on horizontal touchmove exceeding 10px', () => {
+ const item = getTabItem('tab2');
+ expect(item).toBeTruthy();
+ item.dispatchEvent(createTouchEvent('touchstart', 100, 200));
+ item.dispatchEvent(createTouchEvent('touchmove', 115, 202));
+ // 15px horizontal, 2px vertical → angle ~7.6° → horizontal swipe mode
+ expect(item.style.transform).toContain('translateX');
+ });
+
+ it('should NOT apply translateX on vertical touchmove (scroll priority)', () => {
+ const item = getTabItem('tab2');
+ expect(item).toBeTruthy();
+ item.dispatchEvent(createTouchEvent('touchstart', 100, 200));
+ item.dispatchEvent(createTouchEvent('touchmove', 103, 250));
+ // 3px horizontal, 50px vertical → angle ~86.6° → vertical scroll, no swipe
+ expect(item.style.transform).not.toContain('translateX');
+ });
+
+ it('should call bridge.closeTab on touchend with sufficient horizontal swipe', () => {
+ const item = getTabItem('tab2');
+ expect(item).toBeTruthy();
+ item.dispatchEvent(createTouchEvent('touchstart', 100, 200));
+ // Move enough to trigger swipe mode
+ item.dispatchEvent(createTouchEvent('touchmove', 185, 205));
+ // End with 85px horizontal, 5px vertical → dismiss
+ item.dispatchEvent(createTouchEvent('touchend', 185, 205));
+ expect(window.bridge.closeTab).toHaveBeenCalled();
+ });
+
+ it('should snap back on touchend with insufficient horizontal swipe', () => {
+ const item = getTabItem('tab2');
+ expect(item).toBeTruthy();
+ item.dispatchEvent(createTouchEvent('touchstart', 100, 200));
+ item.dispatchEvent(createTouchEvent('touchmove', 130, 203));
+ item.dispatchEvent(createTouchEvent('touchend', 130, 203));
+ // 30px horizontal — below 80px threshold → snap back
+ expect(window.bridge.closeTab).not.toHaveBeenCalled();
+ expect(item.classList.contains('tab-item--snapping')).toBe(true);
+ });
+
+ it('should reset on touchcancel', () => {
+ const item = getTabItem('tab2');
+ expect(item).toBeTruthy();
+ item.dispatchEvent(createTouchEvent('touchstart', 100, 200));
+ item.dispatchEvent(createTouchEvent('touchmove', 150, 203));
+ item.dispatchEvent(createTouchEvent('touchcancel', 150, 203));
+ expect(item.style.transform).toBe('');
+ expect(item.style.opacity).toBe('');
+ expect(item.classList.contains('tab-item--snapping')).toBe(true);
+ });
+
+ it('should NOT switch from scroll to swipe when finger curves horizontally (gesture lock)', () => {
+ const item = getTabItem('tab2');
+ expect(item).toBeTruthy();
+ // Start touch
+ item.dispatchEvent(createTouchEvent('touchstart', 100, 200));
+ // First move: vertical dominant (50px vertical, 3px horizontal → ~86° → scroll locked)
+ item.dispatchEvent(createTouchEvent('touchmove', 103, 250));
+ expect(item.style.transform).not.toContain('translateX');
+ // Second move: cumulative now horizontal-ish (80px horizontal, 50px vertical → ~32°)
+ // But gesture was already locked to scroll — should NOT start swiping
+ item.dispatchEvent(createTouchEvent('touchmove', 180, 250));
+ expect(item.style.transform).not.toContain('translateX');
+ });
+});
diff --git a/Assets/StreamingAssets/TabUI/tests/gesture-conflict.test.js.meta b/Assets/StreamingAssets/TabUI/tests/gesture-conflict.test.js.meta
new file mode 100644
index 00000000..5f66fdb0
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/gesture-conflict.test.js.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: f1d2bcb2f359a8e40b2d50330ccde7b7
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/keyboard.test.js b/Assets/StreamingAssets/TabUI/tests/keyboard.test.js
new file mode 100644
index 00000000..a934c118
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/keyboard.test.js
@@ -0,0 +1,154 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { loadUI, cleanupUI } from './setup.js';
+
+const componentsCss = readFileSync(resolve(__dirname, '../styles/components.css'), 'utf-8');
+const tokensCss = readFileSync(resolve(__dirname, '../styles/tokens.css'), 'utf-8');
+
+/**
+ * Extract the content of a CSS rule block by selector.
+ */
+function extractBlock(css, selector) {
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const regex = new RegExp(escaped + '\\s*\\{([^}]*)\\}', 's');
+ const match = css.match(regex);
+ return match ? match[1] : null;
+}
+
+describe('keyboard state management', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should set --keyboard-height CSS variable when setKeyboardState is called with visible=true', () => {
+ tabUI.setKeyboardState({ visible: true, height: 300 });
+ const root = document.documentElement;
+ expect(root.style.getPropertyValue('--keyboard-height')).toBe('300px');
+ });
+
+ it('should add keyboard-open class to body when keyboard is visible', () => {
+ tabUI.setKeyboardState({ visible: true, height: 300 });
+ expect(document.body.classList.contains('keyboard-open')).toBe(true);
+ });
+
+ it('should remove keyboard-open class and reset --keyboard-height when keyboard is hidden', () => {
+ tabUI.setKeyboardState({ visible: true, height: 300 });
+ tabUI.setKeyboardState({ visible: false, height: 0 });
+ expect(document.body.classList.contains('keyboard-open')).toBe(false);
+ expect(document.documentElement.style.getPropertyValue('--keyboard-height')).toBe('0px');
+ });
+
+ it('should update --keyboard-height when keyboard resizes without removing keyboard-open', () => {
+ tabUI.setKeyboardState({ visible: true, height: 300 });
+ tabUI.setKeyboardState({ visible: true, height: 350 });
+ expect(document.body.classList.contains('keyboard-open')).toBe(true);
+ expect(document.documentElement.style.getPropertyValue('--keyboard-height')).toBe('350px');
+ });
+
+ it('should update state.keyboardVisible and state.keyboardHeight via CSS variable side effects', () => {
+ expect(typeof tabUI.setKeyboardState).toBe('function');
+ // Verify state update through CSS variable (proves state.keyboardHeight was set)
+ tabUI.setKeyboardState({ visible: true, height: 250 });
+ expect(document.documentElement.style.getPropertyValue('--keyboard-height')).toBe('250px');
+ expect(document.body.classList.contains('keyboard-open')).toBe(true);
+ // Verify hidden state resets both
+ tabUI.setKeyboardState({ visible: false, height: 0 });
+ expect(document.documentElement.style.getPropertyValue('--keyboard-height')).toBe('0px');
+ expect(document.body.classList.contains('keyboard-open')).toBe(false);
+ });
+
+ it('should handle null/undefined argument defensively', () => {
+ expect(() => tabUI.setKeyboardState(null)).not.toThrow();
+ expect(() => tabUI.setKeyboardState(undefined)).not.toThrow();
+ expect(() => tabUI.setKeyboardState({})).not.toThrow();
+ // After defensive calls, keyboard should be hidden
+ expect(document.body.classList.contains('keyboard-open')).toBe(false);
+ expect(document.documentElement.style.getPropertyValue('--keyboard-height')).toBe('0px');
+ });
+});
+
+describe('keyboard CSS rules', () => {
+ it('should have CSS rule for .mobile-mode.chrome-bottom.keyboard-open .chrome with bottom referencing --keyboard-height', () => {
+ const block = extractBlock(componentsCss, '.mobile-mode.chrome-bottom.keyboard-open .chrome');
+ expect(block).not.toBeNull();
+ expect(block).toContain('--keyboard-height');
+ });
+
+ it('should NOT reposition chrome-top when keyboard is open', () => {
+ // chrome-top should NOT have a keyboard-open override that changes top
+ const block = extractBlock(componentsCss, '.mobile-mode.chrome-top.keyboard-open .chrome');
+ // Either the rule doesn't exist, or if it does, it shouldn't change top
+ if (block) {
+ expect(block).not.toContain('top:');
+ }
+ });
+
+ it('should have --keyboard-height default in tokens.css', () => {
+ expect(tokensCss).toContain('--keyboard-height');
+ });
+
+ it('should reposition toast container above keyboard when bottom chrome and keyboard open', () => {
+ const block = extractBlock(componentsCss, '.mobile-mode.chrome-bottom.keyboard-open .toast-container');
+ expect(block).not.toBeNull();
+ expect(block).toContain('--keyboard-height');
+ });
+});
+
+describe('keyboard and content frame', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should NOT have content-frame resize rule when keyboard is open (MNFR7)', () => {
+ // Content frame must NOT resize when keyboard opens
+ const block = extractBlock(componentsCss, '.mobile-mode.keyboard-open .content-frame');
+ // Rule should either not exist or not change height/top/bottom
+ if (block) {
+ expect(block).not.toMatch(/\b(height|top|bottom)\s*:/);
+ }
+ });
+});
+
+describe('keyboard bridge integration', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should handle Enter key in URL bar by blurring (dismissing keyboard)', () => {
+ // Mock bridge.navigate since bridge.js is not loaded in test environment
+ window.bridge = { navigate: function() {} };
+
+ const urlBar = document.getElementById('url-bar');
+ expect(urlBar).not.toBeNull();
+ urlBar.value = 'https://example.com';
+ urlBar.focus();
+
+ const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
+ urlBar.dispatchEvent(event);
+
+ // After Enter, URL bar should have been blurred (blur dismisses keyboard on mobile)
+ expect(document.activeElement).not.toBe(urlBar);
+ });
+});
diff --git a/Assets/StreamingAssets/TabUI/tests/keyboard.test.js.meta b/Assets/StreamingAssets/TabUI/tests/keyboard.test.js.meta
new file mode 100644
index 00000000..6c582c02
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/keyboard.test.js.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 995f512c6aa9d474999c40f3b037acc0
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/memory-pressure.test.js b/Assets/StreamingAssets/TabUI/tests/memory-pressure.test.js
new file mode 100644
index 00000000..b42f382d
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/memory-pressure.test.js
@@ -0,0 +1,151 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { loadUI, cleanupUI } from './setup.js';
+
+const bridgeJsPath = resolve(__dirname, '../scripts/bridge.js');
+const bridgeJsSource = readFileSync(bridgeJsPath, 'utf-8');
+
+function loadBridge() {
+ const fn = new Function(bridgeJsSource);
+ fn.call(window);
+}
+
+function simulateUnityMessage(data) {
+ if (window.vuplex && window.vuplex.simulateMessage) {
+ window.vuplex.simulateMessage(data);
+ } else {
+ const handlers = window.vuplex?._listeners?.message || [];
+ const event = { data: JSON.stringify(data) };
+ handlers.forEach(h => h(event));
+ }
+}
+
+describe('suspended tab rendering', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ loadBridge();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('tab with loadState suspended gets tab-item--suspended class on correct element', () => {
+ tabUI.updateTabs([
+ { id: 'tab-1', url: 'http://a.com', displayName: 'World A', loadState: 'suspended', isActive: false },
+ { id: 'tab-2', url: 'http://b.com', displayName: 'World B', loadState: 'loaded', isActive: true }
+ ]);
+
+ const suspendedTab = document.querySelector('[aria-label="World A (suspended)"]');
+ expect(suspendedTab).toBeTruthy();
+ expect(suspendedTab.classList.contains('tab-item--suspended')).toBe(true);
+
+ const loadedTab = document.querySelector('[aria-label="World B"]');
+ expect(loadedTab).toBeTruthy();
+ expect(loadedTab.classList.contains('tab-item--suspended')).toBe(false);
+ });
+
+ it('updateTabLoadState with suspended adds suspended class to correct tab', () => {
+ tabUI.updateTabs([
+ { id: 'tab-1', url: 'http://a.com', displayName: 'World A', loadState: 'loaded', isActive: false },
+ { id: 'tab-2', url: 'http://b.com', displayName: 'World B', loadState: 'loaded', isActive: true }
+ ]);
+
+ // No suspended tabs initially
+ expect(document.querySelectorAll('.tab-item--suspended').length).toBe(0);
+
+ tabUI.updateTabLoadState('tab-1', 'suspended');
+
+ const suspendedTab = document.querySelector('[aria-label="World A (suspended)"]');
+ expect(suspendedTab).toBeTruthy();
+ expect(suspendedTab.classList.contains('tab-item--suspended')).toBe(true);
+ });
+
+ it('tab list renders mix of loaded and suspended tabs correctly', () => {
+ tabUI.updateTabs([
+ { id: 'tab-1', url: 'http://a.com', displayName: 'World A', loadState: 'loaded', isActive: true },
+ { id: 'tab-2', url: 'http://b.com', displayName: 'World B', loadState: 'suspended', isActive: false },
+ { id: 'tab-3', url: 'http://c.com', displayName: 'World C', loadState: 'loaded', isActive: false },
+ { id: 'tab-4', url: 'http://d.com', displayName: 'World D', loadState: 'suspended', isActive: false }
+ ]);
+
+ const allTabs = document.querySelectorAll('.tab-item:not(.tab-item--new)');
+ const suspendedTabs = document.querySelectorAll('.tab-item--suspended');
+ expect(allTabs.length).toBe(4);
+ expect(suspendedTabs.length).toBe(2);
+ });
+
+ it('suspended tab retains displayName in dropdown', () => {
+ tabUI.updateTabs([
+ { id: 'tab-1', url: 'http://a.com', displayName: 'My World', loadState: 'suspended', isActive: false }
+ ]);
+
+ const tabName = document.querySelector('.tab-item__name');
+ expect(tabName).toBeTruthy();
+ expect(tabName.textContent).toContain('My World');
+ });
+
+ it('suspended tab retains URL in dropdown', () => {
+ tabUI.updateTabs([
+ { id: 'tab-1', url: 'http://a.com', displayName: 'My World', loadState: 'suspended', isActive: false }
+ ]);
+
+ const tabUrl = document.querySelector('.tab-item__url');
+ expect(tabUrl).toBeTruthy();
+ expect(tabUrl.textContent).toContain('a.com');
+ });
+
+ it('suspended tab has accessible aria-label with suspended indicator', () => {
+ tabUI.updateTabs([
+ { id: 'tab-1', url: 'http://a.com', displayName: 'World A', loadState: 'suspended', isActive: false }
+ ]);
+
+ const tab = document.querySelector('.tab-item--suspended');
+ expect(tab).toBeTruthy();
+ expect(tab.getAttribute('aria-label')).toBe('World A (suspended)');
+ });
+
+ it('suspended tab transitions back to loaded removes suspended class', () => {
+ tabUI.updateTabs([
+ { id: 'tab-1', url: 'http://a.com', displayName: 'World A', loadState: 'suspended', isActive: false },
+ { id: 'tab-2', url: 'http://b.com', displayName: 'World B', loadState: 'loaded', isActive: true }
+ ]);
+
+ // Verify suspended
+ expect(document.querySelectorAll('.tab-item--suspended').length).toBe(1);
+
+ // Transition back to loaded
+ tabUI.updateTabLoadState('tab-1', 'loaded');
+
+ // Verify suspended class removed
+ expect(document.querySelectorAll('.tab-item--suspended').length).toBe(0);
+ const tab = document.querySelector('[aria-label="World A"]');
+ expect(tab).toBeTruthy();
+ expect(tab.classList.contains('tab-item--suspended')).toBe(false);
+ });
+});
+
+describe('switching to suspended tab via bridge', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ loadBridge();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('showReloadingToast bridge message shows toast with Reloading text', () => {
+ simulateUnityMessage({ type: 'showReloadingToast' });
+
+ const toasts = document.querySelectorAll('.toast');
+ expect(toasts.length).toBeGreaterThan(0);
+ const toastText = Array.from(toasts).map(t => t.textContent).join(' ');
+ expect(toastText).toContain('Reloading');
+ });
+});
diff --git a/Assets/StreamingAssets/TabUI/tests/memory-pressure.test.js.meta b/Assets/StreamingAssets/TabUI/tests/memory-pressure.test.js.meta
new file mode 100644
index 00000000..5d59036f
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/memory-pressure.test.js.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: f9d46ca19f0bae544a74dd44bae2beac
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/mode.test.js b/Assets/StreamingAssets/TabUI/tests/mode.test.js
new file mode 100644
index 00000000..53e39dd3
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/mode.test.js
@@ -0,0 +1,76 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { loadUI, cleanupUI } from './setup.js';
+
+describe('setMode', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should add mobile-mode class when setMode("mobile") is called', () => {
+ tabUI.setMode('mobile');
+ expect(document.body.classList.contains('mobile-mode')).toBe(true);
+ });
+
+ it('should remove vr-mode class when switching to mobile', () => {
+ tabUI.setMode('vr');
+ expect(document.body.classList.contains('vr-mode')).toBe(true);
+ tabUI.setMode('mobile');
+ expect(document.body.classList.contains('vr-mode')).toBe(false);
+ expect(document.body.classList.contains('mobile-mode')).toBe(true);
+ });
+
+ it('should add both mobile-mode and tablet-mode when setMode("tablet") is called', () => {
+ tabUI.setMode('tablet');
+ expect(document.body.classList.contains('mobile-mode')).toBe(true);
+ expect(document.body.classList.contains('tablet-mode')).toBe(true);
+ });
+
+ it('should remove mobile-mode and tablet-mode when setMode("desktop") is called', () => {
+ tabUI.setMode('tablet');
+ tabUI.setMode('desktop');
+ expect(document.body.classList.contains('mobile-mode')).toBe(false);
+ expect(document.body.classList.contains('tablet-mode')).toBe(false);
+ });
+
+ it('should remove mobile-mode and tablet-mode when switching to vr, and add vr-mode', () => {
+ tabUI.setMode('tablet');
+ tabUI.setMode('vr');
+ expect(document.body.classList.contains('mobile-mode')).toBe(false);
+ expect(document.body.classList.contains('tablet-mode')).toBe(false);
+ expect(document.body.classList.contains('vr-mode')).toBe(true);
+ });
+
+ it('should remove vr-mode when switching from vr to desktop', () => {
+ tabUI.setMode('vr');
+ tabUI.setMode('desktop');
+ expect(document.body.classList.contains('vr-mode')).toBe(false);
+ });
+
+ it('should handle rapid mode switching and only retain final mode classes', () => {
+ tabUI.setMode('mobile');
+ tabUI.setMode('desktop');
+ tabUI.setMode('tablet');
+ tabUI.setMode('vr');
+ expect(document.body.classList.contains('vr-mode')).toBe(true);
+ expect(document.body.classList.contains('mobile-mode')).toBe(false);
+ expect(document.body.classList.contains('tablet-mode')).toBe(false);
+ });
+
+ it('should apply correct classes through full mode cycle', () => {
+ tabUI.setMode('mobile');
+ expect(document.body.classList.contains('mobile-mode')).toBe(true);
+ tabUI.setMode('tablet');
+ expect(document.body.classList.contains('mobile-mode')).toBe(true);
+ expect(document.body.classList.contains('tablet-mode')).toBe(true);
+ tabUI.setMode('desktop');
+ expect(document.body.classList.contains('mobile-mode')).toBe(false);
+ expect(document.body.classList.contains('tablet-mode')).toBe(false);
+ expect(document.body.classList.contains('vr-mode')).toBe(false);
+ });
+});
diff --git a/Assets/StreamingAssets/TabUI/tests/mode.test.js.meta b/Assets/StreamingAssets/TabUI/tests/mode.test.js.meta
new file mode 100644
index 00000000..4f3de8ec
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/mode.test.js.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 9a97b7e598096e74987f0c76e169b117
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/orientation.test.js b/Assets/StreamingAssets/TabUI/tests/orientation.test.js
new file mode 100644
index 00000000..5bf1a505
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/orientation.test.js
@@ -0,0 +1,131 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { loadUI, cleanupUI } from './setup.js';
+
+const componentsCss = readFileSync(resolve(__dirname, '../styles/components.css'), 'utf-8');
+
+/**
+ * Extract the content of a CSS rule block by selector.
+ */
+function extractBlock(css, selector) {
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const regex = new RegExp(escaped + '\\s*\\{([^}]*)\\}', 's');
+ const match = css.match(regex);
+ return match ? match[1] : null;
+}
+
+describe('orientation transitions', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should have CSS transition on .mobile-mode .chrome for position properties (max 200ms)', () => {
+ const block = extractBlock(componentsCss, '.mobile-mode .chrome');
+ expect(block).not.toBeNull();
+ expect(block).toContain('transition');
+ // Verify transition includes position-related properties and is <= 200ms
+ expect(block).toMatch(/transition:.*(?:top|bottom).*200ms/);
+ });
+
+ it('should have CSS transition on .mobile-mode .content-frame for position properties (max 200ms)', () => {
+ const block = extractBlock(componentsCss, '.mobile-mode .content-frame');
+ expect(block).not.toBeNull();
+ expect(block).toContain('transition');
+ expect(block).toMatch(/transition:.*(?:top|bottom).*200ms/);
+ });
+
+ it('should update all four CSS variables when setSafeArea is called with landscape insets', () => {
+ tabUI.setSafeArea({ top: 0, bottom: 0, left: 44, right: 44 });
+ const root = document.documentElement;
+ expect(root.style.getPropertyValue('--safe-area-top')).toBe('0px');
+ expect(root.style.getPropertyValue('--safe-area-bottom')).toBe('0px');
+ expect(root.style.getPropertyValue('--safe-area-left')).toBe('44px');
+ expect(root.style.getPropertyValue('--safe-area-right')).toBe('44px');
+ });
+
+ it('should have chrome padding rule that references --safe-area-left and --safe-area-right', () => {
+ const block = extractBlock(componentsCss, '.mobile-mode .chrome');
+ expect(block).not.toBeNull();
+ expect(block).toContain('--safe-area-left');
+ expect(block).toContain('--safe-area-right');
+ });
+
+ it('should apply only final values when setSafeArea is called multiple times rapidly', () => {
+ tabUI.setSafeArea({ top: 59, bottom: 34, left: 0, right: 0 });
+ tabUI.setSafeArea({ top: 0, bottom: 0, left: 59, right: 0 });
+ tabUI.setSafeArea({ top: 0, bottom: 0, left: 44, right: 44 });
+ const root = document.documentElement;
+ expect(root.style.getPropertyValue('--safe-area-top')).toBe('0px');
+ expect(root.style.getPropertyValue('--safe-area-bottom')).toBe('0px');
+ expect(root.style.getPropertyValue('--safe-area-left')).toBe('44px');
+ expect(root.style.getPropertyValue('--safe-area-right')).toBe('44px');
+ });
+
+ it('should expose setOrientation on window.tabUI and update orientation state', () => {
+ expect(typeof tabUI.setOrientation).toBe('function');
+ tabUI.setOrientation('landscape');
+ // Verify by calling again — function should not throw
+ tabUI.setOrientation('portrait');
+ });
+
+ it('should default to portrait when setOrientation receives invalid value', () => {
+ tabUI.setOrientation('landscape');
+ tabUI.setOrientation('invalid');
+ // Invalid value should default to portrait (same pattern as setChromePosition)
+ // Can't read state directly, but verify no throw and subsequent calls work
+ expect(() => tabUI.setOrientation('landscape')).not.toThrow();
+ });
+});
+
+describe('orientation with open UI elements', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ tabUI.setMode('mobile');
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should keep tab dropdown open when setSafeArea is called with new insets', () => {
+ // Open the tab dropdown by clicking tabs button
+ const tabsButton = document.querySelector('.tabs-button');
+ expect(tabsButton).not.toBeNull();
+ tabsButton.click();
+
+ // Verify dropdown is actually open (display: block set by openTabDropdown)
+ const dropdown = document.querySelector('.tab-dropdown');
+ expect(dropdown).not.toBeNull();
+ expect(dropdown.style.display).toBe('block');
+
+ // Simulate orientation change by updating safe area
+ tabUI.setSafeArea({ top: 0, bottom: 0, left: 44, right: 44 });
+
+ // Dropdown should still be open (display: block, not reverted to none)
+ expect(dropdown.style.display).toBe('block');
+ });
+
+ it('should keep modal open when setSafeArea is called with new insets', () => {
+ // Open a modal by adding the modal--open class (simulating openModal)
+ const modalOverlay = document.querySelector('.modal-overlay');
+ expect(modalOverlay).not.toBeNull();
+ modalOverlay.classList.add('modal--open');
+ expect(modalOverlay.classList.contains('modal--open')).toBe(true);
+
+ // Simulate orientation change
+ tabUI.setSafeArea({ top: 0, bottom: 0, left: 44, right: 44 });
+
+ // Modal should still have modal--open class
+ expect(modalOverlay.classList.contains('modal--open')).toBe(true);
+ });
+});
diff --git a/Assets/StreamingAssets/TabUI/tests/orientation.test.js.meta b/Assets/StreamingAssets/TabUI/tests/orientation.test.js.meta
new file mode 100644
index 00000000..01dffa78
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/orientation.test.js.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: ffcdf93d50bfeb14797df6fad9769aad
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/safe-area.test.js b/Assets/StreamingAssets/TabUI/tests/safe-area.test.js
new file mode 100644
index 00000000..2156580d
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/safe-area.test.js
@@ -0,0 +1,55 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { loadUI, cleanupUI } from './setup.js';
+
+describe('setSafeArea', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('should set CSS custom properties for all four insets', () => {
+ tabUI.setSafeArea({ top: 44, bottom: 34, left: 0, right: 0 });
+ const style = document.documentElement.style;
+ expect(style.getPropertyValue('--safe-area-top')).toBe('44px');
+ expect(style.getPropertyValue('--safe-area-bottom')).toBe('34px');
+ expect(style.getPropertyValue('--safe-area-left')).toBe('0px');
+ expect(style.getPropertyValue('--safe-area-right')).toBe('0px');
+ });
+
+ it('should set all variables to 0px when given zero insets', () => {
+ tabUI.setSafeArea({ top: 0, bottom: 0, left: 0, right: 0 });
+ const style = document.documentElement.style;
+ expect(style.getPropertyValue('--safe-area-top')).toBe('0px');
+ expect(style.getPropertyValue('--safe-area-bottom')).toBe('0px');
+ expect(style.getPropertyValue('--safe-area-left')).toBe('0px');
+ expect(style.getPropertyValue('--safe-area-right')).toBe('0px');
+ });
+
+ it('should default missing values to 0', () => {
+ tabUI.setSafeArea({ top: 44 });
+ const style = document.documentElement.style;
+ expect(style.getPropertyValue('--safe-area-top')).toBe('44px');
+ expect(style.getPropertyValue('--safe-area-bottom')).toBe('0px');
+ expect(style.getPropertyValue('--safe-area-left')).toBe('0px');
+ expect(style.getPropertyValue('--safe-area-right')).toBe('0px');
+ });
+
+ it('should update values when called multiple times (not accumulate)', () => {
+ tabUI.setSafeArea({ top: 44, bottom: 34, left: 0, right: 0 });
+ tabUI.setSafeArea({ top: 20, bottom: 0, left: 10, right: 10 });
+ const style = document.documentElement.style;
+ expect(style.getPropertyValue('--safe-area-top')).toBe('20px');
+ expect(style.getPropertyValue('--safe-area-bottom')).toBe('0px');
+ expect(style.getPropertyValue('--safe-area-left')).toBe('10px');
+ expect(style.getPropertyValue('--safe-area-right')).toBe('10px');
+ });
+
+ it('should be exposed on window.tabUI', () => {
+ expect(typeof tabUI.setSafeArea).toBe('function');
+ });
+});
diff --git a/Assets/StreamingAssets/TabUI/tests/safe-area.test.js.meta b/Assets/StreamingAssets/TabUI/tests/safe-area.test.js.meta
new file mode 100644
index 00000000..cc9bf619
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/safe-area.test.js.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 86a38b9426d99c84da5b050e4959fb92
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/session-restore.test.js b/Assets/StreamingAssets/TabUI/tests/session-restore.test.js
new file mode 100644
index 00000000..98f1a9d4
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/session-restore.test.js
@@ -0,0 +1,220 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { loadUI, cleanupUI } from './setup.js';
+
+const bridgeJsPath = resolve(__dirname, '../scripts/bridge.js');
+const bridgeJsSource = readFileSync(bridgeJsPath, 'utf-8');
+
+/**
+ * Load bridge.js after ui.js is loaded.
+ */
+function loadBridge() {
+ const fn = new Function(bridgeJsSource);
+ fn.call(window);
+}
+
+/**
+ * Simulate a message from Unity to the bridge.
+ */
+function simulateUnityMessage(data) {
+ if (window.vuplex && window.vuplex.simulateMessage) {
+ window.vuplex.simulateMessage(data);
+ } else {
+ const handlers = window.vuplex?._listeners?.message || [];
+ const event = { data: JSON.stringify(data) };
+ handlers.forEach(h => h(event));
+ }
+}
+
+describe('restoreSession bridge message handling', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ loadBridge();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('restoreSession message with tab data updates DOM with tabs', () => {
+ const tabs = [
+ { id: 'tab-1', url: 'http://world1.com', displayName: 'World 1', loadState: 'loaded', isActive: false },
+ { id: 'tab-2', url: 'http://world2.com', displayName: 'World 2', loadState: 'loaded', isActive: false }
+ ];
+
+ simulateUnityMessage({ type: 'restoreSession', tabs: tabs, activeTabId: 'tab-2' });
+
+ // DOM side effect — tab items should exist (exclude new-tab button)
+ const tabItems = document.querySelectorAll('.tab-item:not(.tab-item--new)');
+ expect(tabItems.length).toBe(2);
+ });
+
+ it('restoreSession message with empty tabs array clears tab list', () => {
+ // First add some tabs
+ tabUI.updateTabs([
+ { id: 'tab-x', url: 'http://x.com', displayName: 'X', loadState: 'loaded', isActive: true }
+ ]);
+ expect(document.querySelectorAll('.tab-item:not(.tab-item--new)').length).toBe(1);
+
+ // Now restore with empty
+ simulateUnityMessage({ type: 'restoreSession', tabs: [], activeTabId: '' });
+
+ const tabItems = document.querySelectorAll('.tab-item:not(.tab-item--new)');
+ expect(tabItems.length).toBe(0);
+ });
+
+ it('restoreSession message with reloading tab triggers toast', () => {
+ const tabs = [
+ { id: 'tab-1', url: 'http://world1.com', displayName: 'World 1', loadState: 'loaded', isActive: false, reloading: true }
+ ];
+
+ simulateUnityMessage({ type: 'restoreSession', tabs: tabs, activeTabId: 'tab-1', hasReloadingTab: true });
+
+ // Check DOM side effect — toast element should be added to toast container
+ const toasts = document.querySelectorAll('.toast');
+ expect(toasts.length).toBeGreaterThan(0);
+ const toastText = Array.from(toasts).map(t => t.textContent).join(' ');
+ expect(toastText).toContain('Reloading');
+ });
+});
+
+describe('showRestorePrompt bridge message handling', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ loadBridge();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('showRestorePrompt renders a modal with Restore session title', () => {
+ simulateUnityMessage({ type: 'showRestorePrompt' });
+
+ const modal = document.querySelector('[role="dialog"]');
+ expect(modal).toBeTruthy();
+ expect(modal.textContent).toContain('Restore session?');
+ });
+
+ it('showRestorePrompt modal has Accept and Decline buttons', () => {
+ simulateUnityMessage({ type: 'showRestorePrompt' });
+
+ const modal = document.querySelector('[role="dialog"]');
+ expect(modal).toBeTruthy();
+
+ const acceptBtn = modal.querySelector('[data-action="accept"]');
+ const declineBtn = modal.querySelector('[data-action="decline"]');
+ expect(acceptBtn).toBeTruthy();
+ expect(declineBtn).toBeTruthy();
+ });
+
+ it('clicking Accept button calls bridge.acceptSessionRestore()', () => {
+ simulateUnityMessage({ type: 'showRestorePrompt' });
+
+ const modal = document.querySelector('[role="dialog"]');
+ expect(modal).toBeTruthy();
+
+ const postMessageSpy = vi.spyOn(window.vuplex, 'postMessage');
+
+ const acceptBtn = modal.querySelector('[data-action="accept"]');
+ acceptBtn.click();
+
+ // Verify an acceptSessionRestore message was sent to Unity
+ const calls = postMessageSpy.mock.calls.map(c => JSON.parse(c[0]));
+ const restoreCall = calls.find(c => c.type === 'acceptSessionRestore');
+ expect(restoreCall).toBeTruthy();
+
+ postMessageSpy.mockRestore();
+ });
+
+ it('clicking Decline button calls bridge.declineSessionRestore()', () => {
+ simulateUnityMessage({ type: 'showRestorePrompt' });
+
+ const modal = document.querySelector('[role="dialog"]');
+ expect(modal).toBeTruthy();
+
+ const postMessageSpy = vi.spyOn(window.vuplex, 'postMessage');
+
+ const declineBtn = modal.querySelector('[data-action="decline"]');
+ declineBtn.click();
+
+ const calls = postMessageSpy.mock.calls.map(c => JSON.parse(c[0]));
+ const clearCall = calls.find(c => c.type === 'declineSessionRestore');
+ expect(clearCall).toBeTruthy();
+
+ postMessageSpy.mockRestore();
+ });
+
+ it('showRestorePrompt modal has correct accessibility attributes', () => {
+ simulateUnityMessage({ type: 'showRestorePrompt' });
+
+ const modal = document.querySelector('[role="dialog"]');
+ expect(modal).toBeTruthy();
+ expect(modal.getAttribute('aria-label')).toBeTruthy();
+ });
+});
+
+describe('showReloadingToast bridge message handling', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ loadBridge();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('showReloadingToast shows toast with Reloading world text', () => {
+ simulateUnityMessage({ type: 'showReloadingToast' });
+
+ // Check DOM side effect — toast element should appear
+ const toasts = document.querySelectorAll('.toast');
+ expect(toasts.length).toBeGreaterThan(0);
+ const toastText = Array.from(toasts).map(t => t.textContent).join(' ');
+ expect(toastText).toContain('Reloading');
+ });
+});
+
+describe('bridge outgoing session methods', () => {
+ let tabUI;
+
+ beforeEach(() => {
+ tabUI = loadUI();
+ loadBridge();
+ });
+
+ afterEach(() => {
+ cleanupUI();
+ });
+
+ it('bridge.acceptSessionRestore sends acceptSessionRestore message to Unity', () => {
+ const postMessageSpy = vi.spyOn(window.vuplex, 'postMessage');
+
+ window.bridge.acceptSessionRestore();
+
+ const calls = postMessageSpy.mock.calls.map(c => JSON.parse(c[0]));
+ const restoreCall = calls.find(c => c.type === 'acceptSessionRestore');
+ expect(restoreCall).toBeTruthy();
+
+ postMessageSpy.mockRestore();
+ });
+
+ it('bridge.declineSessionRestore sends declineSessionRestore message to Unity', () => {
+ const postMessageSpy = vi.spyOn(window.vuplex, 'postMessage');
+
+ window.bridge.declineSessionRestore();
+
+ const calls = postMessageSpy.mock.calls.map(c => JSON.parse(c[0]));
+ const clearCall = calls.find(c => c.type === 'declineSessionRestore');
+ expect(clearCall).toBeTruthy();
+
+ postMessageSpy.mockRestore();
+ });
+});
diff --git a/Assets/StreamingAssets/TabUI/tests/session-restore.test.js.meta b/Assets/StreamingAssets/TabUI/tests/session-restore.test.js.meta
new file mode 100644
index 00000000..cc8dd4b4
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/session-restore.test.js.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: b6689bb3f3744c34a94b15a8cf321b52
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/StreamingAssets/TabUI/tests/setup.js b/Assets/StreamingAssets/TabUI/tests/setup.js
new file mode 100644
index 00000000..2b4652d9
--- /dev/null
+++ b/Assets/StreamingAssets/TabUI/tests/setup.js
@@ -0,0 +1,76 @@
+/**
+ * Test setup helper — loads ui.js IIFE into jsdom's window.
+ * Call loadUI() in beforeEach to get a fresh window.tabUI instance.
+ */
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+
+const uiJsPath = resolve(__dirname, '../scripts/ui.js');
+const uiJsSource = readFileSync(uiJsPath, 'utf-8');
+
+const indexHtmlPath = resolve(__dirname, '../index.html');
+const indexHtmlSource = readFileSync(indexHtmlPath, 'utf-8');
+
+// Extract body innerHTML from index.html (between and ),
+// excluding