diff --git a/package-lock.json b/package-lock.json
index 96e059f..4ee8e10 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"husky": "^9.1.7",
+ "jsdom": "^26.1.0",
"lint-staged": "^15.2.10",
"prettier": "^3.4.2",
"tsup": "^8.3.5",
@@ -32,6 +33,27 @@
"node": ">=20"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1705,6 +1727,121 @@
"node": ">=v18"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -6761,6 +6898,20 @@
"url": "https://github.com/sponsors/fb55"
}
},
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -6781,6 +6932,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -6853,6 +7018,13 @@
}
}
},
+ "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/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -8859,6 +9031,19 @@
"license": "ISC",
"peer": true
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -8890,6 +9075,20 @@
"node": ">= 0.8"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -8930,6 +9129,19 @@
"url": "https://github.com/sponsors/typicode"
}
},
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -9352,6 +9564,13 @@
"node": ">=8"
}
},
+ "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/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -9767,6 +9986,46 @@
"dev": true,
"license": "0BSD"
},
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -11486,6 +11745,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/nwsapi": {
+ "version": "2.2.27",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz",
+ "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/ob1": {
"version": "0.83.7",
"resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.7.tgz",
@@ -11981,6 +12247,32 @@
"node": ">=10"
}
},
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/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/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -12401,9 +12693,9 @@
}
},
"node_modules/react": {
- "version": "19.2.6",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
- "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -12443,15 +12735,15 @@
}
},
"node_modules/react-dom": {
- "version": "19.2.6",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
- "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
- "react": "^19.2.6"
+ "react": "^19.2.8"
}
},
"node_modules/react-is": {
@@ -13096,6 +13388,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/safe-array-concat": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
@@ -13173,6 +13472,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
@@ -13184,6 +13490,19 @@
"node": ">=11.0.0"
}
},
+ "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/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -13967,6 +14286,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "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/terminal-link": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
@@ -14187,6 +14513,26 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -14225,6 +14571,32 @@
"license": "MIT",
"peer": true
},
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -15226,6 +15598,19 @@
"dev": true,
"license": "MIT"
},
+ "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/walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
@@ -15247,6 +15632,30 @@
"defaults": "^1.0.3"
}
},
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
@@ -15254,6 +15663,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/whatwg-url-minimum": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz",
@@ -15455,7 +15888,6 @@
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -15487,6 +15919,16 @@
"node": ">=10.0.0"
}
},
+ "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/xml2js": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz",
@@ -15524,6 +15966,13 @@
"node": ">=8.0"
}
},
+ "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"
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -15661,6 +16110,7 @@
"@expo/vector-icons": "^15.1.1",
"@types/react": "^19.2.14",
"react": "^19.2.6",
+ "react-dom": "^19.2.6",
"react-native": "^0.85.3",
"react-native-safe-area-context": "^5.7.0",
"react-native-svg": "^15.12.1",
diff --git a/package.json b/package.json
index f21acf8..4269adb 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"husky": "^9.1.7",
+ "jsdom": "^26.1.0",
"lint-staged": "^15.2.10",
"prettier": "^3.4.2",
"tsup": "^8.3.5",
diff --git a/packages/native/package.json b/packages/native/package.json
index bc05739..cee312c 100644
--- a/packages/native/package.json
+++ b/packages/native/package.json
@@ -63,6 +63,7 @@
"@expo/vector-icons": "^15.1.1",
"@types/react": "^19.2.14",
"react": "^19.2.6",
+ "react-dom": "^19.2.6",
"react-native": "^0.85.3",
"react-native-safe-area-context": "^5.7.0",
"react-native-svg": "^15.12.1",
diff --git a/packages/native/src/components/FileViewerHost.test.tsx b/packages/native/src/components/FileViewerHost.test.tsx
new file mode 100644
index 0000000..c33f627
--- /dev/null
+++ b/packages/native/src/components/FileViewerHost.test.tsx
@@ -0,0 +1,138 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { act, useEffect } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import type { FileFetcher, FileReadResult, FileWriteResult } from '@codeam/ide-core';
+import { FileViewerProvider, useFileViewer } from './FileViewerContext';
+import { FileViewerHost } from './FileViewerHost';
+import type { WebViewStubElement } from '../test/setup';
+
+const PATH = 'src/index.ts';
+const VERDICT = "This session isn't responding — is it online?";
+
+function Opener() {
+ const { open } = useFileViewer();
+ useEffect(() => open({ path: PATH, op: 'Write' }), [open]);
+ return null;
+}
+
+let root: Root | null = null;
+let container: HTMLDivElement | null = null;
+
+async function mount(fetcher: FileFetcher) {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ await act(async () => {
+ root!.render(
+
+
+
+ ,
+ );
+ });
+ return container;
+}
+
+afterEach(async () => {
+ await act(async () => root?.unmount());
+ container?.remove();
+ root = null;
+ container = null;
+});
+
+const spinner = (c: HTMLElement) => c.querySelector('[data-rn="activity-indicator"]');
+const webview = (c: HTMLElement) => c.querySelector('[data-rn="webview"]');
+const bridge = (c: HTMLElement, msg: object) =>
+ webview(c)!.__onMessage!({ nativeEvent: { data: JSON.stringify(msg) } });
+const retryButton = (c: HTMLElement) =>
+ Array.from(c.querySelectorAll('button')).find((b) => b.textContent === 'Retry') ?? null;
+const occurrences = (haystack: string, needle: string) => haystack.split(needle).length - 1;
+
+function makeFetcher(read: FileFetcher['read'], write?: FileFetcher['write']): FileFetcher {
+ return {
+ label: 'test',
+ canWrite: true,
+ read,
+ write: write ?? (async () => ({}) as FileWriteResult),
+ };
+}
+
+describe('FileViewerHost — read failure', () => {
+ it('renders the fetcher verdict in the body with Retry, no spinner, and no duplicate bar', async () => {
+ const read = vi.fn(async (): Promise => ({ error: VERDICT }));
+ const c = await mount(makeFetcher(read));
+
+ expect(read).toHaveBeenCalledTimes(1);
+ expect(spinner(c)).toBeNull();
+ expect(c.textContent).not.toContain('Fetching');
+ expect(occurrences(c.textContent ?? '', VERDICT)).toBe(1);
+ expect(retryButton(c)).not.toBeNull();
+ });
+
+ it('Retry re-runs fetcher.read, shows the loading state meanwhile, then renders the file', async () => {
+ let resolveSecond: ((r: FileReadResult) => void) | null = null;
+ const read = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce({ error: VERDICT })
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveSecond = resolve;
+ }),
+ );
+ const c = await mount(makeFetcher(read));
+ expect(retryButton(c)).not.toBeNull();
+
+ await act(async () => retryButton(c)!.click());
+
+ expect(read).toHaveBeenCalledTimes(2);
+ expect(read).toHaveBeenLastCalledWith(PATH);
+ expect(spinner(c)).not.toBeNull();
+ expect(c.textContent).toContain(`Fetching ${PATH}`);
+ expect(c.textContent).not.toContain(VERDICT);
+ expect(retryButton(c)).toBeNull();
+
+ await act(async () => resolveSecond!({ content: 'export const x = 1;\n' }));
+
+ expect(spinner(c)).toBeNull();
+ expect(webview(c)).not.toBeNull();
+ expect(c.textContent).not.toContain(VERDICT);
+ });
+
+ it('a Retry that fails again shows the new verdict once, with Retry still available', async () => {
+ const read = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce({ error: VERDICT })
+ .mockResolvedValueOnce({ error: 'Session is offline.' });
+ const c = await mount(makeFetcher(read));
+
+ await act(async () => retryButton(c)!.click());
+
+ expect(read).toHaveBeenCalledTimes(2);
+ expect(spinner(c)).toBeNull();
+ expect(c.textContent).not.toContain(VERDICT);
+ expect(occurrences(c.textContent ?? '', 'Session is offline.')).toBe(1);
+ expect(retryButton(c)).not.toBeNull();
+ });
+});
+
+describe('FileViewerHost — errors while a file is displayed', () => {
+ it('a save failure goes to the top bar once and keeps the editor mounted', async () => {
+ const read = vi.fn(async (): Promise => ({ content: 'hello' }));
+ const write = vi.fn(
+ async (): Promise => ({ error: 'Save failed: disk full' }),
+ );
+ const c = await mount(makeFetcher(read, write));
+ expect(webview(c)).not.toBeNull();
+
+ // Same path Cmd+S takes inside Monaco: edit the buffer, then save.
+ await act(async () => bridge(c, { type: 'change', value: 'hello world' }));
+ await act(async () => bridge(c, { type: 'save' }));
+
+ expect(write).toHaveBeenCalledWith(PATH, 'hello world');
+ expect(webview(c)).not.toBeNull();
+ expect(occurrences(c.textContent ?? '', 'Save failed: disk full')).toBe(1);
+ expect(retryButton(c)).toBeNull();
+ expect(spinner(c)).toBeNull();
+ });
+});
diff --git a/packages/native/src/components/FileViewerHost.tsx b/packages/native/src/components/FileViewerHost.tsx
index 87ed2c1..8fb7ad7 100644
--- a/packages/native/src/components/FileViewerHost.tsx
+++ b/packages/native/src/components/FileViewerHost.tsx
@@ -97,10 +97,12 @@ export function FileViewerHost() {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [savedAt, setSavedAt] = useState(null);
+ // Bumped by Retry so the read effect re-runs for the same request/fetcher.
+ const [readAttempt, setReadAttempt] = useState(0);
const webRef = useRef(null);
const webReadyRef = useRef(false);
- // Reset + fetch when a new request arrives.
+ // Reset + fetch when a new request arrives (or the user retries).
useEffect(() => {
if (!request || !fetcher) return;
setContent(null);
@@ -135,7 +137,9 @@ export function FileViewerHost() {
return () => {
cancelled = true;
};
- }, [request, fetcher]);
+ }, [request, fetcher, readAttempt]);
+
+ const retry = () => setReadAttempt((n) => n + 1);
const language = useMemo(() => (request ? detectLanguage(request.path) : 'plaintext'), [request]);
const html = useMemo(
@@ -225,7 +229,9 @@ export function FileViewerHost() {
- {error && (
+ {/* With no content on screen the error owns the body (below); the bar
+ is for failures on top of a displayed file (save, editor bridge). */}
+ {error && html !== null && (
{error}
@@ -233,18 +239,27 @@ export function FileViewerHost() {
)}
- {loading || html === null ? (
-
-
- Fetching {request.path}…
-
- ) : !fetcher ? (
+ {!fetcher ? (
No active session. Pair an IDE plugin or CLI first.
+ ) : loading || (html === null && error === null) ? (
+
+
+ Fetching {request.path}…
+
+ ) : html === null ? (
+
+
+ {error}
+
+
+ Retry
+
+
) : (
void;
+ disabled?: boolean;
+ testID?: string;
+ [key: string]: unknown;
+};
+
+function domStub(tag: string, marker?: string) {
+ return function Stub({ children, onPress, disabled, testID }: StubProps) {
+ return createElement(
+ tag,
+ {
+ onClick: onPress,
+ disabled,
+ 'data-testid': testID,
+ 'data-rn': marker,
+ },
+ children,
+ );
+ };
+}
+
+vi.mock('react-native', () => ({
+ View: domStub('div'),
+ Text: domStub('span'),
+ TouchableOpacity: domStub('button'),
+ Pressable: domStub('button'),
+ ScrollView: domStub('div'),
+ TextInput: domStub('input'),
+ ActivityIndicator: domStub('div', 'activity-indicator'),
+ Modal: ({ children, visible = true }: StubProps & { visible?: boolean }) =>
+ visible ? createElement('div', { 'data-rn': 'modal' }, children) : null,
+ StyleSheet: {
+ create: (styles: T) => styles,
+ hairlineWidth: 1,
+ absoluteFill: {},
+ flatten: (s: unknown) => s,
+ },
+ Platform: { OS: 'ios', select: (o: Record) => o.ios ?? o.default },
+ useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }),
+}));
+
+/**
+ * The WebView stub parks its `onMessage` handler on the DOM node so a spec can
+ * drive the Monaco bridge (`change` / `save` / `error` messages) the way the
+ * real editor would.
+ */
+export interface WebViewStubElement extends HTMLElement {
+ __onMessage?: (event: { nativeEvent: { data: string } }) => void;
+}
+
+vi.mock('react-native-webview', () => ({
+ WebView: ({ onMessage }: { onMessage?: WebViewStubElement['__onMessage'] }) =>
+ createElement('div', {
+ 'data-rn': 'webview',
+ ref: (el: WebViewStubElement | null) => {
+ if (el) el.__onMessage = onMessage;
+ },
+ }),
+}));
+
+vi.mock('@expo/vector-icons', () => ({
+ Ionicons: ({ name }: { name: string }) => createElement('i', { 'data-icon': name }),
+}));
+
+vi.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
+}));
diff --git a/packages/native/vitest.config.ts b/packages/native/vitest.config.ts
new file mode 100644
index 0000000..21c2b9e
--- /dev/null
+++ b/packages/native/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vitest/config';
+
+// Component specs render the real components through react-dom into jsdom;
+// the RN host primitives are stubbed in `src/test/setup.tsx` (react-native's
+// own sources are Flow and can't be imported outside the Jest preset).
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setup.tsx'],
+ include: ['src/**/*.test.{ts,tsx}'],
+ },
+});