diff --git a/package-lock.json b/package-lock.json
index e1078c2..c12c945 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,19 +24,30 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
+ "@vitest/coverage-v8": "^4.0.18",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
+ "jsdom": "^28.1.0",
"server-only": "^0.0.1",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.18"
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.31",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
+ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -50,6 +61,64 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
+ "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
+ "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",
+ "lru-cache": "^11.2.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+ "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.1.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.6"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "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/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -341,6 +410,161 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
+ "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
+ "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.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
+ "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
+ "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.1.1"
+ },
+ "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",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.0.tgz",
+ "integrity": "sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0"
+ },
+ "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",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -1013,6 +1237,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.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/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2512,6 +2754,99 @@
"tailwindcss": "4.1.18"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -2523,6 +2858,13 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3278,6 +3620,37 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
+ "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.0.18",
+ "ast-v8-to-istanbul": "^0.3.10",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.1",
+ "obug": "^2.1.1",
+ "std-env": "^3.10.0",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.0.18",
+ "vitest": "4.0.18"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
@@ -3412,6 +3785,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -3429,6 +3812,16 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -3639,6 +4032,25 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.12",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
+ "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -3720,6 +4132,16 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "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/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -4013,6 +4435,46 @@
"postcss-value-parser": "^4.0.2"
}
},
+ "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/cssstyle": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
+ "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.0.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.28",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.6"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cssstyle/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4148,6 +4610,20 @@
"dev": true,
"license": "BSD-2-Clause"
},
+ "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/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -4220,6 +4696,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/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -4301,6 +4784,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4353,6 +4843,19 @@
"node": ">=10.13.0"
}
},
+ "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-abstract": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
@@ -5556,12 +6059,60 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "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/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
+ "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",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5903,6 +6454,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "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",
@@ -6062,6 +6620,45 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -6119,6 +6716,47 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "28.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
+ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.31",
+ "@asamuzakjp/dom-selector": "^6.8.1",
+ "@bramus/specificity": "^2.4.2",
+ "@exodus/bytes": "^1.11.0",
+ "cssstyle": "^6.0.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.0",
+ "undici": "^7.21.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "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",
@@ -6543,6 +7181,16 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -6553,6 +7201,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
+ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6563,6 +7252,13 @@
"node": ">= 0.4"
}
},
+ "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/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -7066,6 +7762,19 @@
"hex-rgb": "^4.1.0"
}
},
+ "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/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7394,6 +8103,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "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/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@@ -7598,6 +8317,19 @@
"node": ">=16"
}
},
+ "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",
@@ -8082,6 +8814,13 @@
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "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/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -8190,6 +8929,26 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "7.0.24",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz",
+ "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.24"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.24",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz",
+ "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -8203,6 +8962,32 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
+ "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
+ "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/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -8396,6 +9181,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici": {
+ "version": "7.22.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
+ "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -8726,6 +9521,54 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "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/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8858,6 +9701,23 @@
"node": ">=0.10.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/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/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index b0c0fcb..fe01b4c 100644
--- a/package.json
+++ b/package.json
@@ -28,16 +28,20 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
+ "@vitest/coverage-v8": "^4.0.18",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
+ "jsdom": "^28.1.0",
"server-only": "^0.0.1",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.18"
}
-}
+}
\ No newline at end of file
diff --git a/src/app/api/card/[username]/route.test.ts b/src/app/api/card/[username]/route.test.ts
index ba44a2d..bfbb614 100644
--- a/src/app/api/card/[username]/route.test.ts
+++ b/src/app/api/card/[username]/route.test.ts
@@ -40,4 +40,16 @@ describe("GET /api/card/[username] cache headers", () => {
expect(response.status).toBe(404);
expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60, stale-while-revalidate=120");
});
+
+ it("uses short cache header on error", async () => {
+ const { fetchCardData } = await import("@/lib/cardDataFetcher");
+ vi.mocked(fetchCardData).mockRejectedValueOnce(new Error("API Error"));
+
+ const { GET } = await import("./route");
+ const req = new Request("http://localhost/api/card/erroruser");
+ const response = await GET(req, { params: Promise.resolve({ username: "erroruser" }) });
+
+ expect(response.status).toBe(503);
+ expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60, stale-while-revalidate=120");
+ });
});
diff --git a/src/app/api/dashboard/year/route.test.ts b/src/app/api/dashboard/year/route.test.ts
new file mode 100644
index 0000000..392efdc
--- /dev/null
+++ b/src/app/api/dashboard/year/route.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+import { type Session } from "next-auth";
+
+import type { YearInReviewData } from "@/lib/types";
+
+const mockSession: Session = {
+ user: { name: "Alice", email: "alice@example.com", image: "", login: "alice" },
+ accessToken: "token",
+ expires: new Date(Date.now() + 2 * 86400 * 1000).toISOString(),
+};
+vi.mock("next-auth", () => ({
+ getServerSession: vi.fn(),
+}));
+
+vi.mock("@/lib/auth", () => ({
+ authOptions: {},
+}));
+
+vi.mock("@/lib/githubViewer", () => ({
+ fetchViewerLogin: vi.fn(),
+}));
+
+vi.mock("@/lib/githubYearInReview", () => ({
+ fetchYearInReviewData: vi.fn(),
+}));
+
+function createMockRequest(url: string): NextRequest {
+ return new NextRequest(url);
+}
+
+describe("GET /api/dashboard/year validation", () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it("returns 401 when not authorized", async () => {
+ const { getServerSession } = await import("next-auth");
+ vi.mocked(getServerSession).mockResolvedValueOnce(null);
+
+ const { GET } = await import("./route");
+ const req = createMockRequest("http://localhost/api/dashboard/year");
+ const response = await GET(req);
+
+ expect(response.status).toBe(401);
+ });
+
+ it("returns 400 when year is invalid (not a number)", async () => {
+ const { getServerSession } = await import("next-auth");
+ vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
+
+ const { GET } = await import("./route");
+ const req = createMockRequest("http://localhost/api/dashboard/year?year=abc");
+ const response = await GET(req);
+
+ expect(response.status).toBe(400);
+ const data = await response.json();
+ expect(data.error).toBe("Invalid year");
+ });
+
+ it("returns 400 when year is before 2008", async () => {
+ const { getServerSession } = await import("next-auth");
+ vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
+
+ const { GET } = await import("./route");
+ const req = createMockRequest("http://localhost/api/dashboard/year?year=2007");
+ const response = await GET(req);
+
+ expect(response.status).toBe(400);
+ const data = await response.json();
+ expect(data.error).toBe("Invalid year");
+ });
+
+ it("returns 400 when year is in the future", async () => {
+ const { getServerSession } = await import("next-auth");
+ vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
+
+ const { GET } = await import("./route");
+ const currentYear = new Date().getUTCFullYear();
+ const req = createMockRequest(`http://localhost/api/dashboard/year?year=${currentYear + 1}`);
+ const response = await GET(req);
+
+ expect(response.status).toBe(400);
+ const data = await response.json();
+ expect(data.error).toBe("Invalid year");
+ });
+
+ it("returns 200 and fetches data when year is valid", async () => {
+ const { getServerSession } = await import("next-auth");
+ vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
+
+ const { fetchYearInReviewData } = await import("@/lib/githubYearInReview");
+ vi.mocked(fetchYearInReviewData).mockResolvedValueOnce({ data: "ok" } as unknown as YearInReviewData);
+
+ const { GET } = await import("./route");
+ const currentYear = new Date().getUTCFullYear();
+ const req = createMockRequest(`http://localhost/api/dashboard/year?year=${currentYear}`);
+ const response = await GET(req);
+
+ expect(response.status).toBe(200);
+ const data = await response.json();
+ expect(data).toEqual({ data: "ok" });
+ expect(fetchYearInReviewData).toHaveBeenCalledWith("alice", currentYear, "token");
+ });
+
+ it("returns 200 and falls back to current year when year is not provided", async () => {
+ const { getServerSession } = await import("next-auth");
+ vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
+
+ const { fetchYearInReviewData } = await import("@/lib/githubYearInReview");
+ vi.mocked(fetchYearInReviewData).mockResolvedValueOnce({ data: "ok" } as unknown as YearInReviewData);
+
+ const { GET } = await import("./route");
+ const req = createMockRequest(`http://localhost/api/dashboard/year`);
+ const response = await GET(req);
+
+ expect(response.status).toBe(200);
+ const data = await response.json();
+ expect(data).toEqual({ data: "ok" });
+
+ const currentYear = new Date().getUTCFullYear();
+ expect(fetchYearInReviewData).toHaveBeenCalledWith("alice", currentYear, "token");
+ });
+});
diff --git a/src/components/ShareButtons.test.tsx b/src/components/ShareButtons.test.tsx
new file mode 100644
index 0000000..3067c63
--- /dev/null
+++ b/src/components/ShareButtons.test.tsx
@@ -0,0 +1,155 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import ShareButtons from "./ShareButtons";
+
+describe("ShareButtons", () => {
+ let originalClipboard: Navigator["clipboard"] | undefined;
+ let originalExecCommand: (commandId: string, showUI?: boolean, value?: string) => boolean;
+ let originalLocation: Location;
+
+ beforeEach(() => {
+ originalClipboard = navigator.clipboard;
+ originalExecCommand = document.execCommand;
+ originalLocation = window.location;
+
+ Object.defineProperty(window, "location", {
+ value: { origin: "http://localhost", href: "http://localhost/johndoe" },
+ writable: true,
+ });
+
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ Object.assign(navigator, { clipboard: originalClipboard });
+ document.execCommand = originalExecCommand;
+
+ Object.defineProperty(window, "location", {
+ value: originalLocation,
+ writable: true,
+ });
+ });
+
+ it("uses document.execCommand as fallback when navigator.clipboard.writeText fails", async () => {
+ // 1. Mock clipboard.writeText to reject
+ const writeTextMock = vi.fn().mockRejectedValue(new Error("Not allowed"));
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: writeTextMock,
+ },
+ });
+
+ // 2. Mock execCommand
+ const execCommandMock = vi.fn().mockReturnValue(true);
+ document.execCommand = execCommandMock;
+
+ // 3. Spy on document.createElement, document.body.appendChild, and document.body.removeChild
+ // to verify the full fallback flow
+ const createElementSpy = vi.spyOn(document, "createElement");
+ const appendChildSpy = vi.spyOn(document.body, "appendChild");
+ const removeChildSpy = vi.spyOn(document.body, "removeChild");
+
+ render();
+
+ const copyButton = screen.getByRole("button", { name: "Copy profile URL" });
+
+ fireEvent.click(copyButton);
+
+ await waitFor(() => {
+ expect(writeTextMock).toHaveBeenCalledWith("http://localhost/johndoe");
+ });
+
+ await waitFor(() => {
+ expect(createElementSpy).toHaveBeenCalledWith("textarea");
+
+ // Find the appendChild call that appends the textarea (since React might also call appendChild)
+ const textareaAppendCall = appendChildSpy.mock.calls.find(
+ (call) => (call[0] as HTMLElement).tagName === "TEXTAREA"
+ );
+
+ expect(textareaAppendCall).toBeDefined();
+ if (textareaAppendCall) {
+ const appendedNode = textareaAppendCall[0] as HTMLTextAreaElement;
+ expect(appendedNode.value).toBe("http://localhost/johndoe");
+
+ expect(execCommandMock).toHaveBeenCalledWith("copy");
+
+ // Verify removeChild was called with the same element
+ expect(removeChildSpy).toHaveBeenCalledWith(appendedNode);
+ }
+ });
+
+ // Clear out React's state updates
+ await act(async () => {
+ vi.advanceTimersByTime(2500);
+ });
+ });
+
+ it("uses navigator.clipboard.writeText when available and successful", async () => {
+ // Mock clipboard.writeText to succeed
+ const writeTextMock = vi.fn().mockResolvedValue(undefined);
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: writeTextMock,
+ },
+ });
+
+ const execCommandMock = vi.fn().mockReturnValue(true);
+ document.execCommand = execCommandMock;
+
+ render();
+
+ const copyButton = screen.getByRole("button", { name: "Copy profile URL" });
+
+ fireEvent.click(copyButton);
+
+ await waitFor(() => {
+ expect(writeTextMock).toHaveBeenCalledWith("http://localhost/johndoe");
+ });
+
+ // Fallback should not be triggered
+ expect(execCommandMock).not.toHaveBeenCalled();
+
+ // Clear out React's state updates
+ await act(async () => {
+ vi.advanceTimersByTime(2500);
+ });
+ });
+
+ it("shows 'Copied!' feedback after copying", async () => {
+ // Mock clipboard.writeText to succeed
+ const writeTextMock = vi.fn().mockResolvedValue(undefined);
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: writeTextMock,
+ },
+ });
+
+ render();
+
+ const copyButton = screen.getByRole("button", { name: "Copy profile URL" });
+
+ fireEvent.click(copyButton);
+
+ // Check if the button text changes
+ await waitFor(() => {
+ expect(screen.getByText("Copied!")).toBeDefined();
+ });
+
+ // Fast forward time
+ await act(async () => {
+ vi.advanceTimersByTime(2500);
+ });
+
+ // Check if the button text changes back
+ await waitFor(() => {
+ expect(screen.getByText("Copy URL")).toBeDefined();
+ });
+ });
+});
diff --git a/src/components/ThemeController.tsx b/src/components/ThemeController.tsx
index 12986f5..b403b49 100644
--- a/src/components/ThemeController.tsx
+++ b/src/components/ThemeController.tsx
@@ -1,8 +1,6 @@
"use client";
-import { useEffect } from "react";
-import { FastAverageColor } from "fast-average-color";
-import { adjustAccentColor } from "@/lib/color";
+import { useThemeColor } from "@/hooks/useThemeColor";
type Props = {
avatarUrl?: string;
@@ -10,56 +8,6 @@ type Props = {
};
export default function ThemeController({ avatarUrl, topLanguageColor }: Props) {
- useEffect(() => {
- // 1. Apply top language color immediately as a fallback/initial state
- if (topLanguageColor) {
- applyColor(topLanguageColor);
- }
-
- const fac = new FastAverageColor();
- let isMounted = true;
-
- // 2. Extract color from avatar asynchronously
- if (avatarUrl) {
- const img = new Image();
- img.crossOrigin = "Anonymous";
- img.src = avatarUrl;
-
- // Use getColorAsync to extract color
- fac.getColorAsync(img, {
- algorithm: 'dominant', // 'dominant' or 'simple' (average)
- })
- .then((color) => {
- if (isMounted) {
- // color.value is [r, g, b, a]
- applyColor(color.value.slice(0, 3) as [number, number, number]);
- }
- })
- .catch((e) => {
- console.warn("Failed to extract color from avatar, keeping fallback color.", e);
- });
- }
-
- // Cleanup: Reset to default theme colors on unmount
- return () => {
- isMounted = false;
- fac.destroy();
- resetColor();
- };
- }, [avatarUrl, topLanguageColor]);
-
+ useThemeColor({ avatarUrl, topLanguageColor });
return null;
}
-
-function applyColor(color: string | [number, number, number]) {
- const result = adjustAccentColor(color);
- document.documentElement.style.setProperty("--accent", result.accent);
- document.documentElement.style.setProperty("--accent-rgb", result.accentRgb);
- document.documentElement.style.setProperty("--accent-hover", result.accentHover);
-}
-
-function resetColor() {
- document.documentElement.style.removeProperty("--accent");
- document.documentElement.style.removeProperty("--accent-rgb");
- document.documentElement.style.removeProperty("--accent-hover");
-}
diff --git a/src/hooks/__tests__/useDashboardData.test.ts b/src/hooks/__tests__/useDashboardData.test.ts
new file mode 100644
index 0000000..60fa150
--- /dev/null
+++ b/src/hooks/__tests__/useDashboardData.test.ts
@@ -0,0 +1,156 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { useDashboardData } from "../useDashboardData";
+import { useSession } from "next-auth/react";
+import useSWR from "swr";
+
+vi.mock("next-auth/react");
+vi.mock("swr");
+
+type MockSessionReturn = ReturnType;
+type MockSWRReturn = ReturnType;
+
+describe("useDashboardData", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("handles loading state", () => {
+ vi.mocked(useSession).mockReturnValue({
+ data: null,
+ status: "loading",
+ update: vi.fn(),
+ } satisfies MockSessionReturn as unknown as MockSessionReturn);
+
+ vi.mocked(useSWR).mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ } satisfies MockSWRReturn as unknown as MockSWRReturn);
+
+ const { result } = renderHook(() => useDashboardData());
+
+ expect(useSWR).toHaveBeenCalledWith(null, expect.any(Function));
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.session).toBeNull();
+ expect(result.current.status).toBe("loading");
+ });
+
+ it("handles unauthenticated state", () => {
+ vi.mocked(useSession).mockReturnValue({
+ data: null,
+ status: "unauthenticated",
+ update: vi.fn(),
+ } satisfies MockSessionReturn as unknown as MockSessionReturn);
+
+ vi.mocked(useSWR).mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ } satisfies MockSWRReturn as unknown as MockSWRReturn);
+
+ const { result } = renderHook(() => useDashboardData());
+
+ expect(useSWR).toHaveBeenCalledWith(null, expect.any(Function));
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.session).toBeNull();
+ expect(result.current.status).toBe("unauthenticated");
+ });
+
+ it("handles authenticated state but without token", () => {
+ vi.mocked(useSession).mockReturnValue({
+ data: { user: { name: "test" }, expires: "2030-01-01T00:00:00.000Z" },
+ status: "authenticated",
+ update: vi.fn(),
+ } satisfies MockSessionReturn as unknown as MockSessionReturn);
+
+ vi.mocked(useSWR).mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ } satisfies MockSWRReturn as unknown as MockSWRReturn);
+
+ const { result } = renderHook(() => useDashboardData());
+
+ expect(useSWR).toHaveBeenCalledWith(null, expect.any(Function));
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it("fetches data when authenticated with token", () => {
+ const mockMutate = vi.fn();
+ const mockSession = {
+ data: { accessToken: "token123", user: { name: "test" }, expires: "2030-01-01T00:00:00.000Z" },
+ status: "authenticated" as const,
+ update: vi.fn(),
+ };
+ vi.mocked(useSession).mockReturnValue(mockSession satisfies MockSessionReturn as unknown as MockSessionReturn);
+
+ vi.mocked(useSWR).mockReturnValue({
+ data: {
+ username: "testuser",
+ summary: { totalCommits: 100 }
+ },
+ error: null,
+ isLoading: false,
+ isValidating: false,
+ mutate: mockMutate,
+ } satisfies MockSWRReturn as unknown as MockSWRReturn);
+
+ const { result } = renderHook(() => useDashboardData());
+
+ expect(useSWR).toHaveBeenCalledWith("/api/dashboard/summary", expect.any(Function));
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.username).toBe("testuser");
+ expect(result.current.summary).toEqual({ totalCommits: 100 });
+ expect(result.current.error).toBeNull();
+ expect(result.current.mutate).toBe(mockMutate);
+ });
+
+ it("handles SWR error", () => {
+ const mockError = new Error("SWR failed");
+ vi.mocked(useSession).mockReturnValue({
+ data: { accessToken: "token123", expires: "2030-01-01T00:00:00.000Z" },
+ status: "authenticated",
+ update: vi.fn(),
+ } satisfies MockSessionReturn as unknown as MockSessionReturn);
+
+ vi.mocked(useSWR).mockReturnValue({
+ data: undefined,
+ error: mockError,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ } satisfies MockSWRReturn as unknown as MockSWRReturn);
+
+ const { result } = renderHook(() => useDashboardData());
+
+ expect(result.current.error).toBe(mockError);
+ });
+
+ it("handles SWR loading", () => {
+ vi.mocked(useSession).mockReturnValue({
+ data: { accessToken: "token123", expires: "2030-01-01T00:00:00.000Z" },
+ status: "authenticated",
+ update: vi.fn(),
+ } satisfies MockSessionReturn as unknown as MockSessionReturn);
+
+ vi.mocked(useSWR).mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: true,
+ isValidating: false,
+ mutate: vi.fn(),
+ } satisfies MockSWRReturn as unknown as MockSWRReturn);
+
+ const { result } = renderHook(() => useDashboardData());
+
+ expect(result.current.isLoading).toBe(true);
+ });
+});
diff --git a/src/hooks/useDashboardData.test.ts b/src/hooks/useDashboardData.test.ts
new file mode 100644
index 0000000..6cdcdad
--- /dev/null
+++ b/src/hooks/useDashboardData.test.ts
@@ -0,0 +1,394 @@
+import { renderHook } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { useDashboardData, useYearInReview, useDashboardStats } from './useDashboardData';
+import * as nextAuthReact from 'next-auth/react';
+import * as swr from 'swr';
+
+vi.mock('next-auth/react', () => ({
+ useSession: vi.fn(),
+}));
+
+vi.mock('swr', () => ({
+ default: vi.fn(),
+}));
+
+describe('useDashboardData', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should not fetch when unauthenticated', () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: null,
+ status: 'unauthenticated',
+ } as unknown as ReturnType);
+
+ vi.mocked(swr.default).mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ });
+
+ const { result } = renderHook(() => useDashboardData());
+
+ expect(swr.default).toHaveBeenCalledWith(null, expect.any(Function));
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should not fetch when loading', () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: null,
+ status: 'loading',
+ } as unknown as ReturnType);
+
+ vi.mocked(swr.default).mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ });
+
+ const { result } = renderHook(() => useDashboardData());
+
+ expect(swr.default).toHaveBeenCalledWith(null, expect.any(Function));
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ it('should fetch when authenticated with token', () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: { accessToken: 'token123' },
+ status: 'authenticated',
+ } as unknown as ReturnType);
+
+ const mockData = {
+ username: 'testuser',
+ summary: { totalStars: 10 }
+ };
+
+ vi.mocked(swr.default).mockReturnValue({
+ data: mockData,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ });
+
+ const { result } = renderHook(() => useDashboardData());
+
+ expect(swr.default).toHaveBeenCalledWith('/api/dashboard/summary', expect.any(Function));
+ expect(result.current.username).toBe('testuser');
+ expect(result.current.summary).toEqual({ totalStars: 10 });
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should handle error from swr', () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: { accessToken: 'token123' },
+ status: 'authenticated',
+ } as unknown as ReturnType);
+
+ const error = new Error('Failed to fetch');
+ vi.mocked(swr.default).mockReturnValue({
+ data: undefined,
+ error,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ });
+
+ const { result } = renderHook(() => useDashboardData());
+
+ expect(result.current.error).toBe(error);
+ });
+});
+
+describe('useYearInReview', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should not fetch when unauthenticated', () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: null,
+ status: 'unauthenticated',
+ } as unknown as ReturnType);
+
+ vi.mocked(swr.default).mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ });
+
+ const { result } = renderHook(() => useYearInReview(2023));
+
+ expect(swr.default).toHaveBeenCalledWith(null, expect.any(Function));
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should not fetch when year is null', () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: { accessToken: 'token123' },
+ status: 'authenticated',
+ } as unknown as ReturnType);
+
+ vi.mocked(swr.default).mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ });
+
+ renderHook(() => useYearInReview(null));
+
+ expect(swr.default).toHaveBeenCalledWith(null, expect.any(Function));
+ });
+
+ it('should fetch when authenticated with token and valid year', () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: { accessToken: 'token123' },
+ status: 'authenticated',
+ } as unknown as ReturnType);
+
+ const mockData = { totalContributions: 100 };
+
+ vi.mocked(swr.default).mockReturnValue({
+ data: mockData,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ });
+
+ const { result } = renderHook(() => useYearInReview(2023));
+
+ expect(swr.default).toHaveBeenCalledWith('/api/dashboard/year?year=2023', expect.any(Function));
+ expect(result.current.data).toEqual(mockData);
+ expect(result.current.isLoading).toBe(false);
+ });
+});
+
+describe('useDashboardStats', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should not fetch when unauthenticated', () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: null,
+ status: 'unauthenticated',
+ } as unknown as ReturnType);
+
+ vi.mocked(swr.default).mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ });
+
+ const { result } = renderHook(() => useDashboardStats(2023));
+
+ expect(swr.default).toHaveBeenCalledWith(null, expect.any(Function));
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('should not fetch when year is null', () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: { accessToken: 'token123' },
+ status: 'authenticated',
+ } as unknown as ReturnType);
+
+ vi.mocked(swr.default).mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ });
+
+ renderHook(() => useDashboardStats(null));
+
+ expect(swr.default).toHaveBeenCalledWith(null, expect.any(Function));
+ });
+
+ it('should fetch when authenticated with token and valid year', () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: { accessToken: 'token123' },
+ status: 'authenticated',
+ } as unknown as ReturnType);
+
+ const mockData = { year: 2023, heatmap: [[1, 2]] };
+
+ vi.mocked(swr.default).mockReturnValue({
+ data: mockData,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ });
+
+ const { result } = renderHook(() => useDashboardStats(2023));
+
+ expect(swr.default).toHaveBeenCalledWith('/api/dashboard/stats?year=2023', expect.any(Function));
+ expect(result.current.year).toBe(2023);
+ expect(result.current.heatmap).toEqual([[1, 2]]);
+ expect(result.current.isLoading).toBe(false);
+ });
+});
+
+describe('fetcher', () => {
+ const originalFetch = global.fetch;
+
+ beforeEach(() => {
+ global.fetch = vi.fn();
+ });
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ });
+
+ it('should fetch data successfully', async () => {
+ // We need to extract fetcher from the module, since it's not exported
+ // The easiest way is to mock useSWR implementation and trigger the fetcher
+
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: { accessToken: 'token123' },
+ status: 'authenticated',
+ } as unknown as ReturnType);
+
+ let capturedFetcher: Parameters[1] | undefined;
+ vi.mocked(swr.default).mockImplementation((url, fetcher) => {
+ capturedFetcher = fetcher;
+ return {
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ } as unknown as ReturnType
+ });
+
+ renderHook(() => useDashboardData());
+
+ expect(capturedFetcher).toBeDefined();
+
+ const mockResponse = { data: 'test data' };
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+
+ ok: true,
+ json: async () => mockResponse,
+ } as unknown as Response);
+
+ const result = await (capturedFetcher as NonNullable)('/test-url');
+ expect(result).toEqual(mockResponse);
+ expect(global.fetch).toHaveBeenCalledWith('/test-url');
+ });
+
+ it('should handle fetch error with text body', async () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: { accessToken: 'token123' },
+ status: 'authenticated',
+ } as unknown as ReturnType);
+
+ let capturedFetcher: Parameters[1] | undefined;
+ vi.mocked(swr.default).mockImplementation((url, fetcher) => {
+ capturedFetcher = fetcher;
+ return {
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ } as unknown as ReturnType
+ });
+
+ renderHook(() => useDashboardData());
+
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+
+ ok: false,
+ status: 404,
+ text: async () => 'Not Found',
+ } as unknown as Response);
+
+ await expect((capturedFetcher as NonNullable)('/test-url')).rejects.toThrow('Not Found');
+ });
+
+ it('should handle fetch error without text body', async () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: { accessToken: 'token123' },
+ status: 'authenticated',
+ } as unknown as ReturnType);
+
+ let capturedFetcher: Parameters[1] | undefined;
+ vi.mocked(swr.default).mockImplementation((url, fetcher) => {
+ capturedFetcher = fetcher;
+ return {
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ } as unknown as ReturnType
+ });
+
+ renderHook(() => useDashboardData());
+
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+
+ ok: false,
+ status: 500,
+ text: async () => { throw new Error('Cannot read body') },
+ } as unknown as Response);
+
+ await expect((capturedFetcher as NonNullable)('/test-url')).rejects.toThrow('Unknown error');
+ });
+});
+
+ describe('fetcher extended', () => {
+ const originalFetch = global.fetch;
+
+ beforeEach(() => {
+ global.fetch = vi.fn();
+ });
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ });
+
+ it('should handle fetch error without text body and with status code', async () => {
+ vi.mocked(nextAuthReact.useSession).mockReturnValue({
+ data: { accessToken: 'token123' },
+ status: 'authenticated',
+ } as unknown as ReturnType);
+
+ let capturedFetcher: Parameters[1] | undefined;
+ vi.mocked(swr.default).mockImplementation((url, fetcher) => {
+ capturedFetcher = fetcher;
+ return {
+ data: undefined,
+ error: undefined,
+ isLoading: false,
+ isValidating: false,
+ mutate: vi.fn(),
+ } as unknown as ReturnType
+ });
+
+ renderHook(() => useDashboardData());
+
+ vi.mocked(global.fetch).mockResolvedValueOnce({
+
+ ok: false,
+ status: 500,
+ text: async () => '',
+ } as unknown as Response);
+
+ await expect((capturedFetcher as NonNullable)('/test-url')).rejects.toThrow('Request failed (500)');
+ });
+});
diff --git a/src/hooks/useThemeColor.ts b/src/hooks/useThemeColor.ts
new file mode 100644
index 0000000..98e0afb
--- /dev/null
+++ b/src/hooks/useThemeColor.ts
@@ -0,0 +1,61 @@
+import { useEffect } from "react";
+import { FastAverageColor } from "fast-average-color";
+import { adjustAccentColor } from "@/lib/color";
+
+function applyColor(color: string | [number, number, number]) {
+ const result = adjustAccentColor(color);
+ document.documentElement.style.setProperty("--accent", result.accent);
+ document.documentElement.style.setProperty("--accent-rgb", result.accentRgb);
+ document.documentElement.style.setProperty("--accent-hover", result.accentHover);
+}
+
+function resetColor() {
+ document.documentElement.style.removeProperty("--accent");
+ document.documentElement.style.removeProperty("--accent-rgb");
+ document.documentElement.style.removeProperty("--accent-hover");
+}
+
+type UseThemeColorOptions = {
+ avatarUrl?: string;
+ topLanguageColor?: string;
+};
+
+export function useThemeColor({ avatarUrl, topLanguageColor }: UseThemeColorOptions) {
+ useEffect(() => {
+ // 1. Apply top language color immediately as a fallback/initial state
+ if (topLanguageColor) {
+ applyColor(topLanguageColor);
+ }
+
+ const fac = new FastAverageColor();
+ let isMounted = true;
+
+ // 2. Extract color from avatar asynchronously
+ if (avatarUrl) {
+ const img = new Image();
+ img.crossOrigin = "Anonymous";
+ img.src = avatarUrl;
+
+ // Use getColorAsync to extract color
+ fac.getColorAsync(img, {
+ algorithm: 'dominant', // 'dominant' or 'simple' (average)
+ })
+ .then((color) => {
+ if (isMounted) {
+ // color.value is [r, g, b, a]
+ applyColor(color.value.slice(0, 3) as [number, number, number]);
+ }
+ })
+ .catch((e) => {
+ console.warn("Failed to extract color from avatar, keeping fallback color.", e);
+ });
+ }
+
+ // Cleanup: Reset to default theme colors on unmount
+ return () => {
+ isMounted = false;
+ fac.destroy();
+ resetColor();
+ };
+ }, [avatarUrl, topLanguageColor]);
+}
diff --git a/src/lib/__tests__/cardSettings.test.ts b/src/lib/__tests__/cardSettings.test.ts
new file mode 100644
index 0000000..b6362ef
--- /dev/null
+++ b/src/lib/__tests__/cardSettings.test.ts
@@ -0,0 +1,103 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { getDefaultCardSettings, loadCardSettings, saveCardSettings } from "../cardSettings";
+import { DEFAULT_CARD_LAYOUT, CardLayout, CardDisplayOptions } from "../types";
+
+describe("cardSettings", () => {
+ let originalWindow: typeof window | undefined;
+ let getItemMock: ReturnType;
+ let setItemMock: ReturnType;
+
+ beforeEach(() => {
+ originalWindow = globalThis.window;
+ getItemMock = vi.fn();
+ setItemMock = vi.fn();
+
+ vi.stubGlobal("window", {
+ localStorage: {
+ getItem: getItemMock,
+ setItem: setItemMock,
+ },
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ describe("loadCardSettings", () => {
+ it("returns defaults when window is undefined", () => {
+ // Remove window from global object to simulate SSR environment
+ vi.stubGlobal("window", undefined);
+ const result = loadCardSettings();
+ expect(result.layout).toEqual(DEFAULT_CARD_LAYOUT);
+ expect(result.options.showCompany).toBe(true);
+ expect(result.options.showTwitter).toBe(true);
+ });
+
+ it("returns defaults when localStorage is empty", () => {
+ getItemMock.mockReturnValue(null);
+ const result = loadCardSettings();
+ expect(result.layout).toEqual(DEFAULT_CARD_LAYOUT);
+ expect(result.options.showCompany).toBe(true);
+ });
+
+ it("safely handles invalid JSON in localStorage (safeParse)", () => {
+ // Mock returning invalid JSON
+ getItemMock.mockReturnValue("{invalid-json: true");
+
+ const result = loadCardSettings();
+
+ // Should fallback to defaults
+ expect(result.layout).toEqual(DEFAULT_CARD_LAYOUT);
+ expect(result.options.showCompany).toBe(true);
+ expect(getItemMock).toHaveBeenCalledTimes(2); // One for layout, one for options
+ });
+
+ it("returns parsed settings from localStorage when window is defined", () => {
+ const mockLayout: CardLayout = { blocks: [{ id: "bio", visible: true, column: "left" }] };
+ const mockOptions: Partial = { showTwitter: false, showLocation: false };
+
+ getItemMock.mockImplementation((key: string) => {
+ if (key === "card-layout") return JSON.stringify(mockLayout);
+ if (key === "card-display-options") return JSON.stringify(mockOptions);
+ return null;
+ });
+
+ const settings = loadCardSettings();
+
+ expect(settings.layout).toEqual(mockLayout);
+ expect(settings.options.showTwitter).toBe(false);
+ expect(settings.options.showLocation).toBe(false);
+ expect(settings.options.showCompany).toBe(true); // default option should be preserved
+ expect(getItemMock).toHaveBeenCalledWith("card-layout");
+ expect(getItemMock).toHaveBeenCalledWith("card-display-options");
+ });
+ });
+
+ describe("saveCardSettings", () => {
+ it("does nothing when window is undefined", () => {
+ vi.stubGlobal("window", undefined);
+ saveCardSettings(DEFAULT_CARD_LAYOUT, getDefaultCardSettings().options);
+ expect(setItemMock).not.toHaveBeenCalled();
+ });
+
+ it("saves settings to localStorage", () => {
+ const customLayout: CardLayout = { blocks: [{ id: "bio", visible: true, column: "full" }] };
+ const customOptions: Partial = { showCompany: false };
+
+ saveCardSettings(customLayout, customOptions);
+
+ expect(setItemMock).toHaveBeenCalledWith("card-layout", JSON.stringify(customLayout));
+ expect(setItemMock).toHaveBeenCalledWith("card-display-options", JSON.stringify(customOptions));
+ });
+ });
+
+ describe("getDefaultCardSettings", () => {
+ it("returns default settings", () => {
+ const result = getDefaultCardSettings();
+ expect(result.layout).toEqual(DEFAULT_CARD_LAYOUT);
+ expect(result.options.showCompany).toBe(true);
+ });
+ });
+});
diff --git a/src/lib/github.ts b/src/lib/github.ts
index 86ec3b4..6e2cb11 100644
--- a/src/lib/github.ts
+++ b/src/lib/github.ts
@@ -553,8 +553,8 @@ export async function fetchStarredRepos(
): Promise {
const allStarred: StarredRepo[] = [];
- for (let page = 1; page <= 2; page += 1) {
- const res = await fetch(
+ const fetchPage = (page: number) =>
+ fetch(
`${GITHUB_API}/users/${encodeURIComponent(username)}/starred?per_page=100&page=${page}`,
{
headers: {
@@ -565,12 +565,14 @@ export async function fetchStarredRepos(
}
);
- const starred = await handleResponse(res);
- allStarred.push(...starred);
+ const [res1, res2] = await Promise.all([fetchPage(1), fetchPage(2)]);
- if (starred.length < 100) {
- break;
- }
+ const starred1 = await handleResponse(res1);
+ allStarred.push(...starred1);
+
+ if (starred1.length === 100) {
+ const starred2 = await handleResponse(res2);
+ allStarred.push(...starred2);
}
const topicCounts = new Map();
@@ -676,6 +678,21 @@ export async function fetchActivity(
// ===== 6. fetchUserSummary =====
+/**
+ * 結果を処理し、エラーがあれば記録するヘルパー関数
+ */
+function processResult(
+ result: PromiseSettledResult,
+ section: string,
+ errors: { section: string; message: string }[]
+): T | null {
+ if (result.status === "fulfilled") {
+ return result.value;
+ }
+ errors.push({ section, message: result.reason?.message ?? String(result.reason ?? "Unknown error") });
+ return null;
+}
+
/**
* 全セクションを並行取得し、UserSummary として集約
* Promise.allSettled で部分失敗に対応(profile 404 のみ再スロー)
@@ -685,7 +702,13 @@ export async function fetchUserSummary(
username: string,
token?: string
): Promise {
- const results = await Promise.allSettled([
+ const [
+ profileResult,
+ repositoriesResult,
+ contributionsResult,
+ activityResult,
+ interestsResult,
+ ] = await Promise.allSettled([
fetchUserProfile(username, token),
fetchRepositories(username, token),
fetchContributions(username, token),
@@ -693,28 +716,19 @@ export async function fetchUserSummary(
fetchStarredRepos(username, token),
]);
- const errors: { section: string; message: string }[] = [];
- const sections = ["profile", "repositories", "contributions", "activity", "interests"] as const;
-
- const values = results.map((r, i) => {
- if (r.status === "fulfilled") {
- return r.value;
- }
- errors.push({ section: sections[i], message: r.reason?.message ?? "Unknown error" });
- return null;
- });
-
// profileが404の場合はUserNotFoundErrorを再スロー
- if (results[0].status === "rejected" && results[0].reason instanceof UserNotFoundError) {
- throw results[0].reason;
+ if (profileResult.status === "rejected" && profileResult.reason instanceof UserNotFoundError) {
+ throw profileResult.reason;
}
+ const errors: { section: string; message: string }[] = [];
+
return {
- profile: values[0] as UserProfile | null,
- repositories: values[1] as RepositoryData | null,
- contributions: values[2] as ContributionData | null,
- activity: values[3] as ActivityData | null,
- interests: values[4] as InterestsData | null,
+ profile: processResult(profileResult, "profile", errors),
+ repositories: processResult(repositoriesResult, "repositories", errors),
+ contributions: processResult(contributionsResult, "contributions", errors),
+ activity: processResult(activityResult, "activity", errors),
+ interests: processResult(interestsResult, "interests", errors),
errors,
};
}
diff --git a/src/lib/githubYearInReview.ts b/src/lib/githubYearInReview.ts
index ca4e113..e63f849 100644
--- a/src/lib/githubYearInReview.ts
+++ b/src/lib/githubYearInReview.ts
@@ -128,9 +128,7 @@ async function fetchCommitDatesForTopRepos(
.sort((a, b) => b.contributions.totalCount - a.contributions.totalCount)
.slice(0, 4);
- const allDates: string[] = [];
-
- for (const repo of candidates) {
+ const promises = candidates.map(async (repo) => {
const path = `/repos/${repo.repository.owner.login}/${repo.repository.name}/commits`;
const url = new URL(`${GITHUB_API}${path}`);
url.searchParams.set("author", username);
@@ -143,19 +141,22 @@ async function fetchCommitDatesForTopRepos(
handleRateLimit(res);
}
if (!res.ok) {
- continue;
+ return [];
}
const commits = (await res.json()) as GitHubCommit[];
+ const dates: string[] = [];
for (const commit of commits) {
const date = commit.commit.author?.date;
if (date) {
- allDates.push(date);
+ dates.push(date);
}
}
- }
+ return dates;
+ });
- return allDates;
+ const results = await Promise.all(promises);
+ return results.flat();
}
export async function fetchYearInReviewData(username: string, year: number, token?: string): Promise {
diff --git a/vitest.config.ts b/vitest.config.ts
index 34750ef..27cb5a9 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -5,13 +5,13 @@ import path from "path";
export default defineConfig({
plugins: [react()],
test: {
- environment: "node",
+ environment: "jsdom",
globals: true,
include: ["src/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
- include: ["src/lib/**/*.ts"],
+ include: ["src/lib/**/*.ts", "src/hooks/**/*.ts"],
},
},
resolve: {