diff --git a/README.md b/README.md index 6996fec..1a3bc5b 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,86 @@ -# T23-Project-1 +# T23-Project-1 — Country Explorer -Repository for team IT2810-H26-T23 in IT2810-H26. +**[Open the web app on the group VM](http://it2810-23.idi.ntnu.no/project1/)** + +Country Explorer lets you browse European countries, filter by subregion, sort by +name or population, and save favorites. Built with React, TypeScript and Vite by +team IT2810-H26-T23. + +## Run locally + +1. Clone the repository and enter the application folder: + + ```bash + git clone https://git.ntnu.no/IT2810-H26/T23-Project-1.git + cd T23-Project-1/country-explorer + ``` + +2. Install the locked dependencies: + + ```bash + npm ci + ``` + +3. Start the development server: + + ```bash + npm run dev + ``` + +4. Open [the local app](http://localhost:5173/project1/). If Vite reports a + different port, use that port instead. Stop the server with `Ctrl+C`. + +## Test and build + +Run these commands from `country-explorer/`: + +```bash +npm test # Run automated tests +npm run lint # Check code with ESLint +npm run format:check # Check formatting +npm run build # Type-check and generate dist/ +npm run preview # Preview the production build locally +``` + +The preview normally runs at `http://localhost:4173/project1/`. +See the deployment document below for serving the build on the VM. + +## Documentation + +- [Component structure](docs/component-structure.md): components, props, state ownership and initial wireframes. +- [API performance](docs/api-performance.md): observed network requests and TanStack Query caching. +- [Testing](docs/testing.md): automated checks, PC/iPhone testing and coverage limitations. +- [Deployment](docs/deployment.md): production build, VM setup and deployment verification. +- [Usability evaluation](docs/usability-evaluation.md): the group's assessment and possible mobile improvements. +- [AI usage](docs/ai-usage.md): tools used, their role and the group's review of suggestions. +- [Git workflow](docs/git-workflow.md): branch and commit templates, pull requests and review rules. +- [Sprint process](docs/sprint-process.md): weekly meetings, issue allocation and progress discussions. +- [REST API details](country-explorer/src/api/README.md): data source, country fields and error handling. ## Working in this repository -`main` is protected — you cannot push to it directly. Work on a branch and -open a pull request: +Development work is integrated through `dev`. Start each issue branch from an +up-to-date `dev` branch, push the issue branch, and open a pull request into +`dev`: ```bash -git checkout -b my-feature -git push -u origin my-feature +git switch dev +git pull --ff-only +git switch -c "-" +git push -u origin "-" ``` +When the complete project is ready for release, `dev` is merged into `main`. +Both shared branches are protected, so changes reach them through reviewed +merge requests rather than direct pushes. + Before a pull request can be merged: 1. **A member of IT2810-H26-T23 must approve it.** You cannot approve your own pull request, and approvals from staff do not count — you review each other's code. 2. **Every review comment must be marked resolved.** Reply to each one, fix it - or explain why not, then click *Resolve conversation*. + or explain why not, then click _Resolve conversation_. 3. **Pushing new commits dismisses the approval**, so if you change something after being approved, ask for another review. This is deliberate: what was - approved is what gets merged. \ No newline at end of file + approved is what gets merged. diff --git a/country-explorer/.gitignore b/country-explorer/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/country-explorer/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/country-explorer/.prettierignore b/country-explorer/.prettierignore new file mode 100644 index 0000000..1786133 --- /dev/null +++ b/country-explorer/.prettierignore @@ -0,0 +1,17 @@ +# Dependencies +node_modules + +# Build output +dist +dist-ssr + +# Logs +*.log + +# Auto-generated, not meant to be formatted +package-lock.json + +# Editor / OS +.vscode +.idea +.DS_Store diff --git a/country-explorer/.prettierrc.json b/country-explorer/.prettierrc.json new file mode 100644 index 0000000..ef2237c --- /dev/null +++ b/country-explorer/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "endOfLine": "lf" +} diff --git a/country-explorer/README.md b/country-explorer/README.md new file mode 100644 index 0000000..719fa9a --- /dev/null +++ b/country-explorer/README.md @@ -0,0 +1,39 @@ +# Country Explorer application + +This directory contains the React, TypeScript, and Vite application. It fetches +European country data from a REST API and lets users filter, sort, navigate, and +save favorite countries. + +Project-level installation, testing, deployment, and Git workflow instructions +are in the [main README](../README.md). Details about the data source and mapping +are in the [API documentation](./src/api/README.md). + +## Project structure + +```text +src/ +├── api/ # REST API client and API documentation +├── components/ # React components, styles, and component tests +├── hooks/ # Custom React and TanStack Query hooks +├── lib/ # Shared library configuration +├── test/ # Shared test setup and fixtures +├── types/ # Shared TypeScript types and interfaces +├── utils/ # Storage and sorting helpers +├── assets/ # Images and other bundled assets +├── index.css # Global stylesheet +└── main.tsx # Application entry point +``` + +Naming conventions: + +- Components use PascalCase file names matching their default export and keep + their CSS and tests beside the component. +- Hooks use camelCase names beginning with `use`. +- API functions and utilities use descriptive camelCase names. +- Shared domain types use descriptive camelCase file names. + +## Tooling + +The app uses React, TanStack Query, TypeScript, Vite, ESLint, Prettier, Vitest, +and Testing Library. The React Compiler is not enabled. Vite's base path is +`/project1/`, matching the deployment path on the group VM. diff --git a/country-explorer/eslint.config.js b/country-explorer/eslint.config.js new file mode 100644 index 0000000..85a3641 --- /dev/null +++ b/country-explorer/eslint.config.js @@ -0,0 +1,26 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import eslintConfigPrettier from 'eslint-config-prettier'; + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + // Must stay last: turns off ESLint stylistic rules that would + // otherwise conflict with Prettier's formatting. + eslintConfigPrettier, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]); diff --git a/country-explorer/index.html b/country-explorer/index.html new file mode 100644 index 0000000..c6a2416 --- /dev/null +++ b/country-explorer/index.html @@ -0,0 +1,13 @@ + + + + + + + Country Explorer + + +
+ + + diff --git a/country-explorer/package-lock.json b/country-explorer/package-lock.json new file mode 100644 index 0000000..370b12c --- /dev/null +++ b/country-explorer/package-lock.json @@ -0,0 +1,4112 @@ +{ + "name": "country-explorer", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "country-explorer", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.102.8", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.6", + "globals": "^17.12.0", + "jsdom": "^25.0.0", + "prettier": "^3.8.3", + "typescript": "~6.0.2", + "typescript-eslint": "^8.69.0", + "vite": "^8.3.0", + "vitest": "^5.0.1" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "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.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "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", + "peer": true, + "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", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz", + "integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "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/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "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/@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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "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.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "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": "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/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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "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/cssstyle/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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/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", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.426", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.426.tgz", + "integrity": "sha512-2Gcq6inCQs/AqfHP3f5ftCzk+pqeW2VlA1LgmPqEj2hfBO8HiZRspNYkD4HzFBe4u6lGqca4BspFr6Ix4Q400g==", + "dev": true, + "license": "ISC" + }, + "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-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "peer": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.6.tgz", + "integrity": "sha512-uZnh24On2bk478AkaDAKcpRiYhS3qFSaSNvpXxV6/Ek/BBDICkWGG7MnBSfjnIlCVBei5vsLlIeQYxbF22+Udg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "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-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/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": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "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": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "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/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "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/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.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/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/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/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "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-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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", + "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/country-explorer/package.json b/country-explorer/package.json new file mode 100644 index 0000000..f8b595d --- /dev/null +++ b/country-explorer/package.json @@ -0,0 +1,42 @@ +{ + "name": "country-explorer", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@tanstack/react-query": "^5.102.8", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.6", + "globals": "^17.12.0", + "jsdom": "^25.0.0", + "prettier": "^3.8.3", + "typescript": "~6.0.2", + "typescript-eslint": "^8.69.0", + "vite": "^8.3.0", + "vitest": "^5.0.1" + } +} diff --git a/country-explorer/public/favicon.svg b/country-explorer/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/country-explorer/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/country-explorer/public/icons.svg b/country-explorer/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/country-explorer/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/country-explorer/src/api/README.md b/country-explorer/src/api/README.md new file mode 100644 index 0000000..3c0c06f --- /dev/null +++ b/country-explorer/src/api/README.md @@ -0,0 +1,129 @@ +# REST API + +The application uses [countries.dev](https://countries.dev) to fetch country data. It's a free, publicly available API with no API key, sign-up, or authentication required. It's the successor to `apicountries.com`, which now redirects to it, and returns the same field shape as the (now retired) `restcountries.com` v2 API. + +## Endpoints + +- `GET /region/{region}` — fetches every country in a region. The application + uses `/region/europe` to load its country list. +- `GET /alpha/{code}` — fetches a single country by its ISO alpha-2 or alpha-3 + code. A client function is implemented for this endpoint, but the current UI + does not call it. + +## Selected countries + +All 53 entries returned by `/region/europe`: + +| Country | Alpha-2 | Alpha-3 | +| ---------------------------------------------------- | ------- | ------- | +| Åland Islands | AX | ALA | +| Albania | AL | ALB | +| Andorra | AD | AND | +| Austria | AT | AUT | +| Belarus | BY | BLR | +| Belgium | BE | BEL | +| Bosnia and Herzegovina | BA | BIH | +| Bulgaria | BG | BGR | +| Croatia | HR | HRV | +| Cyprus | CY | CYP | +| Czech Republic | CZ | CZE | +| Denmark | DK | DNK | +| Estonia | EE | EST | +| Faroe Islands | FO | FRO | +| Finland | FI | FIN | +| France | FR | FRA | +| Germany | DE | DEU | +| Gibraltar | GI | GIB | +| Greece | GR | GRC | +| Guernsey | GG | GGY | +| Vatican City | VA | VAT | +| Hungary | HU | HUN | +| Iceland | IS | ISL | +| Ireland | IE | IRL | +| Isle of Man | IM | IMN | +| Italy | IT | ITA | +| Jersey | JE | JEY | +| Latvia | LV | LVA | +| Liechtenstein | LI | LIE | +| Lithuania | LT | LTU | +| Luxembourg | LU | LUX | +| North Macedonia | MK | MKD | +| Malta | MT | MLT | +| Moldova (Republic of) | MD | MDA | +| Monaco | MC | MCO | +| Montenegro | ME | MNE | +| Netherlands | NL | NLD | +| Norway | NO | NOR | +| Poland | PL | POL | +| Portugal | PT | PRT | +| Republic of Kosovo | XK | UNK | +| Romania | RO | ROU | +| Russian Federation | RU | RUS | +| San Marino | SM | SMR | +| Serbia | RS | SRB | +| Slovakia | SK | SVK | +| Slovenia | SI | SVN | +| Spain | ES | ESP | +| Svalbard and Jan Mayen | SJ | SJM | +| Sweden | SE | SWE | +| Switzerland | CH | CHE | +| Ukraine | UA | UKR | +| United Kingdom of Great Britain and Northern Ireland | GB | GBR | + +This list includes a handful of dependent territories alongside sovereign states (Åland Islands, Faroe Islands, Gibraltar, Guernsey, Isle of Man, Jersey, Svalbard and Jan Mayen), following the API's own region classification. + +## Data fields used + +| UI/internal field | API key(s) | Notes | +| ----------------- | --------------------- | --------------------------------------------------------- | +| Name | `name` | | +| Flag URL | `flags.svg` | Mapped to `flagUrl` | +| Capital | `capital` | | +| Region/filter | `region`, `subregion` | Subregion is preferred by `RegionFilter` | +| Population | `population` | | +| Area | `area` | Displayed in km² | +| Currencies | `currencies` | Names and codes are combined into display strings | +| Languages | `languages` | Language names become display strings | +| Country code | `alpha3Code` | Mapped to `code`; used for selection, favorites, and keys | + +## TypeScript models + +The shared types are defined in `src/types/country.ts`: + +- `CountryApiResponse` describes the API fields used for one country. +- `CountriesApiResponse` describes the array returned by the region endpoint. +- `Country` describes the internal data model used by components. + +The `toCountry` function in `src/api/countries.ts` converts API data +into the internal model: + +- `alpha3Code` becomes `code`, a stable country identifier. +- `flags.svg` becomes `flagUrl`. +- Currencies become strings containing the currency name and code. +- Languages become strings containing the language name. + +Only fields required by the application are retained. +Favorite status is managed separately as application state. + +## Error handling + +The API responds with a normal HTTP status code plus a small JSON error body: + +| Status | Meaning | Example body | +| ------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `400` | Invalid parameter (e.g. a non-numeric value where a numeric code is expected) | `{ "error": "..." }` | +| `404` | No match — unknown/typo'd code or name | `{ "error": "Country not found" }` | +| `429` | Abuse ceiling hit (~60 requests/second per IP; there's no API key or per-key quota) | `{ "error": "Too Many Requests", "message": "Rate limit reached" }` | + +Network failures and non-success HTTP responses, including `404`, are converted +to `ApiError` instances. TanStack Query exposes the error to `App`, which displays +an error message and a retry button. A failed request therefore produces a +recoverable error state rather than crashing the application. Normal UI use only +needs the `/region/europe` request, so the `429` ceiling should not be reached. + +## Data storage + +Fetched country data is only kept in the TanStack Query memory cache and is never +written to `localStorage` or `sessionStorage`. Only user-created state — favorites, +selected region, and sorting option — is persisted to Web Storage. See +[`docs/component-structure.md`](../../../docs/component-structure.md). diff --git a/country-explorer/src/api/countries.ts b/country-explorer/src/api/countries.ts new file mode 100644 index 0000000..290ca24 --- /dev/null +++ b/country-explorer/src/api/countries.ts @@ -0,0 +1,72 @@ +import type { CountriesApiResponse, Country, CountryApiResponse } from '../types/country'; + +const BASE_URL = 'https://countries.dev'; + +export class ApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +function toCountry(raw: CountryApiResponse): Country { + return { + code: raw.alpha3Code, + name: raw.name ?? '', + capital: raw.capital ?? '', + region: raw.region ?? '', + subregion: raw.subregion ?? '', + population: raw.population ?? null, + area: raw.area ?? null, + flagUrl: raw.flags?.svg ?? '', + currencies: (raw.currencies ?? []) + .map((currency) => + [currency.name, currency.code ? `(${currency.code})` : ''].filter(Boolean).join(' '), + ) + .filter(Boolean), + languages: (raw.languages ?? []).flatMap((language) => (language.name ? [language.name] : [])), + }; +} + +async function fetchJson(url: string): Promise { + let response: Response; + try { + response = await fetch(url); + } catch { + throw new ApiError(0, 'Network request failed. Check your internet connection.'); + } + + if (!response.ok) { + let message = response.statusText || `Request failed with status ${response.status}`; + try { + const body: unknown = await response.json(); + if (body && typeof body === 'object' && 'error' in body) { + message = String((body as { error: unknown }).error); + } + } catch { + // ignore, use the status text above + } + throw new ApiError(response.status, message); + } + + return response.json() as Promise; +} + +// GET /region/{region} +export async function getCountriesByRegion(region: string): Promise { + const raw = await fetchJson( + `${BASE_URL}/region/${encodeURIComponent(region)}`, + ); + + return raw.map(toCountry); +} + +// GET /alpha/{code} +export async function getCountryByAlpha(code: string): Promise { + const raw = await fetchJson(`${BASE_URL}/alpha/${encodeURIComponent(code)}`); + + return toCountry(raw); +} diff --git a/country-explorer/src/assets/hero.png b/country-explorer/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/country-explorer/src/assets/hero.png differ diff --git a/country-explorer/src/assets/react.svg b/country-explorer/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/country-explorer/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/country-explorer/src/assets/vite.svg b/country-explorer/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/country-explorer/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/country-explorer/src/components/App.css b/country-explorer/src/components/App.css new file mode 100644 index 0000000..6e550d5 --- /dev/null +++ b/country-explorer/src/components/App.css @@ -0,0 +1,91 @@ +header { + padding: 0 0 var(--space-3); + max-width: 700px; + margin-inline: auto; + text-align: center; +} +header h1 { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25em; + font-size: clamp(1.75rem, 6vw, 4rem); +} +header h1 > span:last-child { + min-width: 0; +} +.header-globe { + flex-shrink: 0; + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif; + font-size: 0.85em; + line-height: 1; +} +header p { + max-width: 540px; + margin-inline: auto; + font-size: 1.1rem; +} +main { + display: grid; + gap: var(--space-3); +} +main > section { + min-width: 0; + padding: clamp(1.25rem, 3vw, 2rem); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 1.25rem; + box-shadow: 0 4px 20px #183c3204; +} +section[aria-labelledby='controls-heading'] { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr); + gap: var(--space-3); +} +#controls-heading { + grid-column: 1 / -1; + margin: 0; +} +section > [role='status'] { + padding: var(--space-3); + border: 1px dashed #b8cabe; + border-radius: 0.75rem; + background: #f8faf7; +} +[role='alert'] { + padding: var(--space-3); + border: 1px solid #e0b6ae; + border-radius: 0.75rem; + color: #883b2c; + background: #fff7f4; +} +[role='alert'] button { + margin-top: var(--space-2); +} +footer { + padding: var(--space-4) 0 var(--space-2); + text-align: center; + font-size: 0.85rem; +} +/* Keep individual controls wide enough on tablets and landscape phones. */ +@media (max-width: 960px) { + section[aria-labelledby='controls-heading'] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .country-selection { + grid-column: 1 / -1; + } +} +@media (max-width: 600px) { + section[aria-labelledby='controls-heading'] { + grid-template-columns: minmax(0, 1fr); + } + main > section { + padding: var(--space-2); + } +} +@media (orientation: landscape) and (max-height: 500px) { + header h1 { + font-size: 2.25rem; + } +} diff --git a/country-explorer/src/components/App.test.tsx b/country-explorer/src/components/App.test.tsx new file mode 100644 index 0000000..184c94c --- /dev/null +++ b/country-explorer/src/components/App.test.tsx @@ -0,0 +1,367 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getCountriesByRegion } from '../api/countries'; +import { mockCountries } from '../test/fixtures'; +import type { Country } from '../types/country'; +import App from './App'; + +vi.mock('../api/countries', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getCountriesByRegion: vi.fn() }; +}); + +const mockedGetCountriesByRegion = vi.mocked(getCountriesByRegion); + +function renderApp() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +// Spans multiple subregions with population figures that don't follow the +// alphabetical order, so region filtering and each sort option can be told +// apart from one another. +const filterSortCountries: Country[] = [ + { + code: 'DEU', + name: 'Germany', + capital: 'Berlin', + region: 'Europe', + subregion: 'Western Europe', + population: 20000000, + area: 357114, + flagUrl: 'https://example.com/flags/de.svg', + currencies: ['Euro (EUR)'], + languages: ['German'], + }, + { + code: 'ITA', + name: 'Italy', + capital: 'Rome', + region: 'Europe', + subregion: 'Southern Europe', + population: 60000000, + area: 301340, + flagUrl: 'https://example.com/flags/it.svg', + currencies: ['Euro (EUR)'], + languages: ['Italian'], + }, + { + code: 'NOR', + name: 'Norway', + capital: 'Oslo', + region: 'Europe', + subregion: 'Northern Europe', + population: 5379475, + area: 323802, + flagUrl: 'https://example.com/flags/no.svg', + currencies: ['Norwegian krone (NOK)'], + languages: ['Norwegian'], + }, + { + code: 'SWE', + name: 'Sweden', + capital: 'Stockholm', + region: 'Europe', + subregion: 'Northern Europe', + population: 10353442, + area: 450295, + flagUrl: 'https://example.com/flags/se.svg', + currencies: ['Swedish krona (SEK)'], + languages: ['Swedish'], + }, +]; + +// Opens a Dropdown by its current accessible name (label + selected value). +function openDropdown(name: string) { + fireEvent.click(screen.getByRole('button', { name })); +} + +// The selected country is kept across a filter/sort change when it's still +// in the list, so order-dependent assertions rewind to the first country +// first rather than assuming what's currently selected. +function rewindToFirstCountry() { + let previous = screen.getByRole('button', { name: 'Previous country' }); + while (!previous.hasAttribute('disabled')) { + fireEvent.click(previous); + previous = screen.getByRole('button', { name: 'Previous country' }); + } +} + +beforeEach(() => { + mockedGetCountriesByRegion.mockResolvedValue(mockCountries); +}); + +describe('App navigation', () => { + it('shows the first country by default with the previous button disabled', async () => { + renderApp(); + + await screen.findByRole('heading', { name: 'Norway' }); + + expect(screen.getByText('Country 1 of 2')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Previous country' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next country' })).toBeEnabled(); + }); + + it('moves to the next country when the next button is clicked', async () => { + renderApp(); + await screen.findByRole('heading', { name: 'Norway' }); + + fireEvent.click(screen.getByRole('button', { name: 'Next country' })); + + expect(screen.getByRole('heading', { name: 'Sweden' })).toBeInTheDocument(); + expect(screen.getByText('Country 2 of 2')).toBeInTheDocument(); + }); + + it('moves back to the previous country when the previous button is clicked', async () => { + renderApp(); + await screen.findByRole('heading', { name: 'Norway' }); + + fireEvent.click(screen.getByRole('button', { name: 'Next country' })); + fireEvent.click(screen.getByRole('button', { name: 'Previous country' })); + + expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument(); + expect(screen.getByText('Country 1 of 2')).toBeInTheDocument(); + }); + + it('shows the selected country when chosen directly from the selector', async () => { + renderApp(); + await screen.findByRole('heading', { name: 'Norway' }); + + openDropdown('Country Norway'); + fireEvent.click(screen.getByRole('radio', { name: 'Sweden' })); + + expect(screen.getByRole('heading', { name: 'Sweden' })).toBeInTheDocument(); + expect(screen.getByText('Stockholm')).toBeInTheDocument(); + expect(screen.getByText('Country 2 of 2')).toBeInTheDocument(); + }); + + it('disables next at the last country and previous at the first country', async () => { + renderApp(); + await screen.findByRole('heading', { name: 'Norway' }); + + expect(screen.getByRole('button', { name: 'Previous country' })).toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: 'Next country' })); + + expect(screen.getByRole('heading', { name: 'Sweden' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next country' })).toBeDisabled(); + }); +}); + +describe('App filtering and sorting', () => { + it('filters the countries down to the selected region', async () => { + mockedGetCountriesByRegion.mockResolvedValue(filterSortCountries); + renderApp(); + await screen.findByRole('heading', { name: 'Germany' }); + + openDropdown('Region All regions'); + fireEvent.click(screen.getByRole('radio', { name: 'Northern Europe' })); + + expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument(); + expect(screen.getByText('Country 1 of 2')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Next country' })); + + expect(screen.getByRole('heading', { name: 'Sweden' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next country' })).toBeDisabled(); + }); + + it('brings every country back into view once "All regions" is selected', async () => { + mockedGetCountriesByRegion.mockResolvedValue(filterSortCountries); + renderApp(); + await screen.findByRole('heading', { name: 'Germany' }); + + openDropdown('Region All regions'); + fireEvent.click(screen.getByRole('radio', { name: 'Northern Europe' })); + expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument(); + expect(screen.getByText('Country 1 of 2')).toBeInTheDocument(); + + openDropdown('Region Northern Europe'); + fireEvent.click(screen.getByRole('radio', { name: 'All regions' })); + + // the previously selected country stays selected since it's still + // available, but the full four-country list is back in view + expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument(); + expect(screen.getByText('Country 3 of 4')).toBeInTheDocument(); + + rewindToFirstCountry(); + expect(screen.getByRole('heading', { name: 'Germany' })).toBeInTheDocument(); + }); + + it('sorts the countries alphabetically by name', async () => { + mockedGetCountriesByRegion.mockResolvedValue(filterSortCountries); + renderApp(); + await screen.findByRole('heading', { name: 'Germany' }); + rewindToFirstCountry(); + + const expectedOrder = ['Germany', 'Italy', 'Norway', 'Sweden']; + expect(screen.getByRole('heading', { name: expectedOrder[0] })).toBeInTheDocument(); + + for (const name of expectedOrder.slice(1)) { + fireEvent.click(screen.getByRole('button', { name: 'Next country' })); + expect(screen.getByRole('heading', { name })).toBeInTheDocument(); + } + expect(screen.getByRole('button', { name: 'Next country' })).toBeDisabled(); + }); + + it('sorts the countries by population from high to low', async () => { + mockedGetCountriesByRegion.mockResolvedValue(filterSortCountries); + renderApp(); + await screen.findByRole('heading', { name: 'Germany' }); + + openDropdown('Sort by Name (A–Z)'); + fireEvent.click(screen.getByRole('radio', { name: 'Population (high to low)' })); + rewindToFirstCountry(); + + const expectedOrder = ['Italy', 'Germany', 'Sweden', 'Norway']; + expect(screen.getByRole('heading', { name: expectedOrder[0] })).toBeInTheDocument(); + + for (const name of expectedOrder.slice(1)) { + fireEvent.click(screen.getByRole('button', { name: 'Next country' })); + expect(screen.getByRole('heading', { name })).toBeInTheDocument(); + } + expect(screen.getByRole('button', { name: 'Next country' })).toBeDisabled(); + }); + + it('shows a message when the selected region has no matching countries', async () => { + window.sessionStorage.setItem('country-explorer:selectedRegion', 'Eastern Europe'); + mockedGetCountriesByRegion.mockResolvedValue(filterSortCountries); + renderApp(); + + await screen.findByText('No countries match the selected region.'); + + expect(screen.queryByRole('heading', { level: 3 })).not.toBeInTheDocument(); + }); +}); + +describe('App storage and restoration', () => { + const regionKey = 'country-explorer:selectedRegion'; + const sortKey = 'country-explorer:sortOption'; + const favoritesKey = 'country-explorer:favoriteCountryCodes'; + + it('stores filter and sorting choices and restores them after a fresh mount', async () => { + mockedGetCountriesByRegion.mockResolvedValue(filterSortCountries); + const app = renderApp(); + await screen.findByRole('heading', { name: 'Germany' }); + openDropdown('Region All regions'); + fireEvent.click(screen.getByRole('radio', { name: 'Northern Europe' })); + openDropdown('Sort by Name (A–Z)'); + fireEvent.click(screen.getByRole('radio', { name: 'Population (high to low)' })); + expect(sessionStorage.getItem(regionKey)).toBe('Northern Europe'); + expect(sessionStorage.getItem(sortKey)).toBe('population-desc'); + expect(localStorage.getItem(regionKey)).toBeNull(); + expect(localStorage.getItem(sortKey)).toBeNull(); + + app.unmount(); + renderApp(); + await screen.findByRole('heading', { name: 'Sweden' }); + expect(screen.getByRole('button', { name: 'Region Northern Europe' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Sort by Population (high to low)' }), + ).toBeInTheDocument(); + expect(screen.getByText('Country 1 of 2')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Next country' })); + expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next country' })).toBeDisabled(); + }); + + it('persists added and removed favorites across fresh mounts and sessions', async () => { + const app = renderApp(); + await screen.findByRole('heading', { name: 'Norway' }); + fireEvent.click(screen.getByRole('button', { name: 'Add Norway to favorites' })); + expect(JSON.parse(localStorage.getItem(favoritesKey)!)).toEqual(['NOR']); + expect(sessionStorage.getItem(favoritesKey)).toBeNull(); + app.unmount(); + sessionStorage.clear(); + + const restarted = renderApp(); + await screen.findByRole('heading', { name: 'Norway' }); + expect(screen.getByRole('button', { name: 'Remove Norway from favorites' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + const favorites = screen.getByRole('region', { name: 'Favorites' }); + expect(within(favorites).getByRole('button', { name: 'Norway' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove Norway from favorites' })); + expect(JSON.parse(localStorage.getItem(favoritesKey)!)).toEqual([]); + restarted.unmount(); + renderApp(); + await screen.findByRole('heading', { name: 'Norway' }); + expect(screen.getByRole('button', { name: 'Add Norway to favorites' })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + expect(screen.getByText("You haven't added any favorites yet.")).toBeInTheDocument(); + }); + + it.each(['invalid-sort', '', '{"sort":true}'])( + 'falls back to alphabetical sorting for %j', + async (stored) => { + sessionStorage.setItem(sortKey, stored); + renderApp(); + await screen.findByRole('heading', { name: 'Norway' }); + expect(screen.getByRole('button', { name: 'Sort by Name (A–Z)' })).toBeInTheDocument(); + expect(sessionStorage.getItem(sortKey)).toBe('name-asc'); + }, + ); + + it('handles an unknown stored filter and lets the user recover', async () => { + sessionStorage.setItem(regionKey, '{invalid region}'); + renderApp(); + await screen.findByText('No countries match the selected region.'); + openDropdown('Region {invalid region}'); + fireEvent.click(screen.getByRole('radio', { name: 'All regions' })); + expect(screen.getByRole('heading', { name: 'Norway' })).toBeInTheDocument(); + expect(sessionStorage.getItem(regionKey)).toBe(''); + }); + + it.each(['{broken', 'null', '{}', '42', '"NOR"', '[null, 7, false, ""]'])( + 'ignores invalid favorite data %j without crashing', + async (stored) => { + localStorage.setItem(favoritesKey, stored); + renderApp(); + await screen.findByRole('heading', { name: 'Norway' }); + expect(screen.getByText("You haven't added any favorites yet.")).toBeInTheDocument(); + expect(JSON.parse(localStorage.getItem(favoritesKey)!)).toEqual([]); + }, + ); + + it('restores valid favorites from a mixed array and ignores unavailable countries', async () => { + localStorage.setItem(favoritesKey, JSON.stringify(['NOR', null, 7, '', 'UNKNOWN'])); + renderApp(); + await screen.findByRole('heading', { name: 'Norway' }); + const favorites = screen.getByRole('region', { name: 'Favorites' }); + expect(within(favorites).getAllByRole('button')).toHaveLength(1); + expect(within(favorites).getByRole('button', { name: 'Norway' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove Norway from favorites' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); + + it('remains usable when storage access throws', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('Storage blocked'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('Storage full'); + }); + renderApp(); + await screen.findByRole('heading', { name: 'Norway' }); + fireEvent.click(screen.getByRole('button', { name: 'Add Norway to favorites' })); + expect(screen.getByRole('button', { name: 'Remove Norway from favorites' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + openDropdown('Sort by Name (A–Z)'); + fireEvent.click(screen.getByRole('radio', { name: 'Population (high to low)' })); + expect( + screen.getByRole('button', { name: 'Sort by Population (high to low)' }), + ).toBeInTheDocument(); + }); +}); diff --git a/country-explorer/src/components/App.tsx b/country-explorer/src/components/App.tsx new file mode 100644 index 0000000..7fa5460 --- /dev/null +++ b/country-explorer/src/components/App.tsx @@ -0,0 +1,178 @@ +import './App.css'; +import { useEffect, useState } from 'react'; +import { useCountries } from '../hooks/useCountries'; +import { useFavorites } from '../hooks/useFavorites'; +import { isSortOption, sortCountries, type SortOption } from '../utils/sortCountries'; +import { readSessionValue, writeSessionValue } from '../utils/sessionStorage'; +import CountryCard from './CountryCard'; +import CountrySelector from './CountrySelector'; +import RegionFilter from './RegionFilter'; +import SortSelector from './SortSelector'; +import Loading from './Loading'; +import ErrorMessage from './ErrorMessage'; +import NavigationControls from './NavigationControls'; +import FavoritesList from './FavoritesList'; + +const REGION_STORAGE_KEY = 'selectedRegion'; +const SORT_STORAGE_KEY = 'sortOption'; + +function App() { + const { countries, isLoading, isError, error, refetch } = useCountries(); + const { isFavorite, addFavorite, removeFavorite } = useFavorites(); + const [selectedRegion, setSelectedRegion] = useState( + () => readSessionValue(REGION_STORAGE_KEY) ?? '', + ); + // countries are all European, so "region" here means subregion + const getRegion = (country: (typeof countries)[number]) => + country.subregion?.trim() || country.region?.trim() || 'Unknown region'; + const regions = [...new Set(countries.map(getRegion))].sort(); + const availableRegions = + regions.includes(selectedRegion) || !selectedRegion + ? regions + : [...regions, selectedRegion].sort(); + const filteredCountries = selectedRegion + ? countries.filter((country) => getRegion(country) === selectedRegion) + : countries; + const [sortOption, setSortOption] = useState(() => { + const stored = readSessionValue(SORT_STORAGE_KEY); + return isSortOption(stored) ? stored : 'name-asc'; + }); + const sortedCountries = sortCountries(filteredCountries, sortOption); + // favorites are drawn from the full list, not the filtered one, so a + // favorite from another region still shows up here + const favoriteCountries = sortCountries( + countries.filter((country) => isFavorite(country.code)), + sortOption, + ); + const [selectedCountryCode, setSelectedCountryCode] = useState(null); + const selectedCountry = + sortedCountries.find((country) => country.code === selectedCountryCode) ?? sortedCountries[0]; + const activeCountryCode = selectedCountry?.code ?? null; + const activeCountryIndex = sortedCountries.findIndex( + (country) => country.code === activeCountryCode, + ); + + useEffect(() => { + writeSessionValue(REGION_STORAGE_KEY, selectedRegion); + }, [selectedRegion]); + + useEffect(() => { + writeSessionValue(SORT_STORAGE_KEY, sortOption); + }, [sortOption]); + + function navigateCountry(direction: -1 | 1) { + setSelectedCountryCode((currentCode) => { + const currentIndex = sortedCountries.findIndex((country) => country.code === currentCode); + if (currentIndex === -1) return sortedCountries[0]?.code ?? null; + + return sortedCountries[currentIndex + direction]?.code ?? currentCode; + }); + } + + function toggleFavorite(code: string) { + if (isFavorite(code)) { + removeFavorite(code); + } else { + addFavorite(code); + } + } + + function selectFavorite(code: string) { + // clear the region filter first, since the favorite might be in a + // different region than the one currently filtered to + setSelectedRegion(''); + setSelectedCountryCode(code); + } + + // correct the selection during render if it's no longer valid (e.g. the + // country list just changed), instead of flashing the old selection first + if (selectedCountryCode !== activeCountryCode) { + setSelectedCountryCode(activeCountryCode); + } + + return ( + <> +
+

+ + Country Explorer +

+

Browse countries in Europe, filter and sort them, and save your favorites.

+
+ +
+
+

Filters and selection

+ + + +
+ +
+

Selected country

+ {isLoading ? ( + + ) : isError ? ( + void refetch()} + /> + ) : selectedCountry ? ( + <> + toggleFavorite(selectedCountry.code)} + /> + navigateCountry(-1)} + onNext={() => navigateCountry(1)} + /> + + ) : ( +

+ {selectedRegion + ? 'No countries match the selected region.' + : 'No countries available.'} +

+ )} +
+ +
+

