From 7d991a07791c1c9bd9c9b8e0d35234d845525bac Mon Sep 17 00:00:00 2001 From: Pablo Gallego Date: Wed, 12 Aug 2026 02:12:43 +0200 Subject: [PATCH 1/2] Harden app security and navigation --- CHANGELOG.md | 10 + README.md | 22 +- deploy/nginx-security-headers.conf | 10 + package-lock.json | 725 ++++++++++--------- package.json | 16 +- public/_headers | 6 +- src/modules/attendance/AttendancePage.tsx | 43 +- src/modules/management/TeacherOnboarding.tsx | 204 ++++-- src/modules/today/TodayPage.tsx | 20 +- src/shared/ai/extensionOverlay.ts | 11 +- src/shared/ai/extensionRuntime.test.ts | 35 + src/shared/ai/extensionRuntime.ts | 226 +++--- src/shared/attendance/attendance.test.ts | 29 + src/shared/attendance/attendance.ts | 19 + src/shared/handoff/handoff.test.ts | 28 + src/shared/handoff/handoff.ts | 91 ++- src/shared/navigation/mainNavigation.test.ts | 43 ++ src/shared/navigation/mainNavigation.ts | 119 +++ src/shared/security/appLock.test.ts | 17 + src/shared/security/appLock.ts | 44 +- src/shared/ui/AppLockGate.tsx | 50 +- src/shared/ui/NoContextBanner.tsx | 12 +- src/shared/ui/TopTabs.tsx | 199 ++--- src/styles.css | 437 +++++++++-- tsconfig.node.json | 3 +- vitest.config.ts | 22 + 26 files changed, 1711 insertions(+), 730 deletions(-) create mode 100644 deploy/nginx-security-headers.conf create mode 100644 src/shared/ai/extensionRuntime.test.ts create mode 100644 src/shared/navigation/mainNavigation.test.ts create mode 100644 src/shared/navigation/mainNavigation.ts create mode 100644 vitest.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b67f58e..d1350b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,16 @@ ### Changed +- Migrated the AI integration to the extension's current versioned `postMessage` bridge and added protocol-envelope tests. +- Hardened student handoff imports with exact scope, real-date, enum, class relationship, and logical-duplicate validation. +- Prevented stale attendance loads from overwriting the active selection or reusing an attendance ID from another session. +- Hardened the local app lock against manipulated work factors and repeated unlock attempts. +- Updated compatible development dependencies, restored full-source coverage reporting, and added reusable Nginx security-header configuration. +- Standardized the first-run experience on the single term "initial setup" across global prompts, onboarding cards, dialogs, and actions. +- Unified initial-setup prompts under the primary blue visual treatment while keeping amber reserved for actionable context warnings. +- Separated the missing-group blocker from the onboarding progress coach, removing duplicated setup messaging and the redundant compact step badge. +- Reorganized the fifteen-item top navigation into five workflow areas with route-aware secondary navigation and a non-scrolling mobile layout. +- Redesigned the teacher setup prompt as a compact, responsive task card with clearer progress, next-step guidance, and accessible primary and secondary actions. - Reports now apply date ranges to manual assessments through their explicit assessment date and exclude undated legacy rows instead of silently mixing periods. - Attendance and reports now distinguish a measured zero percent from a course with no attendance observations. - ACS and reinforcement measures remain visible support context but no longer increase the automatic academic risk label. diff --git a/README.md b/README.md index 0367ef2..dc0790e 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ npm run build:production # Build the production deployment profile npm run preview # Preview the production build npm run clean # Remove generated builds, coverage, logs, and TypeScript caches npm run test # Run Vitest +npm run test:coverage # Run Vitest and write the full source coverage report npm run lint # Run ESLint, including React hook checks npm run typecheck # Run TypeScript project checks npm run audit # Audit dependencies against the reviewed advisory policy @@ -109,17 +110,17 @@ Database payloads are intentionally not backward compatible. Only payloads produ - Academic records remain local to the browser profile in IndexedDB; ProfePlus has no application backend. - Downloaded backups and automatic pre-operation safety backups are encrypted. ProfePlus never stores their passwords. -- An optional local app lock uses a salted PBKDF2-SHA-256 verifier and automatically locks after the configured inactivity period. It protects the visible application from casual access but is not a substitute for operating-system disk and profile encryption. +- An optional local app lock uses a salted PBKDF2-SHA-256 verifier, validates its stored work factor, pauses retries after repeated failures, and automatically locks after the configured inactivity period. It protects the visible application from casual access but is not a substitute for operating-system disk and profile encryption. - CSV exports neutralize spreadsheet formula prefixes before escaping cell values. - Backup imports enforce a size limit and validate metadata, structure, types, relationships, scopes, uniqueness, dates, and numeric ranges before changing the database. -- The application includes a restrictive Content Security Policy, referrer policy, permission policy, clickjacking protection, and MIME-sniffing protection. `public/_headers` applies the full header set on compatible static hosts; other hosts must configure equivalent response headers. +- The application includes a restrictive Content Security Policy, referrer policy, permission policy, cross-origin isolation headers, clickjacking protection, and MIME-sniffing protection. `public/_headers` applies the full header set on compatible static hosts. Nginx deployments can include `deploy/nginx-security-headers.conf` from the HTTPS server or location block. - Anyone with access to an unlocked browser profile can still inspect its IndexedDB data. Use an encrypted operating-system account and do not share the browser profile when handling real student data. ## AI Extension ProfePlus does not include an in-app AI settings page and does not import WebLLM directly. -AI features use the AI Proxy Bridge Chrome extension: +AI features use the AI Proxy Bridge Chrome extension through its versioned same-origin page bridge: - The extension injects its compact overlay into trusted pages. - Feature-level AI requests go through `src/shared/ai/extensionRuntime.ts`. @@ -128,8 +129,8 @@ AI features use the AI Proxy Bridge Chrome extension: Configuration options: -- `VITE_AI_RUNTIME_EXTENSION_ID`: optional default extension ID. -- The app can also detect the extension through the content-script ready event on trusted origins. +- `VITE_AI_RUNTIME_EXTENSION_ID`: optional extension-ID pin. Use it when the deployed extension has a stable ID. +- Without a pin, the app discovers the installed bridge through a versioned `postMessage` availability exchange and keeps the discovered ID fixed for each request. - The extension must authorize the current hostname, such as `localhost`, `127.0.0.1`, or the deployed domain. The extension project is external to this app and should be managed separately. @@ -181,14 +182,18 @@ Automated coverage includes focused Vitest suites for: - Printable reports. - Student follow-up helpers. - AI-generated instrument parsing and validation. +- Versioned AI extension bridge envelopes. +- Student handoff scope, reference, date, and enum validation. -The latest full verification passed with: +Run `npm run test:coverage` to generate the complete text and HTML coverage report for `src/`. + +Run the full verification with: ```bash npm run verify ``` -Expected result: +The expected result is: - TypeScript project checks pass. - ESLint passes without errors or warnings. @@ -223,7 +228,8 @@ provider must: - Serve `dist/` over HTTPS. - Rewrite unknown application routes to `index.html`. - Apply the security headers in `public/_headers`, or equivalent platform - configuration. + configuration. For Nginx, include `deploy/nginx-security-headers.conf` and + reload Nginx after validating the configuration. - Avoid sharing browser storage or service-worker state between the two domains. Their separate origins provide this isolation automatically. diff --git a/deploy/nginx-security-headers.conf b/deploy/nginx-security-headers.conf new file mode 100644 index 0000000..5ba71ef --- /dev/null +++ b/deploy/nginx-security-headers.conf @@ -0,0 +1,10 @@ +# Include this file in the HTTPS server or location block that serves ProfePlus. +add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'" always; +add_header Referrer-Policy "no-referrer" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "DENY" always; +add_header X-XSS-Protection "0" always; +add_header Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=(), usb=()" always; +add_header Cross-Origin-Opener-Policy "same-origin" always; +add_header Cross-Origin-Resource-Policy "same-origin" always; +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; diff --git a/package-lock.json b/package-lock.json index d276c81..49aa176 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,15 +18,16 @@ }, "devDependencies": { "@types/node": "^24.13.3", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.4", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", - "globals": "^17.8.0", + "globals": "^17.9.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.65.0", - "vite": "^8.1.5", + "typescript-eslint": "^8.66.0", + "vite": "^8.2.0", "vitest": "^4.1.10" }, "engines": { @@ -49,6 +50,13 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/compat-data": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", @@ -91,14 +99,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -211,13 +219,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -251,18 +259,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -270,9 +278,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -283,41 +291,14 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" + "engines": { + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -543,32 +524,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", - "integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^2.0.0-alpha.3", - "@emnapi/runtime": "^2.0.0-alpha.3" - } - }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", "dev": true, "license": "MIT", "funding": { @@ -602,9 +561,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", "cpu": [ "arm64" ], @@ -619,9 +578,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", "cpu": [ "arm64" ], @@ -636,9 +595,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", "cpu": [ "x64" ], @@ -653,9 +612,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", "cpu": [ "x64" ], @@ -670,9 +629,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", "cpu": [ "arm" ], @@ -687,9 +646,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", "cpu": [ "arm64" ], @@ -707,9 +666,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", "cpu": [ "arm64" ], @@ -727,9 +686,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", "cpu": [ "ppc64" ], @@ -747,9 +706,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", "cpu": [ "s390x" ], @@ -767,9 +726,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", "cpu": [ "x64" ], @@ -787,9 +746,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", "cpu": [ "x64" ], @@ -807,9 +766,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", "cpu": [ "arm64" ], @@ -823,63 +782,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", "cpu": [ "arm64" ], @@ -894,9 +800,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", "cpu": [ "x64" ], @@ -929,17 +835,6 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -990,9 +885,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1000,9 +895,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1016,17 +911,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", - "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/type-utils": "8.65.0", - "@typescript-eslint/utils": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1039,7 +934,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.65.0", + "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1055,16 +950,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", - "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "engines": { @@ -1080,14 +975,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", - "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.65.0", - "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "engines": { @@ -1102,14 +997,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", - "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0" + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1120,9 +1015,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", - "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", "dev": true, "license": "MIT", "engines": { @@ -1137,15 +1032,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", - "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1162,9 +1057,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", - "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "dev": true, "license": "MIT", "engines": { @@ -1176,16 +1071,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", - "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.65.0", - "@typescript-eslint/tsconfig-utils": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1217,16 +1112,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", - "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0" + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1241,13 +1136,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", - "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1259,9 +1154,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", - "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", "dev": true, "license": "MIT", "dependencies": { @@ -1284,6 +1179,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -1447,6 +1373,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1458,9 +1396,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.6", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz", - "integrity": "sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==", + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1471,9 +1409,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1484,9 +1422,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -1504,11 +1442,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -1518,9 +1456,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "dev": true, "funding": [ { @@ -1632,9 +1570,9 @@ "license": "Apache-2.0" }, "node_modules/electron-to-chromium": { - "version": "1.5.398", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz", - "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==", + "version": "1.5.405", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==", "dev": true, "license": "ISC" }, @@ -1669,9 +1607,9 @@ } }, "node_modules/eslint": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", - "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -1947,9 +1885,9 @@ } }, "node_modules/flatted": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", - "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -1992,9 +1930,9 @@ } }, "node_modules/globals": { - "version": "17.8.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", - "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", + "version": "17.10.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.10.0.tgz", + "integrity": "sha512-V0kztuWST2k8A/VbxAY8+L+7+Rgo3fyA24IHRLrZp7HOzJjV0gHSaZUjK9lpP/IrBSNite2tZ1prhRkinRu1CA==", "dev": true, "license": "MIT", "engines": { @@ -2004,6 +1942,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2030,6 +1978,13 @@ "@babel/runtime": "^7.7.6" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2041,9 +1996,9 @@ } }, "node_modules/immer": { - "version": "11.1.15", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", - "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "version": "11.1.16", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.16.tgz", + "integrity": "sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==", "license": "MIT", "funding": { "type": "opencollective", @@ -2090,10 +2045,49 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, @@ -2477,6 +2471,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -2501,9 +2536,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -2527,9 +2562,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "dev": true, "license": "MIT", "engines": { @@ -2648,9 +2683,9 @@ } }, "node_modules/postcss": { - "version": "8.5.24", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", - "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -2668,7 +2703,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2800,13 +2835,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2816,21 +2851,20 @@ "node": "^20.19.0 || >=22.12.0" }, "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" + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" } }, "node_modules/scheduler": { @@ -2909,6 +2943,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2917,9 +2964,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -2966,14 +3013,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3002,16 +3041,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", - "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.65.0", - "@typescript-eslint/parser": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0" + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3033,9 +3072,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", "dev": true, "funding": [ { @@ -3083,16 +3122,16 @@ } }, "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", + "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "bin": { @@ -3109,7 +3148,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index 26e664e..1dbff1b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "preview": "vite preview", "clean": "node scripts/clean.mjs", "test": "vitest run", - "lint": "eslint src scripts vite.config.ts vite.config.test.ts", + "test:coverage": "vitest run --coverage", + "lint": "eslint src scripts vite.config.ts vite.config.test.ts vitest.config.ts", "typecheck": "tsc -b", "audit": "node scripts/audit.mjs", "verify": "npm run lint && npm run typecheck && npm run test && npm run build && npm run audit" @@ -31,15 +32,16 @@ }, "devDependencies": { "@types/node": "^24.13.3", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.4", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", - "globals": "^17.8.0", + "globals": "^17.9.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.65.0", - "vite": "^8.1.5", + "typescript-eslint": "^8.66.0", + "vite": "^8.2.0", "vitest": "^4.1.10" } } diff --git a/public/_headers b/public/_headers index e77e27f..8cb1e7b 100644 --- a/public/_headers +++ b/public/_headers @@ -3,4 +3,8 @@ Referrer-Policy: no-referrer X-Content-Type-Options: nosniff X-Frame-Options: DENY - Permissions-Policy: camera=(), geolocation=(), microphone=() + X-XSS-Protection: 0 + Permissions-Policy: camera=(), geolocation=(), microphone=(), payment=(), usb=() + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Resource-Policy: same-origin + Strict-Transport-Security: max-age=31536000; includeSubDomains; preload diff --git a/src/modules/attendance/AttendancePage.tsx b/src/modules/attendance/AttendancePage.tsx index d819d92..4515a98 100644 --- a/src/modules/attendance/AttendancePage.tsx +++ b/src/modules/attendance/AttendancePage.tsx @@ -24,7 +24,11 @@ import type { TaskSubjectLink, UnitBlock } from "../../shared/db/types"; -import { normalizeAttendanceNote, resolveAttendanceNoteForSave } from "../../shared/attendance/attendance"; +import { + matchesAttendanceScope, + normalizeAttendanceNote, + resolveAttendanceNoteForSave +} from "../../shared/attendance/attendance"; import { matchesTaskScope } from "../../shared/gradebook/calculations"; import { useStudentDisplay } from "../../shared/hooks/useStudentDisplay"; import { toLocalIsoDate } from "../../shared/utils/date"; @@ -392,18 +396,22 @@ export function AttendancePage({ mode }: AttendancePageProps) { setCalendarMonth(new Date(year, month - 1, 1)); }, [selectedDate]); - const loadData = async () => { + const loadData = async (isCurrent: () => boolean = () => true) => { if (!selectedSubjectSlot) { - setStudents([]); - setAttendanceEntries([]); + if (isCurrent()) { + setStudents([]); + setAttendanceEntries([]); + } return; } const links = await db.subjectStudentLinks.where("subjectId").equals(selectedSubjectSlot.subjectId).toArray(); const studentIds = links.map((link) => link.studentId); if (studentIds.length === 0) { - setStudents([]); - setAttendanceEntries([]); + if (isCurrent()) { + setStudents([]); + setAttendanceEntries([]); + } return; } @@ -411,15 +419,16 @@ export function AttendancePage({ mode }: AttendancePageProps) { db.students.where("id").anyOf(studentIds).toArray(), db.attendanceEntries.where("studentId").anyOf(studentIds).toArray() ]); + if (!isCurrent()) return; setStudents(studentsData.filter((student) => student.classId === selectedSubjectSlot.classId).sort(compareFn)); setAttendanceEntries( - attendanceData.filter( - (entry) => - entry.classId === selectedSubjectSlot.classId && - entry.date === selectedDate && - entry.scheduleSlotId === selectedSubjectSlot.slotId - ) + attendanceData.filter((entry) => matchesAttendanceScope(entry, { + classId: selectedSubjectSlot.classId, + subjectId: selectedSubjectSlot.subjectId, + date: selectedDate, + scheduleSlotId: selectedSubjectSlot.slotId + })) ); }; const loadDataForEffect = useEffectEvent(loadData); @@ -541,7 +550,15 @@ export function AttendancePage({ mode }: AttendancePageProps) { }, [compareFn]); useEffect(() => { - void loadDataForEffect(); + let active = true; + void loadDataForEffect(() => active).catch((error: unknown) => { + if (!active) return; + const message = error instanceof Error ? error.message : "Error desconocido"; + setAttendanceNotice(`No se pudo cargar la asistencia: ${message}`); + }); + return () => { + active = false; + }; }, [selectedDate, selectedSubjectSlot?.slotId, selectedSubjectSlot?.subjectId]); useEffect(() => { diff --git a/src/modules/management/TeacherOnboarding.tsx b/src/modules/management/TeacherOnboarding.tsx index 2a836be..baaab2e 100644 --- a/src/modules/management/TeacherOnboarding.tsx +++ b/src/modules/management/TeacherOnboarding.tsx @@ -33,12 +33,16 @@ export function TeacherOnboarding({ items, isReady }: TeacherOnboardingProps) { const previousCompletedCountRef = useRef(0); const completedCount = items.filter((item) => item.complete).length; + const remainingCount = items.length - completedCount; const isComplete = completedCount === items.length; const completionPercent = Math.round((completedCount / items.length) * 100); const currentStep = useMemo( () => findCurrentOnboardingStep(items, persistedState?.currentStepId), [items, persistedState?.currentStepId] ); + const currentStepNumber = currentStep + ? items.findIndex((item) => item.id === currentStep.id) + 1 + : 1; const persist = (state: OnboardingState): void => { setPersistedState(state); @@ -197,93 +201,151 @@ export function TeacherOnboarding({ items, isReady }: TeacherOnboardingProps) { className={`onboarding-coach${compact ? " compact" : ""}`} aria-labelledby="onboarding-coach-title" > -
-
- Puesta a punto -

