Repository navigation
feat(docs,backup): export/import endpoints, documentation overhaul, screenshots - #70
Conversation
…haul, screenshots - feat: GET /api/db-backups/export — download SQLite database - feat: POST /api/db-backups/import — upload/restore with validation - feat: GET /api/db-backups/exportAll — full backup as .tar.gz archive - feat: Export/Import/ExportAll buttons in SystemStorageTab UI - feat: Translator Playground contextual mode descriptions - docs: Expanded Playground section with 4 modes (en + pt-BR) - docs: Added Roadmap section with 150+ planned features (en + pt-BR) - docs: Created VM_DEPLOYMENT_GUIDE.md (VM + Docker + nginx + Cloudflare) - docs: Created FEATURES.md with 9 dashboard screenshots - docs: Updated API_REFERENCE.md with backup/export endpoints - docs: Updated USER_GUIDE.md with Export/Import section - docs: Added screenshot gallery to both READMEs - chore: Updated .gitignore to track new docs files
|
@codex review |
|
To use Codex here, create a Codex account and connect to github. |
Summary of ChangesHello @diegosouzapw, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly enhances OmniRoute's data management capabilities by introducing robust database export and import functionalities, including a full backup option. It also brings a major overhaul to the project's documentation, providing detailed deployment guides, a visual features gallery, and an updated roadmap, alongside improvements to the Translator Playground UI for better user experience. Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Changelog
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces significant new functionality with database export/import endpoints and a comprehensive documentation overhaul, including a new VM deployment guide and feature gallery. However, it critically introduces highly sensitive administrative endpoints for database export and import that lack authentication and authorization checks. This allows unauthenticated users to exfiltrate the entire database (including AI provider credentials) and overwrite system data. Additionally, several API endpoints leak internal error messages, there are instances of risky error handling (empty catch blocks), code duplication in the frontend, and potential performance bottlenecks with large file handling. Implementing robust access control and CSRF protection is paramount, and addressing these code quality issues will enhance the security, maintainability, and scalability of the new features.
| * GET /api/db-backups/exportAll | ||
| * Exports the entire database + settings as a ZIP archive | ||
| */ | ||
| export async function GET() { |
There was a problem hiding this comment.
The exportAll endpoint is not protected by any authentication or authorization mechanism. An unauthenticated attacker can download a full backup archive of the system, including settings, combos, and provider metadata. While some fields are masked in this specific export, the lack of access control is a critical vulnerability.
| * Accepts multipart/form-data with a single "file" field containing the .sqlite backup. | ||
| * Validates integrity, schema, and required tables before replacing the active database. | ||
| */ | ||
| export async function POST(request: Request) { |
There was a problem hiding this comment.
This endpoint allows unauthenticated users to upload a SQLite file and overwrite the active application database. This is a critical security flaw that could lead to a complete system takeover, data loss, or unauthorized access to AI providers. Furthermore, the endpoint lacks CSRF protection, which could allow an attacker to trick an authenticated administrator into importing a malicious database.
| * Uses SQLite's native backup API to create a consistent snapshot, | ||
| * then streams it as a downloadable attachment. | ||
| */ | ||
| export async function GET() { |
There was a problem hiding this comment.
This endpoint lacks authentication and authorization checks. Any unauthenticated user can trigger a database export and download the entire application database. The database contains highly sensitive information, including AI provider API keys and access tokens stored in plain text in the provider_connections table. This represents a critical security risk.
| }); | ||
| } catch (error) { | ||
| console.error("[API] Error exporting database:", error); | ||
| return NextResponse.json({ error: error.message }, { status: 500 }); |
There was a problem hiding this comment.
Returning error.message directly to the client can leak sensitive information about the server's internal state or file system. It's a security best practice to log the detailed error on the server and return a generic error message to the client.
| return NextResponse.json({ error: error.message }, { status: 500 }); | |
| return NextResponse.json({ error: "Failed to export database." }, { status: 500 }); |
| }); | ||
| } catch (error) { | ||
| console.error("[API] Error importing database:", error); | ||
| return NextResponse.json({ error: error.message }, { status: 500 }); |
There was a problem hiding this comment.
Returning error.message directly to the client can leak sensitive information about the server's internal state. It's a security best practice to log the detailed error on the server and return a generic error message to the client.
| return NextResponse.json({ error: error.message }, { status: 500 }); | |
| return NextResponse.json({ error: "An error occurred during database import." }, { status: 500 }); |
| const db = getDbInstance(); | ||
| await db.backup(tmpPath); | ||
|
|
||
| const fileBuffer = fs.readFileSync(tmpPath); |
There was a problem hiding this comment.
Using fs.readFileSync loads the entire database file into memory before sending it. For larger databases, this can lead to high memory consumption. Consider using streams for better memory efficiency. You can create a readable stream with fs.createReadStream(tmpPath) and pass it directly to the Response constructor. You'll need to handle the cleanup of the temporary file after the stream has been consumed, which can be done by piping through a TransformStream.
|
|
||
| // Create ZIP using tar (available on all Linux/macOS, and the archiver npm package is not installed) | ||
| // We'll use Node.js built-in zlib to create a simple tar.gz instead | ||
| const { execSync } = require("node:child_process"); |
There was a problem hiding this comment.
| } else { | ||
| setImportStatus({ type: "error", message: data.error || "Import failed" }); | ||
| } | ||
| } catch { |
There was a problem hiding this comment.
| URL.revokeObjectURL(url); | ||
| } catch (err) { | ||
| console.error("Export failed:", err); | ||
| setImportStatus({ type: "error", message: `Export failed: ${(err as Error).message}` }); |
There was a problem hiding this comment.
In the catch block of handleExport, an export-related error updates the importStatus state. This can be confusing. Consider creating a separate exportStatus state or renaming importStatus to something more generic like systemStorageStatus to handle messages for both import and export operations. The same applies to the 'Export All' functionality.
| @@ -0,0 +1,399 @@ | |||
| # OmniRoute — Guia de Deploy em VM com Cloudflare | |||
There was a problem hiding this comment.
There was a problem hiding this comment.
Pull request overview
This PR introduces a comprehensive backup and export/import system for OmniRoute along with extensive documentation improvements and a visual features gallery.
Changes:
- Export/Import API: Three new endpoints (
/api/db-backups/export,/api/db-backups/exportAll,/api/db-backups/import) for database backup, full system export as tar.gz, and database restoration from uploaded .sqlite files - UI Enhancements: Added export/import buttons to the Settings → System & Storage tab with confirmation dialogs and status feedback; added contextual mode descriptions to the Translator Playground
- Documentation Overhaul: New VM deployment guide (399 lines covering VM setup, Docker, nginx, Cloudflare, security), features gallery with screenshots, expanded API reference, updated user guide, and comprehensive roadmap with 210+ planned features
Reviewed changes
Copilot reviewed 11 out of 21 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
src/app/api/db-backups/export/route.ts |
New GET endpoint to download current database as .sqlite file using SQLite's native backup API |
src/app/api/db-backups/exportAll/route.ts |
New GET endpoint to create full backup tar.gz archive with database, settings, combos, providers (no credentials), and API key metadata |
src/app/api/db-backups/import/route.ts |
New POST endpoint to upload and validate .sqlite file, with integrity checks, table validation, pre-import backup creation, and atomic database replacement |
src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx |
Added three buttons (Export Database, Export All, Import Database) with confirmation flow, file input handling, loading states, and status messages |
src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx |
Added MODE_DESCRIPTIONS object with contextual descriptions for each translator playground mode (Playground, Chat Tester, Test Bench, Live Monitor) |
docs/VM_DEPLOYMENT_GUIDE.md |
Complete 399-line deployment guide covering VM provisioning, Docker installation, nginx configuration, Cloudflare setup, SSL certificates, firewall rules, and advanced security |
docs/FEATURES.md |
Visual features gallery with 9 dashboard screenshots and descriptions of Providers, Combos, Analytics, Health, Translator, Settings, CLI Tools, Usage Logs, and Endpoint pages |
docs/USER_GUIDE.md |
Added Database Export/Import section with API examples, validation details, and use cases |
docs/API_REFERENCE.md |
Added Backup & Export/Import section documenting all backup-related endpoints |
README.md & README.pt-BR.md |
Updated with roadmap section (210+ planned features across 6 categories), dashboard preview section with screenshot table, and links to new documentation |
docs/screenshots/07-cli-tools.png |
Binary PNG screenshot file (178 lines of binary data) for CLI Tools dashboard |
.gitignore |
Updated to allow new documentation files and screenshots directory |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } catch (error) { | ||
| console.error("[API] Error exporting database:", error); | ||
| return NextResponse.json({ error: error.message }, { status: 500 }); |
There was a problem hiding this comment.
The error handler accesses error.message without checking if error is an instance of Error. In TypeScript, caught errors have type unknown and should be checked before accessing properties. Consider using a type guard or converting to string safely.
| } catch (error) { | |
| console.error("[API] Error exporting database:", error); | |
| return NextResponse.json({ error: error.message }, { status: 500 }); | |
| } catch (error: unknown) { | |
| console.error("[API] Error exporting database:", error); | |
| const errorMessage = error instanceof Error ? error.message : String(error); | |
| return NextResponse.json({ error: errorMessage }, { status: 500 }); |
| } | ||
|
|
||
| // Copy imported file over current DB | ||
| fs.copyFileSync(tmpPath, SQLITE_FILE!); |
There was a problem hiding this comment.
Missing null check for SQLITE_FILE constant before dereferencing it with the spread operator on line 119. While line 15 checks if SQLITE_FILE exists, the TypeScript compiler still treats it as potentially null. Consider adding a non-null assertion or additional runtime check.
| // Remove main file and WAL sidecars | ||
| const sqliteFilesToReplace = [ | ||
| SQLITE_FILE, | ||
| `${SQLITE_FILE}-wal`, | ||
| `${SQLITE_FILE}-shm`, | ||
| `${SQLITE_FILE}-journal`, | ||
| ]; | ||
| for (const filePath of sqliteFilesToReplace) { | ||
| if (!filePath) continue; | ||
| if (fs.existsSync(filePath)) { | ||
| fs.unlinkSync(filePath); | ||
| } | ||
| } | ||
|
|
||
| // Copy imported file over current DB | ||
| fs.copyFileSync(tmpPath, SQLITE_FILE!); | ||
|
|
||
| // Reopen and verify | ||
| const db = getDbInstance(); | ||
| const connCount = | ||
| (db.prepare("SELECT COUNT(*) as cnt FROM provider_connections").get() as any)?.cnt || 0; | ||
| const nodeCount = | ||
| (db.prepare("SELECT COUNT(*) as cnt FROM provider_nodes").get() as any)?.cnt || 0; | ||
| const comboCount = (db.prepare("SELECT COUNT(*) as cnt FROM combos").get() as any)?.cnt || 0; | ||
| const keyCount = (db.prepare("SELECT COUNT(*) as cnt FROM api_keys").get() as any)?.cnt || 0; | ||
|
|
||
| console.log( | ||
| `[DB] Imported database from upload: ${connCount} connections, ${nodeCount} nodes, ${comboCount} combos, ${keyCount} API keys` | ||
| ); | ||
|
|
There was a problem hiding this comment.
The database import operation directly replaces the live database without any transaction or rollback mechanism. If the import fails after deleting the original file but before copying the new one, the database could be left in an inconsistent state. Consider implementing a more robust atomic replacement strategy, such as: 1) Copy new file to final location with temp name, 2) Rename original to .old, 3) Rename temp to final, 4) Delete .old only after confirming new database works.
| // Remove main file and WAL sidecars | |
| const sqliteFilesToReplace = [ | |
| SQLITE_FILE, | |
| `${SQLITE_FILE}-wal`, | |
| `${SQLITE_FILE}-shm`, | |
| `${SQLITE_FILE}-journal`, | |
| ]; | |
| for (const filePath of sqliteFilesToReplace) { | |
| if (!filePath) continue; | |
| if (fs.existsSync(filePath)) { | |
| fs.unlinkSync(filePath); | |
| } | |
| } | |
| // Copy imported file over current DB | |
| fs.copyFileSync(tmpPath, SQLITE_FILE!); | |
| // Reopen and verify | |
| const db = getDbInstance(); | |
| const connCount = | |
| (db.prepare("SELECT COUNT(*) as cnt FROM provider_connections").get() as any)?.cnt || 0; | |
| const nodeCount = | |
| (db.prepare("SELECT COUNT(*) as cnt FROM provider_nodes").get() as any)?.cnt || 0; | |
| const comboCount = (db.prepare("SELECT COUNT(*) as cnt FROM combos").get() as any)?.cnt || 0; | |
| const keyCount = (db.prepare("SELECT COUNT(*) as cnt FROM api_keys").get() as any)?.cnt || 0; | |
| console.log( | |
| `[DB] Imported database from upload: ${connCount} connections, ${nodeCount} nodes, ${comboCount} combos, ${keyCount} API keys` | |
| ); | |
| const mainFile = SQLITE_FILE!; | |
| const sidecarFiles = [ | |
| `${mainFile}-wal`, | |
| `${mainFile}-shm`, | |
| `${mainFile}-journal`, | |
| ]; | |
| // Copy imported file to a temporary location next to the target DB file | |
| const tempMainFile = `${mainFile}.tmp-import-${Date.now()}-${Math.random() | |
| .toString(16) | |
| .slice(2)}`; | |
| fs.copyFileSync(tmpPath, tempMainFile); | |
| // Prepare atomic replacement: rename existing files to .old, then move temp into place. | |
| const renamedBackups: { original: string; backup: string }[] = []; | |
| const filesToReplace = [mainFile, ...sidecarFiles]; | |
| for (const originalPath of filesToReplace) { | |
| if (!originalPath) continue; | |
| if (!fs.existsSync(originalPath)) continue; | |
| const backupPath = `${originalPath}.old`; | |
| // Remove any stale .old backup to avoid rename failures. | |
| if (fs.existsSync(backupPath)) { | |
| fs.unlinkSync(backupPath); | |
| } | |
| fs.renameSync(originalPath, backupPath); | |
| renamedBackups.push({ original: originalPath, backup: backupPath }); | |
| } | |
| let connCount = 0; | |
| let nodeCount = 0; | |
| let comboCount = 0; | |
| let keyCount = 0; | |
| try { | |
| // Move new DB into final location | |
| fs.renameSync(tempMainFile, mainFile); | |
| // Reopen and verify | |
| const db = getDbInstance(); | |
| connCount = | |
| (db.prepare("SELECT COUNT(*) as cnt FROM provider_connections").get() as any)?.cnt || 0; | |
| nodeCount = | |
| (db.prepare("SELECT COUNT(*) as cnt FROM provider_nodes").get() as any)?.cnt || 0; | |
| comboCount = | |
| (db.prepare("SELECT COUNT(*) as cnt FROM combos").get() as any)?.cnt || 0; | |
| keyCount = | |
| (db.prepare("SELECT COUNT(*) as cnt FROM api_keys").get() as any)?.cnt || 0; | |
| console.log( | |
| `[DB] Imported database from upload: ${connCount} connections, ${nodeCount} nodes, ${comboCount} combos, ${keyCount} API keys` | |
| ); | |
| // Verification succeeded; remove .old backups | |
| for (const { backup } of renamedBackups) { | |
| if (fs.existsSync(backup)) { | |
| try { | |
| fs.unlinkSync(backup); | |
| } catch { | |
| // best effort cleanup | |
| } | |
| } | |
| } | |
| } catch (verificationOrReplaceError) { | |
| console.error("[DB] Error during imported database activation, attempting rollback:", verificationOrReplaceError); | |
| // Best-effort rollback: restore previous DB files from .old backups | |
| try { | |
| resetDbInstance(); | |
| } catch { | |
| // ignore rollback reset errors | |
| } | |
| // Remove any new files that do not have .old backups | |
| for (const originalPath of filesToReplace) { | |
| if (!originalPath) continue; | |
| const backupPath = `${originalPath}.old`; | |
| if (fs.existsSync(originalPath) && !fs.existsSync(backupPath)) { | |
| try { | |
| fs.unlinkSync(originalPath); | |
| } catch { | |
| // best effort | |
| } | |
| } | |
| } | |
| // Restore backups | |
| for (const { original, backup } of renamedBackups) { | |
| if (!fs.existsSync(backup)) continue; | |
| try { | |
| if (fs.existsSync(original)) { | |
| fs.unlinkSync(original); | |
| } | |
| fs.renameSync(backup, original); | |
| } catch { | |
| // best effort; if this fails, we still rethrow the original error | |
| } | |
| } | |
| // Ensure temp file is cleaned up if it was never renamed | |
| if (fs.existsSync(tempMainFile)) { | |
| try { | |
| fs.unlinkSync(tempMainFile); | |
| } catch { | |
| // best effort | |
| } | |
| } | |
| throw verificationOrReplaceError; | |
| } finally { | |
| // In normal success path, tempMainFile has been renamed and should not exist, | |
| // but clean up defensively in case of partial failures before rename. | |
| if (fs.existsSync(tempMainFile)) { | |
| try { | |
| fs.unlinkSync(tempMainFile); | |
| } catch { | |
| // best effort | |
| } | |
| } | |
| } |
| } catch (err) { | ||
| console.error("Export failed:", err); | ||
| setImportStatus({ type: "error", message: `Export failed: ${(err as Error).message}` }); |
There was a problem hiding this comment.
The error status from export operations is incorrectly set using setImportStatus instead of a dedicated exportStatus state. This means export errors will appear in the import status message area, which could confuse users. Consider adding separate state for export status or using a more generic status state.
| setImportStatus({ | ||
| type: "error", | ||
| message: `Full export failed: ${(err as Error).message}`, | ||
| }); |
There was a problem hiding this comment.
The "Export All" button handler also incorrectly sets errors to importStatus instead of a dedicated export status state. This is the same issue as the regular export button. Both export functions (lines 112-140 and 260-285) should use consistent error reporting.
| // Cleanup on error | ||
| try { | ||
| if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); |
There was a problem hiding this comment.
The cleanup code attempts to delete zipPath (line 126) but the actual file created is tarPath (line 102). The variable zipPath is defined on line 23 with a .zip extension but then replaced with .tar.gz extension when creating tarPath. The cleanup should delete tarPath instead of zipPath. This could leave orphaned tar.gz files in the temp directory.
| if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); | |
| if (fs.existsSync(tarPath)) fs.unlinkSync(tarPath); |
| } catch (error) { | ||
| console.error("[API] Error importing database:", error); | ||
| return NextResponse.json({ error: error.message }, { status: 500 }); |
There was a problem hiding this comment.
Inconsistent error variable naming between catch blocks. Line 144 uses error, but line 95 uses e, and both access .message property without type checking. For consistency and type safety, standardize on using error with proper type guards throughout the file.
| } catch (error) { | |
| console.error("[API] Error importing database:", error); | |
| return NextResponse.json({ error: error.message }, { status: 500 }); | |
| } catch (error: unknown) { | |
| const message = error instanceof Error ? error.message : "Unexpected error"; | |
| console.error("[API] Error importing database:", error); | |
| return NextResponse.json({ error: message }, { status: 500 }); |
| DATA_DIR=/app/data | ||
| STORAGE_DRIVER=sqlite | ||
| ENABLE_REQUEST_LOGS=true | ||
| AUTH_COOKIE_SECURE=false |
There was a problem hiding this comment.
AUTH_COOKIE_SECURE=false in this production deployment example causes authentication cookies to be issued without the Secure flag, so browsers may send them over plain HTTP in redirect/downgrade scenarios. An attacker on the same network path could capture these cookies and hijack sessions; in production behind HTTPS you should document that AUTH_COOKIE_SECURE must be enabled so cookies are marked Secure.
| AUTH_COOKIE_SECURE=false | |
| AUTH_COOKIE_SECURE=true # Em produção atrás de HTTPS, mantenha true para cookies com flag Secure |
| ```bash | ||
| docker pull diegosouzapw/omniroute:latest | ||
|
|
||
| docker run -d \ | ||
| --name omniroute \ | ||
| --restart unless-stopped \ | ||
| --env-file /opt/omniroute/.env \ | ||
| -p 20128:20128 \ | ||
| -v omniroute-data:/app/data \ | ||
| diegosouzapw/omniroute:latest | ||
| ``` |
There was a problem hiding this comment.
The deployment instructions use docker pull diegosouzapw/omniroute:latest and run the mutable latest tag in production, which is a supply-chain risk. If the Docker Hub account or tag is compromised, future pulls could run attacker-controlled images with access to your JWT_SECRET, API keys, and encryption keys; pin to an immutable image digest or a specific, reviewed version tag instead.
| ```bash | ||
| docker pull diegosouzapw/omniroute:latest | ||
| docker stop omniroute && docker rm omniroute | ||
| docker run -d --name omniroute --restart unless-stopped \ | ||
| --env-file /opt/omniroute/.env \ | ||
| -p 20128:20128 \ | ||
| -v omniroute-data:/app/data \ | ||
| diegosouzapw/omniroute:latest |
There was a problem hiding this comment.
The upgrade procedure again pulls and runs diegosouzapw/omniroute:latest, so each update depends on a mutable Docker tag and inherits the same supply-chain risk as the initial deployment. A compromised or replaced latest image could silently exfiltrate data or alter behavior on upgrade; recommend documenting a pinned image digest or explicit version tag for production updates.
…HoverCard dependency Kit 0.0.87 (upstream #63/#70) announces each toast once through two persistent live regions, assertive for errors and polite for the rest, so the announcement survives a modal dialog; the toast cards no longer carry role=alert or role=status. The NotificationToast bridge tests now check the visible card (text, variant, position, removal, no duplicates, the Open action) and the announcement in the right region, including that both regions stay exposed while a Modal is open. @radix-ui/react-hover-card joins config/quality/dependency-allowlist.json with its justification, so check:deps and its unit test accept the kit's new runtime dependency.
* chore(dashboard): sync the vendored CI UI Kit to 0.0.87 packages/ui-kit moves from upstream deb522b to 681cdb1 (0.0.87) through scripts/dev/sync-ui-kit.sh. * fix(dashboard): load the complete kit stylesheet and add the kit's new HoverCard dependency Kit 0.0.87 splits the pattern families out of ci-ui.css (upstream #133): ci-ui.css is now the core sheet, and the wizard, onboarding, announcement, integration, overview, request-workbench and Cloud families live in their own sheets. The complete sheet is ci-styles.css, which is what the package exports as ./styles.css. The dashboard uses components from those families (the batch and pool wizards, onboarding, the Cloud agents pages), so globals.css now imports ci-styles.css and the workspace's ./styles.css export points at it, mirroring the upstream package. HoverCard (upstream #123) imports @radix-ui/react-hover-card, which the sync script doesn't add, so it joins the root dependencies, pinned exactly like the other Radix packages. The local recharts patch in PATCHES.md is dropped: upstream #149 shipped the same graphicalItemId fix in reporting-charts.tsx, so the snapshot is byte for byte again. The DESIGN_SYSTEM mirrors are refreshed for the import path: 53 through the structure-safe Google engine, the 13 it could not restore through the LLM backend. * test(dashboard): follow the kit Toaster's live regions and allow the HoverCard dependency Kit 0.0.87 (upstream #63/#70) announces each toast once through two persistent live regions, assertive for errors and polite for the rest, so the announcement survives a modal dialog; the toast cards no longer carry role=alert or role=status. The NotificationToast bridge tests now check the visible card (text, variant, position, removal, no duplicates, the Open action) and the announcement in the right region, including that both regions stay exposed while a Modal is open. @radix-ui/react-hover-card joins config/quality/dependency-allowlist.json with its justification, so check:deps and its unit test accept the kit's new runtime dependency. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Changes
New Features
export,import,exportAllDocumentation
Files Changed