Favorites

+ +
+
+ + + + ); +} + +export default App; diff --git a/country-explorer/src/components/CountryCard.css b/country-explorer/src/components/CountryCard.css new file mode 100644 index 0000000..e8f706e --- /dev/null +++ b/country-explorer/src/components/CountryCard.css @@ -0,0 +1,78 @@ +.country-card { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.4fr); + align-items: start; + gap: clamp(1.5rem, 4vw, 3rem); + padding-block: var(--space-1) var(--space-3); + text-align: left; +} +.country-card__flag, +.country-card__flag-placeholder { + width: 100%; + border: 1px solid var(--border); + border-radius: 0.75rem; +} +.country-card__flag { + display: block; + height: auto; +} +.country-card__flag-placeholder { + aspect-ratio: 3 / 2; + background: #f4f6f3; + display: grid; + place-items: center; + padding: var(--space-2); +} +.country-card__content { + min-width: 0; + overflow-wrap: anywhere; +} +.country-card__heading-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + margin-bottom: var(--space-3); +} +.country-card__heading-row h3 { + margin: 0; + font-size: clamp(1.6rem, 3vw, 2.25rem); + letter-spacing: -0.035em; +} +.country-card__details { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + margin: 0; +} +.country-card__details > div { + border-top: 1px solid var(--border); + padding-top: 0.75rem; +} +.country-card__details dt { + font-size: 0.8rem; + font-weight: 500; + color: var(--text); +} +.country-card__details dd { + margin: 0.25rem 0 0; + font-weight: 600; + color: var(--text-h); + font-variant-numeric: tabular-nums; +} +@media (max-width: 800px) { + .country-card { + grid-template-columns: minmax(0, 1fr); + } + .country-card__flag, + .country-card__flag-placeholder { + max-width: 22rem; + justify-self: center; + } +} +@media (max-width: 400px) { + .country-card__details { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/country-explorer/src/components/CountryCard.test.tsx b/country-explorer/src/components/CountryCard.test.tsx new file mode 100644 index 0000000..316f272 --- /dev/null +++ b/country-explorer/src/components/CountryCard.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Country } from '../types/country'; +import { mockCountries } from '../test/fixtures'; +import CountryCard from './CountryCard'; + +const [country] = mockCountries; + +const emptyCountry: Country = { + code: 'XXX', + name: '', + capital: '', + region: '', + subregion: '', + population: null, + area: null, + flagUrl: '', + currencies: [], + languages: [], +}; + +function renderCard(overrides: Partial = {}) { + return render( + , + ); +} + +describe('CountryCard', () => { + it('renders the country name', () => { + renderCard(); + + expect(screen.getByRole('heading', { name: country.name })).toBeInTheDocument(); + }); + + it('renders the flag with its alt text', () => { + renderCard(); + + const flag = screen.getByRole('img', { name: `Flag of ${country.name}` }); + expect(flag).toHaveAttribute('src', country.flagUrl); + }); + + it('shows a placeholder when the flag URL is missing', () => { + render(); + + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByText('Flag not available')).toBeInTheDocument(); + }); + + it('renders capital, region and population', () => { + renderCard(); + + expect(screen.getByText(country.capital)).toBeInTheDocument(); + expect(screen.getByText(country.region)).toBeInTheDocument(); + expect(screen.getByText('5,379,475')).toBeInTheDocument(); + }); + + it('formats population and area with thousands separators', () => { + renderCard({ population: 1234567, area: 323802 }); + + expect(screen.getByText('1,234,567')).toBeInTheDocument(); + expect(screen.getByText('323,802 km²')).toBeInTheDocument(); + }); + + it('falls back to placeholders for missing optional fields without crashing', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Unknown country' })).toBeInTheDocument(); + expect(screen.getAllByText('Not available')).toHaveLength(6); + }); +}); + +describe('CountryCard snapshot', () => { + it('matches the snapshot for a country with complete data', () => { + const { container } = render( + , + ); + + // CountryCard links its heading to the card with an id from useId(). + // That id is a React implementation detail, not something derived from + // the country data, and it isn't guaranteed to stay the same (it can + // shift with the React version or render order). Replace it with a + // fixed placeholder so the snapshot only captures data that should + // actually stay the same, and doesn't fail for unrelated reasons. + const heading = container.querySelector('h3'); + const article = container.querySelector('article'); + heading?.setAttribute('id', 'country-card-heading'); + article?.setAttribute('aria-labelledby', 'country-card-heading'); + + // To intentionally update this snapshot after a real change to + // CountryCard's markup, run `npm test -- -u` (or `npx vitest -u`) and + // review the diff in __snapshots__/CountryCard.test.tsx.snap before + // committing it. + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/country-explorer/src/components/CountryCard.tsx b/country-explorer/src/components/CountryCard.tsx new file mode 100644 index 0000000..318f1ff --- /dev/null +++ b/country-explorer/src/components/CountryCard.tsx @@ -0,0 +1,72 @@ +import { useId } from 'react'; +import type { Country } from '../types/country'; +import FavoriteButton from './FavoriteButton'; +import './CountryCard.css'; + +interface CountryCardProps { + country: Country; + isFavorite: boolean; + onToggleFavorite: () => void; +} + +const numberFormatter = new Intl.NumberFormat('en-GB'); +const unavailable = 'Not available'; + +function formatNumber(value: number | null | undefined, unit = '') { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? `${numberFormatter.format(value)}${unit}` + : unavailable; +} + +function CountryCard({ country, isFavorite, onToggleFavorite }: CountryCardProps) { + const headingId = useId(); + const name = country.name?.trim() || 'Unknown country'; + + return ( +
+ {country.flagUrl ? ( + {`Flag + ) : ( +

Flag not available

+ )} +
+
+

{name}

+ +
+
+
+
Capital
+
{country.capital?.trim() || unavailable}
+
+
+
Region
+
{country.region?.trim() || unavailable}
+
+
+
Population
+
{formatNumber(country.population)}
+
+
+
Area
+
{formatNumber(country.area, ' km²')}
+
+
+
Languages
+
{country.languages?.filter(Boolean).join(', ') || unavailable}
+
+
+
Currency
+
{country.currencies?.filter(Boolean).join(', ') || unavailable}
+
+
+
+
+ ); +} + +export default CountryCard; diff --git a/country-explorer/src/components/CountrySelector.css b/country-explorer/src/components/CountrySelector.css new file mode 100644 index 0000000..7169595 --- /dev/null +++ b/country-explorer/src/components/CountrySelector.css @@ -0,0 +1,11 @@ +.country-selection { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; +} +.country-selection > label { + color: var(--text); + font-size: 0.8rem; + font-weight: 600; +} diff --git a/country-explorer/src/components/CountrySelector.tsx b/country-explorer/src/components/CountrySelector.tsx new file mode 100644 index 0000000..ec1b5e9 --- /dev/null +++ b/country-explorer/src/components/CountrySelector.tsx @@ -0,0 +1,35 @@ +import Dropdown from './Dropdown'; +import type { Country } from '../types/country'; +import './CountrySelector.css'; + +interface CountrySelectorProps { + countries: Country[]; + selectedCountryCode: string | null; + onSelectCountry: (code: string) => void; + disabled?: boolean; +} + +function CountrySelector({ + countries, + selectedCountryCode, + onSelectCountry, + disabled = false, +}: CountrySelectorProps) { + return ( +
+ ({ + value: country.code, + label: country.name || country.code, + }))} + onChange={onSelectCountry} + disabled={disabled || countries.length === 0} + placeholder={countries.length === 0 ? 'No countries available' : 'Select a country'} + /> +
+ ); +} + +export default CountrySelector; diff --git a/country-explorer/src/components/Dropdown.css b/country-explorer/src/components/Dropdown.css new file mode 100644 index 0000000..6700865 --- /dev/null +++ b/country-explorer/src/components/Dropdown.css @@ -0,0 +1,81 @@ +.dropdown { + position: relative; + min-width: 0; + width: 100%; +} +.dropdown__label { + display: block; + margin-bottom: var(--space-1); + color: var(--text); + font-size: 0.8rem; + font-weight: 600; +} +.dropdown__trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + min-width: 0; + min-height: 50px; + text-align: left; + font-weight: 400; + background: #f8faf8; +} +.dropdown__trigger > span:first-child { + min-width: 0; + overflow-wrap: anywhere; +} +.dropdown__arrow { + flex: 0 0 0.5rem; + height: 0.5rem; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); + margin: -0.25rem 0.25rem 0; +} +.dropdown__trigger[aria-expanded='true'] .dropdown__arrow { + transform: rotate(225deg); + margin-top: 0.25rem; +} +.dropdown__options { + position: absolute; + z-index: 10; + top: calc(100% + 0.5rem); + inset-inline: 0; + max-height: min(18rem, 40svh); + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 0.75rem; + background: var(--bg); + box-shadow: 0 8px 24px #183c3220; +} +.dropdown__option { + display: flex; + align-items: center; + gap: 0.75rem; + min-height: 44px; + padding: 0.65rem; + border-radius: 0.4rem; + color: var(--text-h); + cursor: pointer; +} +.dropdown__option span { + min-width: 0; + overflow-wrap: anywhere; +} +.dropdown__option input { + flex-shrink: 0; + margin: 0; + accent-color: var(--accent); +} +.dropdown__option:hover, +.dropdown__option:has(input:checked) { + background: var(--accent-bg); +} +.dropdown__option:focus-within { + outline: 2px solid #286dcc; + outline-offset: -2px; +} diff --git a/country-explorer/src/components/Dropdown.test.tsx b/country-explorer/src/components/Dropdown.test.tsx new file mode 100644 index 0000000..7405b5b --- /dev/null +++ b/country-explorer/src/components/Dropdown.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import Dropdown from './Dropdown'; + +const options = [ + { value: 'NO', label: 'Norway' }, + { value: 'SE', label: 'Sweden' }, +]; + +describe('Dropdown', () => { + it('focuses the current choice and returns focus after selecting', () => { + const onChange = vi.fn(); + render(); + const trigger = screen.getByRole('button', { name: 'Country Norway' }); + fireEvent.click(trigger); + expect(screen.getByRole('radio', { name: 'Norway' })).toHaveFocus(); + fireEvent.click(screen.getByRole('radio', { name: 'Sweden' })); + expect(onChange).toHaveBeenCalledExactlyOnceWith('SE'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).toHaveFocus(); + }); + + it('keeps option labels clickable when the browser temporarily clears focus', () => { + const onChange = vi.fn(); + render(); + const trigger = screen.getByRole('button', { name: 'Country Norway' }); + fireEvent.click(trigger); + const label = screen.getByText('Sweden'); + fireEvent.pointerDown(label); + fireEvent.blur(screen.getByRole('radio', { name: 'Norway' }), { relatedTarget: null }); + expect(label).toBeInTheDocument(); + fireEvent.click(label); + expect(onChange).toHaveBeenCalledExactlyOnceWith('SE'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).toHaveFocus(); + }); + + it('closes with Escape without changing the selection', () => { + const onChange = vi.fn(); + render(); + const trigger = screen.getByRole('button', { name: 'Country Norway' }); + fireEvent.click(trigger); + fireEvent.keyDown(screen.getByRole('radio', { name: 'Norway' }), { key: 'Escape' }); + expect(trigger).toHaveFocus(); + expect(screen.queryByRole('group')).not.toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/country-explorer/src/components/Dropdown.tsx b/country-explorer/src/components/Dropdown.tsx new file mode 100644 index 0000000..f196159 --- /dev/null +++ b/country-explorer/src/components/Dropdown.tsx @@ -0,0 +1,113 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import './Dropdown.css'; + +interface DropdownProps { + label: string; + value: string; + options: { value: string; label: string }[]; + onChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; +} + +/** Native radio controls retain arrow-key selection inside the bounded popup. */ +export default function Dropdown({ + label, + value, + options, + onChange, + disabled = false, + placeholder = 'Select an option', +}: DropdownProps) { + const id = useId(); + const [open, setOpen] = useState(false); + const root = useRef(null); + const trigger = useRef(null); + const expanded = open && !disabled; + + useEffect(() => { + if (!expanded) return; + const selected = root.current?.querySelector('input:checked'); + const first = root.current?.querySelector('input'); + (selected ?? first)?.focus({ preventScroll: true }); + function outside(event: PointerEvent) { + if (event.target instanceof Node && !root.current?.contains(event.target)) setOpen(false); + } + document.addEventListener('pointerdown', outside); + return () => document.removeEventListener('pointerdown', outside); + }, [expanded]); + + return ( +
{ + // Clicking an option's label can briefly move focus to the document + // before the browser forwards the click to its radio input. Keep the + // popup mounted until that selection has completed. Outside pointer + // presses are handled separately by the document listener. + if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) { + setOpen(false); + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape' && expanded) { + event.preventDefault(); + setOpen(false); + trigger.current?.focus(); + } + }} + > + + {label} + + + {expanded && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +} diff --git a/country-explorer/src/components/ErrorMessage.tsx b/country-explorer/src/components/ErrorMessage.tsx new file mode 100644 index 0000000..6416a43 --- /dev/null +++ b/country-explorer/src/components/ErrorMessage.tsx @@ -0,0 +1,17 @@ +interface ErrorMessageProps { + message: string; + onRetry: () => void; +} + +function ErrorMessage({ message, onRetry }: ErrorMessageProps) { + return ( +
+

{message}

+ +
+ ); +} + +export default ErrorMessage; diff --git a/country-explorer/src/components/FavoriteButton.css b/country-explorer/src/components/FavoriteButton.css new file mode 100644 index 0000000..3ca58ad --- /dev/null +++ b/country-explorer/src/components/FavoriteButton.css @@ -0,0 +1,19 @@ +.favorite-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-1); + font-size: 0.875rem; +} +.favorite-button[aria-pressed='true'] { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-bg); +} +.favorite-button[aria-pressed='true']:hover { + background: #d4e5fa; +} +.favorite-button__icon { + font-size: 1.3em; + line-height: 1; +} diff --git a/country-explorer/src/components/FavoriteButton.tsx b/country-explorer/src/components/FavoriteButton.tsx new file mode 100644 index 0000000..8bd1902 --- /dev/null +++ b/country-explorer/src/components/FavoriteButton.tsx @@ -0,0 +1,30 @@ +import './FavoriteButton.css'; + +interface FavoriteButtonProps { + isFavorite: boolean; + countryName: string; + onToggleFavorite: () => void; +} + +function FavoriteButton({ isFavorite, countryName, onToggleFavorite }: FavoriteButtonProps) { + const label = isFavorite + ? `Remove ${countryName} from favorites` + : `Add ${countryName} to favorites`; + + return ( + + ); +} + +export default FavoriteButton; diff --git a/country-explorer/src/components/FavoritesList.css b/country-explorer/src/components/FavoritesList.css new file mode 100644 index 0000000..08fd5c6 --- /dev/null +++ b/country-explorer/src/components/FavoritesList.css @@ -0,0 +1,20 @@ +.favorites-list { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; +} +.favorites-list li { + min-width: 0; + max-width: 100%; +} +.favorites-list button { + border-radius: 2rem; + background: var(--accent-bg); + border-color: var(--border); + color: var(--accent); + overflow-wrap: anywhere; + text-align: left; +} diff --git a/country-explorer/src/components/FavoritesList.tsx b/country-explorer/src/components/FavoritesList.tsx new file mode 100644 index 0000000..ff64c6e --- /dev/null +++ b/country-explorer/src/components/FavoritesList.tsx @@ -0,0 +1,27 @@ +import type { Country } from '../types/country'; +import './FavoritesList.css'; + +interface FavoritesListProps { + countries: Country[]; + onSelectFavorite: (code: string) => void; +} + +function FavoritesList({ countries, onSelectFavorite }: FavoritesListProps) { + if (countries.length === 0) { + return

You haven't added any favorites yet.

; + } + + return ( +
    + {countries.map((country) => ( +
  • + +
  • + ))} +
+ ); +} + +export default FavoritesList; diff --git a/country-explorer/src/components/Loading.test.tsx b/country-explorer/src/components/Loading.test.tsx new file mode 100644 index 0000000..3ec6721 --- /dev/null +++ b/country-explorer/src/components/Loading.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import Loading from './Loading'; + +describe('Loading', () => { + it('renders the given status message', () => { + render(); + + expect(screen.getByRole('status')).toHaveTextContent('Loading countries…'); + }); + + it('falls back to a default message when none is given', () => { + render(); + + expect(screen.getByRole('status')).toHaveTextContent('Loading…'); + }); +}); diff --git a/country-explorer/src/components/Loading.tsx b/country-explorer/src/components/Loading.tsx new file mode 100644 index 0000000..2be988b --- /dev/null +++ b/country-explorer/src/components/Loading.tsx @@ -0,0 +1,13 @@ +interface LoadingProps { + message?: string; +} + +function Loading({ message = 'Loading…' }: LoadingProps) { + return ( +

+ {message} +

+ ); +} + +export default Loading; diff --git a/country-explorer/src/components/NavigationControls.css b/country-explorer/src/components/NavigationControls.css new file mode 100644 index 0000000..0c8a07f --- /dev/null +++ b/country-explorer/src/components/NavigationControls.css @@ -0,0 +1,34 @@ +.country-navigation { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: var(--space-2); + padding-top: var(--space-3); + border-top: 1px solid var(--border); + font-size: 0.875rem; +} +.country-navigation button:not(:disabled) { + background: var(--accent); + border-color: var(--accent); + color: white; +} +.country-navigation button:hover:not(:disabled) { + background: #183d65; +} +@media (max-width: 600px) { + .country-navigation { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } + .country-navigation p { + grid-column: 1 / -1; + grid-row: 1; + text-align: center; + } + .country-navigation button { + min-width: 0; + padding-inline: 0.5rem; + } +} diff --git a/country-explorer/src/components/NavigationControls.tsx b/country-explorer/src/components/NavigationControls.tsx new file mode 100644 index 0000000..de7a9d1 --- /dev/null +++ b/country-explorer/src/components/NavigationControls.tsx @@ -0,0 +1,44 @@ +import './NavigationControls.css'; + +interface NavigationControlsProps { + currentIndex: number; + totalCountries: number; + onPrevious: () => void; + onNext: () => void; +} + +function NavigationControls({ + currentIndex, + totalCountries, + onPrevious, + onNext, +}: NavigationControlsProps) { + const hasSelection = + Number.isInteger(currentIndex) && currentIndex >= 0 && currentIndex < totalCountries; + + return ( + + ); +} + +export default NavigationControls; diff --git a/country-explorer/src/components/RegionFilter.css b/country-explorer/src/components/RegionFilter.css new file mode 100644 index 0000000..bed7bd8 --- /dev/null +++ b/country-explorer/src/components/RegionFilter.css @@ -0,0 +1,11 @@ +.region-filter { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; +} +.region-filter > label { + color: var(--text); + font-size: 0.8rem; + font-weight: 600; +} diff --git a/country-explorer/src/components/RegionFilter.tsx b/country-explorer/src/components/RegionFilter.tsx new file mode 100644 index 0000000..86d03a4 --- /dev/null +++ b/country-explorer/src/components/RegionFilter.tsx @@ -0,0 +1,33 @@ +import Dropdown from './Dropdown'; +import './RegionFilter.css'; + +interface RegionFilterProps { + regions: string[]; + selectedRegion: string; + onRegionChange: (region: string) => void; + disabled?: boolean; +} + +function RegionFilter({ + regions, + selectedRegion, + onRegionChange, + disabled = false, +}: RegionFilterProps) { + return ( +
+ ({ value: region, label: region })), + ]} + onChange={onRegionChange} + disabled={disabled} + /> +
+ ); +} + +export default RegionFilter; diff --git a/country-explorer/src/components/SortSelector.css b/country-explorer/src/components/SortSelector.css new file mode 100644 index 0000000..5e894e7 --- /dev/null +++ b/country-explorer/src/components/SortSelector.css @@ -0,0 +1,11 @@ +.sort-selector { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; +} +.sort-selector > label { + color: var(--text); + font-size: 0.8rem; + font-weight: 600; +} diff --git a/country-explorer/src/components/SortSelector.tsx b/country-explorer/src/components/SortSelector.tsx new file mode 100644 index 0000000..a44000e --- /dev/null +++ b/country-explorer/src/components/SortSelector.tsx @@ -0,0 +1,25 @@ +import Dropdown from './Dropdown'; +import { SORT_OPTIONS, type SortOption } from '../utils/sortCountries'; +import './SortSelector.css'; + +interface SortSelectorProps { + sortOption: SortOption; + onSortChange: (option: SortOption) => void; + disabled?: boolean; +} + +function SortSelector({ sortOption, onSortChange, disabled = false }: SortSelectorProps) { + return ( +
+ ({ ...option }))} + onChange={(value) => onSortChange(value as SortOption)} + disabled={disabled} + /> +
+ ); +} + +export default SortSelector; diff --git a/country-explorer/src/components/__snapshots__/CountryCard.test.tsx.snap b/country-explorer/src/components/__snapshots__/CountryCard.test.tsx.snap new file mode 100644 index 0000000..0e65294 --- /dev/null +++ b/country-explorer/src/components/__snapshots__/CountryCard.test.tsx.snap @@ -0,0 +1,97 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CountryCard snapshot > matches the snapshot for a country with complete data 1`] = ` +
+ Flag of Norway +
+
+

+ Norway +

+ +
+
+
+
+ Capital +
+
+ Oslo +
+
+
+
+ Region +
+
+ Europe +
+
+
+
+ Population +
+
+ 5,379,475 +
+
+
+
+ Area +
+
+ 323,802 km² +
+
+
+
+ Languages +
+
+ Norwegian, Sámi +
+
+
+
+ Currency +
+
+ Norwegian krone (NOK) +
+
+
+
+
+`; diff --git a/country-explorer/src/hooks/useCountries.test.tsx b/country-explorer/src/hooks/useCountries.test.tsx new file mode 100644 index 0000000..f56d74a --- /dev/null +++ b/country-explorer/src/hooks/useCountries.test.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ApiError, getCountriesByRegion } from '../api/countries'; +import { mockCountries } from '../test/fixtures'; +import { useCountries } from './useCountries'; + +// Mock only getCountriesByRegion, keep the real ApiError export +vi.mock('../api/countries', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCountriesByRegion: vi.fn(), + }; +}); + +const mockedGetCountriesByRegion = vi.mocked(getCountriesByRegion); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useCountries', () => { + it('reports a loading state before the request resolves', () => { + mockedGetCountriesByRegion.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useCountries(), { wrapper: createWrapper() }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.countries).toEqual([]); + }); + + it('returns the countries on a successful response', async () => { + mockedGetCountriesByRegion.mockResolvedValue(mockCountries); + + const { result } = renderHook(() => useCountries(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.countries).toEqual(mockCountries); + expect(result.current.isError).toBe(false); + }); + + it('reports an error when the request fails', async () => { + mockedGetCountriesByRegion.mockRejectedValue(new ApiError(500, 'Server error')); + + const { result } = renderHook(() => useCountries(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.countries).toEqual([]); + expect(result.current.error).toBeInstanceOf(ApiError); + }); +}); diff --git a/country-explorer/src/hooks/useCountries.ts b/country-explorer/src/hooks/useCountries.ts new file mode 100644 index 0000000..619bae9 --- /dev/null +++ b/country-explorer/src/hooks/useCountries.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { getCountriesByRegion } from '../api/countries'; + +const DEFAULT_REGION = 'europe'; + +export const countriesQueryKey = (region: string) => ['countries', region] as const; + +export function useCountries(region: string = DEFAULT_REGION) { + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: countriesQueryKey(region), + queryFn: () => getCountriesByRegion(region), + }); + + return { + countries: data ?? [], + isLoading, + isError, + error, + refetch, + }; +} diff --git a/country-explorer/src/hooks/useFavorites.ts b/country-explorer/src/hooks/useFavorites.ts new file mode 100644 index 0000000..294b95b --- /dev/null +++ b/country-explorer/src/hooks/useFavorites.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from 'react'; +import { readLocalValue, writeLocalValue } from '../utils/localStorage'; + +const FAVORITES_STORAGE_KEY = 'favoriteCountryCodes'; + +function readStoredFavorites(): string[] { + const stored = readLocalValue(FAVORITES_STORAGE_KEY); + if (!stored) return []; + + try { + const parsed: unknown = JSON.parse(stored); + if (!Array.isArray(parsed)) return []; + + return parsed.filter((code): code is string => typeof code === 'string' && code.length > 0); + } catch { + return []; + } +} + +export function useFavorites() { + const [favoriteCodes, setFavoriteCodes] = useState(() => readStoredFavorites()); + + useEffect(() => { + writeLocalValue(FAVORITES_STORAGE_KEY, JSON.stringify(favoriteCodes)); + }, [favoriteCodes]); + + const addFavorite = useCallback((code: string) => { + setFavoriteCodes((current) => (current.includes(code) ? current : [...current, code])); + }, []); + + const removeFavorite = useCallback((code: string) => { + setFavoriteCodes((current) => current.filter((favoriteCode) => favoriteCode !== code)); + }, []); + + const isFavorite = useCallback((code: string) => favoriteCodes.includes(code), [favoriteCodes]); + + return { + favoriteCodes, + addFavorite, + removeFavorite, + isFavorite, + }; +} diff --git a/country-explorer/src/index.css b/country-explorer/src/index.css new file mode 100644 index 0000000..10b6863 --- /dev/null +++ b/country-explorer/src/index.css @@ -0,0 +1,142 @@ +/* Shared design tokens and native control styles. */ +:root { + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + color: #52647c; + background: #edf5ff; + color-scheme: light; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + --text: #52647c; + --text-h: #193858; + --bg: #fff; + --border: #d1deed; + --accent: #24558a; + --accent-bg: #e5effb; + --space-1: 0.5rem; + --space-2: 1rem; + --space-3: 1.5rem; + --space-4: 2rem; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + overflow-wrap: anywhere; + font-size: 1rem; + line-height: 1.6; +} +#root { + max-width: 1160px; + min-height: 100svh; + margin-inline: auto; + padding: clamp(1rem, 4vw, 3rem); + padding-top: 1rem; +} +h1, +h2, +h3 { + color: var(--text-h); + line-height: 1.2; +} +h1 { + font-size: clamp(2.25rem, 6vw, 4rem); + letter-spacing: -0.05em; + margin: 0 0 var(--space-2); +} +h2 { + font-size: 1.125rem; + letter-spacing: -0.02em; + margin: 0 0 var(--space-3); +} +p { + margin: 0; +} +a { + color: var(--accent); + text-underline-offset: 0.2em; +} +a:hover { + text-decoration-thickness: 2px; +} +button, +select { + font: inherit; + color: var(--text-h); + background: var(--bg); + border: 1px solid #a9bcd3; + border-radius: 0.65rem; + min-height: 46px; + padding: 0.65rem 1rem; + max-width: 100%; + transition: + background-color 150ms ease, + border-color 150ms ease; +} +button { + cursor: pointer; + font-weight: 600; +} +select { + appearance: none; + min-width: 0; + width: 100%; + min-height: 50px; + padding: 0.75rem 2.75rem 0.75rem 1rem; + border-radius: 0.75rem; + background-color: #f8faf8; + background-image: + linear-gradient(45deg, transparent 50%, var(--accent) 50%), + linear-gradient(135deg, var(--accent) 50%, transparent 50%); + background-position: + calc(100% - 21px) center, + calc(100% - 16px) center; + background-size: 5px 5px; + background-repeat: no-repeat; + box-shadow: 0 1px 2px #19385808; + cursor: pointer; +} +button:hover:not(:disabled), +select:hover:not(:disabled) { + border-color: var(--accent); + background-color: var(--accent-bg); +} +select:focus-visible { + border-color: var(--accent); + background-color: var(--bg); +} +select option { + color: var(--text-h); + background-color: var(--bg); +} +@media (forced-colors: active) { + select { + appearance: auto; + background-image: none; + } +} +:where(button, select, a):focus-visible { + outline: 3px solid #286dcc; + outline-offset: 4px; +} +button:disabled, +select:disabled { + color: #68766e; + background: #edf0ed; + border-color: var(--border); + cursor: not-allowed; +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition: none !important; + } +} diff --git a/country-explorer/src/lib/queryClient.ts b/country-explorer/src/lib/queryClient.ts new file mode 100644 index 0000000..d0e03b3 --- /dev/null +++ b/country-explorer/src/lib/queryClient.ts @@ -0,0 +1,13 @@ +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnMount: true, + refetchOnReconnect: true, + retry: 1, + }, + }, +}); diff --git a/country-explorer/src/main.tsx b/country-explorer/src/main.tsx new file mode 100644 index 0000000..1c4e1a5 --- /dev/null +++ b/country-explorer/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClientProvider } from '@tanstack/react-query'; +import './index.css'; +import App from './components/App'; +import { queryClient } from './lib/queryClient'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/country-explorer/src/test/fixtures.ts b/country-explorer/src/test/fixtures.ts new file mode 100644 index 0000000..2104da1 --- /dev/null +++ b/country-explorer/src/test/fixtures.ts @@ -0,0 +1,28 @@ +import type { Country } from '../types/country'; + +export const mockCountries: Country[] = [ + { + code: 'NOR', + name: 'Norway', + capital: 'Oslo', + region: 'Europe', + subregion: 'Northern Europe', + population: 5379475, + area: 323802, + flagUrl: 'https://example.com/flags/no.svg', + currencies: ['Norwegian krone (NOK)'], + languages: ['Norwegian', 'Sámi'], + }, + { + code: 'SWE', + name: 'Sweden', + capital: 'Stockholm', + region: 'Europe', + subregion: 'Northern Europe', + population: 10353442, + area: 450295, + flagUrl: 'https://example.com/flags/se.svg', + currencies: ['Swedish krona (SEK)'], + languages: ['Swedish'], + }, +]; diff --git a/country-explorer/src/test/setup.ts b/country-explorer/src/test/setup.ts new file mode 100644 index 0000000..8ed4148 --- /dev/null +++ b/country-explorer/src/test/setup.ts @@ -0,0 +1,18 @@ +import '@testing-library/jest-dom/vitest'; + +import { afterEach, beforeEach, vi } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +beforeEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); +}); + +afterEach(() => { + // no globals: true, so RTL won't auto-cleanup between tests + cleanup(); + vi.restoreAllMocks(); + vi.resetAllMocks(); + window.sessionStorage.clear(); + window.localStorage.clear(); +}); diff --git a/country-explorer/src/types/country.ts b/country-explorer/src/types/country.ts new file mode 100644 index 0000000..658f427 --- /dev/null +++ b/country-explorer/src/types/country.ts @@ -0,0 +1,40 @@ +// Relevant fields from a successful single-country API response. +export interface CountryApiResponse { + alpha3Code: string; + name?: string | null; + capital?: string | null; + region?: string | null; + subregion?: string | null; + population?: number | null; + area?: number | null; + flags?: { + svg?: string | null; + } | null; + currencies?: + | { + code?: string | null; + name?: string | null; + }[] + | null; + languages?: + | { + name?: string | null; + }[] + | null; +} + +// Successful response from /region/{region}. +export type CountriesApiResponse = CountryApiResponse[]; + +export interface Country { + code: string; + name: string; + capital: string; + region: string; + subregion: string; + population: number | null; + area: number | null; + flagUrl: string; + currencies: string[]; + languages: string[]; +} diff --git a/country-explorer/src/utils/localStorage.ts b/country-explorer/src/utils/localStorage.ts new file mode 100644 index 0000000..454b515 --- /dev/null +++ b/country-explorer/src/utils/localStorage.ts @@ -0,0 +1,21 @@ +const NAMESPACE = 'country-explorer'; + +function storageKey(key: string): string { + return `${NAMESPACE}:${key}`; +} + +export function readLocalValue(key: string): string | null { + try { + return window.localStorage.getItem(storageKey(key)); + } catch { + return null; + } +} + +export function writeLocalValue(key: string, value: string): void { + try { + window.localStorage.setItem(storageKey(key), value); + } catch { + // storage unavailable or full + } +} diff --git a/country-explorer/src/utils/sessionStorage.ts b/country-explorer/src/utils/sessionStorage.ts new file mode 100644 index 0000000..33d456d --- /dev/null +++ b/country-explorer/src/utils/sessionStorage.ts @@ -0,0 +1,21 @@ +const NAMESPACE = 'country-explorer'; + +function storageKey(key: string): string { + return `${NAMESPACE}:${key}`; +} + +export function readSessionValue(key: string): string | null { + try { + return window.sessionStorage.getItem(storageKey(key)); + } catch { + return null; + } +} + +export function writeSessionValue(key: string, value: string): void { + try { + window.sessionStorage.setItem(storageKey(key), value); + } catch { + // storage unavailable or full + } +} diff --git a/country-explorer/src/utils/sortCountries.test.ts b/country-explorer/src/utils/sortCountries.test.ts new file mode 100644 index 0000000..5544eaf --- /dev/null +++ b/country-explorer/src/utils/sortCountries.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import type { Country } from '../types/country'; +import { isSortOption, sortCountries } from './sortCountries'; + +function makeCountry(overrides: Partial): Country { + return { + code: 'XXX', + name: 'Country', + capital: '', + region: '', + subregion: '', + population: null, + area: null, + flagUrl: '', + currencies: [], + languages: [], + ...overrides, + }; +} + +const norway = makeCountry({ code: 'NOR', name: 'Norway', population: 5379475 }); +const sweden = makeCountry({ code: 'SWE', name: 'Sweden', population: 10353442 }); +const iceland = makeCountry({ code: 'ISL', name: 'Iceland', population: 372520 }); +const unknownPopulation = makeCountry({ code: 'ATL', name: 'Atlantis', population: null }); + +describe('sortCountries', () => { + it('sorts alphabetically by name for name-asc', () => { + const result = sortCountries([sweden, norway, iceland], 'name-asc'); + + expect(result.map((country) => country.name)).toEqual(['Iceland', 'Norway', 'Sweden']); + }); + + it('sorts by population from high to low for population-desc', () => { + const result = sortCountries([iceland, sweden, norway], 'population-desc'); + + expect(result.map((country) => country.name)).toEqual(['Sweden', 'Norway', 'Iceland']); + }); + + it('sorts countries with unknown population last', () => { + const result = sortCountries([unknownPopulation, sweden, iceland], 'population-desc'); + + expect(result.map((country) => country.name)).toEqual(['Sweden', 'Iceland', 'Atlantis']); + }); + + it('returns an empty array for both sort options', () => { + expect(sortCountries([], 'name-asc')).toEqual([]); + expect(sortCountries([], 'population-desc')).toEqual([]); + }); + + it('does not modify the original array or the countries in it', () => { + const original = [sweden, norway, iceland]; + const originalOrder = [...original]; + + const result = sortCountries(original, 'name-asc'); + + expect(original).toEqual(originalOrder); + expect(result).not.toBe(original); + }); + + it('recognizes valid and invalid sort option values', () => { + expect(isSortOption('name-asc')).toBe(true); + expect(isSortOption('population-desc')).toBe(true); + expect(isSortOption('area-desc')).toBe(false); + expect(isSortOption(null)).toBe(false); + }); +}); diff --git a/country-explorer/src/utils/sortCountries.ts b/country-explorer/src/utils/sortCountries.ts new file mode 100644 index 0000000..8ee362e --- /dev/null +++ b/country-explorer/src/utils/sortCountries.ts @@ -0,0 +1,29 @@ +import type { Country } from '../types/country'; + +export type SortOption = 'name-asc' | 'population-desc'; + +export const SORT_OPTIONS: { value: SortOption; label: string }[] = [ + { value: 'name-asc', label: 'Name (A–Z)' }, + { value: 'population-desc', label: 'Population (high to low)' }, +]; + +export function isSortOption(value: string | null): value is SortOption { + return SORT_OPTIONS.some((option) => option.value === value); +} + +export function sortCountries(countries: Country[], sortOption: SortOption): Country[] { + const sorted = [...countries]; + + switch (sortOption) { + case 'population-desc': + // unknown population sorts last + sorted.sort((a, b) => (b.population ?? -Infinity) - (a.population ?? -Infinity)); + break; + case 'name-asc': + default: + sorted.sort((a, b) => a.name.localeCompare(b.name)); + break; + } + + return sorted; +} diff --git a/country-explorer/tsconfig.app.json b/country-explorer/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/country-explorer/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/country-explorer/tsconfig.json b/country-explorer/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/country-explorer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/country-explorer/tsconfig.node.json b/country-explorer/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/country-explorer/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/country-explorer/vite.config.ts b/country-explorer/vite.config.ts new file mode 100644 index 0000000..005146c --- /dev/null +++ b/country-explorer/vite.config.ts @@ -0,0 +1,12 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vitest/config'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + base: '/project1/', + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + }, +}); diff --git a/docs/ai-usage.md b/docs/ai-usage.md new file mode 100644 index 0000000..97f534c --- /dev/null +++ b/docs/ai-usage.md @@ -0,0 +1,28 @@ +# Use of AI tools + +## How we used AI + +We used **ChatGPT, Claude, and Microsoft Copilot** as supplementary tools to +speed up our workflow and help solve smaller problems. They assisted with +translating documentation and README text into English, refining wording, and +providing code examples. + +For documentation, we used AI to turn Norwegian text into English and suggest +clearer wording while keeping the intended meaning. For programming, code examples +served as references for smaller tasks and were assessed against our existing code +and project requirements. + +## Review and quality assurance + +AI suggestions were starting points; the group made the final implementation +and design decisions. We critically reviewed suggestions to understand how they +worked and adapted them where necessary to fit the project. AI was never used +to generate entire modules or features without human review. + +## Experiences + +AI was useful for reducing time spent on translation and wording, and for getting +a starting point when working on smaller coding problems. Our main takeaway was +that suggestions still required understanding and critical review: clear wording +or plausible code alone was not enough to accept an answer. The group retained +responsibility for the final text, code, and design decisions. diff --git a/docs/api-performance.md b/docs/api-performance.md new file mode 100644 index 0000000..4a6c208 --- /dev/null +++ b/docs/api-performance.md @@ -0,0 +1,52 @@ +# API calls and performance + +## How we tested + +We opened the deployed application at http://it2810-23.idi.ntnu.no/project1/ +and inspected the Network tab in the browser's developer tools, then confirmed +the findings by tracking every request via the Resource Timing API +(`performance.getEntriesByType('resource')`), which records every request made +since the page loaded regardless of when we started watching. + +We checked requests to the REST API (`countries.dev`) at each stage: + +- On application start. +- While filtering by region, including switching back to "All regions". +- While changing the sort order. +- While navigating between countries with Previous/Next and the country + selector. +- After leaving the page idle for 10 seconds, to rule out polling or a + refetch loop. + +## Findings + +During the recorded test sequence, we observed **one** request to the REST API: + +``` +GET https://countries.dev/region/europe +``` + +This happens once when the app starts. Region filtering and sorting are both +done client-side on the already-fetched list — the "Region" dropdown filters +by subregion in memory, and "Sort by" reorders the same array — so neither +triggers a new request. Navigating between countries only changes which +already-fetched country is displayed. No additional requests occurred during +any of the above, and none occurred while idle. + +## TanStack Query configuration + +No changes were needed. The existing configuration in `src/lib/queryClient.ts` +already supports this: + +- `staleTime: 5 * 60 * 1000` — fetched data is treated as fresh for 5 minutes, + so remounting the country list within that window doesn't refetch. +- `refetchOnWindowFocus: false` — switching tabs or windows doesn't trigger a + refetch. +- `retry: 1` — a failed request is retried once, not looped indefinitely. + +Since there is only one query in the app (`useCountries`, keyed by region) and +the UI never changes the region it's called with, there is only ever one +active query, and it is never invalidated during normal use. + +This observation is not a one-request-per-session guarantee. Reloading the page, +retrying a failure, or remounting/reconnecting with stale data can fetch again. diff --git a/docs/component-structure.md b/docs/component-structure.md new file mode 100644 index 0000000..b8061ae --- /dev/null +++ b/docs/component-structure.md @@ -0,0 +1,137 @@ +# Wireframes and Component Structure + +This document describes the initial wireframes and the component structure that +was implemented for Country Explorer. + +## Wireframes + +### Desktop + +![Desktop wireframe](wireframes/desktop-wireframe.png) + +The initial desktop wireframe placed the country card and favorites list next to +each other, with filters and selection controls above them. In the implemented +layout, the controls, selected-country section, and favorites section are stacked +vertically at all viewport widths. The content inside each section adapts to the +available width. + +### Mobile + +![Mobile wireframe](wireframes/mobile-wireframe.png) + +The mobile layout displays all sections in one column. The filters appear first, +followed by the country card, navigation controls, and favorites list. + +The wireframes are initial design sketches, so they do not represent every final +component boundary or layout choice. + +## Current component hierarchy + +- `App` + - Header markup + - Controls section + - `RegionFilter` + - `Dropdown` + - `SortSelector` + - `Dropdown` + - `CountrySelector` + - `Dropdown` + - Selected-country section + - `Loading`, `ErrorMessage`, or: + - `CountryCard` + - `FavoriteButton` + - `NavigationControls` + - Favorites section + - `FavoritesList` + - Footer markup + +Header, footer, and the controls section are markup inside `App`, not separate +React components. + +## Component responsibilities + +### App + +The root component coordinates query results and application state. It: + +- Retrieves European country data through `useCountries` +- Stores the selected country code, region, and sorting option +- Uses `useFavorites` to manage favorite country codes +- Produces the filtered and sorted country lists +- Displays loading, error, empty, and successful states +- Passes data and callbacks to child components + +### Dropdown + +Provides the reusable accessible dropdown UI used by all three selection +controls. It displays radio options, manages whether the popup is open, closes +on outside interaction or Escape, and restores focus to its trigger after a +selection. + +### RegionFilter + +Allows the user to filter countries by European subregion. It receives the +available regions, selected region, change callback, and disabled state. + +### SortSelector + +Allows the user to order countries alphabetically or by population. It receives +the selected sorting option, change callback, and disabled state. + +### CountrySelector + +Allows the user to jump directly to a country in the filtered and sorted list. +It receives the available countries, selected country code, selection callback, +and disabled state. + +### CountryCard + +Displays one country's name, flag, capital, region, population, area, languages, +currencies, and favorite control. It receives the selected country, its favorite +status, and a callback for toggling that status. + +### FavoriteButton + +Adds or removes the displayed country from favorites. It receives the favorite +status, country name for its accessible label, and toggle callback. + +### NavigationControls + +Displays the current position and moves to the previous or next country. It +receives the current index, number of available countries, and navigation +callbacks. It determines when the navigation buttons must be disabled. + +### FavoritesList + +Displays countries saved as favorites. Selecting one clears the active region +filter and displays that country in the main country card. + +### Loading and ErrorMessage + +`Loading` displays a status while country data is being fetched. `ErrorMessage` +displays the request error and provides a retry action. + +## State ownership and storage + +| State | Owner/source | Persistence | +| ---------------------- | -------------------- | ---------------- | +| Selected country code | `App` | None | +| Selected region | `App` | `sessionStorage` | +| Sorting option | `App` | `sessionStorage` | +| Favorite country codes | `useFavorites` | `localStorage` | +| Country data | TanStack Query cache | Memory only | + +The storage helpers namespace their keys with `country-explorer:`. Invalid +stored sorting and favorite values fall back safely. An unknown stored region +produces an empty result until the user selects a valid region or "All regions". + +## Displaying one country at a time + +Only one country is displayed in `CountryCard` at a time. The user can change it +with the previous and next buttons, `CountrySelector`, or `FavoritesList`. + +Filtering or sorting can change the available countries and their order. The +selected country remains selected when it is still available. If filtering +removes it, the first country in the resulting list is selected. Selecting a +favorite clears the region filter first so favorites from other subregions can +be displayed. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..4906e69 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,23 @@ +# Deploy a test version to the VM + +## What we did + +We deployed a production build of the application to the group's VM to verify +early that it can be built and served under Apache. + +- Verified SSH access to `it2810-23.idi.ntnu.no`. +- Installed and enabled Apache2 on the VM. +- Built the project locally with `npm run build`. The `base` path in + `vite.config.ts` was already set to `/project1/`, so no code changes were + needed for the build to resolve correctly under that path. +- Copied the `dist` folder to the VM (via `/tmp`) and moved it into + `/var/www/html/project1`, replacing anything already there. + +## Verification + +The application is live at http://it2810-23.idi.ntnu.no/project1/. + +- JavaScript, CSS and images all load correctly. +- Filtering, sorting, country selection and navigation work as expected. +- The country list loads from the REST API on the VM. +- No errors appear in the browser console. diff --git a/docs/git-workflow.md b/docs/git-workflow.md new file mode 100644 index 0000000..94f5ad0 --- /dev/null +++ b/docs/git-workflow.md @@ -0,0 +1,38 @@ +# Git workflow + +Work on an issue branch and open a pull request into `dev`. +Use the branch template `-`. Replace all +placeholders below before running the commands. + +```bash +git switch dev +git pull --ff-only +git switch -c "-" +# Make changes, run relevant checks, and stage the intended files. +git commit -m ": " -m "" -m "Closes #" +git push -u origin "-" +``` + +Commit template: `: `. Use `feat`, `fix`, `style`, `test`, +`docs`, or `refactor` as appropriate. Add `Closes #` only when the change +completes that issue. + +## Pull requests + +Keep the description short: + +```text +Summary: What changed. +Verification: Commands run, results, and any manual checks or limitations. +Closes # +``` + +A different member of IT2810-H26-T23 must approve the PR; approvals from staff do +not count. Resolve every review conversation before merging. New commits dismiss +previous approval, so request another review after making changes. + +## Releasing the completed project + +Issue branches are merged into `dev` throughout development. When the complete +project is ready for release, open a final merge request from `dev` into `main`. +Do not use `main` as the target for individual issue branches. diff --git a/docs/sprint-process.md b/docs/sprint-process.md new file mode 100644 index 0000000..e523b50 --- /dev/null +++ b/docs/sprint-process.md @@ -0,0 +1,8 @@ +# Weekly meetings and sprint planning + +We held one main meeting each week. We split the work into issues, agreed on who +would take each task, and discussed progress and problems we were struggling with. +The meetings helped us clarify tasks and decide what to focus on next. + +Issues described the work and acceptance criteria. Changes were submitted through +pull requests so another group member could review them before merging. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..4cb368e --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,22 @@ +# Testing + +We verified the application from `country-explorer/` with `npm test`, +`npm run lint`, `npm run format:check`, and `npm run build`. All checks passed. +The test suite contained 45 passing tests. + +The tests cover country rendering, loading/error states, filtering, sorting, +navigation, dropdown selection, and storage/restoration, including invalid data. +`CountryCard.test.tsx` also contains a snapshot test, with its committed snapshot +stored in `src/components/__snapshots__/CountryCard.test.tsx.snap`. + +## Browser and device coverage + +The group's [usability evaluation](usability-evaluation.md) reports that the app +worked as expected and identifies scrolling on mobile as an improvement area. +The [deployment report](deployment.md) also records functional browser checks. +The group confirms testing on a PC and an iPhone. + +| Device | Reported result | +| ------ | ---------------------------------------------------------- | +| PC | Functionality worked as expected in the group's evaluation | +| iPhone | Functionality worked; navigation required some scrolling | diff --git a/docs/usability-evaluation.md b/docs/usability-evaluation.md new file mode 100644 index 0000000..2cd5d28 --- /dev/null +++ b/docs/usability-evaluation.md @@ -0,0 +1,19 @@ +# Usability evaluation + +## Overall experience + +Based on the group's evaluation, all functionality worked as expected. We found +the application clear and usable, and overall we think the layout suits its purpose +well. + +## Mobile layout + +The main area for improvement is the placement of buttons on mobile phones. +Users currently need to scroll to reach the navigation buttons when moving to the +next country. A more compact mobile layout could keep more essential controls +and information within view and reduce the need to scroll between actions. + +For example, placing country navigation closer to the country selector or reducing +spacing on small screens could make browsing more convenient. These are possible +future improvements; despite this limitation, the group considers the current +mobile layout a good fit overall. diff --git a/docs/wireframes/desktop-wireframe.png b/docs/wireframes/desktop-wireframe.png new file mode 100644 index 0000000..4355ba0 Binary files /dev/null and b/docs/wireframes/desktop-wireframe.png differ diff --git a/docs/wireframes/mobile-wireframe.png b/docs/wireframes/mobile-wireframe.png new file mode 100644 index 0000000..f18e260 Binary files /dev/null and b/docs/wireframes/mobile-wireframe.png differ