- {compact ? "Termina de preparar tu espacio" : "Tu primera clase, paso a paso"} -

-
- -
- -
-
- -
- {completionPercent}% -
- -

- {completedCount} de {items.length} pasos de preparación completados. -

- - {!compact && currentStep ? ( + {compact ? ( <> -
- - {items.findIndex((item) => item.id === currentStep.id) + 1} - +
- Siguiente paso - {currentStep.label} -

{currentStep.description}

+ Configuración inicial +

+ {currentStep + ? `${completedCount === 0 ? "Empieza por" : "Continúa con"} ${currentStep.shortLabel.toLocaleLowerCase("es")}` + : "Revisa los pasos pendientes"} +

+

+ {remainingCount} {remainingCount === 1 ? "paso pendiente" : "pasos pendientes"} + . +

+
+
+ +
+
+ Tu avance + + {completedCount} de {items.length} + +
+
+
+
+ +
+
-
    - {items.map((item, index) => ( -
  1. - - - {item.shortLabel} - {item.complete ? "Completado" : "Pendiente"} - -
  2. - ))} -
+ +

+ {completedCount} de {items.length} pasos de configuración completados. +

) : ( -

- Quedan {items.length - completedCount} pasos para que Hoy pueda organizar tus - clases y registros. -

- )} + <> +
+
+ Configuración inicial +

Configuración inicial, paso a paso

+
+ + {completedCount} de {items.length} + +
-
- - {!compact ? ( - - ) : null} -
+
+
+ +
+ {completionPercent}% +
+ +

+ {completedCount} de {items.length} pasos de configuración completados. +

+ + {currentStep ? ( + <> +
+ {currentStepNumber} +
+ Siguiente paso + {currentStep.label} +

{currentStep.description}

+
+ +
+
    + {items.map((item, index) => ( +
  1. + + + {item.shortLabel} + {item.complete ? "Completado" : "Pendiente"} + +
  2. + ))} +
+ + ) : null} + +
+ + +
+ + )}
diff --git a/src/modules/today/TodayPage.tsx b/src/modules/today/TodayPage.tsx index 7517673..c786bc9 100644 --- a/src/modules/today/TodayPage.tsx +++ b/src/modules/today/TodayPage.tsx @@ -16,6 +16,7 @@ import type { TaskStudentComment } from "../../shared/db/types"; import { + matchesAttendanceScope, normalizeAttendanceDetails, normalizeAttendanceNote, resolveAttendanceNoteForSave, @@ -339,6 +340,7 @@ export function TodayPage() { }, [dailyClassRecords, selectedDate, selectedSession, selectedSlot]); useEffect(() => { + let active = true; const loadAttendance = async (): Promise => { if (!selectedSlot) { setAttendanceEntries([]); @@ -348,6 +350,7 @@ export function TodayPage() { .where("[classId+date+scheduleSlotId]") .equals([selectedSlot.classId, selectedDate, selectedSlot.slotId]) .toArray(); + if (!active) return; setAttendanceEntries(rows); setStatusDraft(new Map()); setNoteDraft(new Map()); @@ -355,7 +358,14 @@ export function TodayPage() { setNotice(""); }; - void loadAttendance(); + void loadAttendance().catch((error: unknown) => { + if (!active) return; + const message = error instanceof Error ? error.message : "Error desconocido"; + setNotice(`No se pudo cargar la asistencia: ${message}`); + }); + return () => { + active = false; + }; }, [selectedDate, selectedSlot]); useEffect(() => { @@ -438,7 +448,13 @@ export function TodayPage() { try { const now = new Date().toISOString(); const attendanceRows: AttendanceEntry[] = students.map((student) => { - const existing = attendanceByStudent.get(student.id); + const candidate = attendanceByStudent.get(student.id); + const existing = matchesAttendanceScope(candidate, { + classId: selectedSlot.classId, + subjectId: selectedSlot.subjectId, + date: selectedDate, + scheduleSlotId: selectedSlot.slotId + }) ? candidate : undefined; const status = statusDraft.get(student.id) ?? existing?.status ?? "present"; const detailsDraft = attendanceDetailsDraft.get(student.id) ?? { absenceJustified: Boolean(existing?.absenceJustified), diff --git a/src/shared/ai/extensionOverlay.ts b/src/shared/ai/extensionOverlay.ts index e23eaee..1d7b082 100644 --- a/src/shared/ai/extensionOverlay.ts +++ b/src/shared/ai/extensionOverlay.ts @@ -1,12 +1,5 @@ -const EXTENSION_EVENT_TARGET = "ai-runtime-extension"; -const ACTIVATE_EVENT = "ai-runtime-extension:activate"; +import { activateAiExtensionModule } from "./extensionRuntime"; export function enableAiExtensionOverlay(): void { - window.dispatchEvent( - new CustomEvent(ACTIVATE_EVENT, { - detail: { - target: EXTENSION_EVENT_TARGET - } - }) - ); + activateAiExtensionModule(); } diff --git a/src/shared/ai/extensionRuntime.test.ts b/src/shared/ai/extensionRuntime.test.ts new file mode 100644 index 0000000..02a4816 --- /dev/null +++ b/src/shared/ai/extensionRuntime.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { isCompatibleAiExtensionMessage } from "./extensionRuntime"; + +describe("AI extension bridge protocol", () => { + it("accepts only supported, versioned extension envelopes", () => { + expect( + isCompatibleAiExtensionMessage({ + source: "ai-runtime-extension", + protocol: "ai-runtime-extension-bridge", + version: 1, + type: "ai-runtime-available", + extensionId: "abcdefghijklmnopabcdefghijklmnop" + }) + ).toBe(true); + + expect( + isCompatibleAiExtensionMessage({ + source: "untrusted-page", + protocol: "ai-runtime-extension-bridge", + version: 1, + type: "ai-runtime-available", + extensionId: "abcdefghijklmnopabcdefghijklmnop" + }) + ).toBe(false); + expect( + isCompatibleAiExtensionMessage({ + source: "ai-runtime-extension", + protocol: "ai-runtime-extension-bridge", + version: 2, + type: "ai-runtime-response", + extensionId: "abcdefghijklmnopabcdefghijklmnop" + }) + ).toBe(false); + }); +}); diff --git a/src/shared/ai/extensionRuntime.ts b/src/shared/ai/extensionRuntime.ts index 82684a1..f9c38e2 100644 --- a/src/shared/ai/extensionRuntime.ts +++ b/src/shared/ai/extensionRuntime.ts @@ -44,31 +44,35 @@ export type AiPageStatus = { availableProviderCount: number; }; -const EXTENSION_EVENT_TARGET = "ai-runtime-extension"; -const READY_EVENT = "ai-runtime-extension:ready"; -const PING_EVENT = "ai-runtime-extension:ping"; -const ACTIVATE_EVENT = "ai-runtime-extension:activate"; -const PROMPT_EVENT = "ai-runtime-extension:prompt"; -const PROMPT_RESULT_EVENT = "ai-runtime-extension:prompt-result"; -const DEFAULT_TIMEOUT_MS = 180000; +const PAGE_SOURCE = "profeplus"; +const EXTENSION_SOURCE = "ai-runtime-extension"; +const PAGE_PROTOCOL = "ai-runtime-extension-bridge"; +const PAGE_PROTOCOL_VERSION = 1; +const PAGE_PING_MESSAGE = "ai-runtime-ping"; +const PAGE_AVAILABLE_MESSAGE = "ai-runtime-available"; +const PAGE_REQUEST_MESSAGE = "ai-runtime-request"; +const PAGE_RESPONSE_MESSAGE = "ai-runtime-response"; +const DEFAULT_TIMEOUT_MS = 180_000; -function configuredExtensionId(): string { - return import.meta.env.VITE_AI_RUNTIME_EXTENSION_ID?.trim() ?? ""; -} - -type ExtensionReadyDetail = { - target?: string; - extensionId?: string; - version?: string; +type ExtensionAvailabilityMessage = { + source: typeof EXTENSION_SOURCE; + protocol: typeof PAGE_PROTOCOL; + version: typeof PAGE_PROTOCOL_VERSION; + type: typeof PAGE_AVAILABLE_MESSAGE; + extensionId: string; + extensionVersion?: string; }; -type PromptResultDetail = { - target?: string; - extensionId?: string; - ok?: boolean; +type ExtensionResponseMessage = { + source: typeof EXTENSION_SOURCE; + protocol: typeof PAGE_PROTOCOL; + version: typeof PAGE_PROTOCOL_VERSION; + type: typeof PAGE_RESPONSE_MESSAGE; + extensionId: string; requestId?: string; - response?: unknown; - error?: string | null; + ok?: boolean; + result?: unknown; + error?: string | { message?: string } | null; }; type RuntimeChatResponse = { @@ -84,41 +88,49 @@ type RuntimeChatResponse = { }; }; -function createRequestId(): string { - return crypto.randomUUID(); +function configuredExtensionId(): string { + return import.meta.env.VITE_AI_RUNTIME_EXTENSION_ID?.trim() ?? ""; } -function dispatchExtensionEvent(type: string, detail: Record = {}): void { - window.dispatchEvent( - new CustomEvent(type, { - detail: { - target: EXTENSION_EVENT_TARGET, - ...detail - } - }) +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function isCompatibleAiExtensionMessage(value: unknown): boolean { + return ( + isRecord(value) && + value.source === EXTENSION_SOURCE && + value.protocol === PAGE_PROTOCOL && + value.version === PAGE_PROTOCOL_VERSION && + (value.type === PAGE_AVAILABLE_MESSAGE || value.type === PAGE_RESPONSE_MESSAGE) && + typeof value.extensionId === "string" && + value.extensionId.length > 0 ); } -function dispatchPromptEvent(requestId: string, payload: Record): void { - dispatchExtensionEvent(PROMPT_EVENT, { - payload: { - requestId, +function isTrustedWindowMessage(event: MessageEvent): boolean { + return ( + (event.source === window || event.source === null) && + event.origin === window.location.origin && + isCompatibleAiExtensionMessage(event.data) + ); +} + +function postPageMessage(type: string, payload: Record = {}): void { + window.postMessage( + { + source: PAGE_SOURCE, + protocol: PAGE_PROTOCOL, + version: PAGE_PROTOCOL_VERSION, + type, ...payload - } - }); + }, + window.location.origin + ); } -function messageListToPrompt(messages: AiMessage[]): string { - return messages - .map((message) => { - const content = message.content.trim(); - if (!content) { - return ""; - } - return `[${message.role.toUpperCase()}]\n${content}`; - }) - .filter(Boolean) - .join("\n\n"); +function createRequestId(): string { + return crypto.randomUUID(); } function normalizeMessages(messages: AiMessage[]): AiMessage[] { @@ -131,35 +143,33 @@ function normalizeMessages(messages: AiMessage[]): AiMessage[] { } export function activateAiExtensionModule(): void { - dispatchExtensionEvent(ACTIVATE_EVENT); + postPageMessage(PAGE_PING_MESSAGE); } -export function detectAiExtension(timeoutMs = 1200): Promise { +export function detectAiExtension(timeoutMs = 1200): Promise { return new Promise((resolve) => { const expectedExtensionId = configuredExtensionId(); let settled = false; - const finish = (detail: ExtensionReadyDetail | null) => { - if (settled) { - return; - } + const finish = (message: ExtensionAvailabilityMessage | null) => { + if (settled) return; settled = true; - window.removeEventListener(READY_EVENT, handleReady as EventListener); - clearTimeout(timeoutId); - resolve(detail); + window.removeEventListener("message", handleMessage); + window.clearTimeout(timeoutId); + resolve(message); }; - const handleReady = (event: CustomEvent) => { - const detail = event.detail; + const handleMessage = (event: MessageEvent) => { + if (!isTrustedWindowMessage(event)) return; + const message = event.data as ExtensionAvailabilityMessage; if ( - detail?.target === EXTENSION_EVENT_TARGET && - detail.extensionId && - (!expectedExtensionId || detail.extensionId === expectedExtensionId) + message.type === PAGE_AVAILABLE_MESSAGE && + (!expectedExtensionId || message.extensionId === expectedExtensionId) ) { - finish(detail); + finish(message); } }; const timeoutId = window.setTimeout(() => finish(null), timeoutMs); - window.addEventListener(READY_EVENT, handleReady as EventListener); - dispatchExtensionEvent(PING_EVENT, expectedExtensionId ? { extensionId: expectedExtensionId } : {}); + window.addEventListener("message", handleMessage); + postPageMessage(PAGE_PING_MESSAGE); }); } @@ -173,85 +183,77 @@ function extractChatResult(response: unknown): AiChatResult { } return { text: typed.text, - provider: typed.provider ?? "webllm", + provider: typed.provider ?? "unknown", model: typed.model ?? "unknown" }; } +function extensionErrorMessage(error: ExtensionResponseMessage["error"]): string { + if (typeof error === "string" && error) return error; + if (error && typeof error === "object" && typeof error.message === "string") return error.message; + return "AI runtime extension error."; +} + export async function generateAiText(messages: AiMessage[], options: AiChatOptions = {}): Promise { const normalizedMessages = normalizeMessages(messages); - const prompt = messageListToPrompt(normalizedMessages); - if (!prompt) { + if (normalizedMessages.length === 0) { throw new Error("AI prompt is empty."); } const detected = await detectAiExtension(); if (!detected) { - throw new Error("AI Runtime extension is not active on this page. Reload the page after loading the extension."); + throw new Error("AI Runtime extension is not active or does not match the configured extension ID."); } - activateAiExtensionModule(); const requestId = createRequestId(); - const expectedExtensionId = configuredExtensionId(); - - return await new Promise((resolve, reject) => { - const timeoutId = window.setTimeout(() => { - cleanup(); - reject(new Error("Timed out while waiting for the AI runtime extension.")); - }, DEFAULT_TIMEOUT_MS); + const expectedExtensionId = configuredExtensionId() || detected.extensionId; + return new Promise((resolve, reject) => { const cleanup = () => { - clearTimeout(timeoutId); - window.removeEventListener(PROMPT_RESULT_EVENT, handlePromptResult as EventListener); + window.clearTimeout(timeoutId); + window.removeEventListener("message", handleMessage); }; - - const handlePromptResult = (event: CustomEvent) => { - const detail = event.detail; - if (detail?.target !== EXTENSION_EVENT_TARGET || detail.requestId !== requestId) { - return; - } - if ( - (expectedExtensionId && detail.extensionId !== expectedExtensionId) || - (!expectedExtensionId && detail.extensionId && detail.extensionId !== detected.extensionId) - ) { + const handleMessage = (event: MessageEvent) => { + if (!isTrustedWindowMessage(event)) return; + const message = event.data as ExtensionResponseMessage; + if (message.type !== PAGE_RESPONSE_MESSAGE || message.requestId !== requestId) return; + if (message.extensionId !== expectedExtensionId) { cleanup(); reject(new Error("AI runtime extension identity could not be verified.")); return; } cleanup(); - if (!detail.ok) { - try { - if (detail.response) { - extractChatResult(detail.response); - } - } catch (error) { - reject(error); - return; - } - reject(new Error(detail.error || "AI runtime extension error.")); + if (!message.ok) { + reject(new Error(extensionErrorMessage(message.error))); return; } try { - const result = extractChatResult(detail.response); + const result = extractChatResult(message.result); options.onDelta?.(result.text); resolve(result); } catch (error) { reject(error); } }; + const timeoutId = window.setTimeout(() => { + cleanup(); + reject(new Error("Timed out while waiting for the AI runtime extension.")); + }, DEFAULT_TIMEOUT_MS); - window.addEventListener(PROMPT_RESULT_EVENT, handlePromptResult as EventListener); - dispatchPromptEvent(requestId, { - extensionId: expectedExtensionId || detected.extensionId, - prompt, - messages: normalizedMessages, - ...(options.provider ? { provider: options.provider } : {}), - ...(options.model ? { model: options.model } : {}), - ...(options.attachments ? { attachments: options.attachments } : {}), - options: { - ...(options.temperature !== undefined ? { temperature: options.temperature } : {}), - ...(options.maxOutputTokens !== undefined ? { maxOutputTokens: options.maxOutputTokens } : {}), - ...(options.responseFormat ? { responseFormat: options.responseFormat } : {}) + window.addEventListener("message", handleMessage); + postPageMessage(PAGE_REQUEST_MESSAGE, { + requestId, + payload: { + type: "runtime.chat", + messages: normalizedMessages, + ...(options.provider ? { provider: options.provider } : {}), + ...(options.model ? { model: options.model } : {}), + ...(options.attachments ? { attachments: options.attachments } : {}), + options: { + ...(options.temperature !== undefined ? { temperature: options.temperature } : {}), + ...(options.maxOutputTokens !== undefined ? { maxOutputTokens: options.maxOutputTokens } : {}), + ...(options.responseFormat ? { responseFormat: options.responseFormat } : {}) + } } }); }); diff --git a/src/shared/attendance/attendance.test.ts b/src/shared/attendance/attendance.test.ts index bbb5ac0..b2c8bef 100644 --- a/src/shared/attendance/attendance.test.ts +++ b/src/shared/attendance/attendance.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { + matchesAttendanceScope, normalizeAttendanceDetails, normalizeAttendanceNote, resolveAttendanceNoteForSave } from "./attendance"; +import type { AttendanceEntry } from "../db/types"; describe("attendance helpers", () => { it("trims attendance notes and removes empty values", () => { @@ -46,4 +48,31 @@ describe("attendance helpers", () => { }) ).toEqual({ absenceJustified: undefined, lateMinutes: undefined, earlyDepartureMinutes: 720 }); }); + + it("does not reuse an attendance row from a different session scope", () => { + const entry: AttendanceEntry = { + id: "attendance-1", + classId: "class-a", + subjectId: "subject-a", + studentId: "student-a", + date: "2026-08-05", + scheduleSlotId: "slot-a", + status: "present", + createdAt: "2026-08-05T08:00:00.000Z", + updatedAt: "2026-08-05T08:00:00.000Z" + }; + + expect(matchesAttendanceScope(entry, { + classId: "class-a", + subjectId: "subject-a", + date: "2026-08-05", + scheduleSlotId: "slot-a" + })).toBe(true); + expect(matchesAttendanceScope(entry, { + classId: "class-a", + subjectId: "subject-a", + date: "2026-08-06", + scheduleSlotId: "slot-a" + })).toBe(false); + }); }); diff --git a/src/shared/attendance/attendance.ts b/src/shared/attendance/attendance.ts index 958708b..cca7be9 100644 --- a/src/shared/attendance/attendance.ts +++ b/src/shared/attendance/attendance.ts @@ -1,3 +1,22 @@ +import type { AttendanceEntry } from "../db/types"; + +export type AttendanceScope = Pick< + AttendanceEntry, + "classId" | "subjectId" | "date" | "scheduleSlotId" +>; + +export function matchesAttendanceScope( + entry: AttendanceEntry | undefined, + scope: AttendanceScope +): entry is AttendanceEntry { + return ( + entry?.classId === scope.classId && + entry.subjectId === scope.subjectId && + entry.date === scope.date && + entry.scheduleSlotId === scope.scheduleSlotId + ); +} + export function normalizeAttendanceNote(value: string | undefined): string | undefined { const note = value?.trim() ?? ""; return note || undefined; diff --git a/src/shared/handoff/handoff.test.ts b/src/shared/handoff/handoff.test.ts index 61a8abb..13bbf8d 100644 --- a/src/shared/handoff/handoff.test.ts +++ b/src/shared/handoff/handoff.test.ts @@ -133,4 +133,32 @@ describe("student handoff packages", () => { expect(() => parseStudentHandoffPayload(payload)).toThrow(/curso de referencia/); }); + + it("rejects impossible dates and unsupported enum values", () => { + const payload = createStudentHandoffPayload(sourceTables(), ["student-a"], exportedAt); + payload.tables.studentFollowUps[0] = { + ...payload.tables.studentFollowUps[0], + date: "2026-99-99", + kind: "invalid" as "tutorial" + }; + + expect(() => parseStudentHandoffPayload(payload)).toThrow(/datos no válidos/); + }); + + it("rejects records scoped to a different course than their student", () => { + const payload = createStudentHandoffPayload(sourceTables(), ["student-a", "student-b"], exportedAt); + payload.tables.studentFollowUps[0] = { + ...payload.tables.studentFollowUps[0], + classId: "class-b" + }; + + expect(() => parseStudentHandoffPayload(payload)).toThrow(/curso distinto/); + }); + + it("rejects scope metadata that does not match the packaged rows", () => { + const payload = createStudentHandoffPayload(sourceTables(), ["student-a"], exportedAt); + payload.scope.studentIds = ["student-a", "student-b"]; + + expect(() => parseStudentHandoffPayload(payload)).toThrow(/alcance declarado/); + }); }); diff --git a/src/shared/handoff/handoff.ts b/src/shared/handoff/handoff.ts index ac9c068..5a897f9 100644 --- a/src/shared/handoff/handoff.ts +++ b/src/shared/handoff/handoff.ts @@ -67,13 +67,48 @@ function hasOptionalString(record: Record, key: string): boolea } function isIsoDate(value: unknown): boolean { - return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value); + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const [year, month, day] = value.split("-").map(Number); + const parsed = new Date(Date.UTC(year, month - 1, day)); + return ( + parsed.getUTCFullYear() === year && + parsed.getUTCMonth() === month - 1 && + parsed.getUTCDate() === day + ); } function isIsoDateTime(value: unknown): boolean { return typeof value === "string" && Number.isFinite(Date.parse(value)); } +function hasOptionalIsoDate(record: Record, key: string): boolean { + return record[key] === undefined || isIsoDate(record[key]); +} + +function hasOptionalIsoDateTime(record: Record, key: string): boolean { + return record[key] === undefined || isIsoDateTime(record[key]); +} + +function hasOptionalBoolean(record: Record, key: string): boolean { + return record[key] === undefined || typeof record[key] === "boolean"; +} + +function hasAllowedValue( + record: Record, + key: string, + allowedValues: readonly string[] +): boolean { + return typeof record[key] === "string" && allowedValues.includes(record[key]); +} + +function hasOptionalAllowedValue( + record: Record, + key: string, + allowedValues: readonly string[] +): boolean { + return record[key] === undefined || hasAllowedValue(record, key, allowedValues); +} + function requireUniqueIds(rows: Array<{ id: string }>, tableName: HandoffTableName): void { const ids = new Set(); for (const row of rows) { @@ -90,7 +125,8 @@ function validateClassGroup(value: unknown): value is ClassGroup { hasString(value, "id") && hasString(value, "name") && hasString(value, "level") && - hasString(value, "schoolYear") + hasString(value, "schoolYear") && + hasOptionalString(value, "comments") ); } @@ -102,7 +138,12 @@ function validateStudent(value: unknown): value is Student { hasString(value, "firstName") && hasString(value, "lastName") && hasString(value, "fullName") && - hasOptionalString(value, "personId") + hasOptionalString(value, "personId") && + hasOptionalString(value, "comments") && + hasOptionalString(value, "photoDataUrl") && + hasOptionalString(value, "email") && + hasOptionalBoolean(value, "hasAcs") && + hasOptionalBoolean(value, "hasReinforcement") ); } @@ -113,12 +154,17 @@ function validateFollowUp(value: unknown): value is StudentFollowUp { hasString(value, "studentId") && hasString(value, "classId") && isIsoDate(value.date) && - hasString(value, "kind") && + hasAllowedValue(value, "kind", ["incident", "family", "tutorial", "agreement", "adaptation", "wellbeing"]) && hasString(value, "title") && hasString(value, "notes") && typeof value.resolved === "boolean" && - hasOptionalString(value, "dueDate") && - hasOptionalString(value, "responsiblePerson") + hasOptionalString(value, "nextStep") && + hasOptionalIsoDate(value, "dueDate") && + hasOptionalString(value, "responsiblePerson") && + hasOptionalAllowedValue(value, "priority", ["low", "normal", "high"]) && + hasOptionalAllowedValue(value, "status", ["open", "inProgress", "done"]) && + hasOptionalIsoDateTime(value, "createdAt") && + hasOptionalIsoDateTime(value, "updatedAt") ); } @@ -129,10 +175,14 @@ function validateFamilyContact(value: unknown): value is FamilyContact { hasString(value, "studentId") && hasString(value, "classId") && isIsoDate(value.date) && - hasString(value, "channel") && + hasAllowedValue(value, "channel", ["phone", "email", "meeting", "message", "other"]) && hasString(value, "contactName") && hasString(value, "relationship") && hasString(value, "summary") && + hasOptionalString(value, "agreements") && + hasOptionalString(value, "nextStep") && + hasOptionalIsoDate(value, "dueDate") && + hasOptionalString(value, "responsiblePerson") && isIsoDateTime(value.createdAt) && isIsoDateTime(value.updatedAt) ); @@ -187,14 +237,28 @@ function validateReferences(tables: HandoffTables): void { if (!studentIds.has(row.studentId) || !classIds.has(row.classId)) { throw new Error("El paquete contiene seguimiento sin alumno o curso de referencia."); } + const student = tables.students.find((candidate) => candidate.id === row.studentId); + if (student?.classId !== row.classId) { + throw new Error("El paquete contiene seguimiento asignado a un curso distinto del alumno."); + } } + const membershipKeys = new Set(); for (const member of tables.supportGroupMembers) { if (!supportGroupIds.has(member.supportGroupId) || !studentIds.has(member.studentId)) { throw new Error("El paquete contiene una pertenencia de apoyo sin referencias válidas."); } + const key = `${member.supportGroupId}\0${member.studentId}`; + if (membershipKeys.has(key)) { + throw new Error("El paquete contiene pertenencias de apoyo duplicadas."); + } + membershipKeys.add(key); } } +function sameStringSet(left: string[], right: string[]): boolean { + return left.length === right.length && new Set(left).size === left.length && left.every((item) => right.includes(item)); +} + export function createStudentHandoffPayload( source: HandoffSource, selectedStudentIds: Iterable, @@ -249,6 +313,13 @@ export function parseStudentHandoffPayload(value: unknown): StudentHandoffPayloa } const rawTables = value.tables; + const tableNames = Object.keys(rawTables); + if ( + tableNames.length !== HANDOFF_TABLE_NAMES.length || + tableNames.some((tableName) => !HANDOFF_TABLE_NAMES.includes(tableName as HandoffTableName)) + ) { + throw new Error("El paquete de relevo no contiene exactamente las tablas esperadas."); + } const validators: Record boolean> = { classGroups: validateClassGroup, students: validateStudent, @@ -269,6 +340,12 @@ export function parseStudentHandoffPayload(value: unknown): StudentHandoffPayloa } validateReferences(tables); + if ( + !sameStringSet(value.scope.studentIds, tables.students.map((student) => student.id)) || + !sameStringSet(value.scope.supportGroupIds, tables.supportGroups.map((group) => group.id)) + ) { + throw new Error("El alcance declarado del paquete no coincide con sus datos."); + } return value as StudentHandoffPayload; } diff --git a/src/shared/navigation/mainNavigation.test.ts b/src/shared/navigation/mainNavigation.test.ts new file mode 100644 index 0000000..5b1c601 --- /dev/null +++ b/src/shared/navigation/mainNavigation.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + findActiveNavigationArea, + matchesNavigationPath, + navigationAreas +} from "./mainNavigation"; + +describe("main navigation", () => { + it.each([ + ["/today", "today"], + ["/planner", "planning"], + ["/management/units", "planning"], + ["/management/tasks", "planning"], + ["/journal/work", "assessment"], + ["/gradebook", "assessment"], + ["/management/periods", "assessment"], + ["/journal/attendance", "follow-up"], + ["/management/tutor", "follow-up"], + ["/reports", "follow-up"], + ["/management/courses", "organization"], + ["/management/students", "organization"], + ["/management/subjects", "organization"], + ["/management/schedule", "organization"] + ])("maps %s to the %s area", (pathname, areaId) => { + expect(findActiveNavigationArea(pathname)?.id).toBe(areaId); + }); + + it("keeps settings and unknown routes outside workflow areas", () => { + expect(findActiveNavigationArea("/config/preferences")).toBeNull(); + expect(findActiveNavigationArea("/missing")).toBeNull(); + }); + + it("matches nested routes without accepting similar route prefixes", () => { + expect(matchesNavigationPath("/reports/print", "/reports")).toBe(true); + expect(matchesNavigationPath("/reports-old", "/reports")).toBe(false); + }); + + it("uses an item in each area as its default destination", () => { + for (const area of navigationAreas) { + expect(area.items.some((item) => item.to === area.to)).toBe(true); + } + }); +}); diff --git a/src/shared/navigation/mainNavigation.ts b/src/shared/navigation/mainNavigation.ts new file mode 100644 index 0000000..c0923fd --- /dev/null +++ b/src/shared/navigation/mainNavigation.ts @@ -0,0 +1,119 @@ +export type NavigationIcon = + | "today" + | "journal" + | "gradebook" + | "courses" + | "students" + | "tutor" + | "subjects" + | "units" + | "tasks" + | "schedule" + | "planner" + | "reports" + | "config"; + +export type NavigationItem = { + to: string; + label: string; + icon: NavigationIcon; +}; + +export type NavigationArea = { + id: "today" | "planning" | "assessment" | "follow-up" | "organization"; + to: string; + label: string; + shortLabel: string; + description: string; + icon: NavigationIcon; + tone: "today" | "planner" | "work" | "attendance" | "organization"; + items: NavigationItem[]; +}; + +export const navigationAreas: NavigationArea[] = [ + { + id: "today", + to: "/today", + label: "Hoy", + shortLabel: "Hoy", + description: "Impartir y registrar las clases del día", + icon: "today", + tone: "today", + items: [{ to: "/today", label: "Jornada", icon: "today" }] + }, + { + id: "planning", + to: "/planner", + label: "Planificar", + shortLabel: "Plan", + description: "Preparar sesiones, unidades y tareas", + icon: "planner", + tone: "planner", + items: [ + { to: "/planner", label: "Semana", icon: "planner" }, + { to: "/management/units", label: "Unidades", icon: "units" }, + { to: "/management/tasks", label: "Tareas", icon: "tasks" } + ] + }, + { + id: "assessment", + to: "/journal/work", + label: "Evaluar", + shortLabel: "Evaluar", + description: "Registrar evidencias y calcular resultados", + icon: "tasks", + tone: "work", + items: [ + { to: "/journal/work", label: "Evaluación", icon: "tasks" }, + { to: "/gradebook", label: "Cuaderno", icon: "gradebook" }, + { to: "/management/periods", label: "Periodos", icon: "gradebook" } + ] + }, + { + id: "follow-up", + to: "/journal/attendance", + label: "Seguimiento", + shortLabel: "Seguir", + description: "Revisar asistencia, tutoría e informes", + icon: "tutor", + tone: "attendance", + items: [ + { to: "/journal/attendance", label: "Asistencia", icon: "journal" }, + { to: "/management/tutor", label: "Tutoría", icon: "tutor" }, + { to: "/reports", label: "Informes", icon: "reports" } + ] + }, + { + id: "organization", + to: "/management/courses", + label: "Organización", + shortLabel: "Gestión", + description: "Gestionar la estructura académica", + icon: "courses", + tone: "organization", + items: [ + { to: "/management/courses", label: "Cursos", icon: "courses" }, + { to: "/management/students", label: "Alumnos", icon: "students" }, + { to: "/management/subjects", label: "Asignaturas", icon: "subjects" }, + { to: "/management/schedule", label: "Horario", icon: "schedule" } + ] + } +]; + +export const settingsNavigationItem: NavigationItem = { + to: "/config", + label: "Configuración", + icon: "config" +}; + +export function matchesNavigationPath(pathname: string, route: string): boolean { + return pathname === route || pathname.startsWith(`${route}/`); +} + +export function findActiveNavigationArea(pathname: string): NavigationArea | null { + return ( + navigationAreas.find((area) => + area.items.some((item) => matchesNavigationPath(pathname, item.to)) + ) ?? null + ); +} diff --git a/src/shared/security/appLock.test.ts b/src/shared/security/appLock.test.ts index e3aa2c4..4d69d78 100644 --- a/src/shared/security/appLock.test.ts +++ b/src/shared/security/appLock.test.ts @@ -30,4 +30,21 @@ describe("app lock", () => { storage.setItem(APP_LOCK_STORAGE_KEY, "{}"); expect(readAppLockConfig(storage)).toBeNull(); }); + + it("rejects stored work factors and timeouts outside the supported policy", async () => { + const config = await createAppLockConfig("teacher passphrase", 15); + const storage = new MemoryStorage(); + + storage.setItem(APP_LOCK_STORAGE_KEY, JSON.stringify({ ...config, iterations: 1 })); + expect(readAppLockConfig(storage)).toBeNull(); + await expect( + verifyAppLockPassphrase({ ...config, iterations: Number.MAX_SAFE_INTEGER }, "teacher passphrase") + ).resolves.toBe(false); + + storage.setItem(APP_LOCK_STORAGE_KEY, JSON.stringify({ ...config, autoLockMinutes: 121 })); + expect(readAppLockConfig(storage)).toBeNull(); + + storage.setItem(APP_LOCK_STORAGE_KEY, JSON.stringify({ ...config, salt: "invalid" })); + expect(readAppLockConfig(storage)).toBeNull(); + }); }); diff --git a/src/shared/security/appLock.ts b/src/shared/security/appLock.ts index bdcbb1e..fbc29b8 100644 --- a/src/shared/security/appLock.ts +++ b/src/shared/security/appLock.ts @@ -3,6 +3,10 @@ export const APP_LOCK_CHANGED_EVENT = "profeplus:app-lock-changed"; export const APP_LOCK_NOW_EVENT = "profeplus:lock-now"; const APP_LOCK_ITERATIONS = 210_000; +const APP_LOCK_SALT_BYTES = 16; +const APP_LOCK_VERIFIER_BYTES = 32; +const MIN_AUTO_LOCK_MINUTES = 1; +const MAX_AUTO_LOCK_MINUTES = 120; export type AppLockConfig = { version: 1; @@ -12,6 +16,28 @@ export type AppLockConfig = { autoLockMinutes: number; }; +function hasEncodedByteLength(value: string, expectedLength: number): boolean { + try { + return base64ToBytes(value).length === expectedLength; + } catch { + return false; + } +} + +function isValidAppLockConfig(value: Partial): value is AppLockConfig { + return ( + value.version === 1 && + typeof value.salt === "string" && + hasEncodedByteLength(value.salt, APP_LOCK_SALT_BYTES) && + typeof value.verifier === "string" && + hasEncodedByteLength(value.verifier, APP_LOCK_VERIFIER_BYTES) && + value.iterations === APP_LOCK_ITERATIONS && + Number.isInteger(value.autoLockMinutes) && + (value.autoLockMinutes ?? 0) >= MIN_AUTO_LOCK_MINUTES && + (value.autoLockMinutes ?? 0) <= MAX_AUTO_LOCK_MINUTES + ); +} + function bytesToBase64(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); @@ -50,7 +76,11 @@ export async function createAppLockConfig( autoLockMinutes: number ): Promise { if (passphrase.length < 8) throw new Error("La clave debe tener al menos 8 caracteres."); - if (!Number.isInteger(autoLockMinutes) || autoLockMinutes < 1 || autoLockMinutes > 120) { + if ( + !Number.isInteger(autoLockMinutes) || + autoLockMinutes < MIN_AUTO_LOCK_MINUTES || + autoLockMinutes > MAX_AUTO_LOCK_MINUTES + ) { throw new Error("El bloqueo automático debe estar entre 1 y 120 minutos."); } const salt = crypto.getRandomValues(new Uint8Array(16)); @@ -67,6 +97,7 @@ export async function verifyAppLockPassphrase( config: AppLockConfig, passphrase: string ): Promise { + if (!isValidAppLockConfig(config)) return false; const candidate = await deriveVerifier(passphrase, base64ToBytes(config.salt), config.iterations); if (candidate.length !== config.verifier.length) return false; let difference = 0; @@ -81,16 +112,7 @@ export function readAppLockConfig(storage: Storage = window.localStorage): AppLo if (!rawValue) return null; try { const value = JSON.parse(rawValue) as Partial; - if ( - value.version !== 1 || - typeof value.salt !== "string" || - typeof value.verifier !== "string" || - typeof value.iterations !== "number" || - typeof value.autoLockMinutes !== "number" - ) { - return null; - } - return value as AppLockConfig; + return isValidAppLockConfig(value) ? value : null; } catch { return null; } diff --git a/src/shared/ui/AppLockGate.tsx b/src/shared/ui/AppLockGate.tsx index 4a4ccff..9c91c84 100644 --- a/src/shared/ui/AppLockGate.tsx +++ b/src/shared/ui/AppLockGate.tsx @@ -7,14 +7,28 @@ import { type AppLockConfig } from "../security/appLock"; +const MAX_FAILED_UNLOCK_ATTEMPTS = 5; +const UNLOCK_COOLDOWN_MS = 30_000; +const UNLOCK_COOLDOWN_STORAGE_KEY = "profeplus_app_lock_retry_after"; + +function readRetryAfter(): number { + const stored = Number(window.sessionStorage.getItem(UNLOCK_COOLDOWN_STORAGE_KEY)); + const now = Date.now(); + return Number.isFinite(stored) && stored > now && stored <= now + UNLOCK_COOLDOWN_MS + ? stored + : 0; +} + export function AppLockGate({ children }: { children: ReactNode }) { const [config, setConfig] = useState(() => readAppLockConfig()); const [locked, setLocked] = useState(() => Boolean(readAppLockConfig())); const [passphrase, setPassphrase] = useState(""); const [notice, setNotice] = useState(""); const [isVerifying, setIsVerifying] = useState(false); + const [retryAfter, setRetryAfter] = useState(readRetryAfter); const activityTimer = useRef(null); const lastActivityAt = useRef(Date.now()); + const failedAttempts = useRef(0); useEffect(() => { const handleConfigChange = () => { @@ -62,14 +76,40 @@ export function AppLockGate({ children }: { children: ReactNode }) { }; }, [config, locked]); + useEffect(() => { + if (retryAfter <= Date.now()) return; + const timeoutId = window.setTimeout(() => { + window.sessionStorage.removeItem(UNLOCK_COOLDOWN_STORAGE_KEY); + setRetryAfter(0); + setNotice(""); + }, retryAfter - Date.now()); + return () => window.clearTimeout(timeoutId); + }, [retryAfter]); + const unlock = async (): Promise => { if (!config) return; + if (retryAfter > Date.now()) { + setNotice("Demasiados intentos fallidos. Espera 30 segundos antes de volver a intentarlo."); + return; + } setIsVerifying(true); try { if (!(await verifyAppLockPassphrase(config, passphrase))) { - setNotice("La clave no es correcta."); + failedAttempts.current += 1; + if (failedAttempts.current >= MAX_FAILED_UNLOCK_ATTEMPTS) { + const nextRetryAfter = Date.now() + UNLOCK_COOLDOWN_MS; + failedAttempts.current = 0; + window.sessionStorage.setItem(UNLOCK_COOLDOWN_STORAGE_KEY, String(nextRetryAfter)); + setRetryAfter(nextRetryAfter); + setNotice("Demasiados intentos fallidos. Espera 30 segundos antes de volver a intentarlo."); + } else { + setNotice("La clave no es correcta."); + } return; } + failedAttempts.current = 0; + window.sessionStorage.removeItem(UNLOCK_COOLDOWN_STORAGE_KEY); + setRetryAfter(0); setPassphrase(""); setNotice(""); setLocked(false); @@ -104,8 +144,12 @@ export function AppLockGate({ children }: { children: ReactNode }) { /> {notice ?

{notice}

: null} - diff --git a/src/shared/ui/NoContextBanner.tsx b/src/shared/ui/NoContextBanner.tsx index ec430b0..3eca098 100644 --- a/src/shared/ui/NoContextBanner.tsx +++ b/src/shared/ui/NoContextBanner.tsx @@ -12,7 +12,11 @@ export function NoContextBanner({ noClass, noSubject }: NoContextBannerProps) { if (!noClass && !noSubject) return null; return ( -
+
); } diff --git a/src/styles.css b/src/styles.css index 79ace81..c255fe7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -170,6 +170,11 @@ a { box-shadow: 0 0 0 2px rgba(196, 107, 27, 0.16); } +.app-navigation { + position: relative; + z-index: 20; +} + .top-tabs { display: flex; gap: 8px; @@ -177,18 +182,23 @@ a { align-items: center; padding: 10px 14px 8px; margin: 14px 14px 0; - border-radius: 14px 14px 0 0; + border-radius: 14px; background: rgba(255, 255, 255, 0.74); border: 1px solid rgba(145, 169, 216, 0.35); - border-bottom: 0; backdrop-filter: blur(6px); } +.top-tabs.has-secondary { + border-bottom: 0; + border-radius: 14px 14px 0 0; +} + .main-module-tabs.section-tabs { border-bottom: 0; padding: 0; gap: 4px; flex: 1 1 auto; + flex-wrap: nowrap; } .right-module-tabs.section-tabs { @@ -296,10 +306,20 @@ a { color: #344462; } +.main-module-tabs .section-tab.compact.featured.organization { + border-color: #9eaed0; + background: linear-gradient(180deg, #fbfcff 0%, #edf1f9 100%); + color: #3e5175; +} + .main-module-tabs .section-tab.compact.featured.reports::before { background: #60708f; } +.main-module-tabs .section-tab.compact.featured.organization::before { + background: #657ba4; +} + .main-module-tabs .section-tab.compact.featured.active { color: #ffffff; box-shadow: 0 10px 22px rgba(24, 76, 153, 0.24); @@ -339,6 +359,11 @@ a { background: linear-gradient(180deg, #71809e 0%, #465675 100%); } +.main-module-tabs .section-tab.compact.featured.organization.active { + border-color: #4a608b; + background: linear-gradient(180deg, #7186ad 0%, #50658e 100%); +} + .main-module-tabs .section-tab.compact span { overflow: hidden; text-overflow: ellipsis; @@ -380,6 +405,85 @@ a { box-shadow: 0 8px 18px rgba(66, 80, 105, 0.22); } +.workflow-subnav { + display: flex; + align-items: center; + gap: 12px; + margin: 0 14px; + padding: 7px 12px 9px; + border: 1px solid rgba(145, 169, 216, 0.35); + border-top: 1px solid rgba(145, 169, 216, 0.22); + border-radius: 0 0 14px 14px; + background: rgba(248, 251, 255, 0.94); + box-shadow: 0 8px 18px rgba(40, 74, 128, 0.05); +} + +.workflow-subnav-label { + color: #617493; + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.09em; + text-transform: uppercase; + white-space: nowrap; +} + +.workflow-subnav-list.section-tabs { + display: flex; + flex: 1 1 auto; + gap: 5px; + min-width: 0; + padding: 0; + border-bottom: 0; +} + +.workflow-subnav-list .section-tab.compact { + display: inline-flex; + min-width: 108px; + min-height: 40px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 12px; + border: 1px solid transparent; + border-radius: 8px; + color: #526987; + font-size: 0.82rem; + font-weight: 750; + text-decoration: none; + white-space: nowrap; +} + +.workflow-subnav-list .section-tab.compact:hover { + border-color: #c8d6e9; + background: #ffffff; + color: #294f7e; +} + +.workflow-subnav-list .section-tab.compact.active { + border-color: #a9bfdf; + background: #ffffff; + color: #1f4b99; + box-shadow: 0 3px 10px rgba(31, 75, 153, 0.09); +} + +.workflow-subnav-list .section-tab.compact svg { + width: 15px; + height: 15px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} + +.main-module-tabs .section-tab.compact:focus-visible, +.right-module-tabs .section-tab.compact:focus-visible, +.workflow-subnav-list .section-tab.compact:focus-visible { + outline: 3px solid rgba(31, 75, 153, 0.28); + outline-offset: 2px; +} + /* Divider between work and management modules */ .top-tabs-divider { width: 1px; @@ -490,6 +594,38 @@ a { opacity: 0.88; } +.no-context-cta:focus-visible { + outline: 3px solid rgba(31, 75, 153, 0.28); + outline-offset: 2px; +} + +.no-context-banner.setup { + border-color: #a9c0df; + background: linear-gradient(180deg, #f5f9ff 0%, #edf4ff 100%); + color: #315478; +} + +.no-context-banner.setup .no-context-icon { + color: #1f4b99; +} + +.no-context-banner.setup .no-context-text strong { + color: #173d6e; +} + +.no-context-banner.setup .no-context-text span { + color: #435b7c; +} + +.no-context-banner.setup .no-context-cta { + background: #1f4b99; +} + +.no-context-banner.setup .no-context-cta:hover { + background: #183f82; + opacity: 1; +} + .attendance-subject-tabs { display: flex; gap: 2px; @@ -2223,34 +2359,35 @@ td { .onboarding-coach { position: relative; display: grid; - gap: 12px; + gap: 14px; margin: 0 0 16px; - padding: 16px; + padding: 18px; overflow: hidden; - border: 1px solid #b9cde8; - border-radius: 10px; - background: - linear-gradient(100deg, rgba(246, 250, 255, 0.98), rgba(255, 252, 242, 0.98)), - #fff; - box-shadow: 0 8px 24px rgba(29, 66, 117, 0.08); + border: 1px solid #c8d8ee; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 5px 18px rgba(29, 66, 117, 0.07); } .onboarding-coach::after { content: ""; position: absolute; - top: -64px; - right: -42px; - width: 170px; - height: 170px; - border: 28px solid rgba(239, 179, 65, 0.09); - border-radius: 50%; + inset: 0 auto auto 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, #1f4b99 0%, #2f7e8d 58%, #d79a32 100%); pointer-events: none; } .onboarding-coach.compact { - grid-template-columns: minmax(220px, 1fr) minmax(160px, 0.8fr) auto; + grid-template-columns: minmax(300px, 1fr) minmax(190px, 280px) auto; align-items: center; - padding-block: 13px; + gap: 22px; + min-height: 84px; + padding: 14px 16px; + background: + linear-gradient(110deg, rgba(245, 249, 255, 0.82), rgba(255, 255, 255, 0) 48%), + #ffffff; } .onboarding-coach-heading { @@ -2263,31 +2400,86 @@ td { } .onboarding-coach-heading h2 { - margin: 2px 0 0; + margin: 3px 0 0; color: #173d6e; - font-size: 1rem; - line-height: 1.25; + font-size: 1.05rem; + line-height: 1.3; } .onboarding-eyebrow { - color: #a35e05; - font-size: 0.68rem; + color: #315b91; + font-size: 0.66rem; font-weight: 900; - letter-spacing: 0.12em; + letter-spacing: 0.1em; text-transform: uppercase; } .onboarding-count { + display: inline-flex; + min-height: 32px; + align-items: center; + padding: 0 10px; + border: 1px solid #b7c9e3; + border-radius: 999px; + background: #f2f6fc; + color: #315478; + font-size: 0.75rem; + font-weight: 850; + white-space: nowrap; +} + +.onboarding-compact-main { + position: relative; + z-index: 1; + display: flex; + min-width: 0; + align-items: center; + gap: 12px; +} + +.onboarding-compact-main > div { + min-width: 0; +} + +.onboarding-compact-main h2 { + margin: 2px 0 3px; + color: #173d6e; + font-size: 1rem; + line-height: 1.25; +} + +.onboarding-compact-copy { + margin: 0; + color: #5b6f8a; + font-size: 0.78rem; + line-height: 1.4; +} + +.onboarding-compact-copy strong { + color: #294f7e; +} + +.onboarding-compact-progress { + position: relative; + z-index: 1; display: grid; - min-width: 48px; - min-height: 44px; - place-items: center; - border: 1px solid #d7b66a; - border-radius: 7px; - background: #fff8df; - color: #744509; - font-size: 0.86rem; - font-weight: 900; + gap: 7px; + min-width: 0; +} + +.onboarding-progress-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: #647894; + font-size: 0.7rem; + font-weight: 750; +} + +.onboarding-progress-meta strong { + color: #294f7e; + font-size: 0.72rem; } .onboarding-progress-line { @@ -2303,10 +2495,10 @@ td { } .onboarding-progress-track { - height: 7px; + height: 6px; overflow: hidden; border-radius: 999px; - background: #dfe8f4; + background: #dfe8f5; } .onboarding-progress-track > span { @@ -2447,10 +2639,25 @@ td { min-height: 44px; } +.onboarding-compact-actions { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.onboarding-compact-actions .btn { + min-width: 104px; + min-height: 44px; +} + .onboarding-text-action { min-height: 44px; - padding: 6px 4px; + padding: 6px 8px; border: 0; + border-radius: 7px; background: transparent; color: #536b8d; font: inherit; @@ -2461,13 +2668,16 @@ td { cursor: pointer; } -.onboarding-compact-copy { - position: relative; - z-index: 1; +.onboarding-text-action:hover { + background: #eef4fc; + color: #294f7e; } -.onboarding-coach.compact .onboarding-coach-actions { - justify-content: flex-end; +.onboarding-text-action:focus-visible, +.onboarding-coach .btn:focus-visible, +.onboarding-step-rail a:focus-visible { + outline: 3px solid rgba(31, 75, 153, 0.24); + outline-offset: 2px; } .modal-actions { @@ -4225,22 +4435,118 @@ td { @media (max-width: 840px) { .top-tabs { + display: grid; + grid-template-columns: minmax(0, 1fr) 48px; + gap: 6px; margin: 0 10px; - padding: 8px 10px; + padding: 8px; } .main-module-tabs.section-tabs { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 3px; + width: auto; + min-width: 0; + overflow: visible; + padding: 0; + } + + .main-module-tabs .section-tab.compact, + .main-module-tabs .section-tab.compact.featured { + min-width: 0; + min-height: 52px; + padding: 6px 2px; + border-radius: 8px; + flex-direction: column; + justify-content: center; + gap: 2px; + font-size: 0.68rem; + line-height: 1.1; + } + + .main-module-tabs .section-tab.compact.featured::before { + inset: auto 7px 3px; + height: 2px; + } + + .main-module-tabs .section-tab.compact svg { + width: 18px; + height: 18px; + } + + .primary-navigation-label { + max-width: 100%; + font-size: 0; + line-height: 1; + } + + .primary-navigation-label::after { + content: attr(data-short-label); + font-size: 0.68rem; + } + + .right-module-tabs.section-tabs { + align-self: stretch; + margin-left: 0; + } + + .right-module-tabs .section-tab.compact { + width: 48px; + min-width: 48px; + min-height: 52px; + justify-content: center; + padding: 6px; + border-radius: 8px; + } + + .right-module-tabs .section-tab.compact span { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .right-module-tabs .section-tab.compact svg { + width: 19px; + height: 19px; + } + + .workflow-subnav { + display: block; + margin: 0 10px; + padding: 7px 8px 8px; + } + + .workflow-subnav-label { + display: none; + } + + .workflow-subnav-list.section-tabs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(74px, 1fr)); + gap: 4px; width: 100%; - flex-wrap: nowrap; - overflow-x: auto; - padding-bottom: 4px; } - .main-module-tabs .section-tab.compact { - flex: 0 0 auto; + .workflow-subnav-list .section-tab.compact { + min-width: 0; + min-height: 44px; + padding: 6px 4px; + font-size: 0.72rem; + } + + .workflow-subnav-list .section-tab.compact svg { + display: none; } .top-tabs-selectors { + grid-column: 1 / -1; width: 100%; margin-left: 0; align-items: stretch; @@ -4409,8 +4715,18 @@ td { } .onboarding-coach.compact { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr) auto; align-items: stretch; + gap: 12px 16px; + } + + .onboarding-compact-progress { + grid-column: 1 / -1; + } + + .onboarding-compact-actions { + grid-column: 2; + grid-row: 1; } .onboarding-current-step { @@ -4426,10 +4742,6 @@ td { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .onboarding-coach.compact .onboarding-coach-actions { - justify-content: flex-start; - } - .onboarding-welcome { grid-template-columns: 1fr; } @@ -4458,6 +4770,26 @@ td { padding: 13px; } + .onboarding-coach.compact { + grid-template-columns: 1fr; + gap: 12px; + } + + .onboarding-compact-main { + align-items: flex-start; + } + + .onboarding-compact-progress, + .onboarding-compact-actions { + grid-column: auto; + grid-row: auto; + } + + .onboarding-compact-actions { + align-items: stretch; + flex-direction: column-reverse; + } + .onboarding-coach-heading { gap: 8px; } @@ -4481,6 +4813,7 @@ td { } .onboarding-coach-actions .btn, + .onboarding-compact-actions .btn, .onboarding-text-action { width: 100%; } diff --git a/tsconfig.node.json b/tsconfig.node.json index b492d8b..940bdc9 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -4,9 +4,10 @@ "skipLibCheck": true, "module": "ESNext", "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, "allowSyntheticDefaultImports": true, "noEmit": true, "types": ["node"] }, - "include": ["vite.config.ts", "vite.config.test.ts"] + "include": ["vite.config.ts", "vite.config.test.ts", "vitest.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9d01516 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import viteConfig from "./vite.config.ts"; + +export default defineConfig((configEnvironment) => { + const baseConfig = typeof viteConfig === "function" ? viteConfig(configEnvironment) : viteConfig; + return mergeConfig(baseConfig, { + test: { + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["src/**/*.{ts,tsx}"], + exclude: ["src/**/*.test.{ts,tsx}", "src/vite-env.d.ts"], + thresholds: { + statements: 17, + branches: 15, + functions: 13, + lines: 18 + } + } + } + }); +}); From c70bbfaaeb46dd172e6aac511a4e888bd6a4148e Mon Sep 17 00:00:00 2001 From: Pablo Gallego Date: Wed, 12 Aug 2026 02:14:45 +0200 Subject: [PATCH 2/2] Update GitHub Actions runtimes --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23f7e7d..3b79f97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: .node-version cache: npm