diff --git a/frontend/.env b/frontend/.env
new file mode 100644
index 0000000..8af0033
--- /dev/null
+++ b/frontend/.env
@@ -0,0 +1 @@
+VITE_MOCK_API_URL=http://localhost:3001
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..785a308
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,25 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+coverage
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json
new file mode 100644
index 0000000..6fa991d
--- /dev/null
+++ b/frontend/.oxlintrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "typescript", "oxc"],
+ "rules": {
+ "react/rules-of-hooks": "error",
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
+ }
+}
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..a10a7d5
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,71 @@
+# Verity — Cadastro (Teste Técnico Front-End)
+
+Formulário de cadastro em 3 etapas (Dados Pessoais, Informações Residenciais e
+Informações Profissionais) com resumo final e exportação em PDF, construído
+com React + TypeScript + Vite.
+
+## Stack
+
+- **React 19** + **TypeScript** + **Vite**
+- **React Hook Form** + **Zod** para validação dos formulários
+- **react-router-dom** para navegação entre etapas
+- **react-imask** para máscaras de Data de Nascimento, CPF, Telefone e CEP
+- **jsPDF** para exportação do resumo em PDF
+- **json-server** como serviço mockado para busca de CEP e lista de profissões
+- **Vitest** + **Testing Library** para os testes unitários (cobertura ≥ 80%)
+
+## Como rodar
+
+Instale as dependências (pnpm):
+
+```bash
+pnpm install
+```
+
+Suba a aplicação e a API mockada juntas:
+
+```bash
+pnpm dev:all
+```
+
+Isso inicia o Vite em `http://localhost:5173` e o json-server em
+`http://localhost:3001` (dados em `mock/db.json`, com os endpoints
+`GET /ceps/:cep` e `GET /professions`).
+
+Se preferir rodar cada um separadamente:
+
+```bash
+pnpm dev # front-end
+pnpm mock:api # API mockada
+```
+
+## Busca de CEP
+
+Ao digitar um CEP completo, a aplicação primeiro consulta o json-server
+(`/ceps/:cep`). Caso o CEP não exista no mock, ela cai automaticamente para o
+ViaCEP (endpoint público dos Correios/ViaCEP) como fallback — cobrindo tanto o
+requisito principal quanto o "plus" do desafio. CEPs de exemplo disponíveis no
+mock: `01310-100`, `01001-000`, `20040-020`, `30130-010`, `80010-000`,
+`90010-000`, `40010-000`, `70040-010`, `60060-170`, `50030-230`.
+
+## Testes
+
+```bash
+pnpm test # roda a suíte uma vez
+pnpm test:watch # modo watch
+pnpm test:coverage # com relatório de cobertura (mínimo configurado: 80%)
+```
+
+## Build
+
+```bash
+pnpm build
+pnpm preview
+```
+
+## Identidade visual
+
+A paleta (azul/branco) e o wordmark "verity" usados são um placeholder até a
+logo oficial da Verity ser fornecida — o componente `Logo`
+(`src/components/layout/Logo.tsx`) foi isolado exatamente para facilitar essa
+troca depois.
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..358e583
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Verity | Cadastro
+
+
+
+
+
+
diff --git a/frontend/mock/db.json b/frontend/mock/db.json
new file mode 100644
index 0000000..9193d88
--- /dev/null
+++ b/frontend/mock/db.json
@@ -0,0 +1,86 @@
+{
+ "ceps": [
+ {
+ "id": "01310100",
+ "endereco": "Avenida Paulista",
+ "bairro": "Bela Vista",
+ "cidade": "São Paulo",
+ "estado": "SP"
+ },
+ {
+ "id": "01001000",
+ "endereco": "Praça da Sé",
+ "bairro": "Sé",
+ "cidade": "São Paulo",
+ "estado": "SP"
+ },
+ {
+ "id": "20040020",
+ "endereco": "Avenida Rio Branco",
+ "bairro": "Centro",
+ "cidade": "Rio de Janeiro",
+ "estado": "RJ"
+ },
+ {
+ "id": "30130010",
+ "endereco": "Avenida Afonso Pena",
+ "bairro": "Centro",
+ "cidade": "Belo Horizonte",
+ "estado": "MG"
+ },
+ {
+ "id": "80010000",
+ "endereco": "Rua XV de Novembro",
+ "bairro": "Centro",
+ "cidade": "Curitiba",
+ "estado": "PR"
+ },
+ {
+ "id": "90010000",
+ "endereco": "Rua dos Andradas",
+ "bairro": "Centro Histórico",
+ "cidade": "Porto Alegre",
+ "estado": "RS"
+ },
+ {
+ "id": "40010000",
+ "endereco": "Avenida Sete de Setembro",
+ "bairro": "Centro",
+ "cidade": "Salvador",
+ "estado": "BA"
+ },
+ {
+ "id": "70040010",
+ "endereco": "Praça dos Três Poderes",
+ "bairro": "Zona Cívico-Administrativa",
+ "cidade": "Brasília",
+ "estado": "DF"
+ },
+ {
+ "id": "60060170",
+ "endereco": "Avenida Beira Mar",
+ "bairro": "Praia de Iracema",
+ "cidade": "Fortaleza",
+ "estado": "CE"
+ },
+ {
+ "id": "50030230",
+ "endereco": "Avenida Guararapes",
+ "bairro": "Santo Antônio",
+ "cidade": "Recife",
+ "estado": "PE"
+ }
+ ],
+ "professions": [
+ { "id": 1, "name": "Desenvolvedor(a) de Software" },
+ { "id": 2, "name": "Analista de Sistemas" },
+ { "id": 3, "name": "Designer UX/UI" },
+ { "id": 4, "name": "Gerente de Projetos" },
+ { "id": 5, "name": "Analista Financeiro" },
+ { "id": 6, "name": "Advogado(a)" },
+ { "id": 7, "name": "Médico(a)" },
+ { "id": 8, "name": "Professor(a)" },
+ { "id": 9, "name": "Contador(a)" },
+ { "id": 10, "name": "Engenheiro(a) Civil" }
+ ]
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..e679a51
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "dev:all": "run-p dev mock:api",
+ "mock:api": "json-server --port 3001 mock/db.json",
+ "build": "tsc -b && vite build",
+ "lint": "oxlint",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage"
+ },
+ "dependencies": {
+ "@hookform/resolvers": "^5.4.0",
+ "axios": "^1.18.1",
+ "jspdf": "^4.2.1",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
+ "react-hook-form": "^7.81.0",
+ "react-imask": "^7.6.1",
+ "react-router-dom": "^7.18.1",
+ "zod": "^4.4.3"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/node": "^24.13.2",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.3",
+ "@vitest/coverage-v8": "^4.1.10",
+ "jsdom": "^29.1.1",
+ "json-server": "1.0.0-beta.15",
+ "npm-run-all2": "^9.0.2",
+ "oxlint": "^1.71.0",
+ "postcss-custom-media": "^12.0.1",
+ "typescript": "~6.0.2",
+ "vite": "^8.1.1",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
new file mode 100644
index 0000000..f6e1bdd
--- /dev/null
+++ b/frontend/pnpm-lock.yaml
@@ -0,0 +1,2789 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@hookform/resolvers':
+ specifier: ^5.4.0
+ version: 5.4.0(react-hook-form@7.81.0(react@19.2.7))
+ axios:
+ specifier: ^1.18.1
+ version: 1.18.1
+ jspdf:
+ specifier: ^4.2.1
+ version: 4.2.1
+ react:
+ specifier: ^19.2.7
+ version: 19.2.7
+ react-dom:
+ specifier: ^19.2.7
+ version: 19.2.7(react@19.2.7)
+ react-hook-form:
+ specifier: ^7.81.0
+ version: 7.81.0(react@19.2.7)
+ react-imask:
+ specifier: ^7.6.1
+ version: 7.6.1(react@19.2.7)
+ react-router-dom:
+ specifier: ^7.18.1
+ version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
+ devDependencies:
+ '@testing-library/jest-dom':
+ specifier: ^6.9.1
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
+ '@types/node':
+ specifier: ^24.13.2
+ version: 24.13.3
+ '@types/react':
+ specifier: ^19.2.17
+ version: 19.2.17
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.17)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.3
+ version: 6.0.3(vite@8.1.4(@types/node@24.13.3))
+ '@vitest/coverage-v8':
+ specifier: ^4.1.10
+ version: 4.1.10(vitest@4.1.10)
+ jsdom:
+ specifier: ^29.1.1
+ version: 29.1.1
+ json-server:
+ specifier: 1.0.0-beta.15
+ version: 1.0.0-beta.15
+ npm-run-all2:
+ specifier: ^9.0.2
+ version: 9.0.2
+ oxlint:
+ specifier: ^1.71.0
+ version: 1.73.0
+ postcss-custom-media:
+ specifier: ^12.0.1
+ version: 12.0.1(postcss@8.5.18)
+ typescript:
+ specifier: ~6.0.2
+ version: 6.0.3
+ vite:
+ specifier: ^8.1.1
+ version: 8.1.4(@types/node@24.13.3)
+ vitest:
+ specifier: ^4.1.10
+ version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@24.13.3))
+
+packages:
+
+ '@adobe/css-tools@4.5.0':
+ resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
+
+ '@asamuzakjp/css-color@5.1.11':
+ resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/generational-cache@1.0.1':
+ resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/runtime-corejs3@7.29.7':
+ resolution: {integrity: sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
+ '@bcoe/v8-coverage@1.0.2':
+ resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
+ engines: {node: '>=18'}
+
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
+ '@csstools/cascade-layer-name-parser@3.0.0':
+ resolution: {integrity: sha512-/3iksyevwRfSJx5yH0RkcrcYXwuhMQx3Juqf40t97PeEy2/Mz2TItZ/z/216qpe4GgOyFBP8MKIwVvytzHmfIQ==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/color-helpers@6.1.0':
+ resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.2.1':
+ resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.1.9':
+ resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.6':
+ resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/media-query-list-parser@5.0.0':
+ resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@emnapi/core@1.11.1':
+ resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
+
+ '@emnapi/runtime@1.11.1':
+ resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
+
+ '@emnapi/wasi-threads@1.2.2':
+ resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
+
+ '@exodus/bytes@1.15.1':
+ resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
+ '@hookform/resolvers@5.4.0':
+ resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
+ peerDependencies:
+ react-hook-form: ^7.55.0
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@napi-rs/wasm-runtime@1.1.6':
+ resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
+ '@oxc-project/types@0.139.0':
+ resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
+
+ '@oxlint/binding-android-arm-eabi@1.73.0':
+ resolution: {integrity: sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [android]
+
+ '@oxlint/binding-android-arm64@1.73.0':
+ resolution: {integrity: sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@oxlint/binding-darwin-arm64@1.73.0':
+ resolution: {integrity: sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@oxlint/binding-darwin-x64@1.73.0':
+ resolution: {integrity: sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@oxlint/binding-freebsd-x64@1.73.0':
+ resolution: {integrity: sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@oxlint/binding-linux-arm-gnueabihf@1.73.0':
+ resolution: {integrity: sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxlint/binding-linux-arm-musleabihf@1.73.0':
+ resolution: {integrity: sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxlint/binding-linux-arm64-gnu@1.73.0':
+ resolution: {integrity: sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@oxlint/binding-linux-arm64-musl@1.73.0':
+ resolution: {integrity: sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@oxlint/binding-linux-ppc64-gnu@1.73.0':
+ resolution: {integrity: sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@oxlint/binding-linux-riscv64-gnu@1.73.0':
+ resolution: {integrity: sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@oxlint/binding-linux-riscv64-musl@1.73.0':
+ resolution: {integrity: sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@oxlint/binding-linux-s390x-gnu@1.73.0':
+ resolution: {integrity: sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@oxlint/binding-linux-x64-gnu@1.73.0':
+ resolution: {integrity: sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@oxlint/binding-linux-x64-musl@1.73.0':
+ resolution: {integrity: sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@oxlint/binding-openharmony-arm64@1.73.0':
+ resolution: {integrity: sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@oxlint/binding-win32-arm64-msvc@1.73.0':
+ resolution: {integrity: sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@oxlint/binding-win32-ia32-msvc@1.73.0':
+ resolution: {integrity: sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@oxlint/binding-win32-x64-msvc@1.73.0':
+ resolution: {integrity: sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@polka/url@1.0.0-next.29':
+ resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+
+ '@rolldown/binding-android-arm64@1.1.5':
+ resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.1.5':
+ resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.1.5':
+ resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.1.5':
+ resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.1.5':
+ resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.1.5':
+ resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-musl@1.1.5':
+ resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.1.5':
+ resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rolldown/binding-linux-s390x-gnu@1.1.5':
+ resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rolldown/binding-linux-x64-gnu@1.1.5':
+ resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@rolldown/binding-linux-x64-musl@1.1.5':
+ resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@rolldown/binding-openharmony-arm64@1.1.5':
+ resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-wasm32-wasi@1.1.5':
+ resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.1.5':
+ resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.1.5':
+ resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
+ '@standard-schema/utils@0.3.0':
+ resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
+
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ 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
+
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
+ '@tinyhttp/accepts@2.3.0':
+ resolution: {integrity: sha512-hdKkMGAUqnagpWO1R8rVBYqbu4sWQ2Fo682gkJmO0nl54DPvnzxx81b2WZtV3VwB7EdLfUoasj2BAkyTcyZ5aw==}
+ engines: {node: '>=14.13.1'}
+
+ '@tinyhttp/app@3.0.7':
+ resolution: {integrity: sha512-btit/gSWisksJ19crNLct1mwZvX+/AYwh/H1x8SB/VGDmNFkSsdRDZaa54wW1Eq3bM7mCIr7+l7Oh3tAi/C+Bw==}
+ engines: {node: '>=16.10.0'}
+
+ '@tinyhttp/content-disposition@2.2.4':
+ resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==}
+ engines: {node: '>=12.17.0'}
+
+ '@tinyhttp/content-type@0.1.4':
+ resolution: {integrity: sha512-dl6f3SHIJPYbhsW1oXdrqOmLSQF/Ctlv3JnNfXAE22kIP7FosqJHxkz/qj2gv465prG8ODKH5KEyhBkvwrueKQ==}
+ engines: {node: '>=12.4'}
+
+ '@tinyhttp/cookie-signature@2.1.1':
+ resolution: {integrity: sha512-VDsSMY5OJfQJIAtUgeQYhqMPSZptehFSfvEEtxr+4nldPA8IImlp3QVcOVuK985g4AFR4Hl1sCbWCXoqBnVWnw==}
+ engines: {node: '>=12.20.0'}
+
+ '@tinyhttp/cookie@2.1.1':
+ resolution: {integrity: sha512-h/kL9jY0e0Dvad+/QU3efKZww0aTvZJslaHj3JTPmIPC9Oan9+kYqmh3M6L5JUQRuTJYFK2nzgL2iJtH2S+6dA==}
+ engines: {node: '>=12.20.0'}
+
+ '@tinyhttp/cors@2.0.1':
+ resolution: {integrity: sha512-qrmo6WJuaiCzKWagv2yA/kw6hIISfF/hOqPWwmI6w0o8apeTMmRN3DoCFvQ/wNVuWVdU5J4KU7OX8aaSOEq51A==}
+ engines: {node: '>=12.20 || 14.x || >=16'}
+
+ '@tinyhttp/encode-url@2.1.1':
+ resolution: {integrity: sha512-AhY+JqdZ56qV77tzrBm0qThXORbsVjs/IOPgGCS7x/wWnsa/Bx30zDUU/jPAUcSzNOzt860x9fhdGpzdqbUeUw==}
+ engines: {node: '>=12.20.0'}
+
+ '@tinyhttp/etag@2.1.2':
+ resolution: {integrity: sha512-j80fPKimGqdmMh6962y+BtQsnYPVCzZfJw0HXjyH70VaJBHLKGF+iYhcKqzI3yef6QBNa8DKIPsbEYpuwApXTw==}
+ engines: {node: '>=12.20.0'}
+
+ '@tinyhttp/forwarded@2.1.2':
+ resolution: {integrity: sha512-9H/eulJ68ElY/+zYpTpNhZ7vxGV+cnwaR6+oQSm7bVgZMyuQfgROW/qvZuhmgDTIxnGMXst+Ba4ij6w6Krcs3w==}
+ engines: {node: '>=12.20.0'}
+
+ '@tinyhttp/logger@2.1.0':
+ resolution: {integrity: sha512-Ma1fJ9CwUbn9r61/4HW6+nflsVoslpOnCrfQ6UeZq7GGIgwLzofms3HoSVG7M+AyRMJpxlfcDdbH5oFVroDMKA==}
+ engines: {node: '>=14.18 || >=16.20'}
+
+ '@tinyhttp/proxy-addr@3.0.1':
+ resolution: {integrity: sha512-vP0JVsy9ZMIldsaP/QHdMF+sb3B6wn7e2QXRdqpX/Cqz1ie35Am29DK88DeVmiwdTQle3FtYaVNtU3RgTGYZ+w==}
+ engines: {node: '>=16.10.0'}
+
+ '@tinyhttp/req@2.2.8':
+ resolution: {integrity: sha512-HCsceFNgMpssUsnRao16iJyyfdWRwKlhL7OMTPUEjZsZGREnBzpjlrPHA31G5xNNzR7XOWVXDXGyrGgSpcwGSA==}
+ engines: {node: '>=14.13.1'}
+
+ '@tinyhttp/res@2.2.11':
+ resolution: {integrity: sha512-t7GJzjqpG2svJ11RYvqaYU+xTV9MsEr0usbTIAWa5B8d7IIzpMC6AlT2K6wCIu1XGUYRpZY7qMM5cMgluvsfeg==}
+ engines: {node: '>=14.13.1'}
+
+ '@tinyhttp/router@2.2.5':
+ resolution: {integrity: sha512-HI9Mpo9+IVpCzx/36okjJvtvifBSh3Ufhl9n1vylAbNLEykceJiMBkr06+W0qqRlo8TiZeUtg2XinEJu+GFcRA==}
+ engines: {node: '>=14.0.0'}
+
+ '@tinyhttp/send@2.2.5':
+ resolution: {integrity: sha512-XhBwziPOCydOJzb9rVw0xuKX6HmMA0gXKHHqec7V97jU6JtSCCIvwW2FBMh/XrG9S5W8DRETroPohASrIwf7Uw==}
+ engines: {node: '>=14.13.1'}
+
+ '@tinyhttp/type-is@2.2.5':
+ resolution: {integrity: sha512-BCPEB+NV8v/9lzEE9GbfRPAKVsyayp84m6SSWn70j8yFkPBXeuVeq004pwVrjW1CRdmAZz9ZSH147pqqzAdr5g==}
+ engines: {node: '>=14.13.1'}
+
+ '@tinyhttp/url@2.1.1':
+ resolution: {integrity: sha512-POJeq2GQ5jI7Zrdmj22JqOijB5/GeX+LEX7DUdml1hUnGbJOTWDx7zf2b5cCERj7RoXL67zTgyzVblBJC+NJWg==}
+ engines: {node: '>=12.20.0'}
+
+ '@tinyhttp/vary@0.1.3':
+ resolution: {integrity: sha512-SoL83sQXAGiHN1jm2VwLUWQSQeDAAl1ywOm6T0b0Cg1CZhVsjoiZadmjhxF6FHCCY7OHHVaLnTgSMxTPIDLxMg==}
+ engines: {node: '>=12.20'}
+
+ '@tybys/wasm-util@0.10.3':
+ resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
+
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/node@24.13.3':
+ resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
+
+ '@types/pako@2.0.4':
+ resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
+
+ '@types/raf@3.4.3':
+ resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.17':
+ resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
+
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
+ '@vitejs/plugin-react@6.0.3':
+ resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==}
+ 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
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+
+ '@vitest/coverage-v8@4.1.10':
+ resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==}
+ peerDependencies:
+ '@vitest/browser': 4.1.10
+ vitest: 4.1.10
+ peerDependenciesMeta:
+ '@vitest/browser':
+ optional: true
+
+ '@vitest/expect@4.1.10':
+ resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
+
+ '@vitest/mocker@4.1.10':
+ resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.10':
+ resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==}
+
+ '@vitest/runner@4.1.10':
+ resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==}
+
+ '@vitest/snapshot@4.1.10':
+ resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==}
+
+ '@vitest/spy@4.1.10':
+ resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==}
+
+ '@vitest/utils@4.1.10':
+ resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
+
+ agent-base@6.0.2:
+ resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+ engines: {node: '>= 6.0.0'}
+
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
+ ansi-styles@6.2.3:
+ resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
+ engines: {node: '>=12'}
+
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ ast-v8-to-istanbul@1.0.4:
+ resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==}
+
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
+ axios@1.18.1:
+ resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==}
+
+ base64-arraybuffer@1.0.2:
+ resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
+ engines: {node: '>= 0.6.0'}
+
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ canvg@3.0.11:
+ resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
+ engines: {node: '>=10.0.0'}
+
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
+ chalk@5.6.2:
+ resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
+ engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
+ chokidar@5.0.0:
+ resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
+ engines: {node: '>= 20.19.0'}
+
+ colorette@2.0.20:
+ resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
+
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ core-js-pure@3.49.0:
+ resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==}
+
+ core-js@3.49.0:
+ resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ css-line-break@2.1.0:
+ resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
+
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ dayjs@1.11.21:
+ resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
+ dompurify@3.4.12:
+ resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
+
+ dot-prop@10.1.0:
+ resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==}
+ engines: {node: '>=20'}
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ entities@8.0.0:
+ resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
+ engines: {node: '>=20.19.0'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-module-lexer@2.3.1:
+ resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==}
+
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+ eta@4.6.0:
+ resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==}
+ engines: {node: '>=20'}
+
+ expect-type@1.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+ engines: {node: '>=12.0.0'}
+
+ fast-png@6.4.0:
+ resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fflate@0.8.3:
+ resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
+
+ follow-redirects@1.16.0:
+ resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
+ form-data@4.0.6:
+ resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
+ engines: {node: '>= 6'}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ header-range-parser@1.1.5:
+ resolution: {integrity: sha512-n5JOx67HBL0MGqtu6NFoEYWb+xDYAOgBI5dBkyMDff1xHbhGnjCMglj1aiMNPHps6HwXO+2i5jbPU/zJSk7etQ==}
+ engines: {node: '>=12.22.0'}
+
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ html-escaper@2.0.2:
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
+ html2canvas@1.4.1:
+ resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
+ engines: {node: '>=8.0.0'}
+
+ http-status-emojis@2.2.0:
+ resolution: {integrity: sha512-ompKtgwpx8ff0hsbpIB7oE4ax1LXoHmftsHHStMELX56ivG3GhofTX8ZHWlUaFKfGjcGjw6G3rPk7dJRXMmbbg==}
+
+ https-proxy-agent@5.0.1:
+ resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+ engines: {node: '>= 6'}
+
+ imask@7.6.1:
+ resolution: {integrity: sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg==}
+ engines: {npm: '>=4.0.0'}
+
+ indent-string@4.0.0:
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+ engines: {node: '>=8'}
+
+ inflection@3.0.2:
+ resolution: {integrity: sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==}
+ engines: {node: '>=18.0.0'}
+
+ iobuffer@5.4.0:
+ resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
+
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ isexe@4.0.0:
+ resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
+ engines: {node: '>=20'}
+
+ istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
+
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+ engines: {node: '>=8'}
+
+ js-tokens@10.0.0:
+ resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsdom@29.1.1:
+ resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
+ json-parse-even-better-errors@6.0.0:
+ resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==}
+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+
+ json-server@1.0.0-beta.15:
+ resolution: {integrity: sha512-I5UB/OWHLGoQW9IVld2yzZFYYiGYNBn2OYRlGbkfj5xDidT26yKYkV7Sr093zJtPh9zbofaLgyT89Ov5U0RRBQ==}
+ engines: {node: '>=22.12.0'}
+ hasBin: true
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ jspdf@4.2.1:
+ resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
+
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
+ lowdb@7.0.1:
+ resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==}
+ engines: {node: '>=18'}
+
+ lru-cache@11.5.2:
+ resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
+ engines: {node: 20 || >=22}
+
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ magicast@0.5.3:
+ resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
+
+ make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
+ memorystream@0.3.1:
+ resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
+ engines: {node: '>= 0.10.0'}
+
+ milliparsec@5.1.1:
+ resolution: {integrity: sha512-jkEDaSWZp4/Q3vprqdqukBqUEyNNqC1pwTjZ5cp9YkaR1wv5fvTCd8VFsecbw7i8DNBGjzhJ83MDoPZlcTaPQg==}
+ engines: {node: '>=18.13 || >=19.20 || >=20'}
+
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
+ mime@4.1.0:
+ resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==}
+ engines: {node: '>=16'}
+ hasBin: true
+
+ min-indent@1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+
+ mrmime@2.0.1:
+ resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
+ engines: {node: '>=10'}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.16:
+ resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ npm-normalize-package-bin@6.0.0:
+ resolution: {integrity: sha512-tdt4aFn9QamlhdN3HV2D2ccpBwO5/fyjjbXUxYA6uBjyekMZcZvDq0aSj9t5Jo+tih6AYFnt/cuIRn9013e0Uw==}
+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+
+ npm-run-all2@9.0.2:
+ resolution: {integrity: sha512-+dd4SO2jAlLE06OzmJKzIe6QvvjXezcbmobnh8usR0a8BzQCABTdqTXqVPji0ICOhSQpIIrkGd7IzNl5iDaRSA==}
+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0, npm: '>= 10'}
+ hasBin: true
+
+ object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+
+ obug@2.1.3:
+ resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
+ engines: {node: '>=12.20.0'}
+
+ oxlint@1.73.0:
+ resolution: {integrity: sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ oxlint-tsgolint: '>=0.24.0'
+ vite-plus: '*'
+ peerDependenciesMeta:
+ oxlint-tsgolint:
+ optional: true
+ vite-plus:
+ optional: true
+
+ pako@2.2.0:
+ resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==}
+
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ performance-now@2.1.0:
+ resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.5:
+ resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
+ engines: {node: '>=12'}
+
+ pidtree@1.0.0:
+ resolution: {integrity: sha512-avfAvjB9Dd0wdj3rjJX//yS+G79OO0KrS5pJHFJENjYGX6N4SMgEDBBI/yFy0lloOYSaC6XQxzpOAMPfSYFV/Q==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ postcss-custom-media@12.0.1:
+ resolution: {integrity: sha512-66syE14+VeqkUf0rRX0bvbTCbNRJF132jD+ceo8th1dap2YJEAqpdh5uG98CE3IbgHT7m9XM0GIlOazNWqQdeA==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ postcss: ^8.4
+
+ postcss@8.5.18:
+ resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
+ prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+
+ proxy-from-env@2.1.0:
+ resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
+ engines: {node: '>=10'}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ raf@3.4.1:
+ resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
+
+ react-dom@19.2.7:
+ resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
+ peerDependencies:
+ react: ^19.2.7
+
+ react-hook-form@7.81.0:
+ resolution: {integrity: sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18 || ^19
+
+ react-imask@7.6.1:
+ resolution: {integrity: sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA==}
+ engines: {npm: '>=4.0.0'}
+ peerDependencies:
+ react: '>=0.14.0'
+
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
+ react-router-dom@7.18.1:
+ resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+
+ react-router@7.18.1:
+ resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ react@19.2.7:
+ resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
+ engines: {node: '>=0.10.0'}
+
+ read-package-json-fast@6.0.0:
+ resolution: {integrity: sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g==}
+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+
+ readdirp@5.0.0:
+ resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
+ engines: {node: '>= 20.19.0'}
+
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
+ regenerator-runtime@0.13.11:
+ resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
+
+ regexparam@2.0.2:
+ resolution: {integrity: sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==}
+ engines: {node: '>=8'}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
+ rgbcolor@1.0.1:
+ resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
+ engines: {node: '>= 0.8.15'}
+
+ rolldown@1.1.5:
+ resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ shell-quote@1.10.0:
+ resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==}
+ engines: {node: '>= 0.4'}
+
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
+ sirv@3.0.2:
+ resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
+ engines: {node: '>=18'}
+
+ sort-on@7.0.0:
+ resolution: {integrity: sha512-e+4RRxt7jsWdGPp4H5PKOER/ELYlemNB1plvW686Qi3j4WVaCjCpro2zaTD7Cn0VtBImq/hg3x1JfovMNXXfJQ==}
+ engines: {node: '>=20'}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ stackblur-canvas@2.7.0:
+ resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
+ engines: {node: '>=0.1.14'}
+
+ std-env@4.2.0:
+ resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
+
+ steno@4.0.2:
+ resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==}
+ engines: {node: '>=18'}
+
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ svg-pathdata@6.0.3:
+ resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
+ engines: {node: '>=12.0.0'}
+
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
+ tagged-tag@1.0.0:
+ resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
+ engines: {node: '>=20'}
+
+ text-segmentation@1.0.3:
+ resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
+
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.2.4:
+ resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
+ engines: {node: '>=18'}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
+ tldts-core@7.4.8:
+ resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==}
+
+ tldts@7.4.8:
+ resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==}
+ hasBin: true
+
+ totalist@3.0.1:
+ resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
+ engines: {node: '>=6'}
+
+ tough-cookie@6.0.2:
+ resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
+ engines: {node: '>=16'}
+
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ type-fest@5.8.0:
+ resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
+ engines: {node: '>=20'}
+
+ typescript@6.0.3:
+ resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@7.18.2:
+ resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+
+ undici@7.28.0:
+ resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
+ engines: {node: '>=20.18.1'}
+
+ utrie@1.0.2:
+ resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
+
+ vite@8.1.4:
+ resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.3.0
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vitest@4.1.10:
+ resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.10
+ '@vitest/browser-preview': 4.1.10
+ '@vitest/browser-webdriverio': 4.1.10
+ '@vitest/coverage-istanbul': 4.1.10
+ '@vitest/coverage-v8': 4.1.10
+ '@vitest/ui': 4.1.10
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ which@7.0.0:
+ resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==}
+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+ hasBin: true
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
+snapshots:
+
+ '@adobe/css-tools@4.5.0': {}
+
+ '@asamuzakjp/css-color@5.1.11':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+
+ '@asamuzakjp/generational-cache@1.0.1': {}
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@babel/runtime-corejs3@7.29.7':
+ dependencies:
+ core-js-pure: 3.49.0
+
+ '@babel/runtime@7.29.7': {}
+
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@bcoe/v8-coverage@1.0.2': {}
+
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
+ '@csstools/cascade-layer-name-parser@3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/color-helpers@6.1.0': {}
+
+ '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.1.0
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
+ '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@emnapi/core@1.11.1':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.2
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.11.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@exodus/bytes@1.15.1': {}
+
+ '@hookform/resolvers@5.4.0(react-hook-form@7.81.0(react@19.2.7))':
+ dependencies:
+ '@standard-schema/utils': 0.3.0
+ react-hook-form: 7.81.0(react@19.2.7)
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
+ dependencies:
+ '@emnapi/core': 1.11.1
+ '@emnapi/runtime': 1.11.1
+ '@tybys/wasm-util': 0.10.3
+ optional: true
+
+ '@oxc-project/types@0.139.0': {}
+
+ '@oxlint/binding-android-arm-eabi@1.73.0':
+ optional: true
+
+ '@oxlint/binding-android-arm64@1.73.0':
+ optional: true
+
+ '@oxlint/binding-darwin-arm64@1.73.0':
+ optional: true
+
+ '@oxlint/binding-darwin-x64@1.73.0':
+ optional: true
+
+ '@oxlint/binding-freebsd-x64@1.73.0':
+ optional: true
+
+ '@oxlint/binding-linux-arm-gnueabihf@1.73.0':
+ optional: true
+
+ '@oxlint/binding-linux-arm-musleabihf@1.73.0':
+ optional: true
+
+ '@oxlint/binding-linux-arm64-gnu@1.73.0':
+ optional: true
+
+ '@oxlint/binding-linux-arm64-musl@1.73.0':
+ optional: true
+
+ '@oxlint/binding-linux-ppc64-gnu@1.73.0':
+ optional: true
+
+ '@oxlint/binding-linux-riscv64-gnu@1.73.0':
+ optional: true
+
+ '@oxlint/binding-linux-riscv64-musl@1.73.0':
+ optional: true
+
+ '@oxlint/binding-linux-s390x-gnu@1.73.0':
+ optional: true
+
+ '@oxlint/binding-linux-x64-gnu@1.73.0':
+ optional: true
+
+ '@oxlint/binding-linux-x64-musl@1.73.0':
+ optional: true
+
+ '@oxlint/binding-openharmony-arm64@1.73.0':
+ optional: true
+
+ '@oxlint/binding-win32-arm64-msvc@1.73.0':
+ optional: true
+
+ '@oxlint/binding-win32-ia32-msvc@1.73.0':
+ optional: true
+
+ '@oxlint/binding-win32-x64-msvc@1.73.0':
+ optional: true
+
+ '@polka/url@1.0.0-next.29': {}
+
+ '@rolldown/binding-android-arm64@1.1.5':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.1.5':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.1.5':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.1.5':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.1.5':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.1.5':
+ optional: true
+
+ '@rolldown/binding-wasm32-wasi@1.1.5':
+ dependencies:
+ '@emnapi/core': 1.11.1
+ '@emnapi/runtime': 1.11.1
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.1.5':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.1.5':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.1': {}
+
+ '@standard-schema/spec@1.1.0': {}
+
+ '@standard-schema/utils@0.3.0': {}
+
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.29.7
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.5.0
+ aria-query: 5.3.2
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@testing-library/dom': 10.4.1
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ optionalDependencies:
+ '@types/react': 19.2.17
+ '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
+ '@tinyhttp/accepts@2.3.0':
+ dependencies:
+ mime: 4.1.0
+
+ '@tinyhttp/app@3.0.7':
+ dependencies:
+ '@tinyhttp/accepts': 2.3.0
+ '@tinyhttp/cookie': 2.1.1
+ '@tinyhttp/proxy-addr': 3.0.1
+ '@tinyhttp/req': 2.2.8
+ '@tinyhttp/res': 2.2.11
+ '@tinyhttp/router': 2.2.5
+ regexparam: 2.0.2
+
+ '@tinyhttp/content-disposition@2.2.4': {}
+
+ '@tinyhttp/content-type@0.1.4': {}
+
+ '@tinyhttp/cookie-signature@2.1.1': {}
+
+ '@tinyhttp/cookie@2.1.1': {}
+
+ '@tinyhttp/cors@2.0.1':
+ dependencies:
+ '@tinyhttp/vary': 0.1.3
+
+ '@tinyhttp/encode-url@2.1.1': {}
+
+ '@tinyhttp/etag@2.1.2': {}
+
+ '@tinyhttp/forwarded@2.1.2': {}
+
+ '@tinyhttp/logger@2.1.0':
+ dependencies:
+ colorette: 2.0.20
+ dayjs: 1.11.21
+ http-status-emojis: 2.2.0
+
+ '@tinyhttp/proxy-addr@3.0.1':
+ dependencies:
+ '@tinyhttp/forwarded': 2.1.2
+
+ '@tinyhttp/req@2.2.8':
+ dependencies:
+ '@tinyhttp/accepts': 2.3.0
+ '@tinyhttp/type-is': 2.2.5
+ '@tinyhttp/url': 2.1.1
+ header-range-parser: 1.1.5
+
+ '@tinyhttp/res@2.2.11':
+ dependencies:
+ '@tinyhttp/content-disposition': 2.2.4
+ '@tinyhttp/cookie': 2.1.1
+ '@tinyhttp/cookie-signature': 2.1.1
+ '@tinyhttp/encode-url': 2.1.1
+ '@tinyhttp/req': 2.2.8
+ '@tinyhttp/send': 2.2.5
+ '@tinyhttp/vary': 0.1.3
+ mime: 4.1.0
+
+ '@tinyhttp/router@2.2.5':
+ dependencies:
+ regexparam: 2.0.2
+
+ '@tinyhttp/send@2.2.5':
+ dependencies:
+ '@tinyhttp/content-type': 0.1.4
+ '@tinyhttp/etag': 2.1.2
+ mime: 4.1.0
+
+ '@tinyhttp/type-is@2.2.5':
+ dependencies:
+ '@tinyhttp/content-type': 0.1.4
+ mime: 4.1.0
+
+ '@tinyhttp/url@2.1.1': {}
+
+ '@tinyhttp/vary@0.1.3': {}
+
+ '@tybys/wasm-util@0.10.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/aria-query@5.0.4': {}
+
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/deep-eql@4.0.2': {}
+
+ '@types/estree@1.0.9': {}
+
+ '@types/node@24.13.3':
+ dependencies:
+ undici-types: 7.18.2
+
+ '@types/pako@2.0.4': {}
+
+ '@types/raf@3.4.3':
+ optional: true
+
+ '@types/react-dom@19.2.3(@types/react@19.2.17)':
+ dependencies:
+ '@types/react': 19.2.17
+
+ '@types/react@19.2.17':
+ dependencies:
+ csstype: 3.2.3
+
+ '@types/trusted-types@2.0.7':
+ optional: true
+
+ '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@24.13.3))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.1
+ vite: 8.1.4(@types/node@24.13.3)
+
+ '@vitest/coverage-v8@4.1.10(vitest@4.1.10)':
+ dependencies:
+ '@bcoe/v8-coverage': 1.0.2
+ '@vitest/utils': 4.1.10
+ ast-v8-to-istanbul: 1.0.4
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-reports: 3.2.0
+ magicast: 0.5.3
+ obug: 2.1.3
+ std-env: 4.2.0
+ tinyrainbow: 3.1.0
+ vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@24.13.3))
+
+ '@vitest/expect@4.1.10':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@24.13.3))':
+ dependencies:
+ '@vitest/spy': 4.1.10
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.1.4(@types/node@24.13.3)
+
+ '@vitest/pretty-format@4.1.10':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.10':
+ dependencies:
+ '@vitest/utils': 4.1.10
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.10':
+ dependencies:
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/utils': 4.1.10
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.10': {}
+
+ '@vitest/utils@4.1.10':
+ dependencies:
+ '@vitest/pretty-format': 4.1.10
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
+ agent-base@6.0.2:
+ dependencies:
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ ansi-regex@5.0.1: {}
+
+ ansi-styles@5.2.0: {}
+
+ ansi-styles@6.2.3: {}
+
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
+ aria-query@5.3.2: {}
+
+ assertion-error@2.0.1: {}
+
+ ast-v8-to-istanbul@1.0.4:
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ estree-walker: 3.0.3
+ js-tokens: 10.0.0
+
+ asynckit@0.4.0: {}
+
+ axios@1.18.1:
+ dependencies:
+ follow-redirects: 1.16.0
+ form-data: 4.0.6
+ https-proxy-agent: 5.0.1
+ proxy-from-env: 2.1.0
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
+ base64-arraybuffer@1.0.2:
+ optional: true
+
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ canvg@3.0.11:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@types/raf': 3.4.3
+ core-js: 3.49.0
+ raf: 3.4.1
+ regenerator-runtime: 0.13.11
+ rgbcolor: 1.0.1
+ stackblur-canvas: 2.7.0
+ svg-pathdata: 6.0.3
+ optional: true
+
+ chai@6.2.2: {}
+
+ chalk@5.6.2: {}
+
+ chokidar@5.0.0:
+ dependencies:
+ readdirp: 5.0.0
+
+ colorette@2.0.20: {}
+
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
+ convert-source-map@2.0.0: {}
+
+ cookie@1.1.1: {}
+
+ core-js-pure@3.49.0: {}
+
+ core-js@3.49.0:
+ optional: true
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ css-line-break@2.1.0:
+ dependencies:
+ utrie: 1.0.2
+ optional: true
+
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
+ css.escape@1.5.1: {}
+
+ csstype@3.2.3: {}
+
+ data-urls@7.0.0:
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ dayjs@1.11.21: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ decimal.js@10.6.0: {}
+
+ delayed-stream@1.0.0: {}
+
+ dequal@2.0.3: {}
+
+ detect-libc@2.1.2: {}
+
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
+ dompurify@3.4.12:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+ optional: true
+
+ dot-prop@10.1.0:
+ dependencies:
+ type-fest: 5.8.0
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ entities@8.0.0: {}
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-module-lexer@2.3.1: {}
+
+ es-object-atoms@1.1.2:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.4
+
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.9
+
+ eta@4.6.0: {}
+
+ expect-type@1.4.0: {}
+
+ fast-png@6.4.0:
+ dependencies:
+ '@types/pako': 2.0.4
+ iobuffer: 5.4.0
+ pako: 2.2.0
+
+ fdir@6.5.0(picomatch@4.0.5):
+ optionalDependencies:
+ picomatch: 4.0.5
+
+ fflate@0.8.3: {}
+
+ follow-redirects@1.16.0: {}
+
+ form-data@4.0.6:
+ 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
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ math-intrinsics: 1.1.0
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+
+ gopd@1.2.0: {}
+
+ has-flag@4.0.0: {}
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ header-range-parser@1.1.5: {}
+
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ html-escaper@2.0.2: {}
+
+ html2canvas@1.4.1:
+ dependencies:
+ css-line-break: 2.1.0
+ text-segmentation: 1.0.3
+ optional: true
+
+ http-status-emojis@2.2.0: {}
+
+ https-proxy-agent@5.0.1:
+ dependencies:
+ agent-base: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ imask@7.6.1:
+ dependencies:
+ '@babel/runtime-corejs3': 7.29.7
+
+ indent-string@4.0.0: {}
+
+ inflection@3.0.2: {}
+
+ iobuffer@5.4.0: {}
+
+ is-potential-custom-element-name@1.0.1: {}
+
+ isexe@2.0.0: {}
+
+ isexe@4.0.0: {}
+
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
+ js-tokens@10.0.0: {}
+
+ js-tokens@4.0.0: {}
+
+ jsdom@29.1.1:
+ dependencies:
+ '@asamuzakjp/css-color': 5.1.11
+ '@asamuzakjp/dom-selector': 7.1.1
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1)
+ '@exodus/bytes': 1.15.1
+ css-tree: 3.2.1
+ data-urls: 7.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.2
+ parse5: 8.0.1
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.2
+ undici: 7.28.0
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ json-parse-even-better-errors@6.0.0: {}
+
+ json-server@1.0.0-beta.15:
+ dependencies:
+ '@tinyhttp/app': 3.0.7
+ '@tinyhttp/cors': 2.0.1
+ '@tinyhttp/logger': 2.1.0
+ chalk: 5.6.2
+ chokidar: 5.0.0
+ dot-prop: 10.1.0
+ eta: 4.6.0
+ inflection: 3.0.2
+ json5: 2.2.3
+ lowdb: 7.0.1
+ milliparsec: 5.1.1
+ sirv: 3.0.2
+ sort-on: 7.0.0
+
+ json5@2.2.3: {}
+
+ jspdf@4.2.1:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ fast-png: 6.4.0
+ fflate: 0.8.3
+ optionalDependencies:
+ canvg: 3.0.11
+ core-js: 3.49.0
+ dompurify: 3.4.12
+ html2canvas: 1.4.1
+
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
+ lowdb@7.0.1:
+ dependencies:
+ steno: 4.0.2
+
+ lru-cache@11.5.2: {}
+
+ lz-string@1.5.0: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ magicast@0.5.3:
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ source-map-js: 1.2.1
+
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.8.5
+
+ math-intrinsics@1.1.0: {}
+
+ mdn-data@2.27.1: {}
+
+ memorystream@0.3.1: {}
+
+ milliparsec@5.1.1: {}
+
+ mime-db@1.52.0: {}
+
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
+ mime@4.1.0: {}
+
+ min-indent@1.0.1: {}
+
+ mrmime@2.0.1: {}
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.16: {}
+
+ npm-normalize-package-bin@6.0.0: {}
+
+ npm-run-all2@9.0.2:
+ dependencies:
+ ansi-styles: 6.2.3
+ cross-spawn: 7.0.6
+ memorystream: 0.3.1
+ picomatch: 4.0.5
+ pidtree: 1.0.0
+ read-package-json-fast: 6.0.0
+ shell-quote: 1.10.0
+ which: 7.0.0
+
+ object-assign@4.1.1: {}
+
+ obug@2.1.3: {}
+
+ oxlint@1.73.0:
+ optionalDependencies:
+ '@oxlint/binding-android-arm-eabi': 1.73.0
+ '@oxlint/binding-android-arm64': 1.73.0
+ '@oxlint/binding-darwin-arm64': 1.73.0
+ '@oxlint/binding-darwin-x64': 1.73.0
+ '@oxlint/binding-freebsd-x64': 1.73.0
+ '@oxlint/binding-linux-arm-gnueabihf': 1.73.0
+ '@oxlint/binding-linux-arm-musleabihf': 1.73.0
+ '@oxlint/binding-linux-arm64-gnu': 1.73.0
+ '@oxlint/binding-linux-arm64-musl': 1.73.0
+ '@oxlint/binding-linux-ppc64-gnu': 1.73.0
+ '@oxlint/binding-linux-riscv64-gnu': 1.73.0
+ '@oxlint/binding-linux-riscv64-musl': 1.73.0
+ '@oxlint/binding-linux-s390x-gnu': 1.73.0
+ '@oxlint/binding-linux-x64-gnu': 1.73.0
+ '@oxlint/binding-linux-x64-musl': 1.73.0
+ '@oxlint/binding-openharmony-arm64': 1.73.0
+ '@oxlint/binding-win32-arm64-msvc': 1.73.0
+ '@oxlint/binding-win32-ia32-msvc': 1.73.0
+ '@oxlint/binding-win32-x64-msvc': 1.73.0
+
+ pako@2.2.0: {}
+
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.0.0
+
+ path-key@3.1.1: {}
+
+ pathe@2.0.3: {}
+
+ performance-now@2.1.0:
+ optional: true
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.5: {}
+
+ pidtree@1.0.0: {}
+
+ postcss-custom-media@12.0.1(postcss@8.5.18):
+ dependencies:
+ '@csstools/cascade-layer-name-parser': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+ '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ postcss: 8.5.18
+
+ postcss@8.5.18:
+ dependencies:
+ nanoid: 3.3.16
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
+ prop-types@15.8.1:
+ dependencies:
+ loose-envify: 1.4.0
+ object-assign: 4.1.1
+ react-is: 16.13.1
+
+ proxy-from-env@2.1.0: {}
+
+ punycode@2.3.1: {}
+
+ raf@3.4.1:
+ dependencies:
+ performance-now: 2.1.0
+ optional: true
+
+ react-dom@19.2.7(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ scheduler: 0.27.0
+
+ react-hook-form@7.81.0(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+
+ react-imask@7.6.1(react@19.2.7):
+ dependencies:
+ imask: 7.6.1
+ prop-types: 15.8.1
+ react: 19.2.7
+
+ react-is@16.13.1: {}
+
+ react-is@17.0.2: {}
+
+ react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+
+ react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ cookie: 1.1.1
+ react: 19.2.7
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 19.2.7(react@19.2.7)
+
+ react@19.2.7: {}
+
+ read-package-json-fast@6.0.0:
+ dependencies:
+ json-parse-even-better-errors: 6.0.0
+ npm-normalize-package-bin: 6.0.0
+
+ readdirp@5.0.0: {}
+
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
+ regenerator-runtime@0.13.11:
+ optional: true
+
+ regexparam@2.0.2: {}
+
+ require-from-string@2.0.2: {}
+
+ rgbcolor@1.0.1:
+ optional: true
+
+ rolldown@1.1.5:
+ dependencies:
+ '@oxc-project/types': 0.139.0
+ '@rolldown/pluginutils': 1.0.1
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.1.5
+ '@rolldown/binding-darwin-arm64': 1.1.5
+ '@rolldown/binding-darwin-x64': 1.1.5
+ '@rolldown/binding-freebsd-x64': 1.1.5
+ '@rolldown/binding-linux-arm-gnueabihf': 1.1.5
+ '@rolldown/binding-linux-arm64-gnu': 1.1.5
+ '@rolldown/binding-linux-arm64-musl': 1.1.5
+ '@rolldown/binding-linux-ppc64-gnu': 1.1.5
+ '@rolldown/binding-linux-s390x-gnu': 1.1.5
+ '@rolldown/binding-linux-x64-gnu': 1.1.5
+ '@rolldown/binding-linux-x64-musl': 1.1.5
+ '@rolldown/binding-openharmony-arm64': 1.1.5
+ '@rolldown/binding-wasm32-wasi': 1.1.5
+ '@rolldown/binding-win32-arm64-msvc': 1.1.5
+ '@rolldown/binding-win32-x64-msvc': 1.1.5
+
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
+ scheduler@0.27.0: {}
+
+ semver@7.8.5: {}
+
+ set-cookie-parser@2.7.2: {}
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ shell-quote@1.10.0: {}
+
+ siginfo@2.0.0: {}
+
+ sirv@3.0.2:
+ dependencies:
+ '@polka/url': 1.0.0-next.29
+ mrmime: 2.0.1
+ totalist: 3.0.1
+
+ sort-on@7.0.0:
+ dependencies:
+ dot-prop: 10.1.0
+
+ source-map-js@1.2.1: {}
+
+ stackback@0.0.2: {}
+
+ stackblur-canvas@2.7.0:
+ optional: true
+
+ std-env@4.2.0: {}
+
+ steno@4.0.2: {}
+
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ svg-pathdata@6.0.3:
+ optional: true
+
+ symbol-tree@3.2.4: {}
+
+ tagged-tag@1.0.0: {}
+
+ text-segmentation@1.0.3:
+ dependencies:
+ utrie: 1.0.2
+ optional: true
+
+ tinybench@2.9.0: {}
+
+ tinyexec@1.2.4: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
+
+ tinyrainbow@3.1.0: {}
+
+ tldts-core@7.4.8: {}
+
+ tldts@7.4.8:
+ dependencies:
+ tldts-core: 7.4.8
+
+ totalist@3.0.1: {}
+
+ tough-cookie@6.0.2:
+ dependencies:
+ tldts: 7.4.8
+
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
+ tslib@2.8.1:
+ optional: true
+
+ type-fest@5.8.0:
+ dependencies:
+ tagged-tag: 1.0.0
+
+ typescript@6.0.3: {}
+
+ undici-types@7.18.2: {}
+
+ undici@7.28.0: {}
+
+ utrie@1.0.2:
+ dependencies:
+ base64-arraybuffer: 1.0.2
+ optional: true
+
+ vite@8.1.4(@types/node@24.13.3):
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.5
+ postcss: 8.5.18
+ rolldown: 1.1.5
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 24.13.3
+ fsevents: 2.3.3
+
+ vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@24.13.3)):
+ dependencies:
+ '@vitest/expect': 4.1.10
+ '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@24.13.3))
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/runner': 4.1.10
+ '@vitest/snapshot': 4.1.10
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
+ es-module-lexer: 2.3.1
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ obug: 2.1.3
+ pathe: 2.0.3
+ picomatch: 4.0.5
+ std-env: 4.2.0
+ tinybench: 2.9.0
+ tinyexec: 1.2.4
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.0
+ vite: 8.1.4(@types/node@24.13.3)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 24.13.3
+ '@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
+ jsdom: 29.1.1
+ transitivePeerDependencies:
+ - msw
+
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ which@7.0.0:
+ dependencies:
+ isexe: 4.0.0
+
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
+
+ zod@4.4.3: {}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..68b6296
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,5 @@
+import postcssCustomMedia from 'postcss-custom-media'
+
+export default {
+ plugins: [postcssCustomMedia()],
+}
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 0000000..0e2eae1
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg
new file mode 100644
index 0000000..e952219
--- /dev/null
+++ b/frontend/public/icons.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/logo.avif b/frontend/public/logo.avif
new file mode 100644
index 0000000..9d99f29
Binary files /dev/null and b/frontend/public/logo.avif differ
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
new file mode 100644
index 0000000..882f45e
--- /dev/null
+++ b/frontend/src/App.test.tsx
@@ -0,0 +1,69 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import App from './App'
+import { fetchAddressByCep, fetchProfessions } from './lib/api'
+import { generateSummaryPdf } from './lib/pdf'
+
+vi.mock('./lib/api', () => ({
+ fetchAddressByCep: vi.fn(),
+ fetchProfessions: vi.fn(),
+}))
+vi.mock('./lib/pdf', () => ({ generateSummaryPdf: vi.fn() }))
+
+beforeEach(() => {
+ vi.mocked(fetchAddressByCep).mockReset()
+ vi.mocked(fetchProfessions).mockReset()
+ vi.mocked(generateSummaryPdf).mockReset()
+ window.localStorage.clear()
+ window.history.pushState({}, '', '/')
+})
+
+describe('App', () => {
+ it('completes the full wizard flow end-to-end and exports the PDF', async () => {
+ vi.mocked(fetchAddressByCep).mockResolvedValue({
+ endereco: 'Avenida Paulista',
+ bairro: 'Bela Vista',
+ cidade: 'São Paulo',
+ estado: 'SP',
+ })
+ vi.mocked(fetchProfessions).mockResolvedValue([{ id: 1, name: 'Desenvolvedor(a) de Software' }])
+
+ const user = userEvent.setup()
+ render( )
+
+ expect(await screen.findByRole('heading', { name: 'Dados Pessoais' })).toBeInTheDocument()
+
+ await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza')
+ await user.type(screen.getByLabelText('Data de nascimento'), '15051990')
+ await user.type(screen.getByLabelText('CPF'), '52998224725')
+ await user.type(screen.getByLabelText('Telefone'), '11987654321')
+ await user.type(screen.getByLabelText('E-mail'), 'maria@example.com')
+ await user.click(screen.getByText('Próximo'))
+
+ expect(await screen.findByText('Informações Residenciais')).toBeInTheDocument()
+ await user.type(screen.getByLabelText('CEP'), '01310100')
+ await screen.findByDisplayValue('Avenida Paulista')
+ await user.click(screen.getByText('Próximo'))
+
+ expect(await screen.findByText('Informações Profissionais')).toBeInTheDocument()
+ await screen.findByRole('option', { name: 'Desenvolvedor(a) de Software' })
+ await user.type(screen.getByLabelText('Empresa'), 'Verity Tecnologia')
+ await user.selectOptions(screen.getByLabelText('Profissão'), 'Desenvolvedor(a) de Software')
+ await user.type(screen.getByLabelText('Salário'), '850000')
+ await user.selectOptions(screen.getByLabelText('Tempo de empresa'), 'De 2 a 5 anos')
+ await user.click(screen.getByText('Ver resumo'))
+
+ expect(await screen.findByText('Resumo do Cadastro')).toBeInTheDocument()
+ expect(screen.getByText('Maria Souza')).toBeInTheDocument()
+
+ await user.click(screen.getByText('Exportar PDF'))
+ expect(generateSummaryPdf).toHaveBeenCalledOnce()
+ })
+
+ it('redirects unknown routes to step 1', async () => {
+ window.history.pushState({}, '', '/rota-invalida')
+ render( )
+ expect(await screen.findByRole('heading', { name: 'Dados Pessoais' })).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..7ba0cbb
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,28 @@
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
+import { Layout } from './components/layout/Layout'
+import { WizardFormProvider } from './context/WizardFormProvider'
+import { Step1PersonalData } from './pages/Step1PersonalData'
+import { Step2Address } from './pages/Step2Address'
+import { Step3Professional } from './pages/Step3Professional'
+import { SummaryPage } from './pages/SummaryPage'
+
+function App() {
+ return (
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+ )
+}
+
+export default App
diff --git a/frontend/src/components/form/CurrencyField.test.tsx b/frontend/src/components/form/CurrencyField.test.tsx
new file mode 100644
index 0000000..d2c431b
--- /dev/null
+++ b/frontend/src/components/form/CurrencyField.test.tsx
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithForm } from '../../test/renderWithForm'
+import { CurrencyField } from './CurrencyField'
+
+describe('CurrencyField', () => {
+ it('formats typed digits as BRL currency', async () => {
+ const user = userEvent.setup()
+ renderWithForm( )
+
+ const input = screen.getByLabelText('Salário')
+ await user.type(input, '850050')
+
+ expect(input).toHaveValue('R$ 8.500,50')
+ })
+
+ it('removes the last typed digit on backspace', async () => {
+ const user = userEvent.setup()
+ renderWithForm( )
+
+ const input = screen.getByLabelText('Salário')
+ await user.type(input, '1000')
+ expect(input).toHaveValue('R$ 10,00')
+
+ await user.type(input, '{backspace}')
+
+ expect(input).toHaveValue('R$ 1,00')
+ })
+
+ it('clears back to empty once every digit is removed', async () => {
+ const user = userEvent.setup()
+ renderWithForm( )
+
+ const input = screen.getByLabelText('Salário')
+ await user.type(input, '5')
+ expect(input).toHaveValue('R$ 0,05')
+
+ await user.type(input, '{backspace}')
+
+ expect(input).toHaveValue('')
+ })
+})
diff --git a/frontend/src/components/form/CurrencyField.tsx b/frontend/src/components/form/CurrencyField.tsx
new file mode 100644
index 0000000..2206696
--- /dev/null
+++ b/frontend/src/components/form/CurrencyField.tsx
@@ -0,0 +1,56 @@
+import { Controller, useFormContext } from 'react-hook-form'
+import type { FormValues } from '../../schemas/formSchema'
+import { onlyDigits } from '../../lib/masks'
+
+const NON_BREAKING_SPACE = ' '
+
+function formatBRLFromDigits(digits: string): string {
+ const numeric = Number(digits || '0') / 100
+ return numeric
+ .toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
+ .replaceAll(NON_BREAKING_SPACE, ' ')
+}
+
+interface CurrencyFieldProps {
+ name: keyof FormValues
+ label: string
+ fullWidth?: boolean
+}
+
+export function CurrencyField({ name, label, fullWidth }: Readonly) {
+ const {
+ control,
+ formState: { errors },
+ } = useFormContext()
+ const error = errors[name]?.message as string | undefined
+
+ return (
+
+ {label}
+ (
+ {
+ const digits = onlyDigits(event.target.value).replace(/^0+/, '').slice(0, 12)
+ field.onChange(digits ? formatBRLFromDigits(digits) : '')
+ }}
+ onBlur={field.onBlur}
+ />
+ )}
+ />
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/form/MaskedTextField.test.tsx b/frontend/src/components/form/MaskedTextField.test.tsx
new file mode 100644
index 0000000..0625150
--- /dev/null
+++ b/frontend/src/components/form/MaskedTextField.test.tsx
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithForm } from '../../test/renderWithForm'
+import { MaskedTextField } from './MaskedTextField'
+import { CPF_MASK, PHONE_MASK } from '../../constants/masks'
+
+describe('MaskedTextField', () => {
+ it('applies a fixed pattern mask as the user types', async () => {
+ const user = userEvent.setup()
+ renderWithForm( )
+
+ const input = screen.getByLabelText('CPF')
+ await user.type(input, '52998224725')
+
+ expect(input).toHaveValue('529.982.247-25')
+ })
+
+ it('picks the right mask variant for a dynamic pattern', async () => {
+ const user = userEvent.setup()
+ renderWithForm( )
+
+ const input = screen.getByLabelText('Telefone')
+ await user.type(input, '11987654321')
+
+ expect(input).toHaveValue('(11) 98765-4321')
+ })
+})
diff --git a/frontend/src/components/form/MaskedTextField.tsx b/frontend/src/components/form/MaskedTextField.tsx
new file mode 100644
index 0000000..50dec08
--- /dev/null
+++ b/frontend/src/components/form/MaskedTextField.tsx
@@ -0,0 +1,60 @@
+import { Controller, useFormContext } from 'react-hook-form'
+import { IMaskInput } from 'react-imask'
+import type { FormValues } from '../../schemas/formSchema'
+
+interface MaskedTextFieldProps {
+ name: keyof FormValues
+ label: string
+ maskOptions: Record
+ placeholder?: string
+ inputMode?: 'text' | 'numeric' | 'decimal'
+ hint?: string
+ fullWidth?: boolean
+}
+
+export function MaskedTextField({
+ name,
+ label,
+ maskOptions,
+ placeholder,
+ inputMode,
+ hint,
+ fullWidth,
+}: Readonly) {
+ const {
+ control,
+ formState: { errors },
+ } = useFormContext()
+ const error = errors[name]?.message as string | undefined
+
+ return (
+
+ {label}
+ (
+ field.onChange(value)}
+ onBlur={field.onBlur}
+ />
+ )}
+ />
+ {error && (
+
+ {error}
+
+ )}
+ {!error && hint && {hint} }
+
+ )
+}
diff --git a/frontend/src/components/form/SelectField.test.tsx b/frontend/src/components/form/SelectField.test.tsx
new file mode 100644
index 0000000..3393ff8
--- /dev/null
+++ b/frontend/src/components/form/SelectField.test.tsx
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithForm } from '../../test/renderWithForm'
+import { SelectField } from './SelectField'
+
+const options = [
+ { value: 'SP', label: 'São Paulo' },
+ { value: 'RJ', label: 'Rio de Janeiro' },
+]
+
+describe('SelectField', () => {
+ it('renders the placeholder plus every option', () => {
+ renderWithForm( )
+
+ expect(screen.getByRole('option', { name: 'UF' })).toBeInTheDocument()
+ expect(screen.getByRole('option', { name: 'São Paulo' })).toBeInTheDocument()
+ expect(screen.getByRole('option', { name: 'Rio de Janeiro' })).toBeInTheDocument()
+ })
+
+ it('lets the user pick an option', async () => {
+ const user = userEvent.setup()
+ renderWithForm( )
+
+ await user.selectOptions(screen.getByLabelText('Estado'), 'RJ')
+
+ expect(screen.getByLabelText('Estado')).toHaveValue('RJ')
+ })
+
+ it('can be disabled while options are loading', () => {
+ renderWithForm( )
+ expect(screen.getByLabelText('Profissão')).toBeDisabled()
+ })
+})
diff --git a/frontend/src/components/form/SelectField.tsx b/frontend/src/components/form/SelectField.tsx
new file mode 100644
index 0000000..3bc11c6
--- /dev/null
+++ b/frontend/src/components/form/SelectField.tsx
@@ -0,0 +1,48 @@
+import { useFormContext } from 'react-hook-form'
+import type { FormValues } from '../../schemas/formSchema'
+import type { SelectOption } from '../../constants/options'
+
+interface SelectFieldProps {
+ name: keyof FormValues
+ label: string
+ options: SelectOption[]
+ placeholder?: string
+ disabled?: boolean
+ fullWidth?: boolean
+}
+
+export function SelectField({ name, label, options, placeholder, disabled, fullWidth }: Readonly) {
+ const {
+ register,
+ formState: { errors },
+ } = useFormContext()
+ const error = errors[name]?.message as string | undefined
+
+ return (
+
+ {label}
+
+
+ {placeholder ?? 'Selecione...'}
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/form/TextField.test.tsx b/frontend/src/components/form/TextField.test.tsx
new file mode 100644
index 0000000..23bd939
--- /dev/null
+++ b/frontend/src/components/form/TextField.test.tsx
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { renderWithForm } from '../../test/renderWithForm'
+import { TextField } from './TextField'
+
+describe('TextField', () => {
+ it('renders a label wired to the input', () => {
+ renderWithForm( )
+ expect(screen.getByLabelText('Nome completo')).toBeInTheDocument()
+ })
+
+ it('lets the user type into the field', async () => {
+ const user = userEvent.setup()
+ renderWithForm( )
+
+ const input = screen.getByLabelText('Nome completo')
+ await user.type(input, 'Maria Souza')
+
+ expect(input).toHaveValue('Maria Souza')
+ })
+})
diff --git a/frontend/src/components/form/TextField.tsx b/frontend/src/components/form/TextField.tsx
new file mode 100644
index 0000000..fc345a3
--- /dev/null
+++ b/frontend/src/components/form/TextField.tsx
@@ -0,0 +1,49 @@
+import { useFormContext } from 'react-hook-form'
+import type { FormValues } from '../../schemas/formSchema'
+
+interface TextFieldProps {
+ name: keyof FormValues
+ label: string
+ placeholder?: string
+ type?: string
+ autoComplete?: string
+ fullWidth?: boolean
+ readOnly?: boolean
+}
+
+export function TextField({
+ name,
+ label,
+ placeholder,
+ type = 'text',
+ autoComplete,
+ fullWidth,
+ readOnly,
+}: Readonly) {
+ const {
+ register,
+ formState: { errors },
+ } = useFormContext()
+ const error = errors[name]?.message as string | undefined
+
+ return (
+
+ {label}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/layout/Layout.test.tsx b/frontend/src/components/layout/Layout.test.tsx
new file mode 100644
index 0000000..c29adb2
--- /dev/null
+++ b/frontend/src/components/layout/Layout.test.tsx
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import { Layout } from './Layout'
+
+describe('Layout', () => {
+ it('renders the logo, step indicator and page content', () => {
+ render(
+
+
+ conteúdo da etapa
+
+ ,
+ )
+
+ expect(screen.getByAltText('Verity')).toBeInTheDocument()
+ expect(screen.getByText('Dados Pessoais')).toBeInTheDocument()
+ expect(screen.getByText('conteúdo da etapa')).toBeInTheDocument()
+ expect(screen.getByText(/Verity\. Todos os direitos reservados\./)).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx
new file mode 100644
index 0000000..273636c
--- /dev/null
+++ b/frontend/src/components/layout/Layout.tsx
@@ -0,0 +1,20 @@
+import type { ReactNode } from 'react'
+import { Logo } from './Logo'
+import { StepIndicator } from '../wizard/StepIndicator'
+
+export function Layout({ children }: { children: ReactNode }) {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/layout/Logo.test.tsx b/frontend/src/components/layout/Logo.test.tsx
new file mode 100644
index 0000000..17737b3
--- /dev/null
+++ b/frontend/src/components/layout/Logo.test.tsx
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { Logo } from './Logo'
+
+describe('Logo', () => {
+ it('renders the Verity logo image', () => {
+ render( )
+ const image = screen.getByAltText('Verity')
+ expect(image).toBeInTheDocument()
+ expect(image).toHaveAttribute('src', '/logo.avif')
+ })
+})
diff --git a/frontend/src/components/layout/Logo.tsx b/frontend/src/components/layout/Logo.tsx
new file mode 100644
index 0000000..e2196dd
--- /dev/null
+++ b/frontend/src/components/layout/Logo.tsx
@@ -0,0 +1,7 @@
+export function Logo() {
+ return (
+
+
+
+ )
+}
diff --git a/frontend/src/components/wizard/StepIndicator.test.tsx b/frontend/src/components/wizard/StepIndicator.test.tsx
new file mode 100644
index 0000000..f9c9cfa
--- /dev/null
+++ b/frontend/src/components/wizard/StepIndicator.test.tsx
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import { StepIndicator } from './StepIndicator'
+
+describe('StepIndicator', () => {
+ it('marks the current step as active and earlier steps as done', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Endereço').closest('li')).toHaveClass('step-active')
+ expect(screen.getByText('Dados Pessoais').closest('li')).toHaveClass('step-done')
+ expect(screen.getByText('Profissional').closest('li')).toHaveClass('step-pending')
+ })
+
+ it('renders every step label', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Resumo').closest('li')).toHaveClass('step-active')
+ })
+})
diff --git a/frontend/src/components/wizard/StepIndicator.tsx b/frontend/src/components/wizard/StepIndicator.tsx
new file mode 100644
index 0000000..5f30497
--- /dev/null
+++ b/frontend/src/components/wizard/StepIndicator.tsx
@@ -0,0 +1,27 @@
+import { useLocation } from 'react-router-dom'
+
+const STEPS = [
+ { path: '/etapa-1', label: 'Dados Pessoais' },
+ { path: '/etapa-2', label: 'Endereço' },
+ { path: '/etapa-3', label: 'Profissional' },
+ { path: '/resumo', label: 'Resumo' },
+]
+
+export function StepIndicator() {
+ const { pathname } = useLocation()
+ const currentIndex = STEPS.findIndex((step) => step.path === pathname)
+
+ return (
+
+ {STEPS.map((step, index) => {
+ const status = index < currentIndex ? 'done' : index === currentIndex ? 'active' : 'pending'
+ return (
+
+ {status === 'done' ? '✓' : index + 1}
+ {step.label}
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/components/wizard/SummarySection.test.tsx b/frontend/src/components/wizard/SummarySection.test.tsx
new file mode 100644
index 0000000..1a5e473
--- /dev/null
+++ b/frontend/src/components/wizard/SummarySection.test.tsx
@@ -0,0 +1,34 @@
+import { describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { SummarySection } from './SummarySection'
+
+describe('SummarySection', () => {
+ it('renders every item label and value', () => {
+ render(
+ {}}
+ items={[
+ { label: 'Nome completo', value: 'Maria Souza' },
+ { label: 'E-mail', value: '' },
+ ]}
+ />,
+ )
+
+ expect(screen.getByText('Dados Pessoais')).toBeInTheDocument()
+ expect(screen.getByText('Nome completo')).toBeInTheDocument()
+ expect(screen.getByText('Maria Souza')).toBeInTheDocument()
+ expect(screen.getByText('—')).toBeInTheDocument()
+ })
+
+ it('calls onEdit when the edit button is clicked', async () => {
+ const user = userEvent.setup()
+ const onEdit = vi.fn()
+ render( )
+
+ await user.click(screen.getByText('Editar'))
+
+ expect(onEdit).toHaveBeenCalledOnce()
+ })
+})
diff --git a/frontend/src/components/wizard/SummarySection.tsx b/frontend/src/components/wizard/SummarySection.tsx
new file mode 100644
index 0000000..a765e3c
--- /dev/null
+++ b/frontend/src/components/wizard/SummarySection.tsx
@@ -0,0 +1,31 @@
+interface SummaryItem {
+ label: string
+ value: string
+}
+
+interface SummarySectionProps {
+ title: string
+ items: SummaryItem[]
+ onEdit: () => void
+}
+
+export function SummarySection({ title, items, onEdit }: Readonly) {
+ return (
+
+
+
{title}
+
+ Editar
+
+
+
+ {items.map((item) => (
+
+
{item.label}
+ {item.value || '—'}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/wizard/WizardActions.test.tsx b/frontend/src/components/wizard/WizardActions.test.tsx
new file mode 100644
index 0000000..3d7af68
--- /dev/null
+++ b/frontend/src/components/wizard/WizardActions.test.tsx
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { WizardActions } from './WizardActions'
+
+describe('WizardActions', () => {
+ it('omits the back button when onBack is not provided', () => {
+ render( {}} />)
+ expect(screen.queryByText('Voltar')).not.toBeInTheDocument()
+ expect(screen.getByText('Próximo')).toBeInTheDocument()
+ })
+
+ it('calls the provided callbacks', async () => {
+ const user = userEvent.setup()
+ const onNext = vi.fn()
+ const onBack = vi.fn()
+ render( )
+
+ await user.click(screen.getByText('Voltar'))
+ await user.click(screen.getByText('Ver resumo'))
+
+ expect(onBack).toHaveBeenCalledOnce()
+ expect(onNext).toHaveBeenCalledOnce()
+ })
+})
diff --git a/frontend/src/components/wizard/WizardActions.tsx b/frontend/src/components/wizard/WizardActions.tsx
new file mode 100644
index 0000000..6f7af0b
--- /dev/null
+++ b/frontend/src/components/wizard/WizardActions.tsx
@@ -0,0 +1,21 @@
+interface WizardActionsProps {
+ onBack?: () => void
+ onNext: () => void
+ nextLabel?: string
+ backLabel?: string
+}
+
+export function WizardActions({ onBack, onNext, nextLabel = 'Próximo', backLabel = 'Voltar' }: Readonly) {
+ return (
+
+ {onBack && (
+
+ {backLabel}
+
+ )}
+
+ {nextLabel}
+
+
+ )
+}
diff --git a/frontend/src/components/wizard/WizardStepShell.test.tsx b/frontend/src/components/wizard/WizardStepShell.test.tsx
new file mode 100644
index 0000000..3928fd1
--- /dev/null
+++ b/frontend/src/components/wizard/WizardStepShell.test.tsx
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { WizardStepShell } from './WizardStepShell'
+
+describe('WizardStepShell', () => {
+ it('renders the title, optional description and children', () => {
+ render(
+
+ conteúdo
+ ,
+ )
+
+ expect(screen.getByRole('heading', { name: 'Dados Pessoais' })).toBeInTheDocument()
+ expect(screen.getByText('Preencha seus dados')).toBeInTheDocument()
+ expect(screen.getByText('conteúdo')).toBeInTheDocument()
+ })
+
+ it('omits the description when not provided', () => {
+ render(
+
+ conteúdo
+ ,
+ )
+
+ expect(screen.getByRole('heading', { name: 'Resumo' })).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/wizard/WizardStepShell.tsx b/frontend/src/components/wizard/WizardStepShell.tsx
new file mode 100644
index 0000000..d8504c8
--- /dev/null
+++ b/frontend/src/components/wizard/WizardStepShell.tsx
@@ -0,0 +1,17 @@
+import type { ReactNode } from 'react'
+
+interface WizardStepShellProps {
+ title: string
+ description?: string
+ children: ReactNode
+}
+
+export function WizardStepShell({ title, description, children }: Readonly) {
+ return (
+
+ {title}
+ {description && {description}
}
+ {children}
+
+ )
+}
diff --git a/frontend/src/constants/breakpoints.css b/frontend/src/constants/breakpoints.css
new file mode 100644
index 0000000..36d0003
--- /dev/null
+++ b/frontend/src/constants/breakpoints.css
@@ -0,0 +1,2 @@
+@custom-media --breakpoint-tablet (min-width: 40rem);
+@custom-media --breakpoint-mobile (max-width: 30rem);
diff --git a/frontend/src/constants/colors.css b/frontend/src/constants/colors.css
new file mode 100644
index 0000000..bf70e28
--- /dev/null
+++ b/frontend/src/constants/colors.css
@@ -0,0 +1,18 @@
+:root {
+ --verity-blue-900: #082b63;
+ --verity-blue-700: #0d47a1;
+ --verity-blue-600: #1257b8;
+ --verity-blue-500: #1e6fd9;
+ --verity-blue-100: #e8f1fc;
+ --verity-blue-50: #f4f8fe;
+
+ --color-white: #ffffff;
+ --color-gray-900: #1a1f29;
+ --color-gray-600: #5b6472;
+ --color-gray-400: #8b93a1;
+ --color-gray-200: #e2e6ec;
+ --color-gray-100: #f1f3f7;
+
+ --color-error: #c62828;
+ --color-success: #1e8e3e;
+}
diff --git a/frontend/src/constants/masks.ts b/frontend/src/constants/masks.ts
new file mode 100644
index 0000000..d904a11
--- /dev/null
+++ b/frontend/src/constants/masks.ts
@@ -0,0 +1,9 @@
+export const CPF_MASK = { mask: '000.000.000-00' }
+
+export const DATE_MASK = { mask: '00/00/0000' }
+
+export const CEP_MASK = { mask: '00000-000' }
+
+export const PHONE_MASK = {
+ mask: [{ mask: '(00) 0000-0000' }, { mask: '(00) 00000-0000' }],
+}
diff --git a/frontend/src/constants/options.test.ts b/frontend/src/constants/options.test.ts
new file mode 100644
index 0000000..5c1ba0a
--- /dev/null
+++ b/frontend/src/constants/options.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest'
+import { getOptionLabel, TEMPO_EMPRESA_OPTIONS, UF_OPTIONS } from './options'
+
+describe('getOptionLabel', () => {
+ it('returns the matching label', () => {
+ expect(getOptionLabel(UF_OPTIONS, 'SP')).toBe('São Paulo')
+ expect(getOptionLabel(TEMPO_EMPRESA_OPTIONS, '2-5-anos')).toBe('De 2 a 5 anos')
+ })
+
+ it('falls back to the raw value when there is no match', () => {
+ expect(getOptionLabel(UF_OPTIONS, 'ZZ')).toBe('ZZ')
+ })
+})
diff --git a/frontend/src/constants/options.ts b/frontend/src/constants/options.ts
new file mode 100644
index 0000000..79682b1
--- /dev/null
+++ b/frontend/src/constants/options.ts
@@ -0,0 +1,46 @@
+export interface SelectOption {
+ value: string
+ label: string
+}
+
+export const UF_OPTIONS: SelectOption[] = [
+ { value: 'AC', label: 'Acre' },
+ { value: 'AL', label: 'Alagoas' },
+ { value: 'AP', label: 'Amapá' },
+ { value: 'AM', label: 'Amazonas' },
+ { value: 'BA', label: 'Bahia' },
+ { value: 'CE', label: 'Ceará' },
+ { value: 'DF', label: 'Distrito Federal' },
+ { value: 'ES', label: 'Espírito Santo' },
+ { value: 'GO', label: 'Goiás' },
+ { value: 'MA', label: 'Maranhão' },
+ { value: 'MT', label: 'Mato Grosso' },
+ { value: 'MS', label: 'Mato Grosso do Sul' },
+ { value: 'MG', label: 'Minas Gerais' },
+ { value: 'PA', label: 'Pará' },
+ { value: 'PB', label: 'Paraíba' },
+ { value: 'PR', label: 'Paraná' },
+ { value: 'PE', label: 'Pernambuco' },
+ { value: 'PI', label: 'Piauí' },
+ { value: 'RJ', label: 'Rio de Janeiro' },
+ { value: 'RN', label: 'Rio Grande do Norte' },
+ { value: 'RS', label: 'Rio Grande do Sul' },
+ { value: 'RO', label: 'Rondônia' },
+ { value: 'RR', label: 'Roraima' },
+ { value: 'SC', label: 'Santa Catarina' },
+ { value: 'SP', label: 'São Paulo' },
+ { value: 'SE', label: 'Sergipe' },
+ { value: 'TO', label: 'Tocantins' },
+]
+
+export const TEMPO_EMPRESA_OPTIONS: SelectOption[] = [
+ { value: 'menos-6-meses', label: 'Menos de 6 meses' },
+ { value: '6-meses-1-ano', label: 'De 6 meses a 1 ano' },
+ { value: '1-2-anos', label: 'De 1 a 2 anos' },
+ { value: '2-5-anos', label: 'De 2 a 5 anos' },
+ { value: 'mais-5-anos', label: 'Mais de 5 anos' },
+]
+
+export function getOptionLabel(options: SelectOption[], value: string): string {
+ return options.find((option) => option.value === value)?.label ?? value
+}
diff --git a/frontend/src/constants/spacing.css b/frontend/src/constants/spacing.css
new file mode 100644
index 0000000..8fd562f
--- /dev/null
+++ b/frontend/src/constants/spacing.css
@@ -0,0 +1,14 @@
+:root {
+ --space-0: 0.125rem;
+ --space-1: 0.25rem;
+ --space-2: 0.375rem;
+ --space-3: 0.5rem;
+ --space-4: 0.625rem;
+ --space-5: 0.75rem;
+ --space-6: 1rem;
+ --space-7: 1.125rem;
+ --space-8: 1.25rem;
+ --space-9: 1.5rem;
+ --space-10: 2rem;
+ --space-11: 3rem;
+}
diff --git a/frontend/src/context/WizardFormProvider.test.tsx b/frontend/src/context/WizardFormProvider.test.tsx
new file mode 100644
index 0000000..cb266ae
--- /dev/null
+++ b/frontend/src/context/WizardFormProvider.test.tsx
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useFormContext } from 'react-hook-form'
+import { WizardFormProvider } from './WizardFormProvider'
+import type { FormValues } from '../schemas/formSchema'
+import { persistFormValues } from '../lib/storage'
+
+function FullNameProbe() {
+ const { register, watch } = useFormContext()
+ return (
+
+
+ valor: {watch('fullName')}
+
+ )
+}
+
+describe('WizardFormProvider', () => {
+ it('starts from the default empty values when nothing is stored', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('valor:')).toBeInTheDocument()
+ })
+
+ it('hydrates from previously persisted values', () => {
+ persistFormValues({ fullName: 'Maria Souza' })
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByLabelText('Nome completo')).toHaveValue('Maria Souza')
+ })
+
+ it('persists changes to localStorage as the user types', async () => {
+ const user = userEvent.setup()
+ render(
+
+
+ ,
+ )
+
+ await user.type(screen.getByLabelText('Nome completo'), 'Ana')
+
+ expect(JSON.parse(window.localStorage.getItem('verity:cadastro-wizard') ?? '{}').fullName).toBe('Ana')
+ })
+})
diff --git a/frontend/src/context/WizardFormProvider.tsx b/frontend/src/context/WizardFormProvider.tsx
new file mode 100644
index 0000000..2a24c89
--- /dev/null
+++ b/frontend/src/context/WizardFormProvider.tsx
@@ -0,0 +1,26 @@
+import { useEffect, type ReactNode } from 'react'
+import { FormProvider, useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { defaultFormValues, formSchema, type FormValues } from '../schemas/formSchema'
+import { loadStoredFormValues, persistFormValues } from '../lib/storage'
+
+export function WizardFormProvider({ children }: { children: ReactNode }) {
+ const methods = useForm({
+ resolver: zodResolver(formSchema),
+ mode: 'onSubmit',
+ reValidateMode: 'onChange',
+ defaultValues: {
+ ...defaultFormValues,
+ ...loadStoredFormValues(),
+ },
+ })
+
+ useEffect(() => {
+ const subscription = methods.watch((values) => {
+ persistFormValues(values)
+ })
+ return () => subscription.unsubscribe()
+ }, [methods])
+
+ return {children}
+}
diff --git a/frontend/src/hooks/useRequireStepData.test.tsx b/frontend/src/hooks/useRequireStepData.test.tsx
new file mode 100644
index 0000000..ecedb4b
--- /dev/null
+++ b/frontend/src/hooks/useRequireStepData.test.tsx
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import { screen } from '@testing-library/react'
+import { Route, Routes } from 'react-router-dom'
+import { renderWithForm } from '../test/renderWithForm'
+import { useRequireStepData } from './useRequireStepData'
+
+function GuardedStep() {
+ useRequireStepData('fullName', '/etapa-1')
+ return conteúdo protegido
+}
+
+describe('useRequireStepData', () => {
+ it('redirects when the required field is empty', () => {
+ renderWithForm(
+
+ } />
+ etapa 1} />
+ ,
+ { initialPath: '/etapa-2' },
+ )
+
+ expect(screen.getByText('etapa 1')).toBeInTheDocument()
+ expect(screen.queryByText('conteúdo protegido')).not.toBeInTheDocument()
+ })
+
+ it('renders normally when the required field is already filled', () => {
+ renderWithForm(
+
+ } />
+ etapa 1} />
+ ,
+ { initialPath: '/etapa-2', initialValues: { fullName: 'Maria Souza' } },
+ )
+
+ expect(screen.getByText('conteúdo protegido')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/hooks/useRequireStepData.ts b/frontend/src/hooks/useRequireStepData.ts
new file mode 100644
index 0000000..69a7457
--- /dev/null
+++ b/frontend/src/hooks/useRequireStepData.ts
@@ -0,0 +1,15 @@
+import { useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useFormContext } from 'react-hook-form'
+import type { FormValues } from '../schemas/formSchema'
+
+export function useRequireStepData(requiredField: keyof FormValues, redirectTo: string) {
+ const { getValues } = useFormContext()
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ if (!getValues(requiredField)) {
+ navigate(redirectTo, { replace: true })
+ }
+ }, [])
+}
diff --git a/frontend/src/hooks/useStepNavigation.test.tsx b/frontend/src/hooks/useStepNavigation.test.tsx
new file mode 100644
index 0000000..1d402b3
--- /dev/null
+++ b/frontend/src/hooks/useStepNavigation.test.tsx
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Route, Routes } from 'react-router-dom'
+import { renderWithForm } from '../test/renderWithForm'
+import { useStepNavigation } from './useStepNavigation'
+import { TextField } from '../components/form/TextField'
+import type { FormValues } from '../schemas/formSchema'
+
+function TestStep({ fields, nextPath }: { fields: (keyof FormValues)[]; nextPath: string }) {
+ const { goNext, goBack } = useStepNavigation(fields)
+ return (
+
+
+ goNext(nextPath)}>
+ avançar
+
+ goBack('/etapa-1')}>
+ voltar
+
+
+ )
+}
+
+describe('useStepNavigation', () => {
+ it('does not navigate when the required fields are invalid', async () => {
+ const user = userEvent.setup()
+ renderWithForm(
+
+ } />
+ chegou na etapa 2} />
+ ,
+ { initialPath: '/etapa-1' },
+ )
+
+ await user.click(screen.getByText('avançar'))
+
+ expect(screen.queryByText('chegou na etapa 2')).not.toBeInTheDocument()
+ })
+
+ it('navigates once the required fields are valid', async () => {
+ const user = userEvent.setup()
+ renderWithForm(
+
+ } />
+ chegou na etapa 2} />
+ ,
+ { initialPath: '/etapa-1' },
+ )
+
+ await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza')
+ await user.click(screen.getByText('avançar'))
+
+ expect(await screen.findByText('chegou na etapa 2')).toBeInTheDocument()
+ })
+
+ it('goBack always navigates regardless of validity', async () => {
+ const user = userEvent.setup()
+ renderWithForm(
+
+ etapa 1} />
+ } />
+ ,
+ { initialPath: '/etapa-2' },
+ )
+
+ await user.click(screen.getByText('voltar'))
+
+ expect(await screen.findByText('etapa 1')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/hooks/useStepNavigation.ts b/frontend/src/hooks/useStepNavigation.ts
new file mode 100644
index 0000000..9584ed2
--- /dev/null
+++ b/frontend/src/hooks/useStepNavigation.ts
@@ -0,0 +1,21 @@
+import { useNavigate } from 'react-router-dom'
+import { useFormContext } from 'react-hook-form'
+import type { FormValues } from '../schemas/formSchema'
+
+export function useStepNavigation(fields: (keyof FormValues)[]) {
+ const { trigger } = useFormContext()
+ const navigate = useNavigate()
+
+ async function goNext(path: string) {
+ const isValid = await trigger(fields)
+ if (isValid) {
+ navigate(path)
+ }
+ }
+
+ function goBack(path: string) {
+ navigate(path)
+ }
+
+ return { goNext, goBack }
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..393ee2a
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,342 @@
+@import './constants/spacing.css';
+@import './constants/colors.css';
+@import './constants/breakpoints.css';
+
+:root {
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 16px;
+
+ --shadow-card: 0 1px 3px rgba(8, 43, 99, 0.08), 0 8px 24px rgba(8, 43, 99, 0.06);
+
+ color-scheme: light;
+ font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif;
+ color: var(--color-gray-900);
+ background-color: var(--verity-blue-50);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ min-height: 100%;
+}
+
+body {
+ margin: 0;
+ background-color: var(--verity-blue-50);
+}
+
+h1,
+h2,
+h3,
+p {
+ margin: 0;
+}
+
+button {
+ font-family: inherit;
+}
+
+.app-shell {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.app-header {
+ background-color: var(--color-white);
+ border-bottom: 1px solid var(--color-gray-200);
+ padding: var(--space-6) var(--space-9);
+ display: flex;
+ align-items: center;
+}
+
+.app-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--space-9) var(--space-6) var(--space-11);
+}
+
+.app-footer {
+ text-align: center;
+ padding: var(--space-6);
+ color: var(--color-gray-400);
+ font-size: 12px;
+}
+
+.logo-mark {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.logo-image {
+ height: 36px;
+ width: auto;
+ display: block;
+}
+
+.step-indicator {
+ list-style: none;
+ display: flex;
+ justify-content: center;
+ gap: var(--space-3);
+ margin: var(--space-9) auto 0;
+ padding: 0 var(--space-6);
+ max-width: 720px;
+ width: 100%;
+}
+
+.step {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-2);
+ text-align: center;
+}
+
+.step-index {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ font-weight: 700;
+ background-color: var(--color-gray-200);
+ color: var(--color-gray-600);
+}
+
+.step-label {
+ font-size: 12px;
+ color: var(--color-gray-600);
+}
+
+.step-active .step-index {
+ background-color: var(--verity-blue-600);
+ color: var(--color-white);
+}
+
+.step-active .step-label {
+ color: var(--verity-blue-700);
+ font-weight: 600;
+}
+
+.step-done .step-index {
+ background-color: var(--verity-blue-100);
+ color: var(--verity-blue-600);
+ border: 2px solid var(--verity-blue-500);
+}
+
+.card {
+ width: 100%;
+ max-width: 720px;
+ background-color: var(--color-white);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-card);
+ padding: var(--space-10);
+ margin-top: var(--space-9);
+}
+
+.step-title {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--verity-blue-900);
+}
+
+.step-description {
+ margin-top: var(--space-2);
+ color: var(--color-gray-600);
+ font-size: 14px;
+}
+
+.field-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-7);
+ margin-top: var(--space-9);
+}
+
+@media (--breakpoint-tablet) {
+ .field-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .field-full {
+ grid-column: 1 / -1;
+ }
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.field label {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--color-gray-900);
+}
+
+.field input,
+.field select {
+ border: 1px solid var(--color-gray-200);
+ border-radius: var(--radius-sm);
+ padding: var(--space-4) var(--space-5);
+ font-size: 14px;
+ background-color: var(--color-white);
+ color: var(--color-gray-900);
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.field input:focus,
+.field select:focus {
+ outline: none;
+ border-color: var(--verity-blue-500);
+ box-shadow: 0 0 0 3px var(--verity-blue-100);
+}
+
+.field input[aria-invalid='true'],
+.field select[aria-invalid='true'] {
+ border-color: var(--color-error);
+}
+
+.field-error {
+ font-size: 12px;
+ color: var(--color-error);
+}
+
+.field-hint {
+ font-size: 12px;
+ color: var(--color-gray-400);
+}
+
+.field-hint.loading {
+ color: var(--verity-blue-600);
+}
+
+.field-hint.error {
+ color: var(--color-error);
+}
+
+.wizard-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-5);
+ margin-top: var(--space-10);
+}
+
+.btn {
+ border: none;
+ border-radius: var(--radius-sm);
+ padding: var(--space-5) var(--space-9);
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background-color 0.15s ease, transform 0.05s ease;
+}
+
+.btn:active {
+ transform: translateY(1px);
+}
+
+.btn-primary {
+ background-color: var(--verity-blue-600);
+ color: var(--color-white);
+}
+
+.btn-primary:hover {
+ background-color: var(--verity-blue-700);
+}
+
+.btn-secondary {
+ background-color: var(--color-white);
+ color: var(--verity-blue-600);
+ border: 1px solid var(--verity-blue-500);
+}
+
+.btn-secondary:hover {
+ background-color: var(--verity-blue-50);
+}
+
+.btn-link {
+ background: none;
+ border: none;
+ color: var(--verity-blue-600);
+ font-weight: 600;
+ font-size: 13px;
+ cursor: pointer;
+ padding: 0;
+}
+
+.btn-link:hover {
+ text-decoration: underline;
+}
+
+.summary-section {
+ margin-top: var(--space-8);
+ padding: var(--space-7);
+ background-color: var(--verity-blue-50);
+ border-radius: var(--radius-md);
+}
+
+.summary-section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.summary-section-header h3 {
+ color: var(--verity-blue-900);
+ font-size: 15px;
+}
+
+.summary-list {
+ margin: var(--space-5) 0 0;
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-4);
+}
+
+@media (--breakpoint-tablet) {
+ .summary-list {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+.summary-row dt {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--color-gray-400);
+}
+
+.summary-row dd {
+ margin: var(--space-0) 0 0;
+ font-size: 14px;
+ color: var(--color-gray-900);
+ font-weight: 500;
+}
+
+@media (--breakpoint-mobile) {
+ .card {
+ padding: var(--space-8);
+ }
+
+ .wizard-actions {
+ flex-direction: column-reverse;
+ }
+
+ .btn {
+ width: 100%;
+ }
+}
diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts
new file mode 100644
index 0000000..835ad78
--- /dev/null
+++ b/frontend/src/lib/api.test.ts
@@ -0,0 +1,74 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { instanceGet, topLevelGet } = vi.hoisted(() => ({
+ instanceGet: vi.fn(),
+ topLevelGet: vi.fn(),
+}))
+
+vi.mock('axios', () => ({
+ default: {
+ create: vi.fn(() => ({ get: instanceGet })),
+ get: topLevelGet,
+ },
+}))
+
+import { fetchAddressByCep, fetchProfessions } from './api'
+
+beforeEach(() => {
+ instanceGet.mockReset()
+ topLevelGet.mockReset()
+})
+
+describe('fetchAddressByCep', () => {
+ it('returns the address from the mocked json-server when found', async () => {
+ instanceGet.mockResolvedValueOnce({
+ data: { endereco: 'Avenida Paulista', bairro: 'Bela Vista', cidade: 'São Paulo', estado: 'SP' },
+ })
+
+ const result = await fetchAddressByCep('01310100')
+
+ expect(result).toEqual({ endereco: 'Avenida Paulista', bairro: 'Bela Vista', cidade: 'São Paulo', estado: 'SP' })
+ expect(instanceGet).toHaveBeenCalledWith('/ceps/01310100')
+ expect(topLevelGet).not.toHaveBeenCalled()
+ })
+
+ it('falls back to ViaCEP when the CEP is not in the mock db', async () => {
+ instanceGet.mockRejectedValueOnce(new Error('Request failed with status code 404'))
+ topLevelGet.mockResolvedValueOnce({
+ data: { logradouro: 'Rua das Flores', bairro: 'Centro', localidade: 'Rio de Janeiro', uf: 'RJ' },
+ })
+
+ const result = await fetchAddressByCep('20000000')
+
+ expect(result).toEqual({ endereco: 'Rua das Flores', bairro: 'Centro', cidade: 'Rio de Janeiro', estado: 'RJ' })
+ })
+
+ it('returns null when ViaCEP reports the CEP does not exist', async () => {
+ instanceGet.mockRejectedValueOnce(new Error('Request failed with status code 404'))
+ topLevelGet.mockResolvedValueOnce({ data: { erro: true } })
+
+ const result = await fetchAddressByCep('00000000')
+
+ expect(result).toBeNull()
+ })
+
+ it('returns null when both the mock and ViaCEP fail', async () => {
+ instanceGet.mockRejectedValueOnce(new Error('network error'))
+ topLevelGet.mockRejectedValueOnce(new Error('network error'))
+
+ const result = await fetchAddressByCep('00000000')
+
+ expect(result).toBeNull()
+ })
+})
+
+describe('fetchProfessions', () => {
+ it('returns the list of professions from the mocked API', async () => {
+ instanceGet.mockResolvedValueOnce({ data: [{ id: 1, name: 'Engenheiro(a) Civil' }] })
+
+ const result = await fetchProfessions()
+
+ expect(result).toEqual([{ id: 1, name: 'Engenheiro(a) Civil' }])
+ expect(instanceGet).toHaveBeenCalledWith('/professions')
+ })
+})
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..51e3e3c
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,60 @@
+import axios from 'axios'
+
+export const mockApi = axios.create({
+ baseURL: import.meta.env.VITE_MOCK_API_URL ?? 'http://localhost:3001',
+})
+
+export interface CepResult {
+ endereco: string
+ bairro: string
+ cidade: string
+ estado: string
+}
+
+interface ViaCepResponse {
+ logradouro: string
+ bairro: string
+ localidade: string
+ uf: string
+ erro?: boolean
+}
+
+async function fetchCepFromMock(cep: string): Promise {
+ try {
+ const { data } = await mockApi.get(`/ceps/${cep}`)
+ return data
+ } catch {
+ return null
+ }
+}
+
+async function fetchCepFromViaCep(cep: string): Promise {
+ try {
+ const { data } = await axios.get(`https://viacep.com.br/ws/${cep}/json/`)
+ if (data.erro) return null
+ return {
+ endereco: data.logradouro,
+ bairro: data.bairro,
+ cidade: data.localidade,
+ estado: data.uf,
+ }
+ } catch {
+ return null
+ }
+}
+
+export async function fetchAddressByCep(cep: string): Promise {
+ const mockResult = await fetchCepFromMock(cep)
+ if (mockResult) return mockResult
+ return fetchCepFromViaCep(cep)
+}
+
+export interface Profession {
+ id: number | string
+ name: string
+}
+
+export async function fetchProfessions(): Promise {
+ const { data } = await mockApi.get('/professions')
+ return data
+}
diff --git a/frontend/src/lib/cpf.test.ts b/frontend/src/lib/cpf.test.ts
new file mode 100644
index 0000000..4bcf52a
--- /dev/null
+++ b/frontend/src/lib/cpf.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import { isValidCPF, onlyDigits } from './cpf'
+
+describe('onlyDigits', () => {
+ it('strips every non-digit character', () => {
+ expect(onlyDigits('529.982.247-25')).toBe('52998224725')
+ expect(onlyDigits('(11) 98765-4321')).toBe('11987654321')
+ })
+})
+
+describe('isValidCPF', () => {
+ it('accepts a valid formatted CPF', () => {
+ expect(isValidCPF('529.982.247-25')).toBe(true)
+ })
+
+ it('accepts a valid unformatted CPF', () => {
+ expect(isValidCPF('52998224725')).toBe(true)
+ })
+
+ it('rejects a CPF with a wrong check digit', () => {
+ expect(isValidCPF('529.982.247-26')).toBe(false)
+ })
+
+ it('rejects a CPF made of the same repeated digit', () => {
+ expect(isValidCPF('111.111.111-11')).toBe(false)
+ expect(isValidCPF('000.000.000-00')).toBe(false)
+ })
+
+ it('rejects a CPF with the wrong length', () => {
+ expect(isValidCPF('1234567890')).toBe(false)
+ expect(isValidCPF('123456789012')).toBe(false)
+ })
+
+ it('rejects an empty value', () => {
+ expect(isValidCPF('')).toBe(false)
+ })
+})
diff --git a/frontend/src/lib/cpf.ts b/frontend/src/lib/cpf.ts
new file mode 100644
index 0000000..5e06579
--- /dev/null
+++ b/frontend/src/lib/cpf.ts
@@ -0,0 +1,26 @@
+export function onlyDigits(value: string): string {
+ return value.replace(/\D/g, '')
+}
+
+function calcCheckDigit(digits: number[], length: number): number {
+ let sum = 0
+ for (let i = 0; i < length; i++) {
+ sum += digits[i] * (length + 1 - i)
+ }
+ const remainder = (sum * 10) % 11
+ return remainder === 10 ? 0 : remainder
+}
+
+export function isValidCPF(rawValue: string): boolean {
+ const cpf = onlyDigits(rawValue)
+
+ if (cpf.length !== 11 || /^(\d)\1{10}$/.test(cpf)) {
+ return false
+ }
+
+ const digits = cpf.split('').map(Number)
+ const firstCheckDigit = calcCheckDigit(digits, 9)
+ const secondCheckDigit = calcCheckDigit(digits, 10)
+
+ return firstCheckDigit === digits[9] && secondCheckDigit === digits[10]
+}
diff --git a/frontend/src/lib/masks.test.ts b/frontend/src/lib/masks.test.ts
new file mode 100644
index 0000000..7d6252a
--- /dev/null
+++ b/frontend/src/lib/masks.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import { isValidBirthDate, onlyDigits, parseCurrencyToNumber } from './masks'
+
+describe('onlyDigits', () => {
+ it('removes non-numeric characters', () => {
+ expect(onlyDigits('01310-100')).toBe('01310100')
+ })
+})
+
+describe('isValidBirthDate', () => {
+ it('accepts a real past date in dd/mm/yyyy format', () => {
+ expect(isValidBirthDate('15/05/1990')).toBe(true)
+ })
+
+ it('rejects an incomplete date', () => {
+ expect(isValidBirthDate('15/05/19')).toBe(false)
+ expect(isValidBirthDate('')).toBe(false)
+ })
+
+ it('rejects a calendar-impossible date', () => {
+ expect(isValidBirthDate('31/02/2020')).toBe(false)
+ expect(isValidBirthDate('32/01/2020')).toBe(false)
+ expect(isValidBirthDate('01/13/2020')).toBe(false)
+ })
+
+ it('rejects a date in the future', () => {
+ const future = new Date()
+ future.setFullYear(future.getFullYear() + 1)
+ const day = String(future.getDate()).padStart(2, '0')
+ const month = String(future.getMonth() + 1).padStart(2, '0')
+ expect(isValidBirthDate(`${day}/${month}/${future.getFullYear()}`)).toBe(false)
+ })
+
+ it('rejects unreasonable years', () => {
+ expect(isValidBirthDate('01/01/1800')).toBe(false)
+ })
+})
+
+describe('parseCurrencyToNumber', () => {
+ it('parses a BRL formatted string into a number', () => {
+ expect(parseCurrencyToNumber('R$ 8.500,50')).toBe(8500.5)
+ expect(parseCurrencyToNumber('R$ 0,00')).toBe(0)
+ expect(parseCurrencyToNumber('R$ 1.234.567,89')).toBe(1234567.89)
+ })
+
+ it('returns 0 for an unparsable value', () => {
+ expect(parseCurrencyToNumber('')).toBe(0)
+ expect(parseCurrencyToNumber('abc')).toBe(0)
+ })
+})
diff --git a/frontend/src/lib/masks.ts b/frontend/src/lib/masks.ts
new file mode 100644
index 0000000..12494bc
--- /dev/null
+++ b/frontend/src/lib/masks.ts
@@ -0,0 +1,31 @@
+export function onlyDigits(value: string): string {
+ return value.replace(/\D/g, '')
+}
+
+export function isValidBirthDate(value: string): boolean {
+ const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(value)
+ if (!match) return false
+
+ const day = Number(match[1])
+ const month = Number(match[2])
+ const year = Number(match[3])
+ const date = new Date(year, month - 1, day)
+
+ const isRealCalendarDate =
+ date.getFullYear() === year &&
+ date.getMonth() === month - 1 &&
+ date.getDate() === day
+
+ if (!isRealCalendarDate) return false
+
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+
+ return date <= today && year >= 1900
+}
+
+export function parseCurrencyToNumber(value: string): number {
+ const normalized = value.replace(/[^\d,-]/g, '').replace(',', '.')
+ const parsed = Number.parseFloat(normalized)
+ return Number.isNaN(parsed) ? 0 : parsed
+}
diff --git a/frontend/src/lib/pdf.test.ts b/frontend/src/lib/pdf.test.ts
new file mode 100644
index 0000000..83c152a
--- /dev/null
+++ b/frontend/src/lib/pdf.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { FormValues } from '../schemas/formSchema'
+
+const { docMock, MockJsPDF } = vi.hoisted(() => {
+ const docMock = {
+ internal: {
+ pageSize: { getWidth: () => 210, getHeight: () => 297 },
+ },
+ setFillColor: vi.fn(),
+ rect: vi.fn(),
+ setTextColor: vi.fn(),
+ setFont: vi.fn(),
+ setFontSize: vi.fn(),
+ text: vi.fn(),
+ setDrawColor: vi.fn(),
+ line: vi.fn(),
+ save: vi.fn(),
+ }
+ class MockJsPDF {
+ constructor() {
+ return docMock
+ }
+ }
+ return { docMock, MockJsPDF }
+})
+
+vi.mock('jspdf', () => ({ jsPDF: MockJsPDF }))
+
+const { generateSummaryPdf } = await import('./pdf')
+
+const values: FormValues = {
+ fullName: 'Maria da Silva Souza',
+ birthDate: '15/05/1990',
+ cpf: '529.982.247-25',
+ phone: '(11) 98765-4321',
+ email: 'maria@example.com',
+ cep: '01310-100',
+ endereco: 'Avenida Paulista',
+ bairro: 'Bela Vista',
+ cidade: 'São Paulo',
+ estado: 'SP',
+ empresa: 'Verity Tecnologia',
+ profissao: 'Desenvolvedor(a) de Software',
+ salario: 'R$ 8.500,50',
+ tempoDeEmpresa: '2-5-anos',
+}
+
+describe('generateSummaryPdf', () => {
+ it('draws the branded header and every field, then saves the file', () => {
+ generateSummaryPdf(values)
+
+ expect(docMock.text).toHaveBeenCalledWith('verity', expect.any(Number), expect.any(Number))
+ expect(docMock.text).toHaveBeenCalledWith('Maria da Silva Souza', expect.any(Number), expect.any(Number))
+ expect(docMock.text).toHaveBeenCalledWith('São Paulo', expect.any(Number), expect.any(Number))
+ expect(docMock.save).toHaveBeenCalledWith('verity-cadastro-maria-da-silva-souza.pdf')
+ })
+
+ it('falls back to a generic filename when the name is blank', () => {
+ generateSummaryPdf({ ...values, fullName: '' })
+ expect(docMock.save).toHaveBeenCalledWith('verity-cadastro-cadastro.pdf')
+ })
+})
diff --git a/frontend/src/lib/pdf.ts b/frontend/src/lib/pdf.ts
new file mode 100644
index 0000000..be8dc85
--- /dev/null
+++ b/frontend/src/lib/pdf.ts
@@ -0,0 +1,95 @@
+import { jsPDF } from 'jspdf'
+import type { FormValues } from '../schemas/formSchema'
+import { getOptionLabel, TEMPO_EMPRESA_OPTIONS, UF_OPTIONS } from '../constants/options'
+
+const VERITY_BLUE: [number, number, number] = [13, 71, 161]
+const VERITY_BLUE_DARK: [number, number, number] = [8, 43, 99]
+const TEXT_GRAY: [number, number, number] = [60, 68, 82]
+const MARGIN_X = 16
+
+interface Section {
+ title: string
+ rows: [string, string][]
+}
+
+function buildSections(data: FormValues): Section[] {
+ return [
+ {
+ title: 'Dados Pessoais',
+ rows: [
+ ['Nome completo', data.fullName],
+ ['Data de nascimento', data.birthDate],
+ ['CPF', data.cpf],
+ ['Telefone', data.phone],
+ ['E-mail', data.email],
+ ],
+ },
+ {
+ title: 'Informações Residenciais',
+ rows: [
+ ['CEP', data.cep],
+ ['Endereço', data.endereco],
+ ['Bairro', data.bairro],
+ ['Cidade', data.cidade],
+ ['Estado', getOptionLabel(UF_OPTIONS, data.estado)],
+ ],
+ },
+ {
+ title: 'Informações Profissionais',
+ rows: [
+ ['Empresa', data.empresa],
+ ['Profissão', data.profissao],
+ ['Salário', data.salario],
+ ['Tempo de empresa', getOptionLabel(TEMPO_EMPRESA_OPTIONS, data.tempoDeEmpresa)],
+ ],
+ },
+ ]
+}
+
+export function generateSummaryPdf(data: FormValues): void {
+ const doc = new jsPDF()
+ const pageWidth = doc.internal.pageSize.getWidth()
+
+ doc.setFillColor(...VERITY_BLUE)
+ doc.rect(0, 0, pageWidth, 30, 'F')
+ doc.setTextColor(255, 255, 255)
+ doc.setFont('helvetica', 'bold')
+ doc.setFontSize(18)
+ doc.text('verity', MARGIN_X, 19)
+ doc.setFontSize(11)
+ doc.setFont('helvetica', 'normal')
+ doc.text('Resumo do Cadastro', pageWidth - MARGIN_X, 19, { align: 'right' })
+
+ let cursorY = 44
+
+ for (const section of buildSections(data)) {
+ doc.setTextColor(...VERITY_BLUE_DARK)
+ doc.setFont('helvetica', 'bold')
+ doc.setFontSize(13)
+ doc.text(section.title, MARGIN_X, cursorY)
+ cursorY += 3
+ doc.setDrawColor(...VERITY_BLUE)
+ doc.line(MARGIN_X, cursorY, pageWidth - MARGIN_X, cursorY)
+ cursorY += 8
+
+ doc.setFontSize(11)
+ for (const [label, value] of section.rows) {
+ doc.setFont('helvetica', 'bold')
+ doc.setTextColor(...TEXT_GRAY)
+ doc.text(`${label}:`, MARGIN_X, cursorY)
+ doc.setFont('helvetica', 'normal')
+ doc.text(value || '—', MARGIN_X + 55, cursorY)
+ cursorY += 7
+ }
+
+ cursorY += 6
+ }
+
+ const generatedAt = new Date().toLocaleString('pt-BR')
+ doc.setFontSize(9)
+ doc.setTextColor(...TEXT_GRAY)
+ doc.text(`Gerado em ${generatedAt}`, MARGIN_X, doc.internal.pageSize.getHeight() - 10)
+
+ const fileSuffix = data.fullName.trim().toLowerCase().replace(/\s+/g, '-') || 'cadastro'
+ doc.save(`verity-cadastro-${fileSuffix}.pdf`)
+}
diff --git a/frontend/src/lib/storage.test.ts b/frontend/src/lib/storage.test.ts
new file mode 100644
index 0000000..ab74160
--- /dev/null
+++ b/frontend/src/lib/storage.test.ts
@@ -0,0 +1,42 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { clearStoredFormValues, loadStoredFormValues, persistFormValues } from './storage'
+
+interface Sample {
+ fullName: string
+}
+
+afterEach(() => {
+ window.localStorage.clear()
+ vi.restoreAllMocks()
+})
+
+describe('persistFormValues / loadStoredFormValues', () => {
+ it('round-trips values through localStorage', () => {
+ persistFormValues({ fullName: 'Maria Souza' })
+ expect(loadStoredFormValues()).toEqual({ fullName: 'Maria Souza' })
+ })
+
+ it('returns undefined when nothing was stored', () => {
+ expect(loadStoredFormValues()).toBeUndefined()
+ })
+
+ it('returns undefined when stored value is not valid JSON', () => {
+ window.localStorage.setItem('verity:cadastro-wizard', '{not-json')
+ expect(loadStoredFormValues()).toBeUndefined()
+ })
+
+ it('silently ignores a localStorage write failure', () => {
+ vi.spyOn(window.localStorage.__proto__, 'setItem').mockImplementation(() => {
+ throw new Error('quota exceeded')
+ })
+ expect(() => persistFormValues({ fullName: 'Maria' })).not.toThrow()
+ })
+})
+
+describe('clearStoredFormValues', () => {
+ it('removes the stored value', () => {
+ persistFormValues({ fullName: 'Maria Souza' })
+ clearStoredFormValues()
+ expect(loadStoredFormValues()).toBeUndefined()
+ })
+})
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts
new file mode 100644
index 0000000..6c5c777
--- /dev/null
+++ b/frontend/src/lib/storage.ts
@@ -0,0 +1,21 @@
+const STORAGE_KEY = 'verity:cadastro-wizard'
+
+export function loadStoredFormValues(): Partial | undefined {
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY)
+ if (!raw) return undefined
+ return JSON.parse(raw) as Partial
+ } catch {
+ return undefined
+ }
+}
+
+export function persistFormValues(values: unknown): void {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(values))
+ } catch {}
+}
+
+export function clearStoredFormValues(): void {
+ window.localStorage.removeItem(STORAGE_KEY)
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..bef5202
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/frontend/src/pages/Step1PersonalData.test.tsx b/frontend/src/pages/Step1PersonalData.test.tsx
new file mode 100644
index 0000000..5154b70
--- /dev/null
+++ b/frontend/src/pages/Step1PersonalData.test.tsx
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Route, Routes } from 'react-router-dom'
+import { renderWithForm } from '../test/renderWithForm'
+import { Step1PersonalData } from './Step1PersonalData'
+
+function renderStep1() {
+ return renderWithForm(
+
+ } />
+ Informações Residenciais} />
+ ,
+ { initialPath: '/etapa-1' },
+ )
+}
+
+describe('Step1PersonalData', () => {
+ it('renders every personal data field', () => {
+ renderStep1()
+ expect(screen.getByLabelText('Nome completo')).toBeInTheDocument()
+ expect(screen.getByLabelText('Data de nascimento')).toBeInTheDocument()
+ expect(screen.getByLabelText('CPF')).toBeInTheDocument()
+ expect(screen.getByLabelText('Telefone')).toBeInTheDocument()
+ expect(screen.getByLabelText('E-mail')).toBeInTheDocument()
+ })
+
+ it('blocks navigation and shows errors when the form is invalid', async () => {
+ const user = userEvent.setup()
+ renderStep1()
+
+ await user.click(screen.getByText('Próximo'))
+
+ expect(await screen.findByText('Informe o nome completo')).toBeInTheDocument()
+ expect(screen.queryByText('Informações Residenciais')).not.toBeInTheDocument()
+ })
+
+ it('navigates to step 2 once all fields are valid', async () => {
+ const user = userEvent.setup()
+ renderStep1()
+
+ await user.type(screen.getByLabelText('Nome completo'), 'Maria Souza')
+ await user.type(screen.getByLabelText('Data de nascimento'), '15051990')
+ await user.type(screen.getByLabelText('CPF'), '52998224725')
+ await user.type(screen.getByLabelText('Telefone'), '11987654321')
+ await user.type(screen.getByLabelText('E-mail'), 'maria@example.com')
+ await user.click(screen.getByText('Próximo'))
+
+ expect(await screen.findByText('Informações Residenciais')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/Step1PersonalData.tsx b/frontend/src/pages/Step1PersonalData.tsx
new file mode 100644
index 0000000..d99d8e4
--- /dev/null
+++ b/frontend/src/pages/Step1PersonalData.tsx
@@ -0,0 +1,36 @@
+import { TextField } from '../components/form/TextField'
+import { MaskedTextField } from '../components/form/MaskedTextField'
+import { WizardActions } from '../components/wizard/WizardActions'
+import { WizardStepShell } from '../components/wizard/WizardStepShell'
+import { useStepNavigation } from '../hooks/useStepNavigation'
+import { STEP1_FIELDS } from '../schemas/formSchema'
+import { CPF_MASK, DATE_MASK, PHONE_MASK } from '../constants/masks'
+
+export function Step1PersonalData() {
+ const { goNext } = useStepNavigation(STEP1_FIELDS)
+
+ return (
+
+
+
+
+
+
+
+
+ goNext('/etapa-2')} />
+
+ )
+}
diff --git a/frontend/src/pages/Step2Address.test.tsx b/frontend/src/pages/Step2Address.test.tsx
new file mode 100644
index 0000000..6e31378
--- /dev/null
+++ b/frontend/src/pages/Step2Address.test.tsx
@@ -0,0 +1,82 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Route, Routes } from 'react-router-dom'
+import { renderWithForm } from '../test/renderWithForm'
+import { Step2Address } from './Step2Address'
+import { fetchAddressByCep } from '../lib/api'
+
+vi.mock('../lib/api', () => ({
+ fetchAddressByCep: vi.fn(),
+}))
+
+function renderStep2(initialValues = { fullName: 'Maria Souza' }) {
+ return renderWithForm(
+
+ Dados Pessoais} />
+ } />
+ Informações Profissionais} />
+ ,
+ { initialPath: '/etapa-2', initialValues },
+ )
+}
+
+beforeEach(() => {
+ vi.mocked(fetchAddressByCep).mockReset()
+})
+
+describe('Step2Address', () => {
+ it('auto-fills the address fields once the CEP lookup resolves', async () => {
+ vi.mocked(fetchAddressByCep).mockResolvedValueOnce({
+ endereco: 'Avenida Paulista',
+ bairro: 'Bela Vista',
+ cidade: 'São Paulo',
+ estado: 'SP',
+ })
+ const user = userEvent.setup()
+ renderStep2()
+
+ await user.type(screen.getByLabelText('CEP'), '01310100')
+
+ expect(await screen.findByDisplayValue('Avenida Paulista')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('Bela Vista')).toBeInTheDocument()
+ expect(screen.getByLabelText('Cidade')).toHaveValue('São Paulo')
+ expect(screen.getByLabelText('Estado')).toHaveValue('SP')
+ })
+
+ it('shows a not-found hint when no service has the CEP', async () => {
+ vi.mocked(fetchAddressByCep).mockResolvedValueOnce(null)
+ const user = userEvent.setup()
+ renderStep2()
+
+ await user.type(screen.getByLabelText('CEP'), '99999999')
+
+ expect(await screen.findByText('CEP não encontrado, preencha manualmente.')).toBeInTheDocument()
+ })
+
+ it('navigates back to step 1', async () => {
+ const user = userEvent.setup()
+ renderStep2()
+
+ await user.click(screen.getByText('Voltar'))
+
+ expect(await screen.findByText('Dados Pessoais')).toBeInTheDocument()
+ })
+
+ it('navigates to step 3 once the address is valid', async () => {
+ vi.mocked(fetchAddressByCep).mockResolvedValueOnce({
+ endereco: 'Avenida Paulista',
+ bairro: 'Bela Vista',
+ cidade: 'São Paulo',
+ estado: 'SP',
+ })
+ const user = userEvent.setup()
+ renderStep2()
+
+ await user.type(screen.getByLabelText('CEP'), '01310100')
+ await screen.findByDisplayValue('Avenida Paulista')
+ await user.click(screen.getByText('Próximo'))
+
+ expect(await screen.findByText('Informações Profissionais')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/Step2Address.tsx b/frontend/src/pages/Step2Address.tsx
new file mode 100644
index 0000000..a4b9bd4
--- /dev/null
+++ b/frontend/src/pages/Step2Address.tsx
@@ -0,0 +1,81 @@
+import { useEffect, useState } from 'react'
+import { useFormContext, useWatch } from 'react-hook-form'
+import { TextField } from '../components/form/TextField'
+import { SelectField } from '../components/form/SelectField'
+import { MaskedTextField } from '../components/form/MaskedTextField'
+import { WizardActions } from '../components/wizard/WizardActions'
+import { WizardStepShell } from '../components/wizard/WizardStepShell'
+import { useStepNavigation } from '../hooks/useStepNavigation'
+import { useRequireStepData } from '../hooks/useRequireStepData'
+import { STEP2_FIELDS, type FormValues } from '../schemas/formSchema'
+import { CEP_MASK } from '../constants/masks'
+import { UF_OPTIONS } from '../constants/options'
+import { fetchAddressByCep } from '../lib/api'
+import { onlyDigits } from '../lib/masks'
+
+type CepStatus = 'idle' | 'loading' | 'success' | 'not-found'
+
+const CEP_STATUS_HINTS: Partial> = {
+ loading: 'Buscando endereço...',
+ 'not-found': 'CEP não encontrado, preencha manualmente.',
+}
+
+export function Step2Address() {
+ useRequireStepData('fullName', '/etapa-1')
+ const { control, setValue } = useFormContext()
+ const { goNext, goBack } = useStepNavigation(STEP2_FIELDS)
+ const [cepStatus, setCepStatus] = useState('idle')
+ const cep = useWatch({ control, name: 'cep' })
+
+ useEffect(() => {
+ const digits = onlyDigits(cep ?? '')
+ if (digits.length !== 8) {
+ setCepStatus('idle')
+ return
+ }
+
+ let cancelled = false
+ setCepStatus('loading')
+
+ fetchAddressByCep(digits).then((result) => {
+ if (cancelled) return
+ if (result) {
+ setValue('endereco', result.endereco, { shouldValidate: true })
+ setValue('bairro', result.bairro, { shouldValidate: true })
+ setValue('cidade', result.cidade, { shouldValidate: true })
+ setValue('estado', result.estado.toUpperCase(), { shouldValidate: true })
+ setCepStatus('success')
+ } else {
+ setCepStatus('not-found')
+ }
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [cep, setValue])
+
+ return (
+
+
+
+
+
+
+
+
+ goBack('/etapa-1')} onNext={() => goNext('/etapa-3')} />
+
+ )
+}
diff --git a/frontend/src/pages/Step3Professional.test.tsx b/frontend/src/pages/Step3Professional.test.tsx
new file mode 100644
index 0000000..fc38c15
--- /dev/null
+++ b/frontend/src/pages/Step3Professional.test.tsx
@@ -0,0 +1,66 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Route, Routes } from 'react-router-dom'
+import { renderWithForm } from '../test/renderWithForm'
+import { Step3Professional } from './Step3Professional'
+import { fetchProfessions } from '../lib/api'
+
+vi.mock('../lib/api', () => ({
+ fetchProfessions: vi.fn(),
+}))
+
+function renderStep3() {
+ return renderWithForm(
+
+ Informações Residenciais} />
+ } />
+ Resumo do Cadastro} />
+ ,
+ { initialPath: '/etapa-3', initialValues: { endereco: 'Avenida Paulista' } },
+ )
+}
+
+beforeEach(() => {
+ vi.mocked(fetchProfessions).mockReset()
+})
+
+describe('Step3Professional', () => {
+ it('disables the profession select while loading, then populates it', async () => {
+ vi.mocked(fetchProfessions).mockResolvedValueOnce([
+ { id: 1, name: 'Desenvolvedor(a) de Software' },
+ { id: 2, name: 'Analista de Sistemas' },
+ ])
+ renderStep3()
+
+ expect(screen.getByLabelText('Profissão')).toBeDisabled()
+
+ await screen.findByRole('option', { name: 'Desenvolvedor(a) de Software' })
+ expect(screen.getByLabelText('Profissão')).not.toBeDisabled()
+ expect(screen.getByRole('option', { name: 'Analista de Sistemas' })).toBeInTheDocument()
+ })
+
+ it('formats the salary field as currency', async () => {
+ vi.mocked(fetchProfessions).mockResolvedValueOnce([])
+ const user = userEvent.setup()
+ renderStep3()
+
+ await user.type(screen.getByLabelText('Salário'), '500000')
+ expect(screen.getByLabelText('Salário')).toHaveValue('R$ 5.000,00')
+ })
+
+ it('navigates to the summary once the step is valid', async () => {
+ vi.mocked(fetchProfessions).mockResolvedValueOnce([{ id: 1, name: 'Analista de Sistemas' }])
+ const user = userEvent.setup()
+ renderStep3()
+
+ await screen.findByRole('option', { name: 'Analista de Sistemas' })
+ await user.type(screen.getByLabelText('Empresa'), 'Verity Tecnologia')
+ await user.selectOptions(screen.getByLabelText('Profissão'), 'Analista de Sistemas')
+ await user.type(screen.getByLabelText('Salário'), '500000')
+ await user.selectOptions(screen.getByLabelText('Tempo de empresa'), 'De 2 a 5 anos')
+ await user.click(screen.getByText('Ver resumo'))
+
+ expect(await screen.findByText('Resumo do Cadastro')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/Step3Professional.tsx b/frontend/src/pages/Step3Professional.tsx
new file mode 100644
index 0000000..396f475
--- /dev/null
+++ b/frontend/src/pages/Step3Professional.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useState } from 'react'
+import { TextField } from '../components/form/TextField'
+import { SelectField } from '../components/form/SelectField'
+import { CurrencyField } from '../components/form/CurrencyField'
+import { WizardActions } from '../components/wizard/WizardActions'
+import { WizardStepShell } from '../components/wizard/WizardStepShell'
+import { useStepNavigation } from '../hooks/useStepNavigation'
+import { useRequireStepData } from '../hooks/useRequireStepData'
+import { STEP3_FIELDS } from '../schemas/formSchema'
+import { TEMPO_EMPRESA_OPTIONS, type SelectOption } from '../constants/options'
+import { fetchProfessions } from '../lib/api'
+
+export function Step3Professional() {
+ useRequireStepData('endereco', '/etapa-2')
+ const { goNext, goBack } = useStepNavigation(STEP3_FIELDS)
+ const [professionOptions, setProfessionOptions] = useState([])
+ const [loadingProfessions, setLoadingProfessions] = useState(true)
+
+ useEffect(() => {
+ let cancelled = false
+
+ fetchProfessions()
+ .then((professions) => {
+ if (cancelled) return
+ setProfessionOptions(professions.map((p) => ({ value: p.name, label: p.name })))
+ })
+ .catch(() => {
+ if (!cancelled) setProfessionOptions([])
+ })
+ .finally(() => {
+ if (!cancelled) setLoadingProfessions(false)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+
+
+ goBack('/etapa-2')} onNext={() => goNext('/resumo')} nextLabel="Ver resumo" />
+
+ )
+}
diff --git a/frontend/src/pages/SummaryPage.test.tsx b/frontend/src/pages/SummaryPage.test.tsx
new file mode 100644
index 0000000..7214876
--- /dev/null
+++ b/frontend/src/pages/SummaryPage.test.tsx
@@ -0,0 +1,85 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Route, Routes } from 'react-router-dom'
+import { renderWithForm } from '../test/renderWithForm'
+import { SummaryPage } from './SummaryPage'
+import { generateSummaryPdf } from '../lib/pdf'
+import { clearStoredFormValues } from '../lib/storage'
+import type { FormValues } from '../schemas/formSchema'
+
+vi.mock('../lib/pdf', () => ({ generateSummaryPdf: vi.fn() }))
+vi.mock('../lib/storage', () => ({ clearStoredFormValues: vi.fn(), persistFormValues: vi.fn() }))
+
+const filledValues: Partial = {
+ fullName: 'Maria da Silva Souza',
+ birthDate: '15/05/1990',
+ cpf: '529.982.247-25',
+ phone: '(11) 98765-4321',
+ email: 'maria@example.com',
+ cep: '01310-100',
+ endereco: 'Avenida Paulista',
+ bairro: 'Bela Vista',
+ cidade: 'São Paulo',
+ estado: 'SP',
+ empresa: 'Verity Tecnologia',
+ profissao: 'Desenvolvedor(a) de Software',
+ salario: 'R$ 8.500,50',
+ tempoDeEmpresa: '2-5-anos',
+}
+
+function renderSummary() {
+ return renderWithForm(
+
+ Dados Pessoais} />
+ Informações Profissionais} />
+ } />
+ ,
+ { initialPath: '/resumo', initialValues: filledValues },
+ )
+}
+
+beforeEach(() => {
+ vi.mocked(generateSummaryPdf).mockReset()
+ vi.mocked(clearStoredFormValues).mockReset()
+})
+
+describe('SummaryPage', () => {
+ it('renders every filled value grouped by section, translating select values to labels', () => {
+ renderSummary()
+
+ expect(screen.getByText('Maria da Silva Souza')).toBeInTheDocument()
+ expect(screen.getByText('529.982.247-25')).toBeInTheDocument()
+ expect(screen.getByText('R$ 8.500,50')).toBeInTheDocument()
+ expect(screen.getAllByText('São Paulo')).toHaveLength(2)
+ expect(screen.getByText('De 2 a 5 anos')).toBeInTheDocument()
+ })
+
+ it('exports the PDF with the current form values', async () => {
+ const user = userEvent.setup()
+ renderSummary()
+
+ await user.click(screen.getByText('Exportar PDF'))
+
+ expect(generateSummaryPdf).toHaveBeenCalledWith(expect.objectContaining({ fullName: 'Maria da Silva Souza' }))
+ })
+
+ it('navigates to the matching step when Editar is clicked', async () => {
+ const user = userEvent.setup()
+ renderSummary()
+
+ await user.click(screen.getAllByText('Editar')[0])
+
+ expect(await screen.findByText('Dados Pessoais')).toBeInTheDocument()
+ })
+
+ it('clears the stored data and restarts on "Novo cadastro"', async () => {
+ const user = userEvent.setup()
+ renderSummary()
+
+ await user.click(screen.getByText('Novo cadastro'))
+
+ expect(clearStoredFormValues).toHaveBeenCalledOnce()
+ expect(await screen.findByText('Dados Pessoais')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/SummaryPage.tsx b/frontend/src/pages/SummaryPage.tsx
new file mode 100644
index 0000000..2370824
--- /dev/null
+++ b/frontend/src/pages/SummaryPage.tsx
@@ -0,0 +1,71 @@
+import { useNavigate } from 'react-router-dom'
+import { useFormContext } from 'react-hook-form'
+import { WizardStepShell } from '../components/wizard/WizardStepShell'
+import { SummarySection } from '../components/wizard/SummarySection'
+import { useRequireStepData } from '../hooks/useRequireStepData'
+import { defaultFormValues, type FormValues } from '../schemas/formSchema'
+import { getOptionLabel, TEMPO_EMPRESA_OPTIONS, UF_OPTIONS } from '../constants/options'
+import { generateSummaryPdf } from '../lib/pdf'
+import { clearStoredFormValues } from '../lib/storage'
+
+export function SummaryPage() {
+ useRequireStepData('profissao', '/etapa-3')
+ const { getValues, reset } = useFormContext()
+ const navigate = useNavigate()
+ const values = getValues()
+
+ function handleExport() {
+ generateSummaryPdf(values)
+ }
+
+ function handleNewRegistration() {
+ clearStoredFormValues()
+ reset(defaultFormValues)
+ navigate('/etapa-1')
+ }
+
+ return (
+
+ navigate('/etapa-1')}
+ items={[
+ { label: 'Nome completo', value: values.fullName },
+ { label: 'Data de nascimento', value: values.birthDate },
+ { label: 'CPF', value: values.cpf },
+ { label: 'Telefone', value: values.phone },
+ { label: 'E-mail', value: values.email },
+ ]}
+ />
+ navigate('/etapa-2')}
+ items={[
+ { label: 'CEP', value: values.cep },
+ { label: 'Endereço', value: values.endereco },
+ { label: 'Bairro', value: values.bairro },
+ { label: 'Cidade', value: values.cidade },
+ { label: 'Estado', value: getOptionLabel(UF_OPTIONS, values.estado) },
+ ]}
+ />
+ navigate('/etapa-3')}
+ items={[
+ { label: 'Empresa', value: values.empresa },
+ { label: 'Profissão', value: values.profissao },
+ { label: 'Salário', value: values.salario },
+ { label: 'Tempo de empresa', value: getOptionLabel(TEMPO_EMPRESA_OPTIONS, values.tempoDeEmpresa) },
+ ]}
+ />
+
+
+ Novo cadastro
+
+
+ Exportar PDF
+
+
+
+ )
+}
diff --git a/frontend/src/schemas/formSchema.test.ts b/frontend/src/schemas/formSchema.test.ts
new file mode 100644
index 0000000..a6a002a
--- /dev/null
+++ b/frontend/src/schemas/formSchema.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from 'vitest'
+import { addressSchema, defaultFormValues, formSchema, personalDataSchema, professionalSchema } from './formSchema'
+
+const validValues = {
+ fullName: 'Maria da Silva Souza',
+ birthDate: '15/05/1990',
+ cpf: '529.982.247-25',
+ phone: '(11) 98765-4321',
+ email: 'maria@example.com',
+ cep: '01310-100',
+ endereco: 'Avenida Paulista',
+ bairro: 'Bela Vista',
+ cidade: 'São Paulo',
+ estado: 'SP',
+ empresa: 'Verity Tecnologia',
+ profissao: 'Desenvolvedor(a) de Software',
+ salario: 'R$ 8.500,50',
+ tempoDeEmpresa: '2-5-anos',
+}
+
+describe('formSchema', () => {
+ it('accepts a fully valid payload', () => {
+ expect(formSchema.safeParse(validValues).success).toBe(true)
+ })
+
+ it('rejects the untouched default values', () => {
+ expect(formSchema.safeParse(defaultFormValues).success).toBe(false)
+ })
+})
+
+describe('personalDataSchema', () => {
+ it('requires at least two words for the full name', () => {
+ const result = personalDataSchema.safeParse({ ...validValues, fullName: 'Maria' })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects an invalid CPF', () => {
+ const result = personalDataSchema.safeParse({ ...validValues, cpf: '111.111.111-11' })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects a malformed phone number', () => {
+ const result = personalDataSchema.safeParse({ ...validValues, phone: '11987654321' })
+ expect(result.success).toBe(false)
+ })
+
+ it('accepts an 8-digit landline phone number', () => {
+ const result = personalDataSchema.safeParse({ ...validValues, phone: '(11) 3456-7890' })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects an invalid email', () => {
+ const result = personalDataSchema.safeParse({ ...validValues, email: 'not-an-email' })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects an impossible birth date', () => {
+ const result = personalDataSchema.safeParse({ ...validValues, birthDate: '31/02/2020' })
+ expect(result.success).toBe(false)
+ })
+})
+
+describe('addressSchema', () => {
+ it('rejects a malformed CEP', () => {
+ const result = addressSchema.safeParse({ ...validValues, cep: '01310100' })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects an unknown UF', () => {
+ const result = addressSchema.safeParse({ ...validValues, estado: 'XX' })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects an empty bairro', () => {
+ const result = addressSchema.safeParse({ ...validValues, bairro: '' })
+ expect(result.success).toBe(false)
+ })
+})
+
+describe('professionalSchema', () => {
+ it('rejects a zero salary', () => {
+ const result = professionalSchema.safeParse({ ...validValues, salario: 'R$ 0,00' })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects a missing profession', () => {
+ const result = professionalSchema.safeParse({ ...validValues, profissao: '' })
+ expect(result.success).toBe(false)
+ })
+
+ it('rejects a missing tempoDeEmpresa', () => {
+ const result = professionalSchema.safeParse({ ...validValues, tempoDeEmpresa: '' })
+ expect(result.success).toBe(false)
+ })
+})
diff --git a/frontend/src/schemas/formSchema.ts b/frontend/src/schemas/formSchema.ts
new file mode 100644
index 0000000..b49f098
--- /dev/null
+++ b/frontend/src/schemas/formSchema.ts
@@ -0,0 +1,81 @@
+import { z } from 'zod'
+import { isValidCPF } from '../lib/cpf'
+import { isValidBirthDate, parseCurrencyToNumber } from '../lib/masks'
+import { UF_OPTIONS } from '../constants/options'
+
+const PHONE_REGEX = /^\(\d{2}\) \d{4,5}-\d{4}$/
+const CEP_REGEX = /^\d{5}-\d{3}$/
+const UF_CODES = UF_OPTIONS.map((option) => option.value)
+
+export const personalDataSchema = z.object({
+ fullName: z
+ .string()
+ .trim()
+ .min(1, 'Informe o nome completo')
+ .refine((value) => value.trim().split(/\s+/).length >= 2, {
+ message: 'Informe nome e sobrenome',
+ }),
+ birthDate: z
+ .string()
+ .min(1, 'Informe a data de nascimento')
+ .refine(isValidBirthDate, { message: 'Data de nascimento inválida' }),
+ cpf: z
+ .string()
+ .min(1, 'Informe o CPF')
+ .refine(isValidCPF, { message: 'CPF inválido' }),
+ phone: z
+ .string()
+ .min(1, 'Informe o telefone')
+ .regex(PHONE_REGEX, 'Telefone inválido'),
+ email: z.string().min(1, 'Informe o e-mail').email('E-mail inválido'),
+})
+
+export const addressSchema = z.object({
+ cep: z.string().min(1, 'Informe o CEP').regex(CEP_REGEX, 'CEP inválido'),
+ endereco: z.string().min(1, 'Informe o endereço'),
+ bairro: z.string().min(1, 'Informe o bairro'),
+ cidade: z.string().min(1, 'Informe a cidade'),
+ estado: z
+ .string()
+ .min(1, 'Informe o estado')
+ .refine((value) => UF_CODES.includes(value.toUpperCase()), {
+ message: 'Estado inválido',
+ }),
+})
+
+export const professionalSchema = z.object({
+ empresa: z.string().min(1, 'Informe a empresa'),
+ profissao: z.string().min(1, 'Selecione uma profissão'),
+ salario: z
+ .string()
+ .min(1, 'Informe o salário')
+ .refine((value) => parseCurrencyToNumber(value) > 0, {
+ message: 'Informe um salário válido',
+ }),
+ tempoDeEmpresa: z.string().min(1, 'Selecione o tempo de empresa'),
+})
+
+export const formSchema = personalDataSchema.merge(addressSchema).merge(professionalSchema)
+
+export type FormValues = z.infer
+
+export const STEP1_FIELDS = Object.keys(personalDataSchema.shape) as (keyof FormValues)[]
+export const STEP2_FIELDS = Object.keys(addressSchema.shape) as (keyof FormValues)[]
+export const STEP3_FIELDS = Object.keys(professionalSchema.shape) as (keyof FormValues)[]
+
+export const defaultFormValues: FormValues = {
+ fullName: '',
+ birthDate: '',
+ cpf: '',
+ phone: '',
+ email: '',
+ cep: '',
+ endereco: '',
+ bairro: '',
+ cidade: '',
+ estado: '',
+ empresa: '',
+ profissao: '',
+ salario: '',
+ tempoDeEmpresa: '',
+}
diff --git a/frontend/src/test/renderWithForm.tsx b/frontend/src/test/renderWithForm.tsx
new file mode 100644
index 0000000..aa64c30
--- /dev/null
+++ b/frontend/src/test/renderWithForm.tsx
@@ -0,0 +1,36 @@
+import type { ReactElement, ReactNode } from 'react'
+import { render } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import { FormProvider, useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { defaultFormValues, formSchema, type FormValues } from '../schemas/formSchema'
+
+function FormWrapper({
+ children,
+ initialValues,
+}: {
+ children: ReactNode
+ initialValues?: Partial
+}) {
+ const methods = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: { ...defaultFormValues, ...initialValues },
+ })
+
+ return {children}
+}
+
+interface RenderWithFormOptions {
+ initialValues?: Partial
+ initialPath?: string
+}
+
+export function renderWithForm(ui: ReactElement, options: RenderWithFormOptions = {}) {
+ const { initialValues, initialPath = '/' } = options
+
+ return render(
+
+ {ui}
+ ,
+ )
+}
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
new file mode 100644
index 0000000..1555cbe
--- /dev/null
+++ b/frontend/src/test/setup.ts
@@ -0,0 +1,8 @@
+import '@testing-library/jest-dom'
+import { cleanup } from '@testing-library/react'
+import { afterEach } from 'vitest'
+
+afterEach(() => {
+ cleanup()
+ window.localStorage.clear()
+})
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
new file mode 100644
index 0000000..6830b6f
--- /dev/null
+++ b/frontend/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/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..8455dcb
--- /dev/null
+++ b/frontend/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/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..5f23a90
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,31 @@
+///
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test/setup.ts'],
+ css: true,
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'html', 'lcov'],
+ include: ['src/**/*.{ts,tsx}'],
+ exclude: [
+ 'src/main.tsx',
+ 'src/vite-env.d.ts',
+ 'src/test/**',
+ 'src/**/*.d.ts',
+ ],
+ thresholds: {
+ lines: 80,
+ statements: 80,
+ functions: 80,
+ branches: 80,
+ },
+ },
+ },
+})