[
  {
    "path": "CONTRIBUTING.md",
    "content": "> Thank you for considering a contribution to **SafeNova**. This document explains how to do it properly so your effort isn't wasted and the review process goes smoothly.\n\n<a id=\"toc\"></a>\n\n## 📚 Table of Contents\n\n-   [🧭 Before you start](#before-you-start)\n-   [🐛 Reporting bugs](#reporting-bugs)\n-   [💡 Suggesting features](#suggesting-features)\n-   [🔀 Submitting a pull request](#submitting-a-pull-request)\n    -   [Setting up the environment](#setup)\n    -   [Branch naming](#branch-naming)\n    -   [Commit messages](#commit-messages)\n    -   [Pull request checklist](#pr-checklist)\n-   [🎨 Code style](#code-style)\n    -   [General rules](#style-general)\n    -   [JavaScript specifics](#style-js)\n    -   [HTML & CSS](#style-html-css)\n-   [🔐 Security contribution rules](#security-rules)\n-   [🚫 What we do NOT accept](#not-accepted)\n\n---\n\n<a id=\"before-you-start\"></a>\n\n## 🧭 Before you start\n\nSafeNova is a security-first project. Before touching anything, spend time understanding how it actually works:\n\n-   Read the full [README](./README.md) — especially the [SafeNova Proactive](./README.md#safenova-proactive), [Encryption](./README.md#encryption), and [How containers work](./README.md#how-containers-work) sections\n-   Understand the [project structure](./README.md#project-structure) — each file has a specific, narrow responsibility\n-   Look at the existing code style before writing a single line\n\n> **The codebase is small and intentional.** There are no dead files, no legacy layers, no placeholder code. If something looks unusual, there is almost always a documented reason for it — read the surrounding comments before assuming it is wrong.\n\n---\n\n<a id=\"reporting-bugs\"></a>\n\n## 🐛 Reporting bugs\n\nUse [GitHub Issues](https://github.com/DosX-dev/SafeNova/issues) to report bugs. Before opening a new issue:\n\n-   Check if the issue already exists\n-   Reproduce the bug on the latest version\n-   Make sure it happens in a supported browser (Chrome 90+, Firefox 90+, Safari 15+, Edge 90+)\n\nA good bug report includes:\n\n| Field           | What to provide                                                               |\n| --------------- | ----------------------------------------------------------------------------- |\n| **Description** | What happened vs. what you expected                                           |\n| **Steps**       | Exact numbered steps to reproduce                                             |\n| **Environment** | Browser name + version, OS, online vs. local                                  |\n| **Logs**        | DevTools console output if relevant — paste as text, not a screenshot         |\n| **Severity**    | Does it cause data loss? Does it affect security? Does it only affect the UI? |\n\n> **If the bug is security-related** (data exposure, bypass of any protection layer, key material leakage), do **not** file a public issue. See [Security contribution rules](#security-rules) below.\n\n---\n\n<a id=\"suggesting-features\"></a>\n\n## 💡 Suggesting features\n\nOpen a [GitHub Issue](https://github.com/DosX-dev/SafeNova/issues) with the `enhancement` label. Describe:\n\n-   **What problem it solves** — not just what it does, but why it matters\n-   **Who benefits** — casual user, power user, security-conscious user?\n-   **Alternatives you considered** — shows you thought it through\n-   **Any security implications** — SafeNova handles encrypted data; new features can introduce new attack surface\n\nFeatures that don't have a clear security story or that add complexity without proportional value will likely be declined. That's not a rejection of effort — it's a design constraint.\n\n---\n\n<a id=\"submitting-a-pull-request\"></a>\n\n## 🔀 Submitting a pull request\n\n<a id=\"setup\"></a>\n\n### Setting up the environment\n\nThere is no build step. The project runs as static files:\n\n```powershell\n# Clone the repo\ngit clone https://github.com/DosX-dev/SafeNova.git\ncd SafeNova\n\n# Start the local server\n.\\.server.ps1\n```\n\nThe server starts on port `7777` (or the next free port) and opens the app in your browser. Edit files directly in `src/` — no bundler, no transpiler, no `npm install`.\n\n<a id=\"branch-naming\"></a>\n\n### Branch naming\n\n| Prefix      | Use for                                      | Example                          |\n| ----------- | -------------------------------------------- | -------------------------------- |\n| `fix/`      | Bug fixes                                    | `fix/export-blob-url`            |\n| `feature/`  | New functionality                            | `feature/keyboard-shortcut-copy` |\n| `refactor/` | Code cleanup with no behavior change         | `refactor/vfs-node-validation`   |\n| `docs/`     | Documentation only                           | `docs/contributing-guide`        |\n| `security/` | Security improvements (discuss in DMs first) | `security/csp-worker-src`        |\n\n<a id=\"commit-messages\"></a>\n\n### Commit messages\n\nKeep them short and imperative:\n\n```\nFix export producing HTML instead of blob data\nAdd keyboard shortcut for container lock\nRefactor VFS orphan detection to O(n) pass\n```\n\nNo issue numbers in the subject line — put those in the PR description instead. No `WIP:` commits in the final branch.\n\n<a id=\"pr-checklist\"></a>\n\n### Pull request checklist\n\nBefore marking the PR as ready for review:\n\n-   [ ] Tested in at least one supported browser\n-   [ ] No `console.log` or debug artifacts left in the code\n-   [ ] No new external dependencies introduced\n-   [ ] Existing behavior is not broken for cases you didn't touch\n-   [ ] If you changed `daemon.js` — read [Security contribution rules](#security-rules) first\n-   [ ] PR description explains **what** changed and **why**, not just **how**\n\n---\n\n<a id=\"code-style\"></a>\n\n## 🎨 Code style\n\n<a id=\"style-general\"></a>\n\n### General rules\n\n-   **Match the style of the file you're editing.** Indentation, spacing, quote style, comment language — all of it. Don't mix styles within a file\n-   **No unnecessary abstractions.** Don't create a helper for something used once. Don't design for hypothetical future requirements\n-   **Comments explain _why_, not _what_.** If the code is obvious, don't comment it. If it isn't obvious, explain the reasoning — not the mechanics\n-   **No dead code.** Don't comment out unused blocks and leave them — delete them\n\n<a id=\"style-js\"></a>\n\n### JavaScript specifics\n\nThe codebase is vanilla ES2020+ JavaScript — no frameworks, no TypeScript. A few conventions to follow:\n\n-   Use `const` for everything that doesn't need reassignment, `let` otherwise. No `var`\n-   Prefer early returns over deep nesting\n-   Async functions use `async/await` — no raw `.then()` chains unless combining with `Promise.allSettled` or similar\n-   String concatenation uses template literals `` `${x}` `` for readability; the concatenation operator `'' + x` is reserved for places where `String()` calls must be avoided for security reasons (see `daemon.js` for context)\n-   `for` loops with index variables for performance-critical paths; `for...of` for readability in non-critical paths\n-   Group related declarations on one line when they are semantically linked:\n    ```js\n    // Good — same logical unit\n    let offset = 0,\n        count = 0,\n        valid = true;\n    ```\n\n<a id=\"style-html-css\"></a>\n\n### HTML & CSS\n\n-   HTML attributes stay on one line unless there are more than ~4 and readability suffers\n-   CSS follows the existing class naming — BEM is not enforced, but names should be descriptive and scoped to their component\n-   No inline styles in HTML except where dynamic values make them unavoidable (e.g. `style=\"left: ${x}px\"`)\n-   No `!important` except where intentional override is the documented purpose (e.g. lockdown veil)\n\n---\n\n<a id=\"security-rules\"></a>\n\n## 🔐 Security contribution rules\n\nSafeNova handles **encrypted data and derived cryptographic keys in a live browser environment**. This makes security changes fundamentally different from normal feature work.\n\n**If your change touches any of the following, open a discussion issue or contact the maintainer before writing code:**\n\n-   `daemon.js` — the Proactive anti-tamper runtime guard\n-   `crypto.js` — AES-256-GCM + Argon2id layer\n-   `state.js` — session key storage and three-source key wrapping\n-   `db.js` — IndexedDB abstraction (container and file record layout)\n-   The Content Security Policy in `index.html`\n-   Any change that relaxes an existing restriction (e.g. whitelisting a new URL scheme, removing a hook)\n\n> **Why the extra step?** Security changes that look correct can introduce subtle regressions. The Proactive guard in particular has carefully documented reasons for every design decision — a change that seems like a simplification may silently remove a specific defense. Discussing first prevents a PR that cannot be merged from wasting your time.\n\n**Responsible disclosure for vulnerabilities:** If you find a security vulnerability (bypass of the Proactive guard, key material leakage, CSP bypass, etc.), please **do not file a public issue**. Contact the maintainer directly through GitHub. You will get credit in the changelog.\n\n---\n\n<a id=\"not-accepted\"></a>\n\n## 🚫 What we do NOT accept\n\nTo save everyone's time — PRs in the following categories will be closed without merge:\n\n| Category                           | Reason                                                                                                  |\n| ---------------------------------- | ------------------------------------------------------------------------------------------------------- |\n| External runtime dependencies      | SafeNova has zero external dependencies by design. Adding `npm` packages is a non-starter               |\n| Framework migrations               | React, Vue, Svelte, etc. — no. The codebase is intentionally framework-free                             |\n| TypeScript conversion              | Not planned.                                                                                            |\n| Weakened security controls         | Any change that removes or relaxes an existing Proactive check, CSP directive, or encryption constraint |\n| UI cosmetic overhauls              | Minor tweaks are fine; wholesale redesigns need prior discussion                                        |\n| Localization / i18n infrastructure | Out of scope for the current version                                                                    |\n\n---\n\nIf you're unsure whether your idea fits — just open an issue and ask. It's faster than writing code that doesn't land.\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023-2026 DosX\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<img src=\"./pics/intro.png\" style=\"display: block; margin: 0 auto; max-width:80%; max-height:80%; border-radius:8px; margin-bottom:16px\">\n\n> ### Try it online: [https://safenova.dosx.su/](https://safenova.dosx.su/)\n\n<a id=\"what-it-is\"></a>\n\n## ❔ What it is\n\nSafeNova is a single-page web app that lets you create encrypted **containers** — isolated vaults where you can organize files in a folder structure, much like a regular desktop file manager. Everything is encrypted client-side before being written to storage. Nothing ever leaves your device.\n\n![](./pics/screenshot.png)\n\nKey properties:\n\n-   **Zero-knowledge** — the app never sees your password or plaintext data\n-   **Offline-first** — works entirely without network access\n-   **No installation** — start the local server and you're running (or use online)\n\n---\n\n## 📚 Table of Contents\n\n-   [❔ What it is](#what-it-is)\n-   [🚀 Getting started](#getting-started)\n    -   [Option A — Use online version](#getting-started-online)\n    -   [Option B — Local server](#getting-started-local)\n-   [📋 Requirements](#requirements)\n-   [⚙️ Features](#features)\n-   [⚔️ SafeNova vs. the Competition](#comparison)\n-   [📁 Project structure](#project-structure)\n-   [🔒 How containers work](#how-containers-work)\n-   [📄 The `.safenova` Container Format](#container-format)\n    -   [Archive sections](#container-format-archive-sections)\n    -   [Design properties](#container-format-design-properties)\n-   [🔐 Encryption](#encryption)\n    -   [Session token security](#session-token-security)\n    -   [Current tab session](#current-tab-session)\n    -   [Stay signed in](#stay-signed-in)\n    -   [Three-source key wrapping](#three-source-key-wrapping)\n    -   [Session payload format](#session-payload-format)\n    -   [Remaining trade-off](#remaining-trade-off)\n-   [🔏 Content Security Policy](#content-security-policy)\n    -   [Meta tag](#csp-meta-tag)\n    -   [Server-level headers](#csp-server-headers)\n-   [🛡️ Cross-Tab Session Protection](#cross-tab-session-protection)\n-   [🛑 Duress Password](#duress-password)\n    -   [How it works](#duress-how-it-works)\n    -   [Why this design](#duress-why-this-design)\n    -   [Technical details](#duress-technical-details)\n-   [🔬 SafeNova Proactive Anti-Tamper](#safenova-proactive-antitamper)\n    -   [Startup sequence](#proactive-startup-sequence)\n        -   [Why native restoration matters](#proactive-native-restoration-advantage)\n    -   [Real-time watchdog](#proactive-watchdog)\n    -   [Watchdog resilience](#proactive-watchdog-resilience)\n    -   [Intentionally excluded from checks](#proactive-excluded-checks)\n    -   [Network request interception](#proactive-network-interception)\n    -   [DOM exfiltration defense](#proactive-dom-exfiltration)\n    -   [Threat response](#proactive-threat-response)\n    -   [Design philosophy](#proactive-design-philosophy)\n        -   [Hook opacity](#proactive-hook-opacity)\n-   [🔍 Container Integrity Scanner](#container-integrity-scanner)\n    -   [Phase 1 — VFS structural checks](#scanner-phase-1)\n    -   [Phase 2 — Database-level checks](#scanner-phase-2)\n-   [⚡ Performance](#performance)\n    -   [Adaptive concurrency](#adaptive-concurrency)\n    -   [Bulk upload](#bulk-upload)\n    -   [ZIP export](#zip-export)\n    -   [Password change](#password-change)\n    -   [Container export](#container-export)\n    -   [Drag-and-drop performance](#drag-drop-performance)\n-   [📱 Mobile Touch Support](#mobile-touch-support)\n    -   [Long-press to drag](#mobile-long-press)\n    -   [Multi-file drag](#mobile-multi-file-drag)\n    -   [Context menu](#mobile-context-menu)\n    -   [Paste at finger position](#mobile-paste-at-finger-position)\n    -   [Overscroll](#mobile-overscroll)\n-   [�️ Security Audit Changelog](#security-audit)\n-   [�🛠️ Contribute](#contribute)\n-   [💬 Community](#community)\n-   [🤝 Thanks to all contributors](#thanks)\n\n---\n\n<a id=\"getting-started\"></a>\n\n## 🚀 Getting started\n\n<a id=\"getting-started-online\"></a>\n\n### Option A — Use online version\n\nSafeNova is hosted on: [https://safenova.dosx.su/](https://safenova.dosx.su/)\n\n<a id=\"getting-started-local\"></a>\n\n### Option B — Local server\n\nA zero-dependency PowerShell server is included:\n\n```powershell\n.\\\\.server.ps1\n```\n\nOr right-click the file → **Run with PowerShell**. It starts an HTTP server on port `7777` (or the next free port) and opens the app in your default browser.\n\nNo external installs needed — it uses the Windows built-in `HttpListener`.\n\n---\n\n<a id=\"requirements\"></a>\n\n## 📋 Requirements\n\n-   A modern browser: **Chrome 90+**, **Firefox 90+**, **Safari 15+**, or **Edge 90+**\n-   Web Crypto API must be available — this requires either **HTTPS** or **`localhost`**\n-   No plugins, no extensions, no backend\n\n---\n\n<a id=\"features\"></a>\n\n## ⚙️ Features\n\n-   **Multiple containers** — each with its own password and independent storage limit (8 GB per container)\n-   **Virtual filesystem** — nested folders, drag-to-reorder icons, customizable folder colors\n-   **File operations** — upload (drag & drop or browse; folder upload with 4× parallel encryption), download, copy, cut, paste, rename, delete\n-   **Built-in viewers** — text editor, image viewer, audio/video player, PDF viewer\n-   **Hardware key support** — optionally use a WebAuthn passkey to strengthen the container salt\n-   **Session memory** — optionally remember your session per tab (ephemeral, recommended) or persistently until manually signed out, using AES-GCM-encrypted session tokens; persistent sessions survive browser restarts\n-   **Cross-tab session protection** — a container can only be actively open in one browser tab at a time; a lightweight lock protocol detects conflicts and offers instant session takeover\n-   **Container import / export** — portable `.safenova` container files; import reads the archive via streaming `File.slice()` without loading the full file into memory, making multi-gigabyte imports possible; export streams data chunk-by-chunk requiring no single contiguous allocation regardless of container size\n-   **Export password guard** — configurable setting (on by default) to require password confirmation before exporting; when disabled, the container key is taken directly from the active session if one is open; if no session is present, a pre-generated encrypted export cache stored in IDB is used — the cache payload is deflate-compressed before encryption, reducing its IDB footprint significantly for containers with many files; the compressed bytes are then wrapped with a per-container HKDF-SHA-256 derived key (AES-256-GCM), making the cache browser-independent; if the cache is absent or stale (file count or sizes changed), the context menu shows a red dot and falls back to a password prompt — after a successful password-prompted export the cache is rebuilt automatically so subsequent exports require no password; the cache is invalidated on password change or settings re-enable\n-   **Quick export button** — dedicated **Export** button in the desktop toolbar provides one-click passwordless export when the export password guard is disabled\n-   **Sort & arrange** — sort icons by name, date, size, or type; drag to custom positions\n-   **Secure container deletion** — before permanent erasure, every encrypted blob is cryptographically pre-shredded: inline files have random bytes XOR-flipped (position and delta are unknown and unlogged); large chunked files have their AES-GCM IV zeroed, making decryption unconditionally impossible and the operation maximally fast; heavy internal blobs (deferred workspace data, export cache, audit log) are explicitly nullified before the record is deleted so that the browser immediately releases persistent storage and the freed space is reflected without waiting for lazy garbage collection\n-   **Duress password** — optional panic password that, when entered anywhere (unlock, change password, export), looks exactly like an incorrect password but silently destroys all encrypted data in the background; see [Duress Password](#duress-password) below\n-   **SafeNova Proactive** — runtime protection module that loads first in `<head>`, captures all security-critical native function references at startup (including `String.prototype.toLowerCase`, `String.prototype.indexOf`, and `String.prototype.slice` for tamper-proof string operations), validates every capture is truly native (pre-capture tampering guard), hooks outbound network APIs (fetch, XHR, sendBeacon, WebSocket, window.open, EventSource, Worker/SharedWorker — including `data:` and same-origin `blob:` workers) and DOM exfiltration vectors (setAttribute, innerHTML/outerHTML, insertAdjacentHTML, document.write, Location navigation, form submit, resource property setters) to block external requests, silently removes dynamically injected external scripts via MutationObserver, blocks `eval` and `new Function()` constructors, guards string callbacks in setTimeout/setInterval, and runs a quadruple-redundant watchdog with timer-ID protection and a dead man's switch heartbeat — if the watchdog is killed, the app auto-locks all containers\n-   **Container integrity scanner** — 28 automated checks (21 VFS structural + 7 database-level) with one-click auto-repair, **Deep Clean** (flattens over-nested folder trees, repairs all metadata), and a backup prompt before any destructive operation; includes file decryption verification that detects corrupted or unreadable blobs (including those silently destroyed by the duress trigger)\n-   **Settings** — three tabs: personalization, statistics, activity logs\n-   **Keyboard shortcuts** — `Delete`, `F2`, `Ctrl+A`, `Ctrl+C/X/V`, `Ctrl+S` (save in editor), `Escape`, `End` (lock container — only when focus is not in a text field)\n-   **Incognito / private-mode detection** — on first visit the app detects if the browser is in private/incognito mode (Chrome, Firefox, Safari) using engine-fingerprint-based checks (no UA sniffing). If detected, a one-time warning explains that IndexedDB is ephemeral in private mode and encrypted containers will be lost when the tab is closed; the user can acknowledge and continue normally, or switch to a regular browser window first\n-   **Mobile-friendly** — long-press to drag icons, rubber-band selection, single/double-tap gestures, paste at finger position, multi-file drag with per-item snap previews\n\n---\n\n<a id=\"comparison\"></a>\n\n## ⚔️ SafeNova vs. the Competition\n\nWe think SafeNova has real strengths worth knowing about — but every tool has its place. Compare for yourself and pick what fits your use case.\n\nLegend: ✅ Advantage / works well &nbsp;·&nbsp; ❌ Disadvantage / not supported &nbsp;·&nbsp; 🟡 Partial / situational\n\n<table>\n<thead>\n<tr>\n<th align=\"left\">Feature</th>\n<th align=\"left\"><a href=\"https://safenova.dosx.su/\">SafeNova</a></th>\n<th align=\"left\"><a href=\"https://www.veracrypt.fr/\">VeraCrypt</a></th>\n<th align=\"left\"><a href=\"https://learn.microsoft.com/windows/security/operating-system-security/data-protection/bitlocker/\">BitLocker</a></th>\n<th align=\"left\"><a href=\"https://cryptomator.org/\">Cryptomator</a></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td align=\"left\"><b>Best suited for</b></td>\n<td align=\"left\">Personal files on shared or managed machines — zero-install, browser-only, no disk traces</td>\n<td align=\"left\">Large encrypted volumes on own hardware; plausible deniability</td>\n<td align=\"left\">IT-managed Windows with full-disk encryption and central key recovery</td>\n<td align=\"left\">Encrypting files before syncing to cloud (Dropbox, Google Drive, OneDrive…)</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Cross-platform</b></td>\n<td align=\"left\">✅ Any browser — Windows, macOS, Linux, Android, iOS</td>\n<td align=\"left\">🟡 Desktop only — Windows, macOS, Linux</td>\n<td align=\"left\">❌ Windows only</td>\n<td align=\"left\">✅ Windows, macOS, Linux, Android, iOS</td>\n</tr>\n<tr>\n<td align=\"left\"><b>No installation</b></td>\n<td align=\"left\">✅ Zero install, runs in the browser</td>\n<td align=\"left\">❌ Requires system installation</td>\n<td align=\"left\">❌ Windows Pro/Enterprise only</td>\n<td align=\"left\">❌ Requires a desktop or mobile app</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Admin / root rights</b></td>\n<td align=\"left\">✅ None required</td>\n<td align=\"left\">❌ Required for mounting</td>\n<td align=\"left\">❌ Required</td>\n<td align=\"left\">🟡 None on Windows/iOS; macOS needs macFUSE; Linux needs FUSE</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Encryption algorithm</b></td>\n<td align=\"left\">✅ AES-256-GCM — authenticated encryption; every ciphertext has an integrity tag</td>\n<td align=\"left\">✅ AES / Twofish / Serpent (configurable)</td>\n<td align=\"left\">🟡 AES-128/256 XTS — no authentication tag</td>\n<td align=\"left\">✅ AES-256-GCM per file</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Key derivation</b></td>\n<td align=\"left\">✅ Argon2id — memory-hard; GPU brute-force is expensive</td>\n<td align=\"left\">🟡 PBKDF2-SHA-512 / Whirlpool — not memory-hard; GPU-crackable</td>\n<td align=\"left\">🟡 TPM-bound; password KDF is comparatively weak</td>\n<td align=\"left\">✅ scrypt — memory-hard; comparable to Argon2id</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Per-item authentication</b></td>\n<td align=\"left\">✅ GCM tag per chunk — tampering always detected</td>\n<td align=\"left\">❌ Block-level only; no per-file MAC</td>\n<td align=\"left\">❌ XTS provides no authentication</td>\n<td align=\"left\">✅ GCM tag per file</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Portable container</b></td>\n<td align=\"left\">✅ Single <code>.safenova</code> file — copy anywhere, open anywhere</td>\n<td align=\"left\">🟡 Single container file, but fixed pre-allocated size</td>\n<td align=\"left\">❌ Tied to the Windows NTFS partition</td>\n<td align=\"left\">🟡 Folder of encrypted files — portable, but not a single archive</td>\n</tr>\n<tr>\n<td align=\"left\"><b>File stealer protection</b></td>\n<td align=\"left\">✅ Encrypted in IDB; never plaintext on disk</td>\n<td align=\"left\">❌ Mounted volume exposes all files to every process</td>\n<td align=\"left\">❌ Once unlocked, all files accessible to all processes</td>\n<td align=\"left\">🟡 Encrypted on disk; plaintext only in the virtual drive while open</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Session / key management</b></td>\n<td align=\"left\">✅ Three-source HKDF wrap key; tab + browser sessions; cross-tab invalidation</td>\n<td align=\"left\">❌ Key in RAM while mounted; no session concept</td>\n<td align=\"left\">❌ TPM-derived at boot; no session control</td>\n<td align=\"left\">❌ Key in memory while open; no session tokens or expiry</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Duress / emergency wipe</b></td>\n<td align=\"left\">✅ Duress password silently destroys the container</td>\n<td align=\"left\">❌ Not supported</td>\n<td align=\"left\">❌ Not supported</td>\n<td align=\"left\">❌ Not supported</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Runtime anti-tamper</b></td>\n<td align=\"left\">✅ SafeNova Proactive — native API restoration, 20+ hooks, quadruple watchdog</td>\n<td align=\"left\">🟡 N/A — native binary; no browser JS attack surface</td>\n<td align=\"left\">🟡 N/A — same</td>\n<td align=\"left\">🟡 N/A — same</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Content Security Policy</b></td>\n<td align=\"left\">✅ Strict CSP (meta tag + server headers); blocks inline scripts and external loads</td>\n<td align=\"left\">🟡 N/A — browser mechanism; not applicable to native apps</td>\n<td align=\"left\">🟡 N/A — same</td>\n<td align=\"left\">🟡 N/A — same</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Integrity scanner</b></td>\n<td align=\"left\">✅ 28 automated checks (VFS + DB); auto-repair; decryption verification</td>\n<td align=\"left\">❌ No built-in scanning</td>\n<td align=\"left\">❌ No per-file integrity</td>\n<td align=\"left\">🟡 Detects corrupt files; no automated repair</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Export / backup</b></td>\n<td align=\"left\">✅ One-click export as <code>.safenova</code> or ZIP</td>\n<td align=\"left\">🟡 Container file is portable but fixed size; no incremental backup</td>\n<td align=\"left\">❌ Cannot export; tied to the Windows volume</td>\n<td align=\"left\">✅ Files sync individually — cloud acts as continuous backup</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Data deletion</b></td>\n<td align=\"left\">✅ Blob shredding + full IDB purge on delete</td>\n<td align=\"left\">🟡 Delete the file; OS journaling may retain fragments</td>\n<td align=\"left\">❌ Decryption leaves files; separate secure-erase needed</td>\n<td align=\"left\">🟡 Delete the vault; journaling applies; cloud may retain versions</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Code auditability</b></td>\n<td align=\"left\">✅ Open source; plain JS; no build pipeline</td>\n<td align=\"left\">✅ Open source; multiple independent audits</td>\n<td align=\"left\">❌ Closed source; no audit possible</td>\n<td align=\"left\">✅ Open source; independent audits conducted</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Performance at scale</b></td>\n<td align=\"left\">🟡 Good for typical files; slower than native for bulk operations</td>\n<td align=\"left\">✅ Native + AES-NI; minimal overhead</td>\n<td align=\"left\">✅ Kernel driver + AES-NI; transparent to the OS</td>\n<td align=\"left\">✅ Native; per-file overhead is minimal; handles large libraries</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Targeted attack protection</b></td>\n<td align=\"left\">🟡 Blocks JS injection; limited against full-OS compromise</td>\n<td align=\"left\">🟡 Anti-forensic; cannot stop OS-level keyloggers</td>\n<td align=\"left\">❌ TPM bus sniffing (Evil Maid) is a known vector</td>\n<td align=\"left\">🟡 No special runtime protection; same OS-level limits</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Storage size</b></td>\n<td align=\"left\">❌ Max 8 GB per container; IDB quota applies; not for large or industrial-scale data</td>\n<td align=\"left\">✅ Disk-only limit; terabyte-scale supported</td>\n<td align=\"left\">✅ Full drive at any capacity</td>\n<td align=\"left\">✅ No built-in limit; disk / cloud quota only</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Hidden volumes</b></td>\n<td align=\"left\">❌ Not supported</td>\n<td align=\"left\">✅ Hidden volumes + hidden OS partition</td>\n<td align=\"left\">❌ Not supported</td>\n<td align=\"left\">❌ Not supported</td>\n</tr>\n<tr>\n<td align=\"left\"><b>OS / filesystem integration</b></td>\n<td align=\"left\">❌ Browser sandbox only; no virtual drive mount</td>\n<td align=\"left\">✅ Mounts as a real drive letter; full shell integration</td>\n<td align=\"left\">✅ Transparent OS encryption; Group Policy; BitLocker To Go</td>\n<td align=\"left\">✅ Mounts as a virtual drive (WebDAV / FUSE)</td>\n</tr>\n<tr>\n<td align=\"left\"><b>Multi-user access</b></td>\n<td align=\"left\">❌ Single user per container</td>\n<td align=\"left\">❌ Single user at a time</td>\n<td align=\"left\">🟡 Multiple recovery keys; enterprise AD deployment</td>\n<td align=\"left\">❌ Single shared password; per-user control requires Cryptomator Hub (separate server)</td>\n</tr>\n</tbody>\n</table>\n\n---\n\n<a id=\"project-structure\"></a>\n\n## 📁 Project structure\n\n```\nSafeNova/\n│\n├── index.html          # Single-page app entry point\n├── favicon.png         # Application icon\n├── .server.ps1         # Local PowerShell dev server (Windows)\n│\n├── css/\n│   └── app.css         # All application styles\n│\n└── js/\n    ├── proactive/\n    │   └── daemon.js          # SafeNova Proactive — anti-tamper runtime integrity guard (loads first of all)\n    ├── libs/\n    │   └── argon2.umd.min.js  # Argon2id WASM/JS implementation (hashwasm)\n    ├── detectors/\n    │   └── incognito.js       # Incognito / private-mode detector — warns on first visit about limitations and risks\n    ├── docmode.js             # Pre-CSS docmode guard (runs before stylesheet loads)\n    ├── initlog.js             # Initialization stage console logger (InitLog)\n    ├── constants.js           # Shared constants (IDB names, limits, chunk size), utilities, icon SVGs, duress hash helpers\n    ├── db.js                  # IDB abstraction — SafeNovaEFS (containers / files / vfs / chunks stores)\n    ├── crypto.js              # AES-256-GCM + Argon2id encryption layer\n    ├── vfs.js                 # In-memory virtual filesystem (nodes, positions, child index)\n    ├── state.js               # App state singleton — key, session encrypt/decrypt, three-source wrap key\n    ├── home.js                # Container management: create, unlock, import, export, change password\n    ├── desktop.js             # Desktop UI: icons, folder windows, drag & drop, integrity scanner\n    ├── fileops.js             # File operations: upload, download, open, copy/paste, rename, delete, ZIP export; export cache management for passwordless export\n    └── main.js                # App boot, event binding, console security warning\n```\n\n---\n\n<a id=\"how-containers-work\"></a>\n\n## 🔒 How containers work\n\n1. **Create** a container with a name and password\n2. **Unlock** the container — Argon2id derives the key from your password\n3. Files you upload are encrypted with AES-256-GCM before being saved to IDB\n4. The virtual filesystem (folder tree + icon positions) is also encrypted and saved separately\n5. **Lock** the container — the derived container key is wiped from memory; if the **Export password guard** setting is disabled, a pre-generated export cache (built when the setting was disabled and kept up to date after every file operation) remains in the database, ready for the next passwordless export\n6. **Delete** the container — first, every encrypted blob is cryptographically pre-shredded (random bytes XOR-flipped for inline files; IV zeroed for large chunked files); then heavy internal blobs (deferred workspace data, export cache, audit log) are nullified to force immediate browser-level storage release; finally all encrypted records, the VFS blob, and the container metadata are permanently deleted from IDB\n\nAll container data is scoped to the current browser and device. Use **Export Container** to back up or transfer to another device.\n\n---\n\n<a id=\"container-format\"></a>\n\n## 📄 The `.safenova` Container Format\n\nExported containers are saved as `.safenova` files. This is a **self-contained structured archive** with a versioned, deterministic layout. It is designed so that no file content or filesystem metadata is ever present in plaintext within the archive.\n\n<a id=\"container-format-archive-sections\"></a>\n\n### Archive sections\n\n| Section                      | Role                                                                                                                                                                                                                                                                                                                                                 |\n| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `container.xml`              | Plaintext container manifest: name, creation timestamp, Argon2id salt, and the AES-GCM verification IV and blob needed to authenticate a password at import. No file names or content appear here                                                                                                                                                    |\n| `meta/0`                     | The IV (initialization vector) used to encrypt the VFS blob                                                                                                                                                                                                                                                                                          |\n| `meta/1`                     | The encrypted VFS blob — the complete folder hierarchy, file names, MIME types, sizes, timestamps, icon positions, and folder colors, all ciphertext                                                                                                                                                                                                 |\n| `meta/2`                     | The IV for the encrypted file manifest                                                                                                                                                                                                                                                                                                               |\n| `meta/3`                     | The encrypted file manifest — a JSON structure mapping each file's internal ID to its byte offset and length within `workspace.bin`, encrypted with the container key. When the export password guard is disabled, this blob is taken directly from the pre-built export cache stored in the container record, avoiding re-encryption at export time |\n| `safenova_efs/workspace.bin` | A single contiguous block of raw ciphertext — the encrypted content of every file, concatenated end-to-end. Without the decryption key, file boundaries and content are indistinguishable                                                                                                                                                            |\n| `meta/activity_logs/0`       | _(Optional)_ The encrypted activity log, included only when the `exportWithLogs` container setting is enabled                                                                                                                                                                                                                                        |\n\n<a id=\"container-format-design-properties\"></a>\n\n### Design properties\n\n#### Zero plaintext leakage\n\nThe only identifiable plaintext in the archive is the container name in `container.xml` and the Argon2id salt. All file names, folder structure, and content are ciphertext.\n\n#### Lazy import\n\nA `.safenova` file can be imported without entering the container password. The archive is parsed via streaming slicing — only the directory and small metadata entries are fully read into memory; the `workspace.bin` payload is handled as a lightweight reference. The encrypted workspace is stored as-is internally and flagged as a deferred workspace. It is expanded into the local database only on first unlock — so import is instantaneous regardless of container size.\n\n#### Self-authenticating\n\nThe salt and verification blob in `container.xml` allow the application to confirm the correctness of a supplied password before touching any file data, preventing unnecessary decryption work.\n\n#### Versioned\n\nThe `version` attribute in the XML manifest distinguishes between format generations, enabling forward-compatible import logic. Currently only version 3 is supported; earlier formats have been retired.\n\n---\n\n<a id=\"encryption\"></a>\n\n## 🔐 Encryption\n\n| Layer            | Algorithm                                              |\n| ---------------- | ------------------------------------------------------ |\n| Key derivation   | Argon2id (19 MB memory, 2 iterations, 1 thread)        |\n| File encryption  | AES-256-GCM (random 96-bit IV per file)                |\n| VFS encryption   | AES-256-GCM (same key, independent IV)                 |\n| Session tokens   | AES-256-GCM, dual-key: per-tab ephemeral or persistent |\n| Browser key wrap | HKDF-SHA-256 from fingerprint + cookie + IDB           |\n| Integrity check  | AES-256-GCM verification blob authenticated on open    |\n| Duress hash      | SHA-256(random 32-byte salt ‖ password), IDB-only      |\n\nEvery file is encrypted individually — each with its own freshly generated IV. The virtual filesystem (folder tree, file names, sizes, positions) is encrypted as a separate blob using the same derived key. The plaintext password is never stored; only the derived key is held in JavaScript memory for the duration of an active session.\n\nFile keys are derived from passwords through **Argon2id** with OWASP-recommended minimum parameters (19 MB memory cost, 2 iterations), providing strong resistance against brute-force and GPU-accelerated attacks.\n\n<a id=\"session-token-security\"></a>\n\n### Session token security\n\nSafeNova uses a **dual-key model** for session storage — an ephemeral per-tab key and a persistent shared key — each scoped to a distinct user intent.\n\n<a id=\"current-tab-session\"></a>\n\n#### Current tab session _(Recommended)_\n\nThe 32-byte Argon2id key material is encrypted with **`snv-sk`** — a per-tab AES-256-GCM key stored in `sessionStorage`. `snv-sk` is itself wrap-encrypted with the same three-source HKDF key as `snv-bsk` before being written to `sessionStorage`. This means:\n\n-   The session blob (`snv-s-{cid}`) lives in `sessionStorage` and is readable only by the exact tab that created it\n-   Closing the tab permanently destroys `snv-sk` — no residue remains in any persistent storage\n-   An attacker with access to `localStorage`, `sessionStorage`, or disk snapshots gains nothing — even a raw `sessionStorage` dump does not expose the decryption key without also possessing the browser fingerprint, the `snv-kc` cookie, and the `SafeNovaKS` IDB record\n\nThis is the recommended option: the session is automatically gone as soon as the tab is closed.\n\n<a id=\"stay-signed-in\"></a>\n\n#### Stay signed in\n\nThe key material is encrypted with **`snv-bsk`** — a shared AES-256-GCM key available to all tabs of the same browser origin.\n\n<a id=\"three-source-key-wrapping\"></a>\n\n#### Three-source key wrapping\n\nBefore `snv-bsk` is written to `localStorage`, it is itself encrypted with a separate _wrap key_ that is derived on-the-fly via **HKDF-SHA-256** from **three independent sources** and **never stored anywhere**:\n\n| #   | Source              | Storage                                       | Purpose                                                                                          |\n| --- | ------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ |\n| 1   | Browser fingerprint | _(computed)_                                  | `origin \\0 userAgent \\0 platform \\0 language \\0 hardwareConcurrency \\0 colorDepth \\0 pixelDepth` |\n| 2   | `snv-kc` cookie     | Cookie jar (`SameSite=Strict`, ~400 days TTL) | 32 random bytes, isolated from localStorage                                                      |\n| 3   | `snv-ki` record     | Separate IDB `SafeNovaKS`                     | 32 random bytes, independent from main `SafeNovaEFS` database                                    |\n\n```\nikm      = fingerprint \\0 cookie_bytes(32) \\0 idb_bytes(32)\nwrap_key = HKDF-SHA-256( ikm, salt=0×32, info=\"snv-browser-wrap-v3\" )\nsnv-bsk (localStorage)   = IV(12) || AES-256-GCM( wrap_key, raw_bsk_bytes )\nsnv-sk  (sessionStorage) = IV(12) || AES-256-GCM( wrap_key, raw_sk_bytes  )\n```\n\nConsequences:\n\n-   Any tab in the **same browser** recomputes the identical fingerprint, reads the same cookie and IDB secret → identical wrap key → can decrypt `snv-bsk` and resume the session seamlessly\n-   An attacker must compromise **all three storage mechanisms** simultaneously to reconstruct the wrap key — `localStorage` alone, a disk image, or a partial export will not suffice:\n    -   Copying `localStorage` without the cookie and `SafeNovaKS` database → wrap key cannot be derived → `snv-bsk` is opaque\n    -   Clearing cookies invalidates the cookie component → sessions become undecryptable\n    -   Deleting or moving the `SafeNovaKS` database invalidates the IDB component → same effect\n-   The fingerprint includes `navigator.userAgent` and `navigator.platform`, binding sessions to the specific browser version and OS. **Browser updates that change the UA string will invalidate existing sessions** — the user re-enters their password once and a new session is established automatically\n-   If any of the three components change (fingerprint shift, cookie clearing, IDB loss), the stored `snv-bsk` can no longer be decrypted; a new key is generated automatically and the user must re-enter the password once — any `snv-sb-{cid}` blobs encrypted with the old key are silently dropped\n-   **Legacy format migration:** `snv-bsk` and `snv-sk` entries written before wrap-encryption was introduced (raw 32-byte keys, no IV prefix) are detected by their exact byte length and silently re-wrapped in the current `IV(12) || AES-GCM` format on first access — no user action required\n-   The session expires after **7 days** (TTL baked into the encrypted payload), or immediately on explicit sign-out\n\n<a id=\"session-payload-format\"></a>\n\n#### Session payload format\n\nBoth scope types use the same blob layout: `IV(12) || AES-256-GCM(scope_key, expiry(8 bytes, uint64 LE) || raw_key(32 bytes))`. The AES-GCM call is authenticated with the container ID as additional data (`snv-session:{cid}`), preventing a blob from one container from being replayed to unlock a different container. Tab-scope sessions use `expiry = Number.MAX_SAFE_INTEGER` (no TTL — the tab's `sessionStorage` is the only lifetime bound); browser-scope sessions carry a hard 7-day expiry.\n\n<a id=\"remaining-trade-off\"></a>\n\n#### Remaining trade-off\n\nAn attacker with live access to the running browser process (e.g. malicious extension, XSS) can still call the same fingerprint function, read the cookie, and query the `SafeNovaKS` IDB to derive the wrap key. The three-source wrapping layer protects against _offline_ credential theft (disk images, direct `localStorage` dumps, partial storage exports), not against in-browser code execution.\n\n---\n\n<a id=\"content-security-policy\"></a>\n\n## 🔒 Content Security Policy\n\n<a id=\"csp-meta-tag\"></a>\n\n### Meta tag (inline)\n\n`index.html` declares a strict per-directive CSP via `<meta http-equiv=\"Content-Security-Policy\">`:\n\n| Directive     | Value                       |\n| ------------- | --------------------------- |\n| `default-src` | `'none'`                    |\n| `script-src`  | `'self' 'wasm-unsafe-eval'` |\n| `style-src`   | `'self' 'unsafe-inline'`    |\n| `img-src`     | `'self' blob: data:`        |\n| `media-src`   | `blob:`                     |\n| `frame-src`   | `blob: about:`              |\n| `font-src`    | `'self'`                    |\n| `connect-src` | `'self'`                    |\n| `worker-src`  | `'self' blob:`              |\n| `base-uri`    | `'self'`                    |\n| `form-action` | `'none'`                    |\n| `object-src`  | `'none'`                    |\n\n`'unsafe-inline'` is absent from `script-src`. There are no inline `<script>` blocks — all JavaScript is loaded as external files via `'self'`. Argon2id WASM compilation is permitted by `'wasm-unsafe-eval'`. `about:` is added to `frame-src` to allow **SafeNova Proactive** to create a temporary hidden iframe at startup for capturing pristine, extension-untampered native references (the iframe is removed from the DOM immediately after capture).\n\n<a id=\"csp-server-headers\"></a>\n\n### Server-level headers (`.server.ps1`)\n\nWhen running via the included PowerShell dev server, every response additionally carries:\n\n| Header                         | Value                                                          |\n| ------------------------------ | -------------------------------------------------------------- |\n| `X-Content-Type-Options`       | `nosniff`                                                      |\n| `X-Frame-Options`              | `DENY`                                                         |\n| `Referrer-Policy`              | `no-referrer`                                                  |\n| `Permissions-Policy`           | `interest-cohort=(), geolocation=(), camera=(), microphone=()` |\n| `Cross-Origin-Opener-Policy`   | `same-origin`                                                  |\n| `Cross-Origin-Embedder-Policy` | `require-corp`                                                 |\n\n`Cross-Origin-Opener-Policy: same-origin` prevents other origins from holding a reference to the app window. `Cross-Origin-Embedder-Policy: require-corp` blocks cross-origin subresource loads that lack explicit CORP headers — irrelevant in practice since all resources are same-origin, but also a prerequisite for enabling `SharedArrayBuffer` if needed in the future.\n\n---\n\n<a id=\"cross-tab-session-protection\"></a>\n\n## 🛡️ Cross-Tab Session Protection\n\nTo prevent a container from being open in two browser tabs simultaneously — which would risk conflicting VFS writes — SafeNova maintains a lightweight **session lock** in `localStorage`.\n\nWhen a container is unlocked, the tab writes a claim entry (`snv-open-{id}`) containing its unique tab identifier and a timestamp. A **heartbeat** refreshes the timestamp every 5 seconds. Any other tab that reads a live claim (timestamp within the 30-second TTL) before opening the same container is shown a conflict dialog offering to take over the session.\n\nOn accepting the takeover, the requesting tab writes a **kick flag** into the claim entry. The original tab listens for `storage` events on this key and immediately locks itself when the flag is detected. On normal tab close, `beforeunload` and `pagehide` remove the claim entry so the container becomes available to other tabs without waiting for the TTL to expire.\n\n---\n\n<a id=\"duress-password\"></a>\n\n## 🛑 Duress Password\n\nThe duress password is a secondary password you can set for any container. It is designed for situations where you are forced to provide your password under coercion.\n\n<a id=\"duress-how-it-works\"></a>\n\n### How it works\n\n1. You set a duress password in **Settings → Danger Zone** (it must differ from your main password)\n2. When the duress password is entered **anywhere** — the unlock screen, the change password dialog, or the export password prompt — the app responds with the standard **\"Incorrect password\"** error, exactly the same as any wrong password\n3. Behind the scenes, every encrypted file blob in the container is silently and irreversibly corrupted\n4. The duress hash and export cache are erased from the database, leaving no trace that a duress password ever existed\n5. Later, when the real password is entered, the container opens normally — the folder tree and file names are intact — but every file is unreadable, indistinguishable from natural storage corruption\n\n<a id=\"duress-why-this-design\"></a>\n\n### Why this design\n\nAn attacker watching over your shoulder sees exactly what they’d see with any wrong password — an error message. There is no special screen, no empty vault, nothing that reveals a duress mechanism exists at all. The destruction is invisible and happens before the “incorrect” error is shown.\n\nBecause the real password still works, you can unlock the container afterward to confirm the damage. The built-in **integrity scanner** will detect that files cannot be decrypted and can clean up the broken entries.\n\n<a id=\"duress-technical-details\"></a>\n\n### Technical details\n\n-   The duress hash is stored in IDB as a salted SHA-256 hash — never exported to `.safenova` files\n-   Corruption method: random bytes are XOR-flipped with a random non-zero value in each encrypted blob (inline files). For large chunked files, the AES-GCM IV stored in the file record is zeroed instead — no chunk data is read, making corruption of large files maximally fast. Position and XOR delta are unknown, unlogged, and unreproducible. Any byte change in AES-GCM ciphertext, or a zeroed IV, causes authentication failure for the entire file\n-   After triggering, the duress hash and export cache are deleted from the container record — no forensic residue\n-   It can be toggled on and off in Settings → Danger Zone\n-   The duress password must be at least 4 characters and must differ from the main password\n\n---\n\n<a id=\"safenova-proactive-antitamper\"></a>\n\n## 🛡️ SafeNova Proactive Anti-Tamper\n\nSafeNova Proactive is a self-contained **anti-tamper runtime integrity guard** that loads **before every other application script**. Its aggressive threat model is Self-XSS and malicious browser extensions (MV2 `document_start` content scripts, cosmetic-filter injections): both classes of attack require modifying the JavaScript runtime environment in a way that can be detected by capturing native references before any attacker code runs. The application refuses to start if the guard is absent or failed to initialize.\n\n> ![](./pics/screenshot_proactive.png) **Silent by design.** Proactive runs entirely in the background with zero user-visible presence during normal operation. No indicators, no UI overlays, no interaction required — just quiet, constant verification of the cryptographic runtime underneath the application. Think of it as an immune system rather than antivirus: always active, completely invisible, and only surfaces when something genuinely suspicious is detected.\n\n<a id=\"proactive-startup-sequence\"></a>\n\n### Startup sequence\n\n1. **Earliest captures** — at the very first line of execution, before any other code runs, a set of core language primitives is captured into private constants that cannot be reassigned from outside: `Object.freeze`, `RegExp.prototype.test`, `Array.prototype.push`, `String.prototype.slice`, `String.prototype.toLowerCase`, and `String.prototype.indexOf`. Immediately after, three **pure operator-level string utilities** are built (`_pureToLower`, `_pureIndexOf`, `_pureSlice`) using only bracket indexing (`s[i]`), `.length`, `+` concatenation, and comparison operators — these have zero prototype method calls and are used for ALL string processing inside the daemon. The original captured references are retained solely for boot-time and per-tick native validation. If any of them were already replaced by a MV2 `document_start` extension, the structural boot check will detect it\n2. **Native restoration via hidden iframe** — a temporary hidden `about:blank` iframe is created whose browsing context has never been touched by extensions (MV2 extensions only target the main window, not dynamically-created child frames). If the DOM primitives needed to create the iframe are verified as native, **50+ security-critical functions** are restored back to their pristine state on the main window from the iframe's untouched copies:\n    - **Core language primitives** — `Object.defineProperty`, `Object.freeze`, `Reflect.apply`, `Reflect.construct`, and other foundational methods — restored first so all subsequent restoration steps use the native version\n    - **Window globals** — `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `Worker`, `SharedWorker`, `MutationObserver`, `URL`, `Blob`, typed arrays, encoding/decoding, timers, `eval`, `Function`, compression streams\n    - **Crypto** — `crypto.getRandomValues` and all 12 `SubtleCrypto` methods are restored individually (`window.crypto` itself is `[Unforgeable]` and cannot be replaced)\n    - **Prototype methods** — XHR, EventTarget, Element, Node, Document, Storage, IDB, Form, Location, Navigator, typed array, and URL methods\n    - **Console** — a tamper-proof console reference is captured from the iframe, immune to main-window replacement by extensions\n    - The iframe is removed from the DOM immediately after; captured JS references survive removal\n3. **Pre-existence check** — if a guard marker already exists on `window`, it means an attacker pre-defined it via `document_start` to fake the guard as active. This taints the boot\n4. **Bootstrap validation** — `Function.prototype.toString` and `Function.prototype.call` are structurally validated (name, arity, and string coercion — using concatenation operators, not function calls) before building the capture registry. All regex tests in the bootstrap use captured `Reflect.apply`, so replacing `RegExp.prototype.test` at runtime cannot make fakes pass\n5. **Structural validation of early captures** — the primitives captured in step 1 are validated by their structural properties (name, arity). This catches naive spoofing that forgets to replicate the original function's metadata\n6. **Capture registry** — all security-critical native references are frozen into a single immutable object using the captured (not live) `Object.freeze`. From this point forward, the guard never calls live globals — only its own captured copies\n7. **Pre-capture validation** — every captured reference is verified as truly native using indexed for-loops (not `for...of`, which depends on `Symbol.iterator`). If any capture is already non-native, the guard is tainted and the app refuses to boot\n8. **Install protective hooks** — all hooks (network, DOM, timer, constructor, code-injection) are wrapped in an opaque forwarder. Calling `toString()` on any hooked function (e.g. `fetch.toString()` in the DevTools console) reveals only the forwarder — the actual security logic is unreachable from outside the closure. Every internal function invocation uses captured `Reflect.apply` instead of `.apply()` or `.call()`, making the entire daemon immune to `Function.prototype.apply` / `Function.prototype.call` replacement. DOM operations (overlay injection, element removal, attribute stripping, descendant scanning) use captured `Node.prototype.appendChild`, `Node.prototype.removeChild`, `Element.prototype.removeAttribute`, and `Element.prototype.querySelectorAll` references — not live prototype methods\n9. Expose three non-configurable window properties:\n    - A frozen boot-status token with a closure-private canary for cross-checks\n    - A verification function that the application calls at startup to confirm the guard is genuine\n    - An emergency lock function that directly wipes all session storage and in-memory state, bypassing the event system\n10. Start the watchdog — **four independent timer mechanisms** running in parallel\n\n<a id=\"proactive-native-restoration-advantage\"></a>\n\n#### Why native restoration matters\n\nThe iframe restoration in step 2 is the single most impactful defense in the entire startup sequence. Without it, any MV2 extension running at `document_start` — or a compromised CDN that injects a `<script>` above the guard — could replace `crypto.subtle.encrypt`, `fetch`, `Reflect.apply`, or any other global **before** the guard even begins to execute. A pure capture-and-validate approach can only detect such pre-load tampering after the fact and refuse to boot, but provides no recovery path.\n\nNative restoration changes the equation: even if an attacker ran code _before_ the guard, the iframe's browsing context provides untouched native references directly from the browser engine. By restoring 50+ critical functions on the main window from these pristine copies, the guard **undoes pre-load replacements** — the attacker's hooks are overwritten _before_ anything is captured. This eliminates roughly **95% of function-replacement attack vectors** that would otherwise succeed against a capture-only design, reducing the viable pre-load attack surface to scenarios where the browser engine itself is compromised (which is outside the threat model of any userland JS defense).\n\n<a id=\"proactive-watchdog\"></a>\n\n### Real-time watchdog\n\nEach tick performs **five independent checks**:\n\n**Hook integrity** — verifies by reference equality that the installed network hooks are still the exact functions placed by the guard. If any hook was removed or swapped by a third party (extensions like Adblock routinely wrap network APIs), the guard **silently re-installs** them without firing an alert — this is expected browser extension behaviour, not a security threat.\n\n**Native function purity** — verifies that the following functions are still fully native using the captured `Function.prototype.toString` reference (immune to meta-spoofing). Any substitution fires an alert:\n\n| Function                                                               | Purpose                                                                                                                                     |\n| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |\n| `crypto.getRandomValues`                                               | IV / key generation                                                                                                                         |\n| `crypto.subtle.{encrypt,decrypt,importKey,exportKey,deriveKey,digest}` | All cryptographic operations                                                                                                                |\n| `IDBFactory.prototype.open`                                            | IDB access                                                                                                                                  |\n| `Storage.prototype.{getItem,setItem,removeItem}`                       | Session key storage                                                                                                                         |\n| `btoa` / `atob`                                                        | Base-64 encode/decode                                                                                                                       |\n| `TextEncoder.prototype.encode` / `TextDecoder.prototype.decode`        | Text serialization / deserialization                                                                                                        |\n| `Uint8Array`, `.prototype.{set,subarray,slice}`                        | Typed array integrity                                                                                                                       |\n| `ArrayBuffer`, `.prototype.slice`                                      | Binary buffer integrity                                                                                                                     |\n| `DataView`                                                             | Binary data views                                                                                                                           |\n| `Blob`                                                                 | File blob construction                                                                                                                      |\n| `URL`, `URL.createObjectURL` / `URL.revokeObjectURL`                   | URL parsing and blob URL lifecycle                                                                                                          |\n| `CompressionStream` / `DecompressionStream` _(if available)_           | Compression pipeline integrity                                                                                                              |\n| `Function.prototype.call` / `.apply`                                   | Meta-method hardening                                                                                                                       |\n| `Reflect.apply`                                                        | Core of the native-check mechanism — must stay native                                                                                       |\n| `EventTarget.prototype.addEventListener` / `.dispatchEvent`            | Event subscription and heartbeat                                                                                                            |\n| `XMLHttpRequest.prototype.send`                                        | XHR send path hardening                                                                                                                     |\n| `RegExp.prototype.test`                                                | Native-check regex — replacing with `()=>true` would bypass checks                                                                          |\n| `Object.freeze`                                                        | Capture registry immutability                                                                                                               |\n| `Array.prototype.push`                                                 | Captured for validation                                                                                                                     |\n| `String.prototype.slice`                                               | Captured for boot-time + per-tick native validation only; actual string slicing uses `_pureSlice` (bracket + concatenation)                 |\n| `String.prototype.toLowerCase`                                         | Captured for boot-time + per-tick native validation only; actual lowercasing uses `_pureToLower` (frozen A-Z→a-z lookup + bracket indexing) |\n| `String.prototype.indexOf`                                             | Captured for boot-time + per-tick native validation only; actual substring search uses `_pureIndexOf` (nested indexed loop)                 |\n| `Element.prototype.getAttribute`                                       | Used by MutationObserver defense layer to read attribute values safely                                                                      |\n| `Element.prototype.removeAttribute`                                    | Used by MO observer and scanner to strip malicious `on*` handlers and external resource attributes                                          |\n| `Element.prototype.querySelectorAll`                                   | Used by MO observer to scan descendant elements of injected nodes                                                                           |\n| `Node.prototype.appendChild`                                           | Used to inject the alert overlay and security veil into the DOM via captured ref                                                            |\n| `Node.prototype.removeChild`                                           | Used by MO scanner to remove injected external `<script>` elements from the DOM                                                             |\n| `Array.prototype[Symbol.iterator]`                                     | Poisoning this would silently skip validation loops                                                                                         |\n\n**Dead man's switch heartbeat** — every tick dispatches a heartbeat event with a **monotonic counter** that increments inside the private closure. The application only accepts events where the counter is strictly greater than the last seen value **and** within a bounded window (guards against injection of extremely large counter values that would permanently desync the heartbeat). An attacker cannot read or predict the counter from outside the closure. If more than 3 seconds pass without a valid heartbeat, the watchdog has been killed and all open containers are **automatically locked** — derived keys are wiped from memory.\n\n**App function integrity** — at window `load`, all critical application functions are wrapped through `_mkProxy` — the same opaque-forwarder pattern used by network and DOM hooks. The original implementations become closure-private; `toString()` on the live property reveals only the thin forwarder body. Proxied references are frozen into a snapshot and compared by identity on every watchdog tick. A Self-XSS attack that replaces any of these is detected on the very next tick and triggers a full threat response. Raw (unwrapped) references to VFS.init and WinManager.closeAll are kept separately for direct invocation inside `_wipeAppState`. Covered functions: `Crypto.encrypt/decrypt/encryptBin/decryptBin/deriveKey/deriveKeyAndRaw` (exfiltration-path), `Crypto.importRawKey` (called externally — replacement would capture raw AES key bytes), `Crypto.checkVerification` (called on every unlock — replacement with `() => true` bypasses authentication), `Crypto.makeVerification`, `App.lockContainer`, `VFS.init`, `WinManager.closeAll`.\n\n**Scope shadowing guard** — the app's encryption module and the browser's built-in `window.Crypto` (WebCrypto API) share the same identifier. The watchdog confirms it is checking the correct object (app module vs. WebCrypto) by probing for app-specific methods, eliminating false positives.\n<a id=\"proactive-watchdog-resilience\"></a>\n\n### Watchdog resilience\n\nThe watchdog cannot be killed by a single call to `clearInterval` or by replacing a single timer API. **Four independent mechanisms** run in parallel:\n\n| Mechanism                     | Interval | Kill vector                                                                                                                                       |\n| ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `setInterval`                 | 50 ms    | `clearInterval` with the correct ID                                                                                                               |\n| Recursive `setTimeout`        | 937 ms   | `clearTimeout` with the correct ID                                                                                                                |\n| `requestAnimationFrame` chain | ~980 ms  | `cancelAnimationFrame` — **guarded: the rAF chain ID is tracked and any call to `cancelAnimationFrame` with that exact ID is silently swallowed** |\n| `MessageChannel` self-ping    | 800 ms   | Replacing `setInterval`/`setTimeout`/`cancelAnimationFrame` has zero effect — `MessageChannel` is a separate browser message-queue mechanism      |\n\nAll timer functions are captured at startup so even if `window.setInterval` is later replaced, the watchdog timers were already started with the native versions.\n\n**Timer ID protection** — `window.clearInterval`, `window.clearTimeout`, and `window.cancelAnimationFrame` are replaced with guarded versions that silently ignore any attempt to cancel or clear watchdog timer, rAF, or active debugger-trap interval IDs. Legitimate application code that calls these functions on its own IDs is unaffected.\n\nWatchdog timer IDs are stored using plain object properties and tested with the `in` operator — not `Set`. This is intentional: `Set.prototype.has` could be replaced via Self-XSS to let an attacker's timer IDs pass through the guard undetected. The `in` operator is a language-level construct — it cannot be overridden from userland JS.\n\n**Visibility-change fast check** — when the tab transitions from background to visible, an immediate full tick runs so an attacker cannot exploit the ~50 ms inter-tick window while the tab was hidden.\n\n<a id=\"proactive-excluded-checks\"></a>\n\n### Intentionally excluded from checks\n\n| API                                          | Reason                                                                                                                                                                                                                                                                                                                                                                                                     |\n| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `console` namespace                          | Overrides are common and benign (DevTools, logging libraries)                                                                                                                                                                                                                                                                                                                                              |\n| `Function.prototype.toString`                | Bootstrap-validated at init time by structural checks (name, arity, string coercion). Live periodic checks cause false positives because extensions (Adblock, Dark Reader) routinely wrap `toString`                                                                                                                                                                                                       |\n| `document.createElement` _(non-iframe tags)_ | Extensions legitimately create elements (including `<script>`) for their content scripts; blocking all tags causes widespread false positives. **`<iframe>` is the only exception — it is blocked post-init (D3, see below).** `<script>` elements with an external `src=` injected dynamically after page load are silently removed from the DOM and logged to the console — no full modal alert is shown |\n| `JSON.stringify` / `JSON.parse`              | DevTools, debugger extensions, and frameworks actively patch these                                                                                                                                                                                                                                                                                                                                         |\n| `Promise` / `Promise.prototype.then`         | Polyfills and extensions wrap these regularly                                                                                                                                                                                                                                                                                                                                                              |\n| `performance.now`                            | Privacy extensions (Brave, uBlock) intentionally add timing jitter                                                                                                                                                                                                                                                                                                                                         |\n| `Object.defineProperty`                      | Too many legitimate uses across extensions and frameworks                                                                                                                                                                                                                                                                                                                                                  |\n\n<a id=\"proactive-network-interception\"></a>\n\n### Network request interception\n\nEvery outbound request is validated against `window.location.origin` before it is allowed to proceed. The origin check uses a **fail-closed** design: URLs that cannot be parsed by the `URL` constructor are treated as external (unsafe by default). `data:` URLs (inline resources such as canvas-generated thumbnails) are whitelisted using pure bracket-indexing comparison (`s[0] === 'd'`) and `_pureSlice` (immune to any prototype poisoning), and browser extension schemes (`chrome-extension:`, `moz-extension:`, `safari-web-extension:`) are allowed to prevent false positives from legitimate user-installed extensions:\n\n-   **`fetch`** — blocked and rejected with an error\n-   **`XMLHttpRequest.prototype.open`** — blocked and throws synchronously\n-   **`navigator.sendBeacon`** — blocked and returns `false`\n-   **`WebSocket`** — connections to any **host:port** combination other than the current origin are blocked and trigger a threat alert. The origin check uses a captured URL constructor (immune to runtime replacement). Same-origin WebSocket connections are forwarded transparently\n-   **`window.open`** — external URLs blocked; same-origin popups forwarded normally\n-   **`EventSource`** — external URLs blocked at construction; an `EventSource` to an external host would establish a persistent covert SSE channel for data exfiltration\n-   **`Worker` / `SharedWorker`** — `data:` URL workers, `blob:` URL workers, and external-URL workers are blocked. SafeNova does not create Workers; a same-origin `blob:` Worker runs in a separate global scope with an unhooked native `fetch`, so even same-origin blob URLs are an exfiltration vector and are rejected unconditionally\n-   **`ServiceWorker` registration** — blocked preventively. A rogue Service Worker could intercept all fetches on the next page load and inject code before the guard runs. Existing registrations are removed during the threat response\n-   **`setTimeout` / `setInterval` string callbacks** — string-form callbacks (e.g. `setTimeout(\"eval(code)\", 0)`) are stripped to a no-op. Function-form callbacks are forwarded normally\n-   **`eval`** — replaced with a non-configurable, non-writable property that throws synchronously; cannot be restored after the guard runs\n-   **`new Function()`** — the `Function` constructor is proxied; calls via `new Function(...)` throw synchronously. Plain `Function()` calls without `new` (used by feature detection, extensions, etc.) are forwarded to the native constructor — only the constructor path is dangerous\n\nSafeNova makes no legitimate external network requests; any attempt is by definition suspicious.\n\n<a id=\"proactive-dom-exfiltration\"></a>\n\n### DOM exfiltration defense\n\nA second protection layer covers DOM-level exfiltration vectors — APIs that can inject external resources or redirect the page without going through the network layer directly.\n\n| API                                      | Behaviour                                                                                                                                                                                                                                                                                                                                                                                                      |\n| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Element.setAttribute`                   | External URLs in resource attributes (`src`, `href`, `data`, `ping`, `action`, `formaction`, `srcset`, `poster`) are blocked and trigger a full alert. Inline `on*` event handler attributes are stripped. Navigation elements (`<a>`, `<area>`) are exempt from the `href` check — those are user-activated navigation, not resource loaders; `ping=` remains blocked                                         |\n| `Element.innerHTML` / `outerHTML` setter | HTML strings are scanned for external resource URLs and `on*` attribute patterns before assignment; blocked if a threat is found                                                                                                                                                                                                                                                                               |\n| `Element.insertAdjacentHTML`             | Same scan as `innerHTML`                                                                                                                                                                                                                                                                                                                                                                                       |\n| `document.write` / `document.writeln`    | Same scan; blocked before the string is written to the document                                                                                                                                                                                                                                                                                                                                                |\n| `Location.assign` / `Location.replace`   | External URLs blocked; same-origin navigation forwarded normally                                                                                                                                                                                                                                                                                                                                               |\n| `Location.href` setter                   | Same as `assign` / `replace`                                                                                                                                                                                                                                                                                                                                                                                   |\n| `HTMLFormElement.submit`                 | External `action=` URLs blocked                                                                                                                                                                                                                                                                                                                                                                                |\n| Resource property setters                | `HTMLImageElement.src`, `HTMLScriptElement.src`, `HTMLIFrameElement.src`, `HTMLVideoElement.src`, `HTMLAudioElement.src`, `HTMLEmbedElement.src`, `HTMLObjectElement.data`, `HTMLLinkElement.href` — external URL assignments are blocked. For `<script>.src` specifically the block is **silent**: the element is not loaded, a console trace is written, but no modal alert is shown (reduces alert fatigue) |\n\n**Post-init iframe block (D3)** — after the startup iframe-restoration phase completes and the temporary iframe is removed from the DOM, `document.createElement('iframe')` is permanently intercepted. Any subsequent attempt to create an `<iframe>` triggers a full threat alert. This closes the _fresh-realm native-reset_ attack vector: an attacker who gains JS execution after daemon.js has run could otherwise create their own `about:blank` iframe, pull native function references from its unhooked context (which pass `_isNative()` checks because they genuinely are native), and silently overwrite all daemon hooks. All other tags pass through unmodified — extensions creating `<script>` or any other element are unaffected. The hook is self-healing: the watchdog re-installs it every tick if it is removed.\n\n**MutationObserver defense-in-depth** — a `MutationObserver` watches the entire document subtree and catches attacks that bypass the property/method hooks above:\n\n-   **Added elements** — newly appended nodes are scanned for external resource attributes or `on*` handlers; threatening attributes are removed and a full alert fires\n-   **Dynamically injected `<script src=\"https://...\">` elements** — silently removed from the DOM; a console trace is written via a tamper-proof console reference (immune to `console.error` replacement after load). Same-origin, relative-path, and inline `<script>` elements are left untouched\n-   **Attribute mutations** — attribute changes on existing elements that add an external resource URL or an `on*` handler are caught and reversed; `href` changes on `<a>` and `<area>` elements are excluded (navigation-only)\n\n---\n\n<a id=\"proactive-threat-response\"></a>\n\n### Threat response\n\nWhen a native function purity check, App function integrity check, or network request interception fires:\n\n1. **Immediately wipe** all session keys from both `localStorage` and `sessionStorage` using captured native storage references (bypasses any hook placed on the Storage API by the attacker)\n2. **Clear Service Workers and Cache API** — unregisters active Service Workers and wipes all browser cache data. This prevents an attacker from spawning a rogue Service Worker for persistence or stashing intercepted data asynchronously\n3. **Directly zero in-memory app state** — all sensitive state (encryption key, container data, clipboard, thumbnail cache) is nullified directly, bypassing the event system. Critical cleanup functions are invoked using **captured references** snapshotted at window `load`, so a console-level replacement before the threat fires cannot prevent cleanup\n4. Dispatch a lock event — the application locks all open containers via the normal code path, clearing derived keys from memory\n5. **Console threat log** — a styled `console.error` with a red background is emitted via a tamper-proof console reference, providing a forensic trace that cannot be suppressed\n6. **Debugger trap** — a `debugger` statement fires every 50 ms for up to 5 minutes. If the attacker has DevTools open, the JS engine pauses at each breakpoint, blocking further console commands. When DevTools are closed, this is a native no-op with zero performance cost\n7. Show a **security alert overlay** identifying the blocked operation and advising the user to audit browser extensions, with a reload button. The overlay uses a **closed Shadow DOM** so its contents cannot be queried or mutated from `document` scope; a self-healing `MutationObserver` re-appends the overlay if an attacker removes it from the DOM (active for 3 minutes)\n\nAlerts are rate-limited to one per 10 seconds to prevent alert spam while still reporting every distinct threat.\n\n---\n\n<a id=\"proactive-design-philosophy\"></a>\n\n### Design philosophy\n\nSafeNova Proactive is built around one central principle: **protect as many JS primitives and APIs as possible, while using them as little as possible itself.**\n\nAll security-critical references are captured once at the very top of execution into a frozen snapshot — a frozen image of the JS runtime taken before any extension or injected script can interfere. From that point on, the guard never calls live globals. Every internal operation that needs a JS built-in goes through the captured snapshot:\n\n| Instead of...                                   | Proactive uses...                                            | Why                                                                                                                                                                                               |\n| ----------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `window.fetch`                                  | Captured reference                                           | Immune to post-load `window.fetch = ...` replacement                                                                                                                                              |\n| `Array.prototype.push`                          | `arr[arr.length] = x`                                        | Index assignment cannot be hooked                                                                                                                                                                 |\n| `for...of`                                      | Indexed `for` loops                                          | Immune to `Symbol.iterator` poisoning                                                                                                                                                             |\n| `new Set()` / `Set.prototype.has`               | `key in plainObject`                                         | `in` is a language operator, unhookable                                                                                                                                                           |\n| `String.prototype.match` / `.exec`              | Manual `indexOf` loops                                       | Avoids hookable regex prototype methods                                                                                                                                                           |\n| `String.prototype.startsWith`                   | `_pureSlice(s, 0, n) === prefix`                             | Pure bracket + concatenation; zero prototype calls                                                                                                                                                |\n| `String.prototype.toLowerCase` / `.toUpperCase` | `_pureToLower(s)`                                            | Frozen A-Z→a-z lookup + bracket indexing; no prototype dependency at all. Even if every `String.prototype` method is replaced, `_pureToLower` is unaffected                                       |\n| `String.prototype.indexOf` / `.substring`       | `_pureIndexOf(s, needle, from)`                              | Nested indexed loop with bracket comparison; no prototype dependency. Immune to `indexOf = () => -1` or `substring = () => ''` attacks                                                            |\n| `String.prototype.slice`                        | `_pureSlice(s, start, end)`                                  | Bracket indexing + `+=` concatenation; no prototype dependency. Used for URL extraction, key prefix checks, and all substring operations                                                          |\n| `String.prototype.charCodeAt`                   | `s[0] === 'd'` (bracket indexing + `===`)                    | Pure operator; the only `charCodeAt` call site (data: URL check) was replaced with a direct single-char bracket comparison                                                                        |\n| `Array.prototype.forEach`                       | Indexed `for` loops                                          | Unhookable loop construct                                                                                                                                                                         |\n| `String(x)`                                     | `'' + x`                                                     | Concatenation operator, not a function call                                                                                                                                                       |\n| `Array.prototype.slice`                         | Captured reference                                           | Survives prototype replacement                                                                                                                                                                    |\n| `fn.apply(ctx, args)` / `fn.call(ctx, args)`    | `_reflectApply(fn, ctx, args)`                               | `.apply()` / `.call()` resolve through live `Function.prototype`; if replaced post-boot, all pass-through calls are hijacked. `Reflect.apply` is captured at boot and goes directly to `[[Call]]` |\n| `instanceof Request`                            | `typeof input === 'object' && typeof input.url === 'string'` | `Symbol.hasInstance` can be replaced to make `instanceof` return false, bypassing URL extraction in the fetch hook                                                                                |\n| `el.appendChild(child)` / `el.removeChild(c)`   | `_reflectApply(_nodeAppend, parent, [child])`                | Live `Node.prototype.appendChild/removeChild` could be replaced to silently prevent alert overlay injection or prevent removal of malicious `<script>` elements                                   |\n| `el.querySelectorAll('*')`                      | `_reflectApply(_N.elQuerySelectorAll, el, ['*'])`            | Live method could return empty NodeList, letting child elements of injected nodes bypass the MO scanner                                                                                           |\n| `el.removeAttribute(name)`                      | `_reflectApply(_N.removeAttribute, el, [name])`              | Live method could be hooked to prevent stripping of malicious `on*` handlers or external resource attributes                                                                                      |\n| `Object.prototype.hasOwnProperty.call()`        | `'key' in obj`                                               | `in` is a language operator; `hasOwnProperty.call` goes through live `Function.prototype.call`                                                                                                    |\n\nAs a direct result, the integrity-checking core is **well-isolated and resistant to most hook-based attacks**: replacing `window.fetch`, `Array.prototype.push`, `String.prototype.toLowerCase`, `Function.prototype.call`, `Function.prototype.apply`, or any other live global after page load cannot change the guard's behaviour — it uses pure operators for all string processing, `Reflect.apply` for all internal function invocations, captured native DOM methods for overlay/scanner operations, validates captured references on every watchdog tick, and would detect the replacement before an attacker could leverage it.\n\n<a id=\"proactive-hook-opacity\"></a>\n\n#### Hook opacity\n\nEvery public hook function placed on `window` or prototypes — as well as all guarded application functions — is wrapped in an opaque forwarder. When an attacker inspects hooked functions via `toString()` in the DevTools console, they see only the forwarder shell — the actual security logic is hidden inside the closure and unreachable from outside. **All hooks and app functions** share this identical opaque signature:\n\n| Category                | Hooks                                                                                                                                                                                          |\n| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Network**             | `fetch`, `XMLHttpRequest.prototype.open`, `navigator.sendBeacon`, `WebSocket`, `window.open`, `EventSource`                                                                                    |\n| **DOM exfiltration**    | `Element.prototype.setAttribute`, `innerHTML` setter, `outerHTML` setter, `insertAdjacentHTML`, `document.write`, `document.writeln`, `Location.assign/replace/href`, `HTMLFormElement.submit` |\n| **Resource properties** | `img.src`, `script.src`, `iframe.src`, `video.src`, `audio.src`, `embed.src`, `object.data`, `link.href`                                                                                       |\n| **Code injection**      | `window.eval`, `window.Function` (`new Function()` is blocked; plain `Function()` calls are forwarded — only the constructor path is dangerous)                                                |\n| **Timer guards**        | `setTimeout` (string callback), `setInterval` (string callback)                                                                                                                                |\n| **Watchdog protection** | `clearInterval`, `clearTimeout`, `cancelAnimationFrame`                                                                                                                                        |\n| **Workers**             | `Worker`, `SharedWorker`, `ServiceWorkerContainer.register`                                                                                                                                    |\n| **App functions**       | `Crypto.encrypt/decrypt/encryptBin/decryptBin/deriveKey/deriveKeyAndRaw/importRawKey/checkVerification/makeVerification`, `App.lockContainer`, `VFS.init`, `WinManager.closeAll`               |\n\n---\n\n<a id=\"container-integrity-scanner\"></a>\n\n## 🛡️ Container Integrity Scanner\n\n> ![](./pics/screenshot_integrity_scanner.png)\n\nThe built-in scanner performs a deep analysis of the virtual disk image, encrypted file table, folder hierarchy, desktop layout, and workspace environment. It runs **28 checks** in two phases:\n\n<a id=\"scanner-phase-1\"></a>\n\n### Phase 1 — VFS structural checks (21 steps, synchronous)\n\n| #   | Check                        | Repairs                                                                        |\n| --- | ---------------------------- | ------------------------------------------------------------------------------ |\n| 1   | Root node integrity          | Recreates missing root; fixes type and parentId                                |\n| 2   | Node field validation        | Fixes IDs, names, types; restores missing/invalid ctime and mtime to today     |\n| 3   | Node ID format validation    | Reassigns malformed IDs; migrates position data                                |\n| 4   | Timestamp anomaly detection  | Detects mass-identical ctimes; spreads them across a 1-second window on repair |\n| 5   | File name validation         | Sanitizes invalid characters, truncates long names                             |\n| 6   | Orphaned node detection      | Reattaches to root                                                             |\n| 7   | Parent type validation       | Reattaches nodes whose parent is a file                                        |\n| 8   | Parent-child cycle detection | Breaks cycles by reattaching to root                                           |\n| 9   | Node reachability analysis   | O(n) memoized; reattaches unreachable nodes                                    |\n| 10  | Timestamp integrity          | Fixes invalid/future timestamps                                                |\n| 11  | File size validation         | Resets negative/invalid sizes                                                  |\n| 12  | File metadata validation     | Strips unknown properties                                                      |\n| 13  | Duplicate name detection     | Auto-renames collisions                                                        |\n| 14  | Empty folder chain detection | O(n) iterative post-order DFS; informational                                   |\n| 15  | Position table cleanup       | Removes stale entries                                                          |\n| 16  | Folder position maps         | Creates missing position maps                                                  |\n| 17  | Position entry completeness  | Only checks visited (opened) folders; auto-positions on repair                 |\n| 18  | Position collision detection | Relocates overlapping icons                                                    |\n| 19  | Grid alignment verification  | Snaps off-grid positions                                                       |\n| 20  | Folder depth analysis        | O(n) memoized; warns when nesting > 50 levels                                  |\n| 21  | Node count summary           | Informational — file/folder/position counts                                    |\n\n<a id=\"scanner-phase-2\"></a>\n\n### Phase 2 — Database-level checks (7 steps, async)\n\n| #   | Check                        | Repairs                                                                                                             |\n| --- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |\n| 1   | File data existence          | Removes VFS nodes whose encrypted blob is missing from IDB                                                          |\n| 2   | Encryption IV integrity      | Accepts Array/Uint8Array/ArrayBuffer (canonical: plain Array); coerces base64 strings; purges only if truly invalid |\n| 3   | File blob integrity          | Resets declared size to 0 if blob is empty                                                                          |\n| 4   | Orphaned storage records     | Deletes DB records not referenced by any VFS node                                                                   |\n| 5   | Record container binding     | Fixes records bound to wrong container ID                                                                           |\n| 6   | Container size consistency   | Recalculates totalSize from live VFS nodes                                                                          |\n| 7   | File decryption verification | Attempts to decrypt each blob; removes files whose ciphertext is unreadable (e.g. corrupted by duress trigger)      |\n\nBefore auto-repair runs, a **confirmation dialog** recommends exporting the container as a `.safenova` backup — you can do this without leaving the scanner. After a successful repair, a verification scan runs automatically to confirm all issues are resolved.\n\nIf auto-repair cannot fix the remaining issues, a **Deep Clean** option becomes available. It performs an aggressive structural rebuild in five O(n) passes:\n\n1. Scan DB storage records\n2. Purge dead nodes — remove every VFS node with no real encrypted data behind it\n3. Flatten deep folder chains — files nested more than 50 levels deep are reparented to their closest ≤50-level ancestor; all file data is preserved\n4. Repair metadata — each node with a missing or invalid `ctime`/`mtime` gets today's date\n5. Clean storage records — remove orphaned DB entries in a single batch transaction\n\nAfter Deep Clean, a verification scan runs automatically. A backup is offered before Deep Clean runs, same as for auto-repair.\n\n---\n\n<a id=\"performance\"></a>\n\n## ⚡ Performance\n\nSafeNova schedules AES-GCM operations to run with maximum concurrency, taking full advantage of hardware AES acceleration exposed by the browser’s Web Crypto API.\n\n<a id=\"adaptive-concurrency\"></a>\n\n### Adaptive concurrency\n\nThe degree of parallelism is computed once at startup based on `navigator.hardwareConcurrency`, capped at 8. This serves as the default batch width for all bulk encrypt/decrypt loops. On an 8-core machine, up to 8 files are processed simultaneously.\n\n<a id=\"bulk-upload\"></a>\n\n### Bulk upload\n\nFor each batch of files the application reads all `ArrayBuffer` payloads in parallel, encrypts the batch in parallel, then writes every encrypted record to IDB in a **single transaction**, eliminating the per-file transaction overhead that would otherwise dominate for large numbers of small files. Files with encrypted blobs exceeding **50 MB** are stored as split 50 MB chunks across the `chunks` object store, avoiding the browser's ~2 GB structured-clone limit on IDB reads; the chunking is fully transparent to all read paths.\n\n<a id=\"zip-export\"></a>\n\n### ZIP export\n\nExporting files as an archive uses a single IDB read transaction that fetches all required records concurrently. Decryption of all records is then dispatched in one parallel batch rather than being serialised sequentially.\n\n<a id=\"password-change\"></a>\n\n### Password change\n\nRe-encrypting a container under a new key dispatches all decrypt–re-encrypt pairs for every file **fully in parallel**. Results are accumulated and written back in a single batch, reducing total elapsed time to approximately one parallel round-trip plus one database write.\n\n<a id=\"container-export\"></a>\n\n### Container export\n\nExporting a `.safenova` file requires no single contiguous memory allocation regardless of container size. The builder receives each file blob as an individual chunk (no concatenation into one giant buffer), computes CRC32 incrementally, and emits an **array of small output parts**. The parts array is passed directly to the `Blob` constructor — the browser stitches the pieces together internally without requiring a duplicate allocation. The peak RAM footprint for an N-gigabyte export is approximately N bytes (the data already held in IDB), rather than ~3× N.\n\n<a id=\"drag-drop-performance\"></a>\n\n### Drag-and-drop performance (large folders)\n\nIcon dragging in folders with many files previously rebuilt the occupied-cell map on **every** mouse/touch frame (~60 fps). With hundreds of files this became a measurable bottleneck. The hot path is now O(1) per frame:\n\n-   **Touch drag** — the occupied map is built once at drag-start (when the 400 ms long-press fires) and reused throughout the gesture\n-   **Mouse drag** — occupied maps are computed once at drag-start and once when the pointer first enters a drop target, not on every frame\n-   **Snap preview throttle** — snap-preview positions are recomputed only when the pointer crosses a grid cell boundary (96 px steps), not on every pixel movement\n-   **No full map clone** — snap previews use a small overlay map (one entry per selected item) instead of cloning the full occupied map on each call\n\n---\n\n<a id=\"mobile-touch-support\"></a>\n\n## 📱 Mobile Touch Support\n\nSafeNova is fully usable on touchscreen devices (Android Chrome, iOS Safari). All gesture interactions work on real hardware, not only in DevTools device emulation.\n\n<a id=\"mobile-long-press\"></a>\n\n### Long-press to drag\n\nHolding a finger on an icon for **400 ms** activates drag mode (haptic feedback where the OS supports it). The `touchstart` handler is registered as `{ passive: false }` on the icon area and immediately calls `e.preventDefault()` when the touch lands on an icon. This suppresses the native Android long-press gesture (which would otherwise fire `touchcancel` + `contextmenu` at ~500 ms and silently kill the drag). Scrolling on empty area is unaffected — `preventDefault` is only called when a `.file-item` is the touch target, and `.file-item` elements carry `touch-action: none` in CSS to prevent the browser's pan gesture recognizer from competing.\n\n<a id=\"mobile-multi-file-drag\"></a>\n\n### Multi-file drag\n\nAll items in the current selection are dragged simultaneously. Each selected icon follows the same displacement vector as the primary icon. Snap previews are shown for every item in the selection, offset relative to one another to reflect final grid positions.\n\n<a id=\"mobile-context-menu\"></a>\n\n### Context menu\n\nA short tap (< 350 ms) on an icon opens the context menu. A long press (≥ 400 ms) starts a drag instead of opening the menu. The two actions are mutually exclusive — if the native `contextmenu` event fires while a drag is already active, it is suppressed; if it fires before the drag timer completes, the timer is cancelled.\n\n<a id=\"mobile-paste-at-finger-position\"></a>\n\n### Paste at finger position\n\nWhen **Paste** is triggered from the context menu on a touch device, the items are placed at the position where the menu was opened, rather than defaulting to the origin. The screen position is captured when the menu action is confirmed, and each pasted item is snapped to the nearest free grid cell relative to that position.\n\n<a id=\"mobile-overscroll\"></a>\n\n### Overscroll\n\n`overscroll-behavior: none` is applied to `.desktop-area` and `.fw-area` to prevent pull-to-refresh and iOS overscroll bounce from interfering with drag gestures.\n\n---\n\n<a id=\"security-audit\"></a>\n\n## 🛡️ Security Audit Changelog\n\nA detailed record of the most impactful security fixes and hardening steps applied during internal audits:\n\n-   **Security Audit Changelog**: You find it [here](./SECURITY_AUDIT.md)\n\n---\n\n<a id=\"contribute\"></a>\n\n## 🛠️ Contributing\n\nIf you want to contribute check our Contributions guideline first:\n\n-   **Contribution guideline**: You find it [here](https://github.com/DosX-dev/SafeNova/blob/main/CONTRIBUTING.md)\n\n---\n\n<a id=\"community\"></a>\n\n## 💬 Community\n\nHave questions, ideas, or just want to chat? Here's where to find us:\n\n-   **GitHub Issues**: Report bugs or request features via [Issues](https://github.com/DosX-dev/SafeNova/issues)\n\n---\n\n<a id=\"thanks\"></a>\n\n## 🤝 Thanks to all contributors\n\nI (**[DosX](https://github.com/DosX-dev)**) envision **SafeNova** as what it is: a complex engineering product — something created to solve a real problem, not just to sit on a shelf. Nevertheless, I would like to point out that the level of quality and safety achieved in this project is not a purely individual achievement. The architecture, the threat model, the complex cases that I would never have thought to talk about on my own — all this was created with the help of people who really helped, asked difficult questions and identified what I had missed. Great credit goes to everyone who left a review, suggestion, or error message along the way. If you want to contribute, check out the [contribution section](https://github.com/DosX-dev/SafeNova?tab=contributing-ov-file).\n\n<a href=\"https://github.com/DosX-dev/SafeNova/graphs/contributors\">\n<img src=\"https://readme-contribs.as93.net/contributors/DosX-dev/SafeNova?textColor=737373&perRow=9&shape=squircle&isResponsive=true\" />\n</a>\n\n> Special thanks:\n>\n> -   **[Joe12387](https://github.com/Joe12387)** — Private/Incognito detection implementation in TypeScript that was rewritten in JavaScript\n\n> The following tools were used during development:\n>\n> -   **[Visual Studio Code](https://code.visualstudio.com/)** — writing code in it and formatting\n> -   **[Web DevTools](https://en.wikipedia.org/wiki/Web_development_tools)** — debugging and inspecting web applications\n> -   **[Detect It Easy](https://github.com/horsicq/Detect-It-Easy)** — analyzing the ZIP format and structure in practice\n> -   **[Claude Opus Agent (v4.6)](https://www.claude.ai/)** — help with developing the interface part, checking the code and writing local tests\n"
  },
  {
    "path": "SECURITY_AUDIT.md",
    "content": "# 🛡️ Security Audit Changelog\n\n> This document summarizes the most significant security-related changes introduced during internal audits of the SafeNova codebase. Each entry represents a hardening step that meaningfully raises the security posture of the project.\n>\n> Entries are ordered newest-first. The **Area** column indicates which subsystem was affected:\n>\n> -   **SafeNova Proactive** — the runtime anti-tamper guard (`src/js/proactive/daemon.js`)\n> -   **SafeNova Core** — the application itself (crypto, state, VFS, UI, DB)\n\n---\n\n## Audit Results\n\n| Commit    | Area               | What was fixed                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | Security impact                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |\n| --------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `b2fa4fe` | SafeNova Core      | Fixed three bugs discovered during internal audit: (1) `_corruptFileBlobs` in `db.js` — duress IV-zeroing was a no-op for chunked files because `rec.iv` is stored as a plain JS `Array` (via `Array.from()`), not an `ArrayBuffer` or `TypedArray`; the old code called `.iv.buffer` on a plain array and got `undefined`, so `new Uint8Array(undefined)` produced a zero-length view and `.fill(0)` did nothing — the IV was never actually zeroed; fixed by branching on `Array.isArray`, `instanceof ArrayBuffer`, and `ArrayBuffer.isView`. (2) `_reassemble` in `db.js` — if any chunk was absent from IDB the missing slot was silently skipped: `totalSize` was not reduced and all subsequent chunks were written at a shifted offset, producing garbled plaintext with no error; fixed by rejecting the promise the moment all reads complete if any `parts[k]` slot is empty. (3) `downloadFile` in `fileops.js` — lacked the empty-file guard that `openFile` already had; calling `Crypto.decryptBin` on a `null`/zero-length blob threw an uncaught exception and crashed the download; fixed by mirroring the `openFile` check and returning an empty `ArrayBuffer` for empty files. | (1) Duress protection was completely bypassed for large (chunked) files — the IV was left intact, making those files decryptable after a panic trigger. (2) A single missing IndexedDB chunk caused silent data corruption instead of a detectable error. (3) A zero-byte file caused an unhandled crash on export. |\n| `606ffdc` | SafeNova Proactive | Captured `MessagePort.prototype.postMessage` into `_N.portPostMessage`; added it to `_CAPTURE_MUST_BE_NATIVE` and `_NATIVE_CHECKS`; routed both MC watchdog self-ping call sites through `_reflectApply(_N.portPostMessage, _mc.port1, [null])`; introduced `_RESOURCE_ATTRS_NO_SRCSET` (identical to `_RESOURCE_ATTRS` minus `srcset`) and switched `_scanElementForThreats` and the MO attribute-change path to use it — `srcset` is kept in `_RESOURCE_ATTRS` so the `setAttribute`/innerHTML hooks (which receive individual tokens) still block srcset-based exfiltration | Closes the MC watchdog kill-switch: a post-boot replacement of `MessagePort.prototype.postMessage` could silently stop the fourth watchdog mechanism (the other three remain active, but defence-in-depth is weakened); fixes a high-severity false positive where any element with a responsive-image `srcset` (e.g. `\"photo.jpg 2x\"`) caused `_isExternal` → `new URL()` to throw, the fail-closed return triggered an immediate alert and emergency key wipe — destroying the user's data on a completely legitimate page |\n| `ede90fe` | SafeNova Proactive | Captured `Promise.prototype.then` into `_N.promiseThen` and `ServiceWorkerRegistration.prototype.unregister` into `_N.swUnregister`; added both to `_CAPTURE_MUST_BE_NATIVE` and `_NATIVE_CHECKS`; rewrote `_nukeCachesAndWorkers` to use captured refs so `Promise.prototype.then` or `.unregister` replacement cannot silently kill the cache/SW wipe; changed `_showAlert` healer `MutationObserver` target from `document.body` to `document.documentElement` (subtree: true) so replacing `document.body` after render does not leave the healer dead on a detached node; fixed `_pureCollapseAttrSpaces` to track quote state so spaces around `=` inside quoted attribute values (e.g. URL query strings) are no longer collapsed | Closes the `Promise.prototype.then` replacement bypass that turns the entire cache and Service Worker wipe into a silent no-op; prevents an attacker from keeping a rogue Service Worker alive after a threat response by replacing `.unregister`; ensures the security overlay self-healer survives `document.body` replacement (attacker gains persistent removal of the warning); prevents URL corruption in alert messages caused by collapsing `=` spaces inside quoted values |\n| `93d5de5` | SafeNova Proactive | Added `_pureCollapseAttrSpaces` to normalise whitespace around `=` before attribute scanning in `_htmlHasThreat`; captured `Date.now`, `Location.prototype.reload`, `CacheStorage.prototype.keys/delete`, `ServiceWorkerContainer.prototype.getRegistrations` into `_N` with validation in `_CAPTURE_MUST_BE_NATIVE` and `_NATIVE_CHECKS`; rewrote `_nukeCachesAndWorkers` to use captured object refs and `_reflectApply`; replaced all `Date.now()` and `window.location.reload()` call sites with captured equivalents; replaced `window.addEventListener` fallback in `_showAlert` with captured `_N.addEventListener`; replaced `instanceof Set` with a structural duck-type check | Closes the HTML attribute whitespace-bypass (`src = \"url\"` was invisible to the scanner); makes alert rate-limiting, healer deadline, debugger-trap timeout, and post-alert reload tamper-proof; prevents live `window.caches`/`navigator.serviceWorker` replacement from silently neutering the threat-response cache wipe; guards the `DOMContentLoaded` alert fallback against `addEventListener` replacement; prevents `Symbol.hasInstance` poisoning from skipping selection clear during emergency state wipe |\n| `0a7add8` | SafeNova Proactive | Fixed dead man's switch false-triggering on fresh browser sessions: the 50 ms `setInterval` in daemon.js could advance `_heartbeatN` far beyond the +15 upper bound before `main.js` registered its `snv:alive` listener; added a one-time bootstrap exemption that skips the upper-bound check on the very first accepted heartbeat                                                                                                                                                                                                                                                                                                                                                    | Eliminates a denial-of-service race condition where a legitimate user on a cold cache was immediately locked out after unlock, while preserving the protection against an attacker dispatching a fake `snv:alive` with `n = Number.MAX_SAFE_INTEGER` to silence real heartbeats                                                                                                                                                                                                                                     |\n| `78af635` | SafeNova Proactive | Wrapped critical `Crypto.*` and `App.*` methods with closure-private proxies; `toString()` now shows only a thin forwarder; frozen references used for per-tick tamper detection                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | Prevents an attacker from reading or replacing encryption/decryption routines at runtime; makes devtools inspection of real implementations infeasible                                                                                                                                                                                                                                                                                                                                                              |\n| `47f7a83` | SafeNova Proactive | Replaced live `.call`/`.apply` usage with a captured `Reflect.apply`; captured additional DOM methods (`appendChild`, `removeChild`, `querySelectorAll`, `removeAttribute`) and routed all overlay/observer operations through them                                                                                                                                                                                                                                                                                                                                                                                                                                                     | Eliminates a class of attacks that poison `Function.prototype.call`/`.apply` to intercept every hooked call; DOM operations can no longer be subverted by redefining prototype methods                                                                                                                                                                                                                                                                                                                              |\n| `967bc1f` | SafeNova Proactive | Introduced pure operator-level string utilities (`_pureToLower`, `_pureIndexOf`, `_pureSlice`) that use only bracket indexing and comparisons — no prototype method calls                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | Makes all string processing inside the daemon immune to `String.prototype` poisoning, closing a subtle bypass vector for URL/attribute checks                                                                                                                                                                                                                                                                                                                                                                       |\n| `9f48c33` | SafeNova Proactive | Reduced watchdog `setInterval` tick from 1 000 ms to 50 ms                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Shrinks the window in which a tamper can exist undetected from ~1 s to ~50 ms, drastically limiting the usefulness of race-condition attacks                                                                                                                                                                                                                                                                                                                                                                        |\n| `8c8579c` | SafeNova Proactive | Captured `String.prototype.toLowerCase`/`.indexOf`; added fail-closed handling for unparseable URLs; blocked `blob:` Worker/SharedWorker URLs; added a fourth watchdog mechanism (MessageChannel self-ping)                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Closes live-prototype bypass paths in DOM hooks and URL checks; prevents covert same-origin workers from running attacker code outside the daemon's control                                                                                                                                                                                                                                                                                                                                                         |\n| `0ee205b` | SafeNova Proactive | Blocked `<iframe>` creation after init (D3 defense); intercepted `document.createElement('iframe')` to return a harmless `<div>`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | Prevents the fresh-realm native-reset attack where an attacker spawns an iframe to obtain untampered `Function`, `Object`, etc. and circumvent all Proactive hooks                                                                                                                                                                                                                                                                                                                                                  |\n| `f2f64a8` | SafeNova Proactive | Added DOM exfiltration defenses: hooked `setAttribute`, `innerHTML`/`outerHTML`, `insertAdjacentHTML`, `document.write`, `HTMLFormElement.submit`, resource property setters, `window.open`, `EventSource`, `Worker`/`SharedWorker`, `setTimeout`/`setInterval` string callbacks; blocked `eval` and `new Function()`                                                                                                                                                                                                                                                                                                                                                                   | Covers the major DOM-based data-exfiltration and dynamic-code-injection vectors; a malicious extension can no longer silently smuggle plaintext out through HTML attributes, navigation, or injected scripts                                                                                                                                                                                                                                                                                                        |\n| `ae415ac` | SafeNova Proactive | Bumped daemon to v6; replaced all `Set`/`forEach`/`repeat`/`replace` usage with index-based loops and plain objects; added MessageChannel self-ping; tightened heartbeat monotonicity bounds                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | Eliminates dependency on `Set`/`Array` iterators and prototype methods inside the daemon itself — a tampered iterator can no longer crash or silently skip the watchdog                                                                                                                                                                                                                                                                                                                                             |\n| `daffd47` | SafeNova Proactive | Moved hook logic into closure-private `_*Impl` functions; public forwarders on `_H` are one-liners whose `toString()` reveals nothing                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | Raises the bar for reverse-engineering hook internals; devtools `toString()` inspection no longer exposes the URL-matching or blocking logic                                                                                                                                                                                                                                                                                                                                                                        |\n| `7cab50f` | SafeNova Proactive | On threat alert: unregister all Service Workers and delete all CacheStorage entries                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | Prevents a persistent attacker payload from surviving a page reload via Service Worker cache; ensures the threat response leaves no injectable residue                                                                                                                                                                                                                                                                                                                                                              |\n| `5e97757` | SafeNova Proactive | Upgraded to v4; added pre-capture validation (verify natives are genuine before boot); triple-redundant watchdog (setInterval + recursive setTimeout + rAF); dead-man heartbeat with auto-lock; closed-ShadowDOM alert host with session-unique class                                                                                                                                                                                                                                                                                                                                                                                                                                   | Pre-capture validation blocks \"early bird\" attacks that replace natives before the daemon loads; triple redundancy makes it practically impossible to silently kill the watchdog; cosmetic-filter-resistant alert overlay cannot be hidden by ad blockers or extensions                                                                                                                                                                                                                                             |\n| `dc3025d` | SafeNova Proactive | Captured raw `localStorage`/`sessionStorage` references; two-pass storage wipe (overwrite with zeros → delete); dispatched `snv:lock` event on threat                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | Getter-level interception of `window.localStorage` can no longer suppress key wiping; zero-overwrite before deletion prevents data remanence on storage engines that defer physical erasure                                                                                                                                                                                                                                                                                                                         |\n| `d88dc63` | SafeNova Proactive | Initial introduction of the SafeNova Proactive runtime guard — native capture at startup, fetch/XHR/sendBeacon hooks, 1 s watchdog, storage wipe, alert overlay                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | Established the foundational anti-tamper and anti-exfiltration layer; without this commit, no runtime integrity guarantees existed                                                                                                                                                                                                                                                                                                                                                                                  |\n| `78af635` | SafeNova Core      | Wrapped `VFS.init` and `WinManager.closeAll` with proxied references for use in `_wipeAppState`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | Ensures the emergency state-wipe routine always calls the real methods even if the live references on the module objects have been overwritten                                                                                                                                                                                                                                                                                                                                                                      |\n| `c4adf7f` | SafeNova Core      | AES-GCM-wrapped the per-tab session key (`snv-sk`); bound browser fingerprint to `navigator.userAgent` and `navigator.platform`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | A raw sessionStorage dump no longer yields the plain session key; copying storage blobs to a different browser/OS silently invalidates them                                                                                                                                                                                                                                                                                                                                                                         |\n| `8a6d6ed` | SafeNova Core      | Added a cookie secret (`snv-kc`) and an IndexedDB secret (`snv-ki`) to the browser wrap-key derivation (three-source HKDF)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Compromising a single storage mechanism (localStorage, cookie, or IDB) is no longer sufficient to reconstruct the wrap key; all three must be present simultaneously                                                                                                                                                                                                                                                                                                                                                |\n| `bf6fa26` | SafeNova Core      | Derived a browser-specific HKDF-SHA-256 key from a stable fingerprint and used it to AES-GCM-wrap the persistent `snv-bsk`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Persistent session keys stored in localStorage are now encrypted at rest; offline exfiltration of the storage file does not reveal the key without the matching browser fingerprint                                                                                                                                                                                                                                                                                                                                 |\n| `9f962a0` | SafeNova Core      | Introduced a dual-key session model: per-tab `snv-sk` (sessionStorage) and persistent `snv-bsk` (localStorage), both AES-256-GCM encrypted with scoped expiries                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | Splits session trust into two independent scopes — a compromised tab key does not unlock the persistent session and vice versa; expiries limit the blast radius of a leaked blob                                                                                                                                                                                                                                                                                                                                    |\n| `b986213` | SafeNova Core      | Implemented cross-tab session guard with tab identity, localStorage heartbeat (5 s), 30 s TTL, and force-kick protocol                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | Prevents parallel access to the same container from multiple tabs, eliminating a class of race conditions that could corrupt the VFS or leak decrypted state through a forgotten tab                                                                                                                                                                                                                                                                                                                                |\n| `9d90b94` | SafeNova Core      | Added duress (panic) password support — silently and irreversibly corrupts all encrypted blobs; erases duress hash and export cache after trigger                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | Provides plausible deniability under coercion; the destruction is indistinguishable from a wrong-password attempt and leaves no forensic trace of the duress feature itself                                                                                                                                                                                                                                                                                                                                         |\n| `710fac9` | SafeNova Core      | Randomized XOR pre-shred for secure container deletion — random bytes XOR-flipped at random positions before blob removal                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | Prevents AES-GCM ciphertext recovery on storage engines that lazily reclaim pages; the overwritten bytes are cryptographically unpredictable                                                                                                                                                                                                                                                                                                                                                                        |\n| `7760914` | SafeNova Core      | Migrated session storage from plaintext to AES-256-GCM encrypted blobs; removed legacy plaintext import path and PBKDF2 fallback                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | Eliminated the last remaining plaintext credential storage; even if an attacker reads sessionStorage, they obtain only encrypted blobs that require the in-memory key                                                                                                                                                                                                                                                                                                                                               |\n| `7a2c7a5` | SafeNova Core      | Sanitized CSS hex colors and HTML-escaped extension text before SVG embedding; `CSS.escape` for selector queries; stripped HTML special chars from filenames                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | Closed XSS / CSS-injection vectors in dynamically generated SVG icons and DOM queries; prevents attacker-crafted filenames from executing code in the UI                                                                                                                                                                                                                                                                                                                                                            |\n| `10477c1` | SafeNova Core      | Introduced a strict Content Security Policy (`<meta>` tag + server headers); extracted inline scripts to external files for CSP compliance                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Blocks inline script injection entirely; even if an attacker injects HTML, the browser refuses to execute attacker-supplied `<script>` blocks                                                                                                                                                                                                                                                                                                                                                                       |\n| `52de238` | SafeNova Core      | Derived a per-container HKDF-SHA-256 key for the export cache (info `snv-export-cache-v1`) from the Argon2id salt                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | Export cache is now browser-independent but container-bound; a stolen cache blob from one container cannot be used to reconstruct another container's manifest                                                                                                                                                                                                                                                                                                                                                      |\n| `a9c2edf` | SafeNova Core      | Nullified heavy blobs (`lazyWorkspace`, `_alogZ`, `_exportCache`) in IDB before record deletion on container removal                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | Forces the browser to release persistent storage immediately; prevents ghost data from lingering in IDB until lazy garbage collection runs                                                                                                                                                                                                                                                                                                                                                                          |\n| `fa0794f` | SafeNova Core      | Added recursion guards (visited sets, depth caps) in `deleteSelected`, `deepCopy`, `_folderSize`, ZIP export; sanitized filenames; hard-capped import size                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Eliminates infinite-loop and stack-overflow vulnerabilities on corrupt/malicious VFS data; blocks filename-based injection and oversized imports that could exhaust memory                                                                                                                                                                                                                                                                                                                                          |\n| `6648cf7` | SafeNova Core      | Detected and broken parent↔child cycles in VFS; added visited-set guard to `remove()` to prevent infinite recursion                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | A crafted or corrupted container with cyclic folder references can no longer hang the browser or crash the tab                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `2321820` | SafeNova Core      | Encrypted activity logs with AES-GCM before flushing to storage; preserved legacy/compressed migration path                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Activity logs no longer leak operation history in plaintext; even with IDB access, the log content requires the active session key                                                                                                                                                                                                                                                                                                                                                                                  |\n| `1adf58c` | SafeNova Core      | Added incognito/private-mode detection using engine-fingerprint checks (no UA sniffing) with a one-time warning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | Warns users that containers created in private mode are ephemeral; prevents accidental data loss due to IDB volatility in incognito without relying on spoofable user-agent strings                                                                                                                                                                                                                                                                                                                                 |\n\n---\n\n> **How to read this table**: each row is a single commit that addressed a concrete vulnerability or hardening gap. Commits are grouped by area and sorted by significance. The table is not exhaustive — only changes with a direct, measurable impact on the security model are included.\n"
  },
  {
    "path": "src/.server.ps1",
    "content": "# Local Dev Server\n# Run: right-click -> \"Run with PowerShell\"  |  or: .\\._server.ps1\n# Requirements: Windows only, no external dependencies\n\n# UTF-8 output so Cyrillic paths and special chars display correctly in console\n[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n$OutputEncoding           = [System.Text.Encoding]::UTF8\n\n# ── Config ────────────────────────────────────────────────────\n$PREFERRED_PORT = 7777\n$PORT_RANGE_MIN = 3000\n$PORT_RANGE_MAX = 9999\n$PORT_MAX_TRIES = 20\n$root           = $PSScriptRoot.TrimEnd('\\') + '\\'\n\n# ── MIME types ────────────────────────────────────────────────\n$mime = @{\n    \".html\"  = \"text/html; charset=utf-8\"\n    \".htm\"   = \"text/html; charset=utf-8\"\n    \".css\"   = \"text/css; charset=utf-8\"\n    \".js\"    = \"application/javascript; charset=utf-8\"\n    \".mjs\"   = \"application/javascript; charset=utf-8\"\n    \".json\"  = \"application/json; charset=utf-8\"\n    \".svg\"   = \"image/svg+xml\"\n    \".png\"   = \"image/png\"\n    \".jpg\"   = \"image/jpeg\"\n    \".jpeg\"  = \"image/jpeg\"\n    \".gif\"   = \"image/gif\"\n    \".webp\"  = \"image/webp\"\n    \".ico\"   = \"image/x-icon\"\n    \".woff\"  = \"font/woff\"\n    \".woff2\" = \"font/woff2\"\n    \".ttf\"   = \"font/ttf\"\n    \".otf\"   = \"font/otf\"\n    \".mp4\"   = \"video/mp4\"\n    \".webm\"  = \"video/webm\"\n    \".txt\"   = \"text/plain; charset=utf-8\"\n    \".xml\"   = \"application/xml; charset=utf-8\"\n}\n\n# ── Port availability check ───────────────────────────────────\n# Uses TcpListener to probe the port BEFORE passing it to HttpListener.\n# This avoids the misleading generic \"Access Denied\" exception from HttpListener.\nfunction Test-PortFree([int]$p) {\n    try {\n        $tcp = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $p)\n        $tcp.Start()\n        $tcp.Stop()\n        return $true\n    } catch {\n        return $false\n    }\n}\n\n# ── Port selection ────────────────────────────────────────────\n# 1. Try preferred port first.\n# 2. Fall back to random ports in range until one is free.\n$port = $null\n\nif (Test-PortFree $PREFERRED_PORT) {\n    $port = $PREFERRED_PORT\n} else {\n    Write-Host \"\"\n    Write-Host \"  Port $PREFERRED_PORT is busy, picking a random one...\" -ForegroundColor DarkYellow\n\n    $rng  = [System.Random]::new()\n    for ($i = 0; $i -lt $PORT_MAX_TRIES; $i++) {\n        $candidate = $rng.Next($PORT_RANGE_MIN, $PORT_RANGE_MAX + 1)\n        if (Test-PortFree $candidate) {\n            $port = $candidate\n            break\n        }\n    }\n}\n\nif ($null -eq $port) {\n    Write-Host \"\"\n    Write-Host \"  [!] Could not find a free port after $PORT_MAX_TRIES attempts.\" -ForegroundColor Red\n    Write-Host \"      Range: $PORT_RANGE_MIN-$PORT_RANGE_MAX\" -ForegroundColor Red\n    Write-Host \"\"\n    Read-Host \"  Press Enter to exit\"\n    exit 1\n}\n\n# ── Start listener ────────────────────────────────────────────\n$prefix   = \"http://localhost:$port/\"\n$listener = [System.Net.HttpListener]::new()\n$listener.Prefixes.Add($prefix)\n\ntry {\n    $listener.Start()\n} catch {\n    Write-Host \"\"\n    Write-Host \"  [!] Failed to bind on port $port.\" -ForegroundColor Red\n    Write-Host \"      $_\" -ForegroundColor Red\n    Write-Host \"\"\n    Read-Host \"  Press Enter to exit\"\n    exit 1\n}\n\nWrite-Host \"\"\nWrite-Host \"  DosX's Dev Server\" -ForegroundColor Cyan\nWrite-Host \"  http://localhost:$port\" -ForegroundColor Cyan\nWrite-Host \"  Root: $root\" -ForegroundColor Cyan\nWrite-Host \"  Stop: Ctrl+C\" -ForegroundColor Cyan\nWrite-Host \"\"\n\nStart-Process $prefix\n\n# ── Request loop ──────────────────────────────────────────────\ntry {\n    while ($listener.IsListening) {\n        $ctx = $listener.GetContext()\n\n        # Per-request isolation — one bad request never crashes the loop\n        try {\n            $req  = $ctx.Request\n            $resp = $ctx.Response\n\n            $ts      = Get-Date -Format \"HH:mm:ss\"\n            $method  = $req.HttpMethod.ToUpper()\n            $urlPath = [System.Uri]::UnescapeDataString($req.Url.AbsolutePath)\n            $relPath = $urlPath.TrimStart('/')\n\n            # ── Security: block path traversal ──────────────\n            # Resolve to absolute path and verify it stays inside $root\n            $resolved = [System.IO.Path]::GetFullPath((Join-Path $root $relPath))\n            if (-not $resolved.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {\n                $resp.StatusCode = 403\n                $body = [System.Text.Encoding]::UTF8.GetBytes(\"403 Forbidden\")\n                $resp.ContentType     = \"text/plain; charset=utf-8\"\n                $resp.ContentLength64 = $body.Length\n                $resp.OutputStream.Write($body, 0, $body.Length)\n                $resp.OutputStream.Close()\n                Write-Host \"  [$ts]  403  $urlPath  [traversal blocked]\" -ForegroundColor Red\n                continue\n            }\n\n            $filePath = $resolved\n\n            # ── Directory: redirect to trailing slash ────────\n            if ((Test-Path $filePath -PathType Container) -and -not $urlPath.EndsWith('/')) {\n                $qs       = $req.Url.Query   # сохраняем query string (?2, ?project-id=2, ...)\n                $location = $urlPath + '/' + $qs\n                $resp.StatusCode = 301\n                $resp.Headers.Add('Location', $location)\n                $resp.ContentLength64 = 0\n                $resp.OutputStream.Close()\n                Write-Host \"  [$ts]  301  $urlPath -> $location\" -ForegroundColor DarkYellow\n                continue\n            }\n\n            # ── Directory: serve index.html or index.htm ────\n            if (Test-Path $filePath -PathType Container) {\n                $indexFile = $null\n                foreach ($filename in @(\"index.html\", \"index.htm\")) {\n                    $candidate = Join-Path $filePath $filename\n                    if (Test-Path $candidate -PathType Leaf) {\n                        $indexFile = $candidate\n                        break\n                    }\n                }\n                if ($indexFile) {\n                    $filePath = $indexFile\n                } else {\n                    # Directory exists but no index file found — treat as 404\n                    $filePath = $null\n                }\n            }\n\n            # ── Serve file (GET / HEAD) ──────────────────────\n            if (Test-Path $filePath -PathType Leaf) {\n                $ext         = [System.IO.Path]::GetExtension($filePath).ToLower()\n                $contentType = if ($mime.ContainsKey($ext)) { $mime[$ext] } else { \"application/octet-stream\" }\n                $bytes       = [System.IO.File]::ReadAllBytes($filePath)\n\n                $resp.StatusCode      = 200\n                $resp.ContentType     = $contentType\n                $resp.ContentLength64 = $bytes.Length\n                # Cache-Control: no-cache — browser always revalidates; avoids stale files during dev\n                $resp.Headers.Add('Cache-Control', 'no-cache')\n                $resp.Headers.Add('X-Content-Type-Options', 'nosniff')\n                $resp.Headers.Add('X-Frame-Options', 'DENY')\n                $resp.Headers.Add('Referrer-Policy', 'no-referrer')\n                $resp.Headers.Add('Permissions-Policy', 'interest-cohort=(), geolocation=(), camera=(), microphone=()')\n                $resp.Headers.Add('Cross-Origin-Opener-Policy', 'same-origin')\n                $resp.Headers.Add('Cross-Origin-Embedder-Policy', 'require-corp')\n\n                # HEAD — headers only, no body\n                if ($method -ne 'HEAD') {\n                    $resp.OutputStream.Write($bytes, 0, $bytes.Length)\n                }\n\n                $label = if ($method -eq 'HEAD') { \"HEAD\" } else { \" GET\" }\n                Write-Host \"  [$ts]  200  $label  $urlPath\" -ForegroundColor Green\n\n            # ── 404 ─────────────────────────────────────────\n            } else {\n                $notFoundPage = $null\n                foreach ($filename in @(\"404.html\", \"404.htm\")) {\n                    $candidate = Join-Path $root $filename\n                    if (Test-Path $candidate -PathType Leaf) {\n                        $notFoundPage = $candidate\n                        break\n                    }\n                }\n\n                if ($notFoundPage) {\n                    $body = [System.IO.File]::ReadAllBytes($notFoundPage)\n                    $resp.ContentType = \"text/html; charset=utf-8\"\n                } else {\n                    $body = [System.Text.Encoding]::UTF8.GetBytes(\"404 - Not Found: $urlPath\")\n                    $resp.ContentType = \"text/plain; charset=utf-8\"\n                }\n\n                $resp.StatusCode      = 404\n                $resp.ContentLength64 = $body.Length\n                if ($method -ne 'HEAD') {\n                    $resp.OutputStream.Write($body, 0, $body.Length)\n                }\n\n                Write-Host \"  [$ts]  404  $urlPath\" -ForegroundColor DarkGray\n            }\n\n        } catch {\n            # Swallow per-request errors so the server keeps running\n            Write-Host \"  [!] Request error: $_\" -ForegroundColor Red\n        } finally {\n            # Always close — prevents hanging connections\n            try { $ctx.Response.OutputStream.Close() } catch {}\n        }\n    }\n} finally {\n    $listener.Stop()\n    Write-Host \"\"\n    Write-Host \"  Server stopped.\" -ForegroundColor Yellow\n    Write-Host \"\"\n}\n"
  },
  {
    "path": "src/css/app.css",
    "content": "/* ============================================================\n   SafeNova — VS Code Dark Theme\n   ============================================================ */\n*,\n*::before,\n*::after {\n    box-sizing: border-box;\n    margin: 0;\n    padding: 0;\n}\n\n/* Remove browser default focus outlines — inputs use border-color for focus instead */\n:focus {\n    outline: none;\n}\n\n:root {\n    --bg: #1e1e1e;\n    --bg2: #252526;\n    --bg3: #2d2d30;\n    --bg4: #3c3c3c;\n    --bg5: #454545;\n    --border: #3c3c3c;\n    --border2: #555555;\n    --text: #d4d4d4;\n    --text-dim: #858585;\n    --text-bright: #ffffff;\n    --text-code: #9cdcfe;\n    --accent: #0078d4;\n    --accent-h: #1b8fd8;\n    --accent2: #4ec9b0;\n    --orange: #ce9178;\n    --green: #4caf50;\n    --green2: #6a9955;\n    --red: #f44747;\n    --yellow: #dcdcaa;\n    --purple: #c678dd;\n    --blue: #569cd6;\n    --r: 2px;\n    --shadow: 0 8px 32px rgba(0, 0, 0, 0.6);\n    --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);\n    --font: \"Segoe UI\", system-ui, -apple-system, sans-serif;\n    --mono: \"Cascadia Code\", \"Consolas\", \"Courier New\", monospace;\n    --transition: 0.15s ease;\n}\n\nhtml,\nbody {\n    width: 100%;\n    height: 100%;\n    font-family: var(--font);\n    background: var(--bg);\n    color: var(--text);\n    overflow: hidden;\n    user-select: none;\n}\n\n/* ---- SCROLLBAR ---- */\n/* WebKit (Chrome, Edge, Safari) */\n::-webkit-scrollbar {\n    width: 8px;\n    height: 8px;\n}\n::-webkit-scrollbar-track {\n    background: var(--bg2);\n}\n::-webkit-scrollbar-thumb {\n    background: var(--bg5);\n    border-radius: 1px;\n}\n::-webkit-scrollbar-thumb:hover {\n    background: var(--border2);\n}\n/* Firefox — scrollbar-width / scrollbar-color (standard W3C spec) */\n* {\n    scrollbar-width: thin;\n    scrollbar-color: var(--bg5) var(--bg2);\n}\n\n/* ---- VIEWS ---- */\n.view {\n    position: fixed;\n    inset: 0;\n    display: none;\n    flex-direction: column;\n    background: var(--bg);\n}\n.view.active {\n    display: flex;\n}\n\n/* ============================================================\n   HOME VIEW\n   ============================================================ */\n#view-home {\n    display: none;\n    flex-direction: column;\n}\n#view-home.active {\n    display: flex;\n}\n\n.app-header {\n    height: 48px;\n    min-height: 48px;\n    background: var(--bg2);\n    border-bottom: 1px solid var(--border);\n    display: flex;\n    align-items: center;\n    padding: 0 20px;\n    gap: 16px;\n    box-shadow: 0 1px 0 rgba(0, 0, 0, 0.3);\n}\n.header-logo {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n}\n.header-logo-icon {\n    width: 28px;\n    height: 28px;\n    flex-shrink: 0;\n    object-fit: contain;\n}\n.header-logo span {\n    font-size: 16px;\n    font-weight: 600;\n    letter-spacing: 0.02em;\n    color: var(--text-bright);\n}\n.header-logo small {\n    font-size: 10px;\n    color: var(--text-dim);\n    font-weight: 400;\n    border: 1px solid var(--border2);\n    padding: 1px 5px;\n    border-radius: 1px;\n    margin-left: 4px;\n}\n.header-brand {\n    font-size: 16px;\n    font-weight: 700;\n    letter-spacing: 0.02em;\n    color: var(--text-bright);\n    font-family: var(--font);\n}\n.header-spacer {\n    flex: 1;\n}\n.header-actions {\n    display: flex;\n    gap: 8px;\n    align-items: center;\n}\n\n.home-body {\n    flex: 1;\n    overflow-y: auto;\n    padding: 24px;\n    display: flex;\n    flex-direction: column;\n    gap: 20px;\n}\n.home-section-title {\n    font-size: 11px;\n    font-weight: 600;\n    color: var(--text-dim);\n    text-transform: uppercase;\n    letter-spacing: 0.08em;\n    margin-bottom: 4px;\n}\n\n/* ---- HOME DOC BLOCK ---- */\n.home-doc-wrap {\n    margin-top: auto;\n    align-self: flex-start;\n    display: flex;\n    flex-direction: column;\n    gap: 4px;\n}\n.home-doc {\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    background: var(--bg2);\n    max-width: 420px;\n    padding: 11px 14px 12px;\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n    position: relative;\n    overflow: hidden;\n    transition:\n        max-height 0.3s ease,\n        opacity 0.22s ease,\n        padding 0.3s ease;\n    max-height: 500px;\n    opacity: 1;\n}\n.home-doc.collapsed {\n    max-height: 0;\n    opacity: 0;\n    padding: 0;\n    pointer-events: none;\n}\n.home-doc-collapse {\n    position: absolute;\n    top: 7px;\n    right: 7px;\n    width: 18px;\n    height: 18px;\n    padding: 0;\n    border: none;\n    background: none;\n    color: var(--text-dim);\n    cursor: pointer;\n    border-radius: 2px;\n    line-height: 0;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    transition:\n        color 0.15s,\n        background 0.15s;\n}\n.home-doc-collapse:hover {\n    color: var(--text);\n    background: var(--bg3);\n}\n.home-doc-badge {\n    font-size: 8px;\n    font-weight: 700;\n    text-transform: uppercase;\n    letter-spacing: 0.13em;\n    color: var(--accent);\n    border: 1px solid rgba(0, 120, 212, 0.65);\n    border-radius: 0;\n    padding: 2px 6px;\n    width: fit-content;\n}\n.home-doc-title {\n    font-size: 11.5px;\n    font-weight: 600;\n    color: var(--text-bright);\n    line-height: 1.3;\n}\n.home-doc-p {\n    font-size: 10.5px;\n    line-height: 1.62;\n    color: var(--text-dim);\n    margin: 0;\n}\n.home-doc-p strong {\n    color: var(--text);\n    font-weight: 500;\n}\n.home-doc-stack {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 3px;\n    padding-top: 7px;\n    border-top: 1px solid var(--border);\n    margin-top: 2px;\n}\n.home-doc-stack span {\n    font-size: 9px;\n    color: var(--text-dim);\n    background: var(--bg3);\n    border: 1px solid var(--border);\n    border-radius: 2px;\n    padding: 1px 5px;\n}\n/* Tab shown when doc is collapsed */\n.home-doc-tab {\n    display: none;\n    align-self: flex-start;\n    align-items: center;\n    gap: 5px;\n    font-size: 9.5px;\n    font-weight: 500;\n    color: var(--text-dim);\n    background: var(--bg2);\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    padding: 4px 9px;\n    cursor: pointer;\n    margin-top: auto;\n    transition:\n        color 0.15s,\n        border-color 0.15s,\n        background 0.15s;\n    line-height: 1;\n}\n.home-doc-tab:hover {\n    color: var(--accent);\n    border-color: rgba(0, 120, 212, 0.4);\n    background: var(--bg3);\n}\n.home-doc-tab.visible {\n    display: flex;\n}\n/* Pre-hide on reload (before JS runs) — eliminates flash */\nhtml.snv-doc-hidden #home-doc {\n    max-height: 0 !important;\n    opacity: 0 !important;\n    padding: 0 !important;\n    overflow: hidden;\n}\nhtml.snv-doc-hidden #home-doc-tab {\n    display: flex !important;\n}\n\n.container-grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));\n    gap: 12px;\n}\n.container-card {\n    background: var(--bg2);\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    padding: 18px 18px 46px 32px;\n    cursor: pointer;\n    transition:\n        border-color var(--transition),\n        background var(--transition),\n        transform 0.1s;\n    position: relative;\n    overflow: hidden;\n    min-height: 136px;\n    /* Establish a GPU compositing layer at rest so that the :active scale(0.99)\n       fires on an already-promoted layer. Without this, Firefox promotes the\n       layer mid-animation which causes a one-frame sub-pixel text reposition\n       (the visible \"jump\" in the inspector at 112.39 × 35 px). */\n    will-change: transform;\n    backface-visibility: hidden;\n}\n.container-card:hover {\n    border-color: var(--accent);\n    background: #2a2d2e;\n}\n.container-card:active {\n    transform: scale(0.99);\n}\n@keyframes card-highlight {\n    0% {\n        box-shadow: 0 0 0 2px var(--accent);\n    }\n    100% {\n        box-shadow: 0 0 0 2px transparent;\n    }\n}\n.container-card.highlight {\n    animation: card-highlight 1.8s ease-out forwards;\n}\n/* Enterprise / tier badge */\n.tier-badge {\n    display: inline-flex;\n    align-items: center;\n    font-size: 8.5px;\n    font-weight: 600;\n    letter-spacing: 0.18em;\n    color: var(--text-dim);\n    border: 1px solid var(--border2);\n    border-radius: 2px;\n    padding: 1px 6px;\n    line-height: 1.8;\n    font-style: normal;\n    background: transparent;\n    flex-shrink: 0;\n    margin-left: 8px;\n    font-family: var(--font);\n}\n/* Drag-to-reorder handle */\n.container-drag-handle {\n    position: absolute;\n    left: 0;\n    top: 0;\n    bottom: 0;\n    width: 26px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    cursor: grab;\n    color: var(--text-dim);\n    opacity: 0;\n    border-radius: var(--r) 0 0 var(--r);\n    transition:\n        opacity 0.15s,\n        background 0.15s;\n}\n.container-card:hover .container-drag-handle {\n    opacity: 1;\n}\n.container-drag-handle:hover {\n    background: var(--bg3);\n    color: var(--text);\n}\n.container-drag-handle:active {\n    cursor: grabbing;\n}\n.container-card.drag-reorder-source {\n    opacity: 0.4;\n    transform: scale(0.97);\n    box-shadow: none;\n    pointer-events: none;\n    transition:\n        opacity 0.15s,\n        transform 0.15s;\n}\n.container-card.drag-reorder-over {\n    border-color: var(--accent);\n    background: rgba(0, 120, 212, 0.07);\n    box-shadow:\n        0 0 0 2px rgba(0, 120, 212, 0.22),\n        inset 3px 0 0 var(--accent);\n}\n\n.container-card-header {\n    display: flex;\n    align-items: center;\n    gap: 12px;\n    margin-bottom: 14px;\n}\n.container-card-icon {\n    width: 36px;\n    height: 36px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    background: rgba(0, 120, 212, 0.12);\n    border: 1px solid rgba(0, 120, 212, 0.3);\n    border-radius: var(--r);\n    flex-shrink: 0;\n}\n.container-card-icon svg {\n    width: 20px;\n    height: 20px;\n}\n.container-card-icon img {\n    width: 20px;\n    height: 20px;\n    object-fit: contain;\n}\n.container-card-name {\n    font-weight: 600;\n    font-size: 14px;\n    color: var(--text-bright);\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}\n.container-card-date {\n    font-size: 11px;\n    color: var(--text-dim);\n    margin-top: 1px;\n}\n.container-card-body {\n    display: block;\n}\n.container-bar-wrap {\n    position: relative;\n    height: 4px;\n    background: var(--bg4);\n    border-radius: 1px;\n    overflow: hidden;\n    margin: 8px 0;\n}\n.container-bar-fill {\n    position: absolute;\n    left: 0;\n    top: 0;\n    bottom: 0;\n    background: var(--accent);\n    border-radius: 1px;\n    transition: width 0.4s;\n}\n.container-bar-fill.warn {\n    background: var(--orange);\n}\n.container-bar-fill.danger {\n    background: var(--red);\n}\n.container-card-sizes {\n    display: flex;\n    justify-content: space-between;\n    font-size: 11px;\n    color: var(--text-dim);\n}\n.container-card-menu {\n    position: absolute;\n    top: 10px;\n    right: 10px;\n    width: 24px;\n    height: 24px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    opacity: 0;\n    border-radius: var(--r);\n    transition: opacity var(--transition);\n    cursor: pointer;\n    color: var(--text-dim);\n}\n.container-card:hover .container-card-menu {\n    opacity: 1;\n}\n.container-card-menu:hover {\n    background: var(--bg4);\n    color: var(--text);\n}\n.container-empty {\n    grid-column: 1/-1;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    min-height: 200px;\n    gap: 12px;\n    color: var(--text-dim);\n}\n.container-empty svg {\n    width: 48px;\n    height: 48px;\n    opacity: 0.3;\n}\n.container-empty p {\n    font-size: 14px;\n}\n\n/* ---- Storage Footer ---- */\n.storage-footer {\n    border-top: 1px solid var(--border);\n    background: var(--bg2);\n    padding: 14px 20px;\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n}\n.github-link {\n    display: inline-flex;\n    align-items: center;\n    gap: 6px;\n    font-size: 10.5px;\n    color: var(--text-dim);\n    text-decoration: none;\n    padding: 5px 8px;\n    margin-top: 8px;\n    border-radius: var(--r);\n    border: 1px solid transparent;\n    align-self: flex-start;\n    transition:\n        color var(--transition),\n        border-color var(--transition),\n        background var(--transition);\n}\n.github-link:hover {\n    color: var(--text);\n    background: var(--bg3);\n    border-color: var(--border);\n}\n.github-link-arrow {\n    opacity: 0;\n    margin-left: 1px;\n    transition: opacity var(--transition);\n}\n.github-link:hover .github-link-arrow {\n    opacity: 0.6;\n}\n.storage-row {\n    display: flex;\n    align-items: center;\n    gap: 12px;\n}\n.storage-label {\n    font-size: 11px;\n    color: var(--text-dim);\n    white-space: nowrap;\n    min-width: 110px;\n}\n.storage-bar-wrap {\n    flex: 1;\n    height: 6px;\n    background: var(--bg4);\n    border-radius: 1px;\n    overflow: hidden;\n}\n.storage-bar-fill {\n    height: 100%;\n    background: var(--accent2);\n    border-radius: 1px;\n    transition: width 0.5s;\n}\n.storage-bar-fill.warn {\n    background: var(--orange);\n}\n.storage-bar-fill.danger {\n    background: var(--red);\n}\n.storage-text {\n    font-size: 11px;\n    color: var(--text-dim);\n    white-space: nowrap;\n    min-width: 140px;\n    text-align: right;\n}\n\n/* ---- Storage warning banner ---- */\n.storage-warning-banner {\n    display: none;\n    align-items: center;\n    gap: 10px;\n    padding: 8px 20px;\n    background: rgba(244, 71, 71, 0.08);\n    border-top: 1px solid rgba(244, 71, 71, 0.3);\n    font-size: 12px;\n    color: var(--red);\n}\n.storage-warning-banner.show {\n    display: flex;\n}\n.storage-warning-banner svg {\n    flex-shrink: 0;\n}\n\n/* ---- Home warning box (browser storage) ---- */\n.home-warning-box {\n    display: flex;\n    align-items: flex-start;\n    gap: 10px;\n    padding: 10px 12px;\n    margin-bottom: 4px;\n    background: rgba(244, 71, 71, 0.07);\n    border: 1px solid rgba(244, 71, 71, 0.22);\n    border-left: 3px solid var(--red);\n    border-radius: var(--r);\n    font-size: 11.5px;\n    line-height: 1.6;\n    color: rgba(244, 71, 71, 0.85);\n    transition:\n        opacity 0.2s,\n        max-height 0.25s;\n}\n.home-warning-box.hidden {\n    display: none;\n}\n.home-warning-box strong {\n    color: #ff6b6b;\n}\n.warning-dismiss {\n    flex-shrink: 0;\n    margin-top: 1px;\n    padding: 3px;\n    border: none;\n    background: none;\n    color: rgba(244, 71, 71, 0.45);\n    cursor: pointer;\n    border-radius: 2px;\n    line-height: 0;\n    transition:\n        color 0.15s,\n        background 0.15s;\n}\n.warning-dismiss:hover {\n    color: var(--red);\n    background: rgba(244, 71, 71, 0.1);\n}\n\n/* ============================================================\n   BUTTONS\n   ============================================================ */\n.btn {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    gap: 6px;\n    padding: 6px 14px;\n    border: none;\n    border-radius: var(--r);\n    font-family: var(--font);\n    font-size: 13px;\n    cursor: pointer;\n    transition:\n        background var(--transition),\n        color var(--transition);\n    white-space: nowrap;\n    color: var(--text);\n    background: var(--bg4);\n}\n.btn:hover {\n    background: var(--bg5);\n}\n.btn:active {\n    opacity: 0.8;\n}\n.btn:disabled,\n.btn[disabled] {\n    opacity: 0.4;\n    cursor: not-allowed;\n    pointer-events: none;\n}\n.btn-primary {\n    background: var(--accent);\n    color: #fff;\n}\n.btn-primary:hover {\n    background: var(--accent-h);\n}\n.btn-danger {\n    background: #5a1a1a;\n    color: var(--red);\n    border: 1px solid #7a2222;\n}\n.btn-danger:hover {\n    background: #7a2222;\n}\n.btn-ghost {\n    background: transparent;\n    color: var(--text-dim);\n    border: 1px solid transparent;\n}\n.btn-ghost:hover {\n    background: var(--bg3);\n    border-color: var(--border);\n    color: var(--text);\n}\n.btn-ghost.active {\n    background: var(--bg3);\n    border-color: var(--accent);\n    color: var(--accent);\n}\n.btn-icon {\n    padding: 6px;\n    width: 32px;\n    height: 32px;\n}\n.btn-sm {\n    padding: 4px 10px;\n    font-size: 12px;\n}\n.btn-lg {\n    padding: 10px 24px;\n    font-size: 14px;\n    font-weight: 600;\n    width: 100%;\n    justify-content: center;\n}\n\n/* ============================================================\n   INPUTS\n   ============================================================ */\n.input {\n    display: block;\n    width: 100%;\n    background: var(--bg);\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    color: var(--text);\n    font-family: var(--font);\n    font-size: 13px;\n    padding: 8px 12px;\n    outline: none;\n    transition: border-color var(--transition);\n}\n.input:focus {\n    border-color: var(--accent);\n}\n.input::placeholder {\n    color: var(--text-dim);\n}\n.input-wrap {\n    position: relative;\n}\n.input-wrap .input {\n    padding-right: 40px;\n}\n.input-eye {\n    position: absolute;\n    right: 0;\n    top: 0;\n    bottom: 0;\n    width: 38px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    background: none;\n    border: none;\n    cursor: pointer;\n    color: var(--text-dim);\n}\n.input-eye:hover {\n    color: var(--text);\n}\n\n#nc-hwkey-btn {\n    display: none;\n    margin-right: auto;\n    flex-shrink: 0;\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    background: var(--bg3);\n    color: var(--text-dim);\n    cursor: pointer;\n    font-family: var(--font);\n    font-size: 11px;\n    flex-direction: row;\n    align-items: center;\n    justify-content: center;\n    gap: 5px;\n    padding: 0 10px;\n    height: 30px;\n    white-space: nowrap;\n    overflow: hidden;\n    transition:\n        border-color var(--transition),\n        color var(--transition);\n}\n#nc-hwkey-btn.show {\n    display: flex;\n}\n#nc-hwkey-btn:not(:disabled):hover {\n    border-color: var(--border2);\n    color: var(--text);\n}\n#nc-hwkey-btn:disabled {\n    cursor: not-allowed;\n    opacity: 0.45;\n}\n\n/* Password strength */\n.pw-strength {\n    height: 3px;\n    border-radius: 1px;\n    margin-top: 4px;\n    transition:\n        width 0.3s,\n        background 0.3s;\n    background: var(--bg4);\n}\n.pw-strength-row {\n    font-size: 11px;\n    color: var(--text-dim);\n    margin-top: 4px;\n}\n\n/* ============================================================\n   UNLOCK VIEW\n   ============================================================ */\n#view-unlock {\n    justify-content: center;\n    align-items: center;\n    background: radial-gradient(ellipse at 50% 40%, #1a2233 0%, var(--bg) 70%);\n}\n.unlock-box {\n    width: 100%;\n    max-width: 380px;\n    background: var(--bg2);\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    padding: 24px 28px 22px;\n    box-shadow: var(--shadow);\n    display: flex;\n    flex-direction: column;\n    gap: 10px;\n}\n.unlock-back {\n    align-self: flex-start;\n}\n.unlock-icon {\n    display: flex;\n    justify-content: center;\n    margin-bottom: -4px;\n}\n.unlock-identity {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: 4px;\n}\n.unlock-eyebrow {\n    font-size: 10px;\n    font-weight: 600;\n    text-transform: uppercase;\n    letter-spacing: 0.11em;\n    color: var(--text-dim);\n    opacity: 0.65;\n}\n.unlock-title {\n    text-align: center;\n    font-size: 22px;\n    font-weight: 700;\n    color: var(--text-bright);\n}\n.unlock-name-badge {\n    text-align: center;\n    font-size: 16px;\n    color: var(--text);\n    font-weight: 600;\n    align-self: center;\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n.unlock-error {\n    font-size: 12px;\n    color: var(--red);\n    display: none;\n    align-items: center;\n    gap: 8px;\n    max-height: 0;\n    overflow: hidden;\n    padding: 0;\n    border: 1px solid transparent;\n    border-radius: var(--r);\n    background: transparent;\n    transition:\n        max-height 0.2s ease,\n        padding 0.15s ease,\n        background var(--transition),\n        border-color var(--transition);\n}\n.unlock-error:not(:empty) {\n    display: flex;\n    max-height: 52px;\n    padding: 8px 10px;\n    background: var(--bg4);\n    border-color: var(--border2);\n}\n.unlock-spinner {\n    display: none;\n    justify-content: center;\n    align-items: center;\n    gap: 8px;\n    font-size: 12px;\n    color: var(--text-dim);\n}\n.unlock-spinner.show {\n    display: flex;\n}\n.spinner {\n    width: 16px;\n    height: 16px;\n    border: 2px solid var(--bg4);\n    border-top-color: var(--accent);\n    border-radius: 50%;\n    animation: spin 0.8s linear infinite;\n}\n@keyframes spin {\n    to {\n        transform: rotate(360deg);\n    }\n}\n\n/* ============================================================\n   DESKTOP VIEW\n   ============================================================ */\n#view-desktop {\n    background: var(--bg);\n}\n.desktop-topbar {\n    height: 40px;\n    min-height: 40px;\n    background: var(--bg2);\n    border-bottom: 1px solid var(--border);\n    display: flex;\n    align-items: center;\n    padding: 0 12px;\n    gap: 8px;\n}\n.desktop-topbar .separator {\n    width: 1px;\n    height: 20px;\n    background: var(--border);\n    margin: 0 4px;\n}\n.breadcrumb {\n    flex: 1;\n    display: flex;\n    align-items: center;\n    gap: 2px;\n    overflow: hidden;\n    font-size: 12px;\n    color: var(--text-dim);\n}\n.breadcrumb-item {\n    cursor: pointer;\n    padding: 2px 6px;\n    border-radius: var(--r);\n    white-space: nowrap;\n    transition:\n        color var(--transition),\n        background var(--transition);\n}\n.breadcrumb-item:hover {\n    color: var(--text);\n    background: var(--bg3);\n}\n.breadcrumb-item.current {\n    color: var(--text);\n    cursor: default;\n}\n.breadcrumb-item.current:hover {\n    background: transparent;\n}\n.breadcrumb-sep {\n    color: var(--text-dim);\n    opacity: 0.5;\n}\n\n.desktop-area {\n    flex: 1;\n    position: relative;\n    overflow: auto;\n    overscroll-behavior: none;\n    background: #1e1e1e;\n    background-image: radial-gradient(circle, #2a2a2a 1px, transparent 1px);\n    background-size: calc(24px * var(--icon-scale)) calc(24px * var(--icon-scale));\n    outline: none;\n}\n.desktop-area.no-grid-dots {\n    background-image: none;\n}\n.fw-area.no-grid-dots {\n    background-image: none;\n}\n\n/* Icon size modifiers */\nbody {\n    --icon-scale: 1;\n}\nbody.icons-small {\n    --icon-scale: 0.75;\n}\nbody.icons-large {\n    --icon-scale: 1.25;\n}\n/* Animation disable */\nbody.no-animations * {\n    animation-duration: 0s !important;\n    transition-duration: 0s !important;\n}\n\n/* Rubber band */\n.rubberband {\n    position: absolute;\n    border: 1px solid var(--accent);\n    background: rgba(0, 120, 212, 0.08);\n    border-radius: 1px;\n    pointer-events: none;\n    z-index: 200;\n}\n\n/* File icons */\n.file-item {\n    position: absolute;\n    width: 80px;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: 5px;\n    padding: 6px 4px;\n    border-radius: var(--r);\n    cursor: pointer;\n    transition: background var(--transition);\n    z-index: 1;\n    transform: scale(var(--icon-scale));\n    transform-origin: top left;\n    -webkit-touch-callout: none; /* prevent native iOS long-press callout */\n    touch-action: none; /* prevent browser from stealing touch for scroll/pan gestures */\n}\n.file-item:hover {\n    background: rgba(255, 255, 255, 0.05);\n}\n.file-item.selected {\n    background: rgba(0, 120, 212, 0.2);\n    outline: 1px solid rgba(0, 120, 212, 0.5);\n}\n.file-item.dragging {\n    opacity: 0.5;\n    z-index: 100;\n}\n.file-item.drag-target {\n    background: rgba(78, 201, 176, 0.15);\n    outline: 1px solid var(--accent2);\n}\n\n.file-thumb {\n    width: 56px;\n    height: 56px;\n    border-radius: var(--r);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    background: var(--bg3);\n    border: 1px solid var(--border);\n    overflow: hidden;\n    flex-shrink: 0;\n    position: relative;\n}\n.file-thumb img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n}\n.file-thumb.folder-icon {\n    background: transparent;\n    border: none;\n}\n.file-thumb svg {\n    pointer-events: none;\n}\n\n.file-name {\n    font-size: 11px;\n    text-align: center;\n    color: var(--text);\n    line-height: 1.3;\n    width: 100%;\n    overflow: hidden;\n    word-wrap: break-word;\n    max-height: 32px;\n    display: -webkit-box;\n    -webkit-line-clamp: 2;\n    line-clamp: 2;\n    -webkit-box-orient: vertical;\n}\n\n/* Taskbar */\n.taskbar {\n    height: 36px;\n    min-height: 36px;\n    background: var(--bg2);\n    border-top: 1px solid var(--border);\n    display: flex;\n    align-items: center;\n    padding: 0 12px;\n    gap: 10px;\n    overflow: hidden;\n}\n.taskbar-logo-icon {\n    flex-shrink: 0;\n    display: block;\n    object-fit: contain;\n}\n.taskbar-container-name {\n    font-size: 13px;\n    font-weight: 600;\n    color: var(--text-bright);\n    display: flex;\n    align-items: center;\n    gap: 6px;\n}\n.taskbar-sep {\n    flex: 1;\n}\n.taskbar-storage {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    font-size: 11px;\n    color: var(--text-dim);\n}\n.taskbar-bar-wrap {\n    width: 80px;\n    height: 4px;\n    background: var(--bg4);\n    border-radius: 1px;\n    overflow: hidden;\n}\n.taskbar-bar-fill {\n    height: 100%;\n    background: var(--accent);\n    border-radius: 1px;\n    transition: width 0.4s;\n}\n.taskbar-bar-fill.warn {\n    background: var(--orange);\n}\n.taskbar-bar-fill.danger {\n    background: var(--red);\n}\n\n/* ============================================================\n   CONTEXT MENU\n   ============================================================ */\n.ctx-menu {\n    position: fixed;\n    background: var(--bg2);\n    border: 1px solid var(--border2);\n    border-radius: var(--r);\n    box-shadow: var(--shadow);\n    min-width: 190px;\n    z-index: 9000;\n    padding: 4px 0;\n    display: none;\n    user-select: none;\n}\n.ctx-menu.show {\n    display: block;\n}\n.ctx-item {\n    padding: 7px 14px;\n    font-size: 13px;\n    cursor: pointer;\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    color: var(--text);\n    white-space: nowrap;\n    transition: background var(--transition);\n}\n.ctx-item:hover {\n    background: var(--accent);\n    color: #fff;\n}\n.ctx-item.danger {\n    color: var(--red);\n}\n.ctx-item.danger:hover {\n    background: var(--red);\n    color: #fff;\n}\n.ctx-sep {\n    height: 1px;\n    background: var(--border);\n    margin: 3px 0;\n}\n.ctx-item-icon {\n    width: 16px;\n    height: 16px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    flex-shrink: 0;\n}\n\n/* ============================================================\n   MODALS\n   ============================================================ */\n.modal-overlay {\n    position: fixed;\n    inset: 0;\n    background: rgba(0, 0, 0, 0.65);\n    z-index: 8000;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    opacity: 0;\n    transition: opacity 0.2s;\n    pointer-events: none;\n}\n.modal-overlay.show {\n    opacity: 1;\n    pointer-events: all;\n}\n.modal {\n    background: var(--bg2);\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    box-shadow: var(--shadow);\n    min-width: 360px;\n    max-width: 90vw;\n    transform: scale(0.97) translateY(8px);\n    transition: transform 0.2s;\n    display: flex;\n    flex-direction: column;\n    max-height: 90vh;\n    /* Keeps the element on a GPU compositing layer for the full animation\n       lifetime, preventing the sub-pixel text jump visible in Firefox\n       when the compositor layer is promoted/demoted mid-animation. */\n    will-change: transform;\n    backface-visibility: hidden;\n}\n.modal-overlay.show .modal {\n    transform: scale(1) translateY(0);\n}\n.modal-header {\n    padding: 16px 20px 12px;\n    border-bottom: 1px solid var(--border);\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    flex-shrink: 0;\n}\n.modal-title {\n    font-size: 15px;\n    font-weight: 600;\n    color: var(--text-bright);\n}\n.modal-close {\n    background: none;\n    border: none;\n    cursor: pointer;\n    color: var(--text-dim);\n    padding: 4px;\n    border-radius: var(--r);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n}\n.modal-close:hover {\n    background: var(--bg4);\n    color: var(--text);\n}\n.modal-body {\n    padding: 20px;\n    display: flex;\n    flex-direction: column;\n    gap: 14px;\n    overflow-y: auto;\n    flex: 1;\n}\n.modal-footer {\n    padding: 12px 20px;\n    border-top: 1px solid var(--border);\n    display: flex;\n    justify-content: flex-end;\n    gap: 8px;\n    flex-shrink: 0;\n}\n.form-label {\n    font-size: 12px;\n    color: var(--text-dim);\n    margin-bottom: 4px;\n    display: block;\n}\n.form-group {\n    display: flex;\n    flex-direction: column;\n}\n\n/* Text Editor Modal */\n.modal-editor {\n    width: 85vw;\n    max-width: 1000px;\n    height: 80vh;\n    position: relative;\n    overflow: hidden;\n}\n.editor-area {\n    flex: 1;\n    overflow: hidden;\n    display: flex;\n    flex-direction: row;\n}\n.editor-line-numbers {\n    flex-shrink: 0;\n    width: 48px;\n    overflow: hidden;\n    padding: 16px 0;\n    background: var(--bg3);\n    border-right: 1px solid var(--border);\n    font-family: var(--mono);\n    font-size: 13px;\n    line-height: 1.6;\n    color: var(--text-dim);\n    user-select: none;\n    -webkit-user-select: none;\n}\n.editor-line-numbers > div {\n    padding-right: 8px;\n    text-align: right;\n    white-space: nowrap;\n    display: flex;\n    align-items: flex-start;\n    justify-content: flex-end;\n}\n.editor-meta {\n    padding: 6px 12px;\n    background: var(--bg3);\n    border-top: 1px solid var(--border);\n    font-size: 11px;\n    color: var(--text-dim);\n    display: flex;\n    gap: 16px;\n}\ntextarea.code-editor {\n    flex: 1;\n    width: 100%;\n    background: var(--bg);\n    color: var(--text-code);\n    font-family: var(--mono);\n    font-size: 13px;\n    line-height: 1.6;\n    border: none;\n    outline: none;\n    resize: none;\n    padding: 16px;\n    tab-size: 2;\n    white-space: pre;\n    overflow-wrap: normal;\n}\ntextarea.code-editor.word-wrap {\n    white-space: pre-wrap;\n    overflow-wrap: break-word;\n}\ntextarea.code-editor::-webkit-scrollbar-thumb {\n    cursor: default;\n}\n\n/* Drag snap preview overlay */\n.snap-preview {\n    position: absolute;\n    width: 84px;\n    height: 90px;\n    border: 2px dashed rgba(0, 120, 212, 0.55);\n    border-radius: 4px;\n    transform: scale(var(--icon-scale));\n    transform-origin: top left;\n    background: rgba(0, 120, 212, 0.07);\n    pointer-events: none;\n    z-index: 1;\n    transition:\n        left 0.05s linear,\n        top 0.05s linear;\n}\n.no-snap-highlight .snap-preview {\n    display: none !important;\n}\n\n/* File Viewer Modal */\n.modal-viewer {\n    width: 90vw;\n    max-width: 1200px;\n    height: 85vh;\n}\n.viewer-content {\n    flex: 1;\n    overflow: auto;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: 20px;\n    background: var(--bg);\n}\n.viewer-content img {\n    max-width: 100%;\n    max-height: 100%;\n    object-fit: contain;\n}\n.viewer-content video,\n.viewer-content audio {\n    max-width: 100%;\n    max-height: 100%;\n}\n.viewer-content iframe {\n    width: 100%;\n    height: 100%;\n    border: none;\n}\n\n/* ---- Custom media player ---- */\n.twc-player {\n    display: flex;\n    flex-direction: column;\n    width: 100%;\n    max-width: 720px;\n    gap: 0;\n}\n.twc-player.audio-only {\n    gap: 0;\n}\n.twc-player video {\n    width: 100%;\n    max-height: 60vh;\n    background: #000;\n    border-radius: var(--r) var(--r) 0 0;\n    display: block;\n    cursor: pointer;\n    object-fit: contain;\n}\n.twc-player .player-controls {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    padding: 10px 14px;\n    background: var(--bg2);\n    border: 1px solid var(--border);\n    border-radius: 0 0 var(--r) var(--r);\n}\n.twc-player.audio-only .player-controls {\n    border-radius: var(--r);\n}\n/* Fullscreen mode — YouTube-style overlay controls */\n.twc-player:fullscreen,\n.twc-player:-webkit-full-screen {\n    position: relative;\n    background: #000;\n    display: flex;\n    flex-direction: column;\n}\n.twc-player:fullscreen video,\n.twc-player:-webkit-full-screen video {\n    max-height: none;\n    flex: 1;\n    border-radius: 0;\n    width: 100%;\n    object-fit: contain;\n}\n.twc-player:fullscreen .player-controls,\n.twc-player:-webkit-full-screen .player-controls {\n    position: absolute;\n    bottom: 0;\n    left: 0;\n    right: 0;\n    background: linear-gradient(transparent, rgba(0, 0, 0, 0.82));\n    border: none;\n    border-radius: 0;\n    padding: 28px 16px 14px;\n    opacity: 0;\n    transition: opacity 0.25s;\n    pointer-events: none;\n}\n.twc-player:fullscreen:hover .player-controls,\n.twc-player:-webkit-full-screen:hover .player-controls,\n.twc-player:fullscreen .player-controls:focus-within,\n.twc-player:-webkit-full-screen .player-controls:focus-within {\n    opacity: 1;\n    pointer-events: auto;\n}\n.twc-player:fullscreen .player-btn,\n.twc-player:-webkit-full-screen .player-btn {\n    color: #fff;\n}\n.twc-player:fullscreen .player-time,\n.twc-player:-webkit-full-screen .player-time {\n    color: rgba(255, 255, 255, 0.8);\n}\n.twc-player .player-btn {\n    background: none;\n    border: none;\n    cursor: pointer;\n    color: var(--text);\n    padding: 4px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    border-radius: var(--r);\n    transition: background var(--transition);\n}\n.twc-player .player-btn:hover {\n    background: var(--bg4);\n}\n.twc-player .player-seek {\n    flex: 1;\n    height: 4px;\n    -webkit-appearance: none;\n    appearance: none;\n    background: var(--bg4);\n    border-radius: 2px;\n    outline: none;\n    cursor: pointer;\n    position: relative;\n}\n.twc-player .player-seek::-webkit-slider-thumb {\n    -webkit-appearance: none;\n    width: 12px;\n    height: 12px;\n    border-radius: 50%;\n    background: var(--accent);\n    cursor: pointer;\n}\n.twc-player .player-seek::-moz-range-thumb {\n    width: 12px;\n    height: 12px;\n    border-radius: 50%;\n    background: var(--accent);\n    cursor: pointer;\n    border: none;\n}\n.twc-player .player-seek::-moz-range-track {\n    background: var(--bg4);\n    height: 4px;\n    border-radius: 2px;\n}\n.twc-player .player-time {\n    font-size: 11px;\n    color: var(--text-dim);\n    font-family: var(--mono);\n    min-width: 75px;\n    text-align: center;\n}\n.twc-player .player-vol {\n    width: 70px;\n    height: 4px;\n    -webkit-appearance: none;\n    appearance: none;\n    background: var(--bg4);\n    border-radius: 2px;\n    outline: none;\n    cursor: pointer;\n}\n.twc-player .player-vol::-webkit-slider-thumb {\n    -webkit-appearance: none;\n    width: 10px;\n    height: 10px;\n    border-radius: 50%;\n    background: var(--accent-h);\n    cursor: pointer;\n}\n.twc-player .player-vol::-moz-range-thumb {\n    width: 10px;\n    height: 10px;\n    border-radius: 50%;\n    background: var(--accent-h);\n    cursor: pointer;\n    border: none;\n}\n.twc-player .player-vol::-moz-range-track {\n    background: var(--bg4);\n    height: 4px;\n    border-radius: 2px;\n}\n.twc-player .audio-vis {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: 40px 20px;\n    background: var(--bg3);\n    border-radius: var(--r) var(--r) 0 0;\n    border: 1px solid var(--border);\n    border-bottom: none;\n}\n.twc-player .audio-vis svg {\n    width: 64px;\n    height: 64px;\n    color: var(--accent);\n    opacity: 0.6;\n}\n\n/* ---- Context menu disabled with tooltip ---- */\n.ctx-item.disabled {\n    opacity: 0.5;\n    pointer-events: auto;\n    cursor: default;\n}\n.ctx-item.disabled:hover {\n    background: transparent;\n    color: var(--text-dim);\n}\n.ctx-tooltip {\n    position: fixed;\n    background: var(--bg3);\n    border: 1px solid var(--border2);\n    color: var(--text-dim);\n    font-size: 11px;\n    padding: 6px 10px;\n    border-radius: var(--r);\n    box-shadow: var(--shadow-sm);\n    z-index: 9500;\n    pointer-events: none;\n    white-space: nowrap;\n}\n\n/* Properties Modal */\n.modal-props {\n    min-width: 320px;\n    max-width: 420px;\n}\n.props-icon {\n    display: flex;\n    justify-content: center;\n    margin: 8px 0;\n}\n.props-table {\n    width: 100%;\n    border-collapse: collapse;\n    font-size: 13px;\n}\n.props-table td {\n    padding: 6px 4px;\n    border-bottom: 1px solid var(--border);\n}\n.props-table td:first-child {\n    color: var(--text-dim);\n    width: 40%;\n}\n\n/* ============================================================\n   TOAST\n   ============================================================ */\n#toast-container {\n    position: fixed;\n    bottom: 52px;\n    right: 16px;\n    z-index: 9999;\n    display: flex;\n    flex-direction: column;\n    gap: 8px;\n    pointer-events: none;\n}\n.toast {\n    background: var(--bg3);\n    border: 1px solid var(--border2);\n    border-radius: var(--r);\n    padding: 10px 16px;\n    font-size: 13px;\n    max-width: 320px;\n    box-shadow: var(--shadow-sm);\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    animation:\n        toastIn 0.2s ease,\n        toastOut 0.3s ease 2.7s forwards;\n    pointer-events: all;\n}\n.toast.success {\n    border-left: 3px solid var(--green);\n}\n.toast.error {\n    border-left: 3px solid var(--red);\n}\n.toast.info {\n    border-left: 3px solid var(--accent);\n}\n.toast.warn {\n    border-left: 3px solid var(--orange);\n}\n@keyframes toastIn {\n    from {\n        opacity: 0;\n        transform: translateX(20px);\n    }\n    to {\n        opacity: 1;\n        transform: translateX(0);\n    }\n}\n@keyframes toastOut {\n    to {\n        opacity: 0;\n        transform: translateX(20px);\n    }\n}\n\n/* ============================================================\n   DRAG UPLOAD OVERLAY\n   ============================================================ */\n.drop-overlay {\n    position: absolute;\n    inset: 0;\n    background: rgba(0, 120, 212, 0.12);\n    border: 2px dashed var(--accent);\n    border-radius: var(--r);\n    z-index: 500;\n    display: none;\n    align-items: center;\n    justify-content: center;\n    flex-direction: column;\n    gap: 12px;\n    font-size: 18px;\n    color: var(--accent);\n    pointer-events: none;\n}\n.drop-overlay.show {\n    display: flex;\n}\n.fw-drop-overlay {\n    position: absolute;\n    inset: 0;\n    background: rgba(0, 120, 212, 0.12);\n    border: 2px dashed var(--accent);\n    border-radius: var(--r);\n    z-index: 500;\n    display: none;\n    align-items: center;\n    justify-content: center;\n    flex-direction: column;\n    gap: 10px;\n    font-size: 14px;\n    color: var(--accent);\n    pointer-events: none;\n}\n.fw-drop-overlay.show {\n    display: flex;\n}\n\n/* ============================================================\n   LOADING OVERLAY\n   ============================================================ */\n.loading-overlay {\n    position: fixed;\n    inset: 0;\n    background: rgba(30, 30, 30, 0.8);\n    z-index: 10000;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    gap: 16px;\n    display: none;\n}\n.loading-overlay.show {\n    display: flex;\n}\n.loading-spinner {\n    width: 40px;\n    height: 40px;\n    border: 3px solid var(--bg4);\n    border-top-color: var(--accent);\n    border-radius: 50%;\n    animation: spin 0.8s linear infinite;\n}\n.loading-msg {\n    font-size: 14px;\n    color: var(--text-dim);\n}\n\n/* ============================================================\n   INCOGNITO WARNING\n   ============================================================ */\n.incognito-warning {\n    position: fixed;\n    inset: 0;\n    z-index: 10001;\n    background: rgba(30, 30, 30, 0.88);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: 24px;\n}\n.incognito-warning-card {\n    background: var(--bg2);\n    border: 1px solid var(--border);\n    border-top: 2px solid var(--orange);\n    border-radius: var(--r);\n    padding: 20px 22px 16px;\n    max-width: 480px;\n    width: 100%;\n    box-shadow: var(--shadow);\n}\n.incognito-warning-header {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    margin-bottom: 14px;\n}\n.incognito-warning-header svg {\n    flex-shrink: 0;\n}\n.incognito-warning-title {\n    font-size: 14px;\n    font-weight: 600;\n    color: var(--text);\n}\n.incognito-warning-body {\n    font-size: 12.5px;\n    color: var(--text-dim);\n    line-height: 1.7;\n    margin-bottom: 16px;\n    padding-left: 32px;\n}\n.incognito-warning-body p {\n    margin-bottom: 8px;\n}\n.incognito-warning-body ul {\n    padding-left: 16px;\n    margin-bottom: 8px;\n    display: flex;\n    flex-direction: column;\n    gap: 3px;\n}\n.incognito-warning-body li strong {\n    color: var(--text);\n}\n.incognito-warning-recommend {\n    display: block;\n    padding: 8px 10px;\n    background: var(--bg3);\n    border-left: 2px solid var(--accent2);\n    color: var(--accent2);\n    font-size: 11.5px;\n    line-height: 1.5;\n    margin-top: 10px;\n    margin-bottom: 0 !important;\n}\n.incognito-warning-footer {\n    display: flex;\n    justify-content: flex-end;\n    padding-top: 6px;\n    border-top: 1px solid var(--border);\n}\n\n/* ============================================================\n   MISC\n   ============================================================ */\n.badge {\n    display: inline-block;\n    font-size: 10px;\n    font-weight: 600;\n    padding: 1px 6px;\n    border-radius: var(--r);\n    background: var(--bg4);\n    color: var(--text-dim);\n}\n.badge.encrypted {\n    background: rgba(0, 120, 212, 0.15);\n    color: var(--accent);\n}\n\n/* Selection info bar */\n.selection-bar {\n    position: fixed;\n    bottom: 42px;\n    left: 50%;\n    transform: translateX(-50%);\n    background: var(--bg3);\n    border: 1px solid var(--border2);\n    padding: 4px 14px;\n    font-size: 12px;\n    color: var(--text-dim);\n    border-radius: 2px;\n    white-space: nowrap;\n    z-index: 100;\n    pointer-events: none;\n    opacity: 0;\n    transition: opacity 0.2s;\n}\n.selection-bar.show {\n    opacity: 1;\n}\n\n/* Warning notice box */\n.notice-box {\n    display: flex;\n    align-items: flex-start;\n    gap: 10px;\n    padding: 10px 12px;\n    background: var(--bg3);\n    border-left: 2px solid var(--accent);\n    font-size: 11px;\n    color: var(--text-dim);\n    line-height: 1.5;\n}\n.notice-box.warn {\n    border-left-color: var(--orange);\n}\n.notice-box.danger {\n    border-left-color: var(--red);\n}\n.notice-box.danger svg {\n    color: var(--red) !important;\n}\n.notice-box svg {\n    flex-shrink: 0;\n    margin-top: 1px;\n    color: var(--orange);\n}\n\n/* Delete container warning block */\n.del-container-warning {\n    display: flex;\n    align-items: flex-start;\n    gap: 12px;\n    padding: 12px;\n    background: rgba(244, 71, 71, 0.08);\n    border: 1px solid rgba(244, 71, 71, 0.25);\n    border-radius: 6px;\n    margin-bottom: 6px;\n}\n.del-container-warning svg {\n    flex-shrink: 0;\n    margin-top: 2px;\n}\n\n/* ============================================================\n   HIDE NATIVE BROWSER PASSWORD REVEAL BUTTON (Edge/IE)\n   ============================================================ */\ninput[type=\"password\"]::-ms-reveal,\ninput[type=\"password\"]::-ms-clear {\n    display: none;\n}\n\n/* ============================================================\n   PREVENT NATIVE BROWSER DRAG-SELECT ON THUMBNAILS\n   ============================================================ */\n.file-item * {\n    user-select: none;\n    -webkit-user-drag: none;\n}\n.file-thumb img {\n    -webkit-user-drag: none;\n    pointer-events: none;\n}\n\n/* ============================================================\n   REMEMBER SESSION SECTION  (in unlock view)\n   ============================================================ */\n.remember-row {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    margin: 0;\n    font-size: 12px;\n    color: var(--text-dim);\n    cursor: pointer;\n}\n/* ---- Custom checkbox ---- */\n.remember-row input[type=\"checkbox\"] {\n    -webkit-appearance: none;\n    appearance: none;\n    width: 14px;\n    height: 14px;\n    min-width: 14px;\n    border: 1.5px solid var(--border2);\n    background: var(--bg3);\n    border-radius: 2px;\n    cursor: pointer;\n    flex-shrink: 0;\n    position: relative;\n    transition:\n        background var(--transition),\n        border-color var(--transition);\n}\n.remember-row input[type=\"checkbox\"]:hover {\n    border-color: var(--accent-h);\n}\n.remember-row input[type=\"checkbox\"]:checked {\n    background: var(--accent);\n    border-color: var(--accent);\n}\n.remember-row input[type=\"checkbox\"]:checked::after {\n    content: \"\";\n    position: absolute;\n    left: 3px;\n    top: 1px;\n    width: 5px;\n    height: 8px;\n    border: 1.5px solid #fff;\n    border-top: none;\n    border-left: none;\n    transform: rotate(45deg);\n}\n.remember-opts {\n    display: flex;\n    flex-direction: column;\n    gap: 4px;\n    margin: 4px 0 0 22px;\n    padding: 0;\n    background: none;\n    border-left: none;\n    border-radius: 0;\n}\n.remember-opt {\n    display: flex;\n    align-items: center;\n    gap: 7px;\n    font-size: 11.5px;\n    color: var(--text-dim);\n    cursor: pointer;\n}\n.remember-opt > span {\n    display: inline-flex;\n    align-items: center;\n    gap: 5px;\n}\n/* ---- Spacing in unlock box ---- */\n.unlock-box .remember-section {\n    margin-top: 6px;\n}\n#btn-unlock {\n    margin-top: 6px;\n}\n/* ---- Custom radio ---- */\n.remember-opt input[type=\"radio\"] {\n    -webkit-appearance: none;\n    appearance: none;\n    width: 14px;\n    height: 14px;\n    min-width: 14px;\n    border: 1.5px solid var(--border2);\n    background: var(--bg3);\n    border-radius: 50%;\n    cursor: pointer;\n    flex-shrink: 0;\n    position: relative;\n    transition: border-color var(--transition);\n}\n.remember-opt input[type=\"radio\"]:hover {\n    border-color: var(--accent-h);\n}\n.remember-opt input[type=\"radio\"]::after {\n    content: \"\";\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    width: 6px;\n    height: 6px;\n    margin: -3px 0 0 -3px;\n    border-radius: 50%;\n    background: var(--accent);\n    transform: scale(0);\n    transition: transform var(--transition);\n}\n.remember-opt input[type=\"radio\"]:checked {\n    border-color: var(--accent);\n}\n.remember-opt input[type=\"radio\"]:checked::after {\n    transform: scale(1);\n}\n.remember-opt.disabled {\n    opacity: 0.4;\n    pointer-events: none;\n    cursor: default;\n}\n.remember-opt.disabled input[type=\"radio\"] {\n    cursor: default;\n}\n.remember-opt-badge {\n    font-size: 10px;\n    font-weight: 500;\n    color: var(--text-dim);\n    background: rgba(133, 133, 133, 0.1);\n    border: 1px solid rgba(133, 133, 133, 0.2);\n    border-radius: 3px;\n    padding: 1px 5px;\n    margin-left: 4px;\n    vertical-align: middle;\n    white-space: nowrap;\n    pointer-events: none;\n}\n\n/* ============================================================\n   SESSION BADGE  (on container cards — Home view)\n   ============================================================ */\n.session-badge {\n    position: absolute;\n    bottom: 10px;\n    left: 30px;\n    display: inline-flex;\n    align-items: center;\n    gap: 5px;\n    font-size: 11px;\n    color: var(--accent2);\n    background: rgba(78, 201, 176, 0.1);\n    border: 1px solid rgba(78, 201, 176, 0.3);\n    border-radius: var(--r);\n    padding: 3px 8px;\n    cursor: pointer;\n    user-select: none;\n    transition: background var(--transition);\n}\n.session-badge:hover {\n    background: rgba(78, 201, 176, 0.22);\n}\n\n/* ============================================================\n   FOLDER WINDOW  (floating explorer window)\n   ============================================================ */\n.folder-window {\n    position: absolute;\n    background: var(--bg2);\n    border: 1px solid var(--border2);\n    border-radius: var(--r);\n    box-shadow: var(--shadow);\n    display: flex;\n    flex-direction: column;\n    overflow: hidden;\n    min-width: 420px;\n    min-height: 260px;\n}\n\n/* Title bar */\n.fw-titlebar {\n    height: 32px;\n    min-height: 32px;\n    background: var(--bg3);\n    border-bottom: 1px solid var(--border);\n    display: flex;\n    align-items: center;\n    padding: 0 6px;\n    gap: 6px;\n    user-select: none;\n    flex-shrink: 0;\n}\n.fw-drag-area {\n    flex: 1;\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    cursor: move;\n    min-width: 0;\n    overflow: hidden;\n}\n.fw-folder-icon {\n    flex-shrink: 0;\n    width: 18px;\n    height: 18px;\n    display: flex;\n    align-items: center;\n}\n.fw-folder-icon svg {\n    width: 18px;\n    height: 18px;\n}\n.fw-title {\n    font-size: 12px;\n    font-weight: 600;\n    color: var(--text-bright);\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}\n.fw-controls {\n    display: flex;\n    gap: 1px;\n    flex-shrink: 0;\n}\n.fw-btn {\n    background: none;\n    border: none;\n    padding: 4px 5px;\n    border-radius: var(--r);\n    color: var(--text-dim);\n    cursor: pointer;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    transition:\n        background var(--transition),\n        color var(--transition);\n}\n.fw-btn:hover {\n    background: var(--bg5);\n    color: var(--text);\n}\n.fw-btn.close:hover {\n    background: var(--red);\n    color: #fff;\n}\n\n/* Toolbar */\n.fw-toolbar {\n    height: 30px;\n    min-height: 30px;\n    background: var(--bg2);\n    border-bottom: 1px solid var(--border);\n    display: flex;\n    align-items: center;\n    padding: 0 6px;\n    gap: 4px;\n    overflow: hidden;\n    flex-shrink: 0;\n}\n.fw-breadcrumb {\n    flex: 1;\n    display: flex;\n    align-items: center;\n    font-size: 11px;\n    color: var(--text-dim);\n    min-width: 0;\n    overflow: hidden;\n    margin-left: 4px;\n    background: var(--bg);\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    padding: 2px 6px;\n}\n.fw-bc-item {\n    color: var(--text-dim);\n    cursor: pointer;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    padding: 1px 3px;\n    border-radius: var(--r);\n    transition:\n        color var(--transition),\n        background var(--transition);\n}\n.fw-bc-item:hover {\n    color: var(--text);\n    background: var(--bg3);\n}\n.fw-bc-item.current {\n    color: var(--accent);\n    cursor: default;\n}\n.fw-bc-item.current:hover {\n    background: transparent;\n}\n.fw-bc-sep {\n    color: var(--border2);\n    flex-shrink: 0;\n    padding: 0 1px;\n}\n\n/* Content area */\n.fw-area {\n    flex: 1;\n    position: relative;\n    overflow-x: auto;\n    overflow-y: auto;\n    overscroll-behavior: none;\n    outline: none;\n    background: #1e1e1e;\n    background-image: radial-gradient(circle, #2a2a2a 1px, transparent 1px);\n    background-size: calc(24px * var(--icon-scale)) calc(24px * var(--icon-scale));\n    min-height: 0;\n    min-width: 0;\n}\n.fw-canvas {\n    position: absolute;\n    top: 0;\n    left: 0;\n    pointer-events: none;\n    min-width: 1px;\n    min-height: 1px;\n}\n\n/* Status bar */\n.fw-statusbar {\n    height: 22px;\n    min-height: 22px;\n    background: var(--bg2);\n    border-top: 1px solid var(--border);\n    display: flex;\n    align-items: center;\n    padding: 0 10px;\n    font-size: 11px;\n    color: var(--text-dim);\n    flex-shrink: 0;\n}\n\n/* ============================================================\n   CONTEXT MENU SUBMENU ARROW\n   ============================================================ */\n.ctx-item-arrow {\n    margin-left: auto;\n    padding-left: 8px;\n    font-size: 12px;\n    opacity: 0.6;\n}\n\n/* ============================================================\n   CONTEXT MENU KEY HINT (right-side auth indicator)\n   ============================================================ */\n.ctx-item-key-hint {\n    margin-left: auto;\n    padding-left: 8px;\n    display: flex;\n    align-items: center;\n    flex-shrink: 0;\n}\n\n/* ============================================================\n   FOLDER WINDOW RESIZE HANDLE\n   ============================================================ */\n.fw-resize-handle {\n    position: absolute;\n    bottom: 0;\n    right: 0;\n    width: 16px;\n    height: 16px;\n    cursor: se-resize;\n    z-index: 10;\n    border-radius: 0 0 var(--r) 0;\n    background: linear-gradient(135deg, transparent 40%, var(--border2) 40%, var(--border2) 50%, transparent 50%, transparent 60%, var(--border2) 60%, var(--border2) 70%, transparent 70%, transparent 80%, var(--border2) 80%, var(--border2) 90%, transparent 90%);\n}\n\n/* ============================================================\n   FILE HOVER TOOLTIP\n   ============================================================ */\n.file-tooltip {\n    position: fixed;\n    background: var(--bg3);\n    border: 1px solid var(--border2);\n    border-radius: var(--r);\n    box-shadow: var(--shadow-sm);\n    padding: 8px 12px;\n    min-width: 180px;\n    max-width: 300px;\n    z-index: 99999;\n    pointer-events: none;\n    font-size: 12px;\n    line-height: 1.6;\n    animation: tooltipIn 0.12s ease;\n}\n.file-tooltip .ft-name {\n    font-weight: 600;\n    color: var(--text-bright);\n    margin-bottom: 5px;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n.file-tooltip .ft-row {\n    color: var(--text-dim);\n}\n@keyframes tooltipIn {\n    from {\n        opacity: 0;\n        transform: translateY(-3px);\n    }\n    to {\n        opacity: 1;\n        transform: translateY(0);\n    }\n}\n\n/* ============================================================\n   CUT ITEM VISUAL FEEDBACK\n   ============================================================ */\n.file-item.cut-item {\n    opacity: 0.45;\n    filter: saturate(0.3);\n    transition:\n        opacity 0.15s,\n        filter 0.15s;\n}\n\n/* ============================================================\n   ICON APPEAR ANIMATION\n   ============================================================ */\n@keyframes iconPop {\n    from {\n        opacity: 0;\n        transform: scale(calc(var(--icon-scale) * 0.72));\n    }\n    to {\n        opacity: 1;\n        transform: scale(var(--icon-scale));\n    }\n}\n\n/* ============================================================\n   SETTINGS MODAL\n   ============================================================ */\n.modal-settings {\n    width: 560px;\n    height: 500px;\n    display: flex;\n    flex-direction: column;\n    overflow: hidden;\n}\n.settings-tabs {\n    display: flex;\n    border-bottom: 1px solid var(--border);\n    flex-shrink: 0;\n}\n.settings-tab {\n    flex: 1;\n    padding: 10px 0;\n    background: none;\n    border: none;\n    color: var(--text-dim);\n    font-size: 13px;\n    font-weight: 500;\n    cursor: pointer;\n    border-bottom: 2px solid transparent;\n    transition:\n        color var(--transition),\n        border-color var(--transition);\n}\n.settings-tab:hover {\n    color: var(--text);\n}\n.settings-tab.active {\n    color: var(--accent);\n    border-bottom-color: var(--accent);\n}\n.settings-panel {\n    padding: 20px;\n    display: flex;\n    flex-direction: column;\n    gap: 16px;\n    flex: 1;\n    overflow-y: auto;\n}\n.settings-section {\n    display: flex;\n    flex-direction: column;\n    gap: 14px;\n}\n.settings-row {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 12px;\n}\n.settings-label {\n    display: inline-flex;\n    align-items: center;\n    font-size: 13px;\n    color: var(--text);\n    white-space: nowrap;\n}\n.settings-toggle-group {\n    display: flex;\n    gap: 0;\n    border: 1px solid var(--border2);\n    border-radius: var(--r);\n    overflow: hidden;\n}\n.settings-toggle-btn {\n    padding: 5px 14px;\n    font-size: 12px;\n    background: none;\n    border: none;\n    color: var(--text-dim);\n    cursor: pointer;\n    transition:\n        background var(--transition),\n        color var(--transition);\n}\n.settings-toggle-btn:not(:last-child) {\n    border-right: 1px solid var(--border2);\n}\n.settings-toggle-btn:hover {\n    background: var(--bg3);\n    color: var(--text);\n}\n.settings-toggle-btn.active {\n    background: var(--accent);\n    color: #fff;\n}\n\n/* Switch toggle */\n.switch {\n    position: relative;\n    display: inline-block;\n    width: 36px;\n    height: 20px;\n    flex-shrink: 0;\n}\n.switch input {\n    opacity: 0;\n    width: 0;\n    height: 0;\n}\n.switch-slider {\n    position: absolute;\n    inset: 0;\n    background: var(--bg4);\n    border-radius: 10px;\n    cursor: pointer;\n    transition: background 0.2s;\n}\n.switch-slider::before {\n    content: \"\";\n    position: absolute;\n    width: 14px;\n    height: 14px;\n    left: 3px;\n    top: 3px;\n    background: #fff;\n    border-radius: 50%;\n    transition: transform 0.2s;\n}\n.switch input:checked + .switch-slider {\n    background: var(--accent);\n}\n.switch input:checked + .switch-slider::before {\n    transform: translateX(16px);\n}\n.switch input:checked + .switch-slider-danger {\n    background: var(--red);\n}\n\n/* ── Danger Zone (Duress) ────────────────────────────────────── */\n.danger-zone-section {\n    margin-top: 4px;\n}\n.danger-zone-title {\n    color: var(--red);\n    border-color: rgba(244, 71, 71, 0.3);\n}\n.danger-zone-body {\n    padding: 12px;\n    background: rgba(244, 71, 71, 0.06);\n    border: 1px solid rgba(244, 71, 71, 0.22);\n    border-radius: var(--r);\n}\n.danger-zone-header {\n    display: flex;\n    align-items: flex-start;\n    justify-content: space-between;\n    gap: 12px;\n}\n.danger-zone-label {\n    font-size: 13px;\n    font-weight: 500;\n    color: var(--text);\n    display: flex;\n    align-items: center;\n    gap: 5px;\n}\n.danger-zone-label svg {\n    flex-shrink: 0;\n}\n.danger-zone-desc {\n    font-size: 12px;\n    color: var(--text-dim);\n    margin-top: 5px;\n    line-height: 1.5;\n}\n.danger-zone-switch {\n    flex-shrink: 0;\n    margin-top: 2px;\n}\n\n/* Duress form animated expand/collapse */\n#duress-form {\n    max-height: 0;\n    opacity: 0;\n    overflow: hidden;\n    margin-top: 0;\n    transition:\n        max-height 0.3s ease,\n        opacity 0.3s ease,\n        margin-top 0.3s ease;\n}\n#duress-form.open {\n    max-height: 300px;\n    opacity: 1;\n    margin-top: 10px;\n}\n.duress-fg {\n    margin-bottom: 8px;\n}\n.duress-fl {\n    font-size: 12px;\n}\n.duress-hint {\n    font-size: 11px;\n    color: var(--text-dim);\n    font-weight: 400;\n}\n.duress-set-error {\n    display: none;\n    font-size: 12px;\n    color: var(--red);\n    margin-top: 4px;\n    margin-bottom: 4px;\n}\n.duress-set-btn {\n    width: 100%;\n    margin-top: 2px;\n}\n\n/* Duress active-info block */\n.duress-active-info {\n    display: none;\n    margin-top: 10px;\n}\n.duress-active-badge {\n    font-size: 11px;\n    color: var(--red);\n    display: flex;\n    align-items: center;\n    gap: 5px;\n    margin-bottom: 8px;\n}\n.duress-active-dot {\n    width: 6px;\n    height: 6px;\n    background: var(--red);\n    border-radius: 50%;\n    display: inline-block;\n    flex-shrink: 0;\n}\n.duress-remove-btn {\n    width: 100%;\n    font-size: 12px;\n}\n\n/* Stats */\n.stats-grid {\n    display: grid;\n    grid-template-columns: repeat(3, 1fr);\n    gap: 10px;\n}\n.stats-card {\n    background: var(--bg3);\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    padding: 12px 14px;\n    display: flex;\n    flex-direction: column;\n    gap: 4px;\n}\n.stats-card-value {\n    font-size: 17px;\n    font-weight: 600;\n    color: var(--text-bright);\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n.stats-card-label {\n    font-size: 11px;\n    color: var(--text-dim);\n    text-transform: uppercase;\n    letter-spacing: 0.5px;\n}\n.stats-chart-wrap {\n    margin-top: 10px;\n}\n.stats-bar-chart {\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n}\n.stats-bar-row {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    font-size: 12px;\n    color: var(--text-dim);\n}\n.stats-bar-row-label {\n    width: 70px;\n    text-align: right;\n    flex-shrink: 0;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n.stats-bar-row-track {\n    flex: 1;\n    height: 8px;\n    background: var(--bg4);\n    border-radius: var(--r);\n    overflow: hidden;\n}\n.stats-bar-row-fill {\n    height: 100%;\n    border-radius: var(--r);\n    transition: width 0.4s ease;\n}\n.stats-bar-row-meta {\n    display: flex;\n    flex-direction: column;\n    align-items: flex-end;\n    width: 52px;\n    flex-shrink: 0;\n    font-size: 11px;\n    color: var(--text-dim);\n    line-height: 1.4;\n}\n.stats-bar-row-meta-size {\n    font-size: 10px;\n    opacity: 0.65;\n}\n.stats-storage-bar {\n    height: 14px;\n    background: var(--bg4);\n    border-radius: var(--r);\n    overflow: hidden;\n    position: relative;\n}\n.stats-storage-fill {\n    height: 100%;\n    border-radius: var(--r);\n    background: var(--accent);\n    transition: width 0.4s ease;\n}\n.stats-storage-text {\n    position: absolute;\n    inset: 0;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    font-size: 10px;\n    color: #fff;\n    font-weight: 600;\n    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);\n}\n.stats-storage-labels {\n    display: flex;\n    justify-content: space-between;\n    margin-top: 5px;\n    font-size: 11px;\n    color: var(--text-dim);\n}\n.stats-top-files {\n    display: flex;\n    flex-direction: column;\n    gap: 4px;\n}\n.stats-top-file {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    padding: 5px 8px;\n    background: var(--bg3);\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    min-width: 0;\n}\n.stats-top-file-dot {\n    width: 8px;\n    height: 8px;\n    border-radius: var(--r);\n    flex-shrink: 0;\n}\n.stats-top-file-name {\n    flex: 1;\n    font-size: 12px;\n    color: var(--text);\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    min-width: 0;\n}\n.stats-top-file-size {\n    flex-shrink: 0;\n    font-size: 11px;\n    color: var(--text-dim);\n    font-family: var(--mono);\n}\n\n/* ============================================================\n   ACTIVITY LOGS\n   ============================================================ */\n.alog-toolbar {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    padding: 10px 16px 8px;\n    border-bottom: 1px solid var(--border);\n}\n.alog-filters {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 4px;\n    flex: 1;\n    min-width: 0;\n}\n.alog-filter {\n    display: inline-flex;\n    align-items: center;\n    gap: 4px;\n    padding: 3px 8px;\n    border-radius: 3px;\n    font-size: 11px;\n    border: 1px solid var(--border2);\n    background: transparent;\n    color: var(--text-dim);\n    cursor: pointer;\n    transition:\n        background 0.15s,\n        border-color 0.15s,\n        color 0.15s;\n    white-space: nowrap;\n}\n.alog-filter.active {\n    color: var(--text);\n    border-color: var(--accent);\n    background: color-mix(in srgb, var(--accent) 8%, transparent);\n}\n.alog-filter:hover {\n    background: var(--bg3);\n}\n.alog-filter-count {\n    font-size: 10px;\n    opacity: 0.5;\n    font-weight: 600;\n}\n.alog-clear-btn {\n    display: inline-flex;\n    align-items: center;\n    gap: 4px;\n    padding: 4px 10px;\n    border: 1px solid var(--border2);\n    background: transparent;\n    color: var(--text-dim);\n    border-radius: var(--r);\n    font-size: 11px;\n    cursor: pointer;\n    white-space: nowrap;\n    flex-shrink: 0;\n    transition:\n        color 0.15s,\n        border-color 0.15s;\n}\n.alog-clear-btn:hover {\n    color: var(--red, #e74856);\n    border-color: var(--red, #e74856);\n}\n.alog-list {\n    height: 300px;\n    overflow-y: auto;\n}\n.alog-group {\n    padding: 6px 16px 4px;\n    font-size: 10px;\n    font-weight: 600;\n    text-transform: uppercase;\n    letter-spacing: 0.5px;\n    color: var(--text-dim);\n    background: var(--bg3);\n    border-bottom: 1px solid var(--border);\n    position: sticky;\n    top: 0;\n    z-index: 1;\n}\n.alog-item {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    padding: 7px 16px;\n    border-bottom: 1px solid var(--border);\n    font-size: 12.5px;\n}\n.alog-badge {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    min-width: 80px;\n    padding: 2px 8px;\n    border-radius: 3px;\n    font-size: 11px;\n    font-weight: 500;\n    white-space: nowrap;\n    flex-shrink: 0;\n    color: var(--bc);\n    background: color-mix(in srgb, var(--bc) 12%, transparent);\n    border: 1px solid color-mix(in srgb, var(--bc) 28%, transparent);\n}\n.alog-detail {\n    flex: 1;\n    min-width: 0;\n    display: flex;\n    flex-direction: column;\n    gap: 2px;\n    overflow: hidden;\n}\n.alog-detail-main {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    color: var(--text);\n}\n.alog-path-chip {\n    display: block;\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    font-family: var(--mono);\n    font-size: 10px;\n    color: var(--text-dim);\n    background: var(--bg3);\n    border: 1px solid var(--border);\n    border-radius: 3px;\n    padding: 0 5px;\n    line-height: 17px;\n    letter-spacing: 0.02em;\n    cursor: default;\n    user-select: text;\n    margin-top: 1px;\n}\n.alog-path-chip:hover {\n    color: var(--text);\n    border-color: var(--border2);\n}\n.alog-paths {\n    display: flex;\n    align-items: center;\n    gap: 4px;\n    min-width: 0;\n    margin-top: 1px;\n}\n.alog-paths > .alog-path-chip {\n    display: inline-block;\n    max-width: calc(50% - 10px);\n    flex: 0 1 auto;\n    margin-top: 0;\n}\n.alog-arrow {\n    flex-shrink: 0;\n    font-size: 11px;\n    color: var(--text-dim);\n}\n.alog-time {\n    flex-shrink: 0;\n    font-size: 11px;\n    color: var(--text-dim);\n    align-self: flex-start;\n    padding-top: 2px;\n}\n.alog-off,\n.alog-empty {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    height: 340px;\n    color: var(--text-dim);\n    gap: 12px;\n    padding: 0 24px;\n}\n.alog-off-text {\n    font-size: 13px;\n}\n\n/* ============================================================\n   SETTINGS SUB-OPTION ROW\n   ============================================================ */\n.settings-sub-row {\n    padding-left: 28px;\n    position: relative;\n}\n.settings-sub-indicator {\n    position: absolute;\n    left: 12px;\n    top: -6px;\n    width: 10px;\n    height: calc(50% + 4px);\n    border-left: 1.5px solid var(--border2);\n    border-bottom: 1.5px solid var(--border2);\n    border-radius: 0;\n}\n.settings-sub-row.disabled {\n    opacity: 0.35;\n    pointer-events: none;\n}\n\n/* ============================================================\n   CONTAINER INTEGRITY SCANNER MODAL\n   ============================================================ */\n.modal-scanner {\n    width: 560px;\n    min-width: 520px;\n    max-width: 620px;\n    height: 500px;\n    display: flex;\n    flex-direction: column;\n    overflow: hidden;\n}\n.scanner-body {\n    display: flex;\n    flex-direction: column;\n    gap: 14px;\n    padding: 18px 20px !important;\n    flex: 1 1 0;\n    min-height: 0;\n    overflow: hidden;\n}\n.scanner-desc {\n    display: flex;\n    align-items: flex-start;\n    gap: 12px;\n    flex-shrink: 0;\n}\n.scanner-shield {\n    flex-shrink: 0;\n    color: var(--accent);\n    opacity: 0.7;\n    margin-top: 2px;\n}\n.scanner-desc-title {\n    font-size: 13px;\n    font-weight: 600;\n    color: var(--text);\n    margin-bottom: 3px;\n}\n.scanner-desc-text {\n    font-size: 11.5px;\n    color: var(--text-dim);\n    line-height: 1.55;\n}\n.scanner-log {\n    border: 1px solid var(--border2);\n    border-radius: var(--r);\n    background: var(--bg2);\n    padding: 10px 12px;\n    font-size: 12px;\n    font-family: var(--font);\n    flex: 1 1 0;\n    min-height: 0;\n    overflow-y: auto;\n    display: flex;\n    flex-direction: column;\n    gap: 0;\n}\n.scanner-log:empty {\n    /* Placeholder-like appearance before scan starts */\n    background: color-mix(in srgb, var(--bg2) 80%, transparent);\n}\n.scanner-step {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    height: 26px;\n    min-height: 26px;\n    max-height: 26px;\n    padding: 0;\n    color: var(--text-dim);\n    font-size: 12px;\n    line-height: 26px;\n    contain: layout style;\n}\n.scanner-step-icon {\n    flex-shrink: 0;\n    width: 16px;\n    height: 16px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    overflow: hidden;\n}\n.scanner-step-icon .spinner {\n    width: 12px;\n    height: 12px;\n    border: 1.5px solid var(--border2);\n    border-top-color: var(--accent);\n    border-radius: 50%;\n    animation: spin 0.6s linear infinite;\n}\n@keyframes spin {\n    to {\n        transform: rotate(360deg);\n    }\n}\n.scanner-step-icon svg {\n    display: block;\n}\n.scanner-step.pass .scanner-step-icon {\n    color: #34d399;\n}\n.scanner-step.fail .scanner-step-icon {\n    color: #e74856;\n}\n.scanner-step.warn .scanner-step-icon {\n    color: #f59e0b;\n}\n.scanner-step .scanner-step-label {\n    flex: 1;\n}\n.scanner-step .scanner-step-result {\n    font-size: 11px;\n    color: var(--text-dim);\n    opacity: 0.7;\n    white-space: nowrap;\n}\n.scanner-step.fail .scanner-step-result {\n    color: #e74856;\n    opacity: 1;\n}\n.scanner-step.warn .scanner-step-result {\n    color: #f59e0b;\n    opacity: 1;\n}\n.scanner-summary {\n    border-radius: var(--r);\n    padding: 12px 14px;\n    font-size: 12.5px;\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    flex-shrink: 0;\n}\n.scanner-summary.healthy {\n    background: color-mix(in srgb, #34d399 8%, var(--bg2));\n    border: 1px solid color-mix(in srgb, #34d399 25%, transparent);\n    color: #34d399;\n}\n.scanner-summary.issues {\n    background: color-mix(in srgb, #f59e0b 8%, var(--bg2));\n    border: 1px solid color-mix(in srgb, #f59e0b 25%, transparent);\n    color: #f59e0b;\n}\n.scanner-summary.repaired {\n    background: color-mix(in srgb, #34d399 8%, var(--bg2));\n    border: 1px solid color-mix(in srgb, #34d399 25%, transparent);\n    color: #34d399;\n}\n.scanner-summary.critical {\n    background: color-mix(in srgb, #e74856 8%, var(--bg2));\n    border: 1px solid color-mix(in srgb, #e74856 25%, transparent);\n    color: #e74856;\n}\n.scanner-summary.warnings {\n    background: color-mix(in srgb, #60a5fa 8%, var(--bg2));\n    border: 1px solid color-mix(in srgb, #60a5fa 25%, transparent);\n    color: #60a5fa;\n}\n.scanner-summary svg {\n    flex-shrink: 0;\n}\n.scanner-summary-text {\n    line-height: 1.5;\n}\n.scanner-summary-text strong {\n    font-weight: 600;\n    color: var(--text);\n}\n.btn-deep-clean {\n    background: color-mix(in srgb, #e74856 15%, var(--bg2));\n    border-color: color-mix(in srgb, #e74856 40%, transparent);\n    color: #e74856;\n}\n.btn-deep-clean:hover {\n    background: color-mix(in srgb, #e74856 25%, var(--bg2));\n    border-color: #e74856;\n}\n\n/* ── Repair confirmation overlay (above scanner modal) ── */\n.repair-confirm-overlay {\n    position: fixed;\n    inset: 0;\n    z-index: 9000;\n    background: rgba(0, 0, 0, 0.55);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    opacity: 0;\n    pointer-events: none;\n    transition: opacity 0.18s ease;\n}\n.repair-confirm-overlay.show {\n    opacity: 1;\n    pointer-events: auto;\n}\n.repair-confirm-dialog {\n    background: var(--bg);\n    border: 1px solid var(--border);\n    border-radius: var(--r2);\n    padding: 28px 28px 22px;\n    max-width: 400px;\n    text-align: center;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: 10px;\n    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);\n}\n.repair-confirm-icon {\n    color: #f59e0b;\n}\n.repair-confirm-title {\n    font-size: 14px;\n    font-weight: 600;\n    color: var(--text);\n}\n.repair-confirm-text {\n    font-size: 12.5px;\n    color: var(--text-dim);\n    line-height: 1.6;\n}\n.repair-confirm-actions {\n    display: flex;\n    gap: 8px;\n    margin-top: 8px;\n    flex-wrap: wrap;\n    justify-content: center;\n}\n\n/* ============================================================\n   AUTH DOT (red dot in context menu)\n   ============================================================ */\n.auth-dot {\n    width: 6px;\n    height: 6px;\n    border-radius: 50%;\n    background: #e74856;\n    display: inline-block;\n    flex-shrink: 0;\n}\n\n/* Burger button + dropdown: hidden by default (desktop) */\n.topbar-burger {\n    display: none;\n}\n.topbar-dropdown {\n    display: none;\n}\n\n/* ============================================================\n   EXTRACTED INLINE STYLES\n   ============================================================ */\n\n/* Home warning box */\n.home-warning-box > svg {\n    flex-shrink: 0;\n    margin-top: 2px;\n}\n.home-warning-box > div {\n    flex: 1;\n}\n\n/* Unlock crypto info */\n.unlock-crypto-info {\n    text-align: center;\n    font-size: 11px;\n    color: var(--text-dim);\n    margin-top: 4px;\n}\n\n/* Taskbar lock button */\n#btn-lock-taskbar {\n    margin-left: 8px;\n}\n\n/* Hidden file inputs */\n#file-input,\n#import-container-input {\n    display: none;\n}\n\n/* Notice-box consecutive spacing */\n.notice-box + .notice-box {\n    margin-top: 10px;\n}\n\n/* Agree checkbox label */\n.agree-label {\n    display: flex;\n    align-items: center;\n    gap: 9px;\n    margin-top: 10px;\n    cursor: pointer;\n    user-select: none;\n}\n.agree-cb {\n    width: 15px;\n    height: 15px;\n    accent-color: var(--accent);\n    cursor: pointer;\n    flex-shrink: 0;\n}\n.agree-text {\n    font-size: 12px;\n    color: var(--text-dim);\n    line-height: 1.4;\n}\n\n/* Modal header actions */\n.modal-header-actions {\n    display: flex;\n    gap: 6px;\n    align-items: center;\n}\n\n/* Editor meta modified */\n#editor-meta-modified {\n    color: var(--orange);\n}\n\n/* Unsaved changes dialog */\n.unsaved-overlay {\n    position: absolute;\n    inset: 0;\n    background: rgba(0, 0, 0, 0.58);\n    z-index: 20;\n    align-items: center;\n    justify-content: center;\n}\n.unsaved-dialog {\n    background: var(--bg2);\n    border: 1px solid var(--border2);\n    border-radius: var(--r);\n    padding: 24px;\n    min-width: 300px;\n    max-width: 90%;\n    display: flex;\n    flex-direction: column;\n    gap: 16px;\n    box-shadow: var(--shadow);\n}\n.unsaved-title {\n    font-size: 14px;\n    font-weight: 600;\n    color: var(--text-bright);\n}\n.unsaved-msg {\n    font-size: 13px;\n    color: var(--text-dim);\n    line-height: 1.5;\n}\n.unsaved-actions {\n    display: flex;\n    gap: 8px;\n    justify-content: flex-end;\n    flex-wrap: wrap;\n}\n\n/* Modal title variants */\n.modal-title-danger {\n    color: var(--red);\n}\n.modal-title-warn {\n    color: var(--orange);\n}\n\n/* Modal text variants */\n.modal-text {\n    font-size: 13px;\n    line-height: 1.6;\n}\n.modal-text-dim {\n    font-size: 13px;\n    line-height: 1.6;\n    color: var(--text-dim);\n}\n.modal-secondary {\n    font-size: 12px;\n    color: var(--text-dim);\n    line-height: 1.6;\n}\n\n/* Modal specific widths */\n#modal-new-container,\n#modal-del-container,\n#modal-change-pw,\n#modal-rename-container {\n    min-width: 420px;\n}\n#modal-rename,\n#modal-delete,\n#modal-new-text,\n#modal-new-folder,\n#modal-export-confirm {\n    min-width: 340px;\n}\n#modal-session-conflict,\n#modal-import-pw {\n    min-width: 380px;\n}\n#modal-alog-disable,\n#modal-alog-clear {\n    min-width: 320px;\n}\n#modal-export-pw {\n    width: 400px;\n    min-width: 360px;\n    max-width: 400px;\n}\n#modal-settings {\n    min-width: 520px;\n    max-width: 620px;\n}\n\n/* Session conflict gap */\n#modal-session-conflict .modal-body {\n    gap: 10px;\n}\n\n/* Delete container warning */\n.dc-warn-title {\n    font-weight: 700;\n    font-size: 14px;\n    color: var(--red);\n    margin-bottom: 4px;\n}\n.dc-warn-text {\n    font-size: 12px;\n    color: var(--text-dim);\n    line-height: 1.5;\n}\n#dc-name {\n    color: var(--text);\n}\n#dc-msg {\n    font-size: 13px;\n    line-height: 1.6;\n    margin-top: 8px;\n}\n\n/* Export modal header */\n.exp-header {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n}\n.exp-icon {\n    width: 32px;\n    height: 32px;\n    border-radius: 8px;\n    background: rgba(0, 120, 212, 0.12);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    flex-shrink: 0;\n}\n.exp-icon > svg {\n    color: var(--accent);\n}\n.exp-subtitle {\n    font-size: 11px;\n    color: var(--text-dim);\n    margin-top: 1px;\n}\n\n/* Export modal body/footer */\n#modal-export-pw .modal-body {\n    gap: 12px;\n}\n.notice-box.accent {\n    border-left-color: var(--accent);\n}\n.notice-box.accent > svg {\n    color: var(--accent);\n}\n.code-tag {\n    background: var(--bg4);\n    padding: 1px 4px;\n    border-radius: 3px;\n    font-size: 11px;\n}\n#ec-filename {\n    font-weight: 600;\n}\n.modal-footer-split {\n    justify-content: space-between;\n    align-items: center;\n}\n.encrypt-label {\n    font-size: 11px;\n    color: var(--text-dim);\n    display: flex;\n    align-items: center;\n    gap: 5px;\n}\n.modal-actions {\n    display: flex;\n    gap: 8px;\n}\n\n/* Export confirm notice spacing */\n#modal-export-confirm .notice-box {\n    margin-top: 8px;\n}\n\n/* Import modal text spacing */\n#modal-import-pw .modal-text {\n    margin-bottom: 8px;\n}\n\n/* Settings modal body */\n#modal-settings .modal-body {\n    padding: 0;\n}\n.settings-section + .settings-section {\n    margin-top: 4px;\n}\n.settings-desc {\n    font-size: 12px;\n    color: var(--text-dim);\n    line-height: 1.5;\n    margin-bottom: 8px;\n}\n.settings-label-block {\n    display: block;\n    margin-bottom: 8px;\n}\n\n/* Activity logs SVG opacity */\n#alog-off > svg {\n    opacity: 0.45;\n}\n#alog-empty > svg {\n    opacity: 0.35;\n}\n\n/* TWC bar background */\n#twc-bar-fill {\n    background: var(--purple);\n}\n\n/* Eye-closed SVG initial state */\n.eye-closed {\n    display: none;\n}\n\n/* ============================================================\n   MOBILE RESPONSIVE\n   ============================================================ */\n@media (max-width: 640px) {\n    /* Header */\n    .app-header {\n        padding: 0 10px;\n        gap: 8px;\n    }\n    .header-logo span {\n        font-size: 14px;\n    }\n    .header-logo small {\n        display: none;\n    }\n    .header-actions .btn {\n        padding: 5px 8px;\n        font-size: 11px;\n        gap: 4px;\n    }\n    .header-actions .btn svg {\n        width: 12px;\n        height: 12px;\n    }\n\n    /* Home body */\n    .home-body {\n        padding: 12px;\n        gap: 14px;\n    }\n    .container-grid {\n        grid-template-columns: 1fr;\n        gap: 10px;\n    }\n    .container-card {\n        padding: 14px;\n    }\n    .home-warning-box {\n        font-size: 11px;\n        padding: 10px;\n    }\n\n    /* Storage footer */\n    .storage-footer {\n        padding: 10px 12px;\n    }\n\n    /* Desktop topbar — show only back + breadcrumb + burger */\n    .desktop-topbar {\n        padding: 0 8px;\n        gap: 6px;\n        height: 44px;\n        min-height: 44px;\n    }\n    .desktop-topbar .btn-sm:not(.topbar-burger):not(#btn-lock) {\n        display: none;\n    }\n    .desktop-topbar .separator {\n        display: none;\n    }\n    .topbar-burger {\n        display: flex;\n        align-items: center;\n        gap: 5px;\n        margin-left: auto;\n        flex-shrink: 0;\n        font-size: 13px;\n        font-weight: 500;\n        padding: 6px 10px;\n    }\n    .breadcrumb {\n        font-size: 12px;\n    }\n\n    /* Burger dropdown — fixed so it overlays the view without breaking layout */\n    .topbar-dropdown {\n        position: fixed;\n        top: 44px;\n        left: 0;\n        right: 0;\n        z-index: 7500;\n        background: var(--bg2);\n        border-bottom: 1px solid var(--border);\n        box-shadow: 0 6px 20px rgba(0, 0, 0, 0.55);\n        display: flex;\n        flex-direction: column;\n        transform: translateY(-8px);\n        opacity: 0;\n        pointer-events: none;\n        transition:\n            transform 0.18s ease,\n            opacity 0.18s ease;\n        /* Ensure a stable compositing layer for the entire animation so that\n           text inside does not snap/jump at the end of the enter transition\n           (Firefox sub-pixel GPU layer promotion artefact). */\n        will-change: transform, opacity;\n        backface-visibility: hidden;\n    }\n    .topbar-dropdown.open {\n        transform: translateY(0);\n        opacity: 1;\n        pointer-events: all;\n    }\n    .topbar-dd-item {\n        display: flex;\n        align-items: center;\n        gap: 12px;\n        padding: 13px 18px;\n        font-size: 14px;\n        font-family: var(--font);\n        color: var(--text);\n        background: none;\n        border: none;\n        cursor: pointer;\n        text-align: left;\n        transition: background var(--transition);\n        -webkit-tap-highlight-color: transparent;\n    }\n    .topbar-dd-item:hover,\n    .topbar-dd-item:active {\n        background: var(--bg3);\n    }\n    .topbar-dd-item svg {\n        flex-shrink: 0;\n        color: var(--text-dim);\n    }\n    .topbar-dd-sep {\n        height: 1px;\n        background: var(--border);\n        margin: 2px 0;\n    }\n\n    /* Taskbar */\n    .taskbar {\n        padding: 0 8px;\n        gap: 6px;\n        height: 36px;\n        min-height: 36px;\n    }\n    .taskbar-container-name {\n        font-size: 12px;\n    }\n    .taskbar-storage {\n        font-size: 10px;\n    }\n    .taskbar-bar-wrap {\n        width: 50px;\n    }\n    #btn-lock-taskbar {\n        font-size: 11px;\n        padding: 5px 8px;\n    }\n\n    /* Context menu */\n    .ctx-menu {\n        min-width: 180px;\n    }\n    .ctx-item {\n        padding: 11px 16px;\n        font-size: 14px;\n    }\n\n    /* Modals */\n    .modal {\n        min-width: 0 !important;\n        width: 95vw;\n        max-width: 95vw;\n    }\n    .modal-editor {\n        width: 98vw !important;\n        height: 90vh !important;\n    }\n    .modal-viewer {\n        width: 98vw !important;\n        height: 90vh !important;\n    }\n    .modal-settings {\n        width: 95vw !important;\n        max-height: 85vh !important;\n    }\n\n    /* Prevent iOS auto-zoom on input focus (font-size must be ≥16px) */\n    .input,\n    textarea.code-editor {\n        font-size: 16px !important;\n    }\n\n    /* Folder windows: full-screen on mobile, leave room for taskbar */\n    .folder-window {\n        position: fixed !important;\n        left: 0 !important;\n        top: 0 !important;\n        width: 100vw !important;\n        height: calc(100dvh - 37px) !important;\n        min-width: unset !important;\n        min-height: unset !important;\n        border-radius: 0;\n        z-index: 7800 !important;\n    }\n    .fw-resize {\n        display: none !important;\n    }\n\n    .stats-grid {\n        grid-template-columns: 1fr 1fr;\n        gap: 8px;\n    }\n    .stats-card-value {\n        font-size: 16px;\n    }\n\n    /* Unlock view */\n    .unlock-box {\n        padding: 24px 20px;\n        max-width: 95vw;\n    }\n    .unlock-title {\n        font-size: 18px;\n    }\n\n    /* Toast */\n    #toast-container {\n        bottom: 46px;\n        right: 8px;\n        left: 8px;\n    }\n    .toast {\n        max-width: 100%;\n    }\n}\n\n/* Prevent touch callout and text selection on interactive areas */\n@media (pointer: coarse) {\n    .desktop-area,\n    .fw-area,\n    .file-item,\n    .ctx-item,\n    .container-card {\n        -webkit-touch-callout: none;\n        -webkit-tap-highlight-color: transparent;\n    }\n    .ctx-item {\n        padding: 10px 14px;\n    }\n}\n\n.settings-section-title {\n    font-size: 11px;\n    font-weight: 600;\n    text-transform: uppercase;\n    color: var(--text-dim);\n    margin-bottom: 4px;\n    letter-spacing: 0.5px;\n    border-bottom: 1px solid var(--border2);\n    padding-bottom: 4px;\n}\n\n/* ---- Settings inline hint icon + tooltip ---- */\n.settings-hint {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    flex-shrink: 0;\n    width: 14px;\n    height: 14px;\n    margin-left: 5px;\n    position: relative;\n    top: 1px;\n    color: var(--text-dim);\n    opacity: 0.55;\n    cursor: default;\n    background: none;\n    border: none;\n    padding: 0;\n    transition:\n        opacity var(--transition),\n        color var(--transition);\n}\n.settings-hint:hover {\n    opacity: 1;\n    color: var(--accent);\n}\n.settings-hint svg {\n    display: block;\n}\n.settings-tip {\n    position: fixed;\n    background: var(--bg3);\n    border: 1px solid var(--border2);\n    color: var(--text-dim);\n    font-size: 11px;\n    line-height: 1.6;\n    padding: 7px 10px;\n    border-radius: var(--r);\n    box-shadow: var(--shadow-sm);\n    z-index: 9800;\n    pointer-events: none;\n    white-space: normal;\n    max-width: 260px;\n    animation: tooltipIn 0.1s ease;\n}\n\n/* CUSTOM DROPDOWN UI */\n.custom-dd {\n    position: relative;\n    width: 140px;\n}\n.custom-dd-head {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    background: var(--bg3);\n    border: 1px solid var(--border);\n    padding: 6px 10px;\n    border-radius: var(--r);\n    cursor: pointer;\n    font-size: 13px;\n    color: var(--text);\n    transition: border-color var(--transition);\n    user-select: none;\n}\n.custom-dd-head:hover {\n    border-color: var(--border2);\n}\n.custom-dd-head.active {\n    border-color: var(--accent);\n}\n.custom-dd-menu {\n    position: absolute;\n    top: calc(100% + 4px);\n    left: 0;\n    right: 0;\n    background: var(--bg3);\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    box-shadow: var(--shadow);\n    z-index: 9999;\n    display: none;\n    max-height: 200px;\n    overflow-y: auto;\n}\n.custom-dd.open .custom-dd-menu {\n    display: block;\n}\n.custom-dd-opt {\n    padding: 8px 10px;\n    font-size: 13px;\n    color: var(--text);\n    cursor: pointer;\n    transition: background var(--transition);\n}\n.custom-dd-opt:hover {\n    background: var(--accent);\n    color: #fff;\n}\n.custom-dd-opt.selected {\n    background: rgba(0, 120, 212, 0.2);\n    color: var(--accent);\n}\n\n/* ============================================================\n   DAEMON — Alert overlay + Security barrier veil\n   ============================================================ */\n/* Overlay backdrop — covers full viewport, centres the card */\n.snv-overlay {\n    position: fixed;\n    inset: 0;\n    z-index: 2147483647;\n    background: rgba(0, 0, 0, 0.85);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    font-family: var(--font);\n}\n/* Alert card */\n.snv-card {\n    max-width: 520px;\n    width: calc(100% - 96px);\n    background: var(--bg);\n    border: 1px solid var(--red);\n    border-radius: var(--r);\n    padding: 24px 28px;\n    color: var(--text);\n    text-align: left;\n    box-shadow: var(--shadow);\n}\n/* Header row (icon + title) */\n.snv-header {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    margin-bottom: 16px;\n}\n.snv-icon {\n    color: var(--red);\n    flex-shrink: 0;\n}\n.snv-title {\n    font-size: 14px;\n    font-weight: 600;\n    color: var(--red);\n    letter-spacing: 0.01em;\n}\n/* Reason code block */\n.snv-reason {\n    font-size: 12px;\n    font-family: var(--mono);\n    background: var(--bg2);\n    padding: 8px 12px;\n    border: 1px solid var(--border);\n    border-radius: var(--r);\n    margin-bottom: 14px;\n    word-break: break-all;\n    color: var(--red);\n}\n/* Description paragraphs */\n.snv-desc {\n    font-size: 13px;\n    line-height: 1.6;\n    color: var(--text);\n    margin-bottom: 6px;\n}\n.snv-desc strong {\n    color: var(--text-bright);\n}\n.snv-hint {\n    font-size: 13px;\n    line-height: 1.6;\n    color: var(--text-dim);\n    margin-bottom: 18px;\n}\n/* Reload button */\n.snv-btn {\n    background: #5a1a1a;\n    color: var(--red);\n    border: 1px solid #7a2222;\n    border-radius: var(--r);\n    padding: 6px 14px;\n    font-family: var(--font);\n    font-size: 13px;\n    cursor: pointer;\n}\n.snv-btn:hover {\n    background: #7a2222;\n}\n/* Barrier veil — diagonal animated stripes */\n.snv-veil {\n    position: fixed;\n    inset: 0;\n    z-index: 2147483646;\n    background-image: linear-gradient(-45deg, rgba(6, 0, 0, 0.92) 0%, rgba(6, 0, 0, 0.92) 25%, rgba(155, 18, 18, 0.6) 25%, rgba(155, 18, 18, 0.6) 50%, rgba(6, 0, 0, 0.92) 50%, rgba(6, 0, 0, 0.92) 75%, rgba(155, 18, 18, 0.6) 75%, rgba(155, 18, 18, 0.6) 100%);\n    background-size: 32px 32px;\n    display: block;\n}\n"
  },
  {
    "path": "src/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n    <meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src blob:; frame-src blob: about:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; base-uri 'self'; form-action 'none'; object-src 'none'\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"description\" content=\"SafeNova — browser-based encrypted virtual file system. AES-256-GCM + Argon2id, zero-knowledge, fully offline.\">\n    <meta name=\"theme-color\" content=\"#1e1e1e\">\n    <meta name=\"color-scheme\" content=\"dark\">\n    <meta name=\"robots\" content=\"noindex, nofollow\">\n    <meta name=\"referrer\" content=\"no-referrer\">\n    <title>SafeNova</title>\n    <link rel=\"icon\" href=\"favicon.png\" type=\"image/png\">\n    <link rel=\"apple-touch-icon\" href=\"favicon.png\">\n    <script src=\"js/docmode.js\"></script>\n    <script src=\"js/proactive/daemon.js\"></script>\n    <link rel=\"stylesheet\" href=\"css/app.css\">\n</head>\n\n<body>\n\n    <!-- ==================== LOADING OVERLAY ==================== -->\n    <div class=\"loading-overlay\" id=\"loading-overlay\">\n        <div class=\"loading-spinner\"></div>\n        <div class=\"loading-msg\" id=\"loading-msg\">Processing...</div>\n    </div>\n\n    <!-- ==================== INCOGNITO WARNING ==================== -->\n    <div id=\"incognito-warning\" class=\"incognito-warning\" style=\"display:none\">\n        <div class=\"incognito-warning-card\">\n            <div class=\"incognito-warning-header\">\n                <svg width=\"22\" height=\"22\" viewBox=\"0 0 28 28\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"M14 3L2 25h24L14 3z\" fill=\"var(--orange)\" opacity=\".15\" stroke=\"var(--orange)\" stroke-width=\"1.5\" stroke-linejoin=\"round\" />\n                    <path d=\"M14 11v7\" stroke=\"var(--orange)\" stroke-width=\"2\" stroke-linecap=\"square\" />\n                    <circle cx=\"14\" cy=\"21\" r=\"1.2\" fill=\"var(--orange)\" />\n                </svg>\n                <span class=\"incognito-warning-title\">Private / Incognito Mode Detected</span>\n            </div>\n            <div class=\"incognito-warning-body\">\n                <p>SafeNova stores encrypted containers in IndexedDB. Private mode imposes severe restrictions:</p>\n                <ul>\n                    <li>Containers will be <strong>permanently lost</strong> when you close this window</li>\n                    <li>Sessions cannot persist between browser restarts</li>\n                    <li>Storage quota is capped</li>\n                </ul>\n                <p class=\"incognito-warning-recommend\">For reliable use, open SafeNova in a normal browser window.</p>\n            </div>\n            <div class=\"incognito-warning-footer\">\n                <button class=\"btn btn-primary\" id=\"incognito-continue-btn\" disabled>\n                    <span id=\"incognito-continue-lbl\">Wait… 3</span>\n                </button>\n            </div>\n        </div>\n    </div>\n\n    <!-- ==================== HOME VIEW ==================== -->\n    <div id=\"view-home\" class=\"view\">\n        <header class=\"app-header\">\n            <div class=\"header-logo\">\n                <img src=\"favicon.png\" class=\"header-logo-icon\" alt=\"logo\">\n                <span class=\"header-brand\">SafeNova</span><span class=\"tier-badge\">Enterprise</span>\n            </div>\n            <span class=\"header-spacer\"></span>\n            <div class=\"header-actions\">\n                <button class=\"btn btn-ghost btn-sm\" id=\"btn-import-container\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M7 9V1M3 6l4 4 4-4M2 11h10v2H2z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                    Import\n                </button>\n                <button class=\"btn btn-primary\" id=\"btn-new-container\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M7 1v12M1 7h12\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"square\" />\n                    </svg>\n                    New Container\n                </button>\n            </div>\n        </header>\n\n        <div class=\"home-body\">\n            <div class=\"home-warning-box hidden\" id=\"home-warning-box\">\n                <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"M8 1.5L1 14.5h14z\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linejoin=\"round\" />\n                    <line x1=\"8\" y1=\"6.5\" x2=\"8\" y2=\"10\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" />\n                    <circle cx=\"8\" cy=\"12.5\" r=\"0.85\" fill=\"currentColor\" />\n                </svg>\n                <div>\n                    <strong>All files are stored directly in your browser</strong> and are never sent to any server.\n                    Clearing site data, reinstalling the browser, or using a different browser/device will\n                    <strong>permanently erase all containers and files</strong>. Use the Export function to back up your data.\n                </div>\n                <button class=\"warning-dismiss\" id=\"warning-dismiss\" title=\"Don't show again\">\n                    <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\">\n                        <path d=\"M2 2l8 8M10 2L2 10\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"round\" />\n                    </svg>\n                </button>\n            </div>\n            <div>\n                <div class=\"home-section-title\">SafeNova Containers</div>\n                <div class=\"container-grid\" id=\"container-grid\">\n                    <div class=\"container-empty\" id=\"container-empty\">\n                        <svg viewBox=\"0 0 48 48\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <rect x=\"8\" y=\"18\" width=\"32\" height=\"22\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"2\" />\n                            <path d=\"M16 18V13a8 8 0 0116 0v5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"square\" />\n                            <circle cx=\"24\" cy=\"29\" r=\"3\" stroke=\"currentColor\" stroke-width=\"2\" />\n                        </svg>\n                        <p>No containers yet. Create your first one.</p>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"home-doc-wrap\">\n                <div class=\"home-doc\" id=\"home-doc\">\n                    <button class=\"home-doc-collapse\" id=\"home-doc-collapse\" title=\"Hide\">\n                        <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\">\n                            <path d=\"M2 2l7 7M9 2L2 9\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" />\n                        </svg>\n                    </button>\n                    <span class=\"home-doc-badge\">Enterprise Security Platform</span>\n                    <div class=\"home-doc-title\">SafeNova &mdash; Keep it private.</div>\n                    <p class=\"home-doc-p\">Store and manage your sensitive files in <strong>encrypted containers</strong> that never leave your browser &mdash; no servers, no accounts, no cloud. Your password is never saved anywhere; close the tab and access is gone instantly. The <strong>duress password</strong> silently destroys everything if you&rsquo;re ever forced to open it. Runtime anti-tamper protection <strong>SafeNova Proactive</strong> shields your data in the background at all times.</p>\n                    <div class=\"home-doc-stack\">\n                        <span>AES-256-GCM</span>\n                        <span>Argon2id</span>\n                        <span>Web Crypto API</span>\n                        <span>WebAuthn</span>\n                        <span>IndexedDB</span>\n                        <span>WASM</span>\n                    </div>\n                    <a href=\"https://github.com/DosX-dev/SafeNova\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"github-link\">\n                        <svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"currentColor\" xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\">\n                            <path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z\" />\n                        </svg>\n                        <span>DosX-dev/SafeNova</span>\n                        <svg width=\"9\" height=\"9\" viewBox=\"0 0 10 10\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" class=\"github-link-arrow\">\n                            <path d=\"M2 8L8 2M8 2H4M8 2v4\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"square\" />\n                        </svg>\n                    </a>\n                </div>\n                <button class=\"home-doc-tab\" id=\"home-doc-tab\" title=\"Security info\">\n                    <svg width=\"11\" height=\"11\" viewBox=\"0 0 12 12\" fill=\"none\">\n                        <path d=\"M6 1L1 3.5v4c0 2.5 2 4 5 4.5 3-.5 5-2 5-4.5v-4z\" stroke=\"currentColor\" stroke-width=\"1.1\" stroke-linejoin=\"round\" />\n                        <path d=\"M4 6l1.5 1.5L8 4\" stroke=\"currentColor\" stroke-width=\"1.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" />\n                    </svg>\n                    <span>Security Info</span>\n                </button>\n            </div>\n        </div>\n\n        <div class=\"storage-warning-banner\" id=\"storage-warning-banner\">\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                <path d=\"M8 2L1 14h14z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\" />\n                <path d=\"M8 6v4\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" />\n                <circle cx=\"8\" cy=\"12\" r=\"0.8\" fill=\"currentColor\" />\n            </svg>\n            <span>Low storage</span>\n        </div>\n\n        <div class=\"storage-footer\">\n            <div class=\"storage-row\">\n                <span class=\"storage-label\">Device Storage</span>\n                <div class=\"storage-bar-wrap\">\n                    <div class=\"storage-bar-fill\" id=\"storage-bar-fill\" style=\"width:0%\"></div>\n                </div>\n                <span class=\"storage-text\" id=\"storage-text\">-</span>\n            </div>\n            <div class=\"storage-row\">\n                <span class=\"storage-label\">SafeNova</span>\n                <div class=\"storage-bar-wrap\">\n                    <div class=\"storage-bar-fill\" id=\"twc-bar-fill\" style=\"width:0%\"></div>\n                </div>\n                <span class=\"storage-text\" id=\"twc-text\">-</span>\n            </div>\n        </div>\n    </div>\n\n    <!-- ==================== UNLOCK VIEW ==================== -->\n    <div id=\"view-unlock\" class=\"view\">\n        <div class=\"unlock-box\">\n            <button class=\"btn btn-ghost btn-sm unlock-back\" id=\"btn-back\">\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"M9 2L4 7l5 5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                </svg>\n                Back\n            </button>\n            <div class=\"unlock-icon\">\n                <svg width=\"36\" height=\"36\" viewBox=\"0 0 40 40\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <rect x=\"7\" y=\"18\" width=\"26\" height=\"18\" rx=\"2\" fill=\"var(--bg3)\" stroke=\"var(--border2)\" stroke-width=\"1.5\" />\n                    <path d=\"M13 18v-5a7 7 0 0114 0v5\" stroke=\"var(--text-dim)\" stroke-width=\"2.5\" stroke-linecap=\"square\" />\n                    <circle cx=\"20\" cy=\"27\" r=\"3\" fill=\"var(--text-dim)\" />\n                </svg>\n            </div>\n            <div class=\"unlock-identity\">\n                <div class=\"unlock-eyebrow\">SafeNova</div>\n                <div class=\"unlock-name-badge\" id=\"unlock-name\">-</div>\n            </div>\n            <div class=\"form-group\">\n                <div class=\"input-wrap\">\n                    <input type=\"password\" class=\"input\" id=\"unlock-pw\" placeholder=\"Enter container password\" autocomplete=\"current-password\">\n                    <button class=\"input-eye\" id=\"unlock-pw-eye\" tabindex=\"-1\">\n                        <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path d=\"M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z\" stroke=\"currentColor\" stroke-width=\"1.5\" />\n                            <circle cx=\"8\" cy=\"8\" r=\"2.5\" stroke=\"currentColor\" stroke-width=\"1.5\" />\n                        </svg>\n                    </button>\n                </div>\n            </div>\n            <div class=\"unlock-spinner\" id=\"unlock-spinner\">\n                <div class=\"spinner\"></div>\n                <span>Deriving key (AES-256 / Argon2id)...</span>\n            </div>\n            <div class=\"unlock-error\" id=\"unlock-error\"></div>\n            <div class=\"remember-section\">\n                <label class=\"remember-row\">\n                    <input type=\"checkbox\" id=\"unlock-remember\">\n                    <span>Remember password</span>\n                </label>\n                <div class=\"remember-opts\" id=\"remember-opts\">\n                    <label class=\"remember-opt disabled\">\n                        <input type=\"radio\" name=\"remember-scope\" id=\"remember-tab\" value=\"tab\" checked disabled>\n                        <span>Current tab session <span class=\"remember-opt-badge\">Recommended</span></span>\n                    </label>\n                    <label class=\"remember-opt disabled\">\n                        <input type=\"radio\" name=\"remember-scope\" id=\"remember-browser\" value=\"browser\" disabled>\n                        <span>Stay signed in</span>\n                    </label>\n                </div>\n            </div>\n            <button class=\"btn btn-primary btn-lg\" id=\"btn-unlock\">\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <rect x=\"2\" y=\"6\" width=\"10\" height=\"7\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.5\" />\n                    <path d=\"M4 6V4a3 3 0 015.8-1\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                </svg>\n                Unlock Container\n            </button>\n            <div class=\"unlock-crypto-info\">\n                AES-256-GCM &middot; Argon2id\n            </div>\n        </div>\n    </div>\n\n    <!-- ==================== DESKTOP VIEW ==================== -->\n    <div id=\"view-desktop\" class=\"view\">\n        <div class=\"desktop-topbar\">\n            <button class=\"btn btn-ghost btn-icon btn-sm\" id=\"btn-lock\" title=\"Back to menu\">\n                <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"M10 2L4 8l6 6\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                </svg>\n            </button>\n            <div class=\"breadcrumb\" id=\"breadcrumb\"></div>\n\n            <button class=\"btn btn-ghost btn-sm\" id=\"btn-settings\">\n                <svg width=\"15\" height=\"15\" viewBox=\"0 0 15 15\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"M7.5 9.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z\" stroke=\"currentColor\" stroke-width=\"1.4\" />\n                    <path d=\"M6.1 1.5 5.7 3.1a5.2 5.2 0 0 0-1.4.8L2.7 3.5 1.2 6l1.4 1.1a5 5 0 0 0 0 1.8L1.2 10l1.5 2.5 1.6-.4c.4.3.9.6 1.4.8l.4 1.6h3l.4-1.6c.5-.2 1-.5 1.4-.8l1.6.4L13.8 10l-1.4-1.1a5 5 0 0 0 0-1.8L13.8 6 12.3 3.5l-1.6.4a5.2 5.2 0 0 0-1.4-.8l-.4-1.6H6.1Z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\" />\n                </svg>\n                Environment\n            </button>\n            <div class=\"separator\"></div>\n            <button class=\"btn btn-ghost btn-sm\" id=\"btn-upload-toolbar\">\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"M7 9V1M3 5l4-4 4 4M2 11h10v2H2z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                </svg>\n                Import\n            </button>\n            <button class=\"btn btn-ghost btn-sm\" id=\"btn-new-file-toolbar\">\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"M2 1h7l3 3v9H2z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    <path d=\"M9 1v3h3\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    <path d=\"M7 7v4M5 9h4\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                </svg>\n                New File\n            </button>\n            <button class=\"btn btn-ghost btn-sm\" id=\"btn-new-folder-toolbar\">\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"M1 3h5l1.5 2H13v7H1z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    <path d=\"M7 7v3M5.5 8.5h3\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                </svg>\n                New Folder\n            </button>\n\n            <!-- Mobile burger button — hidden on desktop via CSS -->\n            <button class=\"btn btn-ghost btn-sm topbar-burger\" id=\"topbar-burger\" title=\"Menu\" aria-label=\"Menu\">\n                <svg width=\"18\" height=\"18\" viewBox=\"0 0 16 16\" fill=\"none\">\n                    <rect x=\"2\" y=\"3.5\" width=\"12\" height=\"1.5\" rx=\".75\" fill=\"currentColor\" />\n                    <rect x=\"2\" y=\"7.25\" width=\"12\" height=\"1.5\" rx=\".75\" fill=\"currentColor\" />\n                    <rect x=\"2\" y=\"11\" width=\"12\" height=\"1.5\" rx=\".75\" fill=\"currentColor\" />\n                </svg>\n                Menu\n            </button>\n        </div>\n\n        <!-- Mobile topbar dropdown — hidden on desktop -->\n        <div class=\"topbar-dropdown\" id=\"topbar-dropdown\">\n            <button class=\"topbar-dd-item\" id=\"topbar-dd-settings\">\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 15 15\" fill=\"none\">\n                    <path d=\"M7.5 9.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z\" stroke=\"currentColor\" stroke-width=\"1.4\" />\n                    <path d=\"M6.1 1.5 5.7 3.1a5.2 5.2 0 0 0-1.4.8L2.7 3.5 1.2 6l1.4 1.1a5 5 0 0 0 0 1.8L1.2 10l1.5 2.5 1.6-.4c.4.3.9.6 1.4.8l.4 1.6h3l.4-1.6c.5-.2 1-.5 1.4-.8l1.6.4L13.8 10l-1.4-1.1a5 5 0 0 0 0-1.8L13.8 6 12.3 3.5l-1.6.4a5.2 5.2 0 0 0-1.4-.8l-.4-1.6H6.1Z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\" />\n                </svg>\n                Environment\n            </button>\n            <div class=\"topbar-dd-sep\"></div>\n            <button class=\"topbar-dd-item\" id=\"topbar-dd-upload\">\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\">\n                    <path d=\"M7 9V1M3 5l4-4 4 4M2 11h10v2H2z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                </svg>\n                Import File\n            </button>\n            <button class=\"topbar-dd-item\" id=\"topbar-dd-newfile\">\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\">\n                    <path d=\"M2 1h7l3 3v9H2z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    <path d=\"M9 1v3h3\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    <path d=\"M7 7v4M5 9h4\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                </svg>\n                New File\n            </button>\n            <button class=\"topbar-dd-item\" id=\"topbar-dd-newfolder\">\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\">\n                    <path d=\"M1 3h5l1.5 2H13v7H1z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    <path d=\"M7 7v3M5.5 8.5h3\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                </svg>\n                New Folder\n            </button>\n        </div>\n\n        <div class=\"desktop-area\" id=\"desktop-area\" tabindex=\"0\">\n            <div class=\"drop-overlay\" id=\"drop-overlay\">\n                <svg width=\"48\" height=\"48\" viewBox=\"0 0 48 48\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"M24 8v24M12 20l12-12 12 12M8 36h32v4H8z\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"square\" />\n                </svg>\n                Drop files to upload and encrypt\n            </div>\n            <div class=\"selection-bar\" id=\"selection-bar\"></div>\n        </div>\n\n        <div class=\"taskbar\">\n            <img src=\"favicon.png\" class=\"taskbar-logo-icon\" width=\"16\" height=\"16\" alt=\"logo\">\n            <span class=\"taskbar-container-name\" id=\"taskbar-name\">-</span>\n            <span class=\"taskbar-sep\"></span>\n            <div class=\"taskbar-storage\">\n                <span id=\"taskbar-size-text\">-</span>\n                <div class=\"taskbar-bar-wrap\">\n                    <div class=\"taskbar-bar-fill\" id=\"taskbar-bar-fill\" style=\"width:0%\"></div>\n                </div>\n                <span id=\"taskbar-size-pct\">0%</span>\n            </div>\n            <button class=\"btn btn-danger btn-sm\" id=\"btn-lock-taskbar\">\n                <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <rect x=\"1\" y=\"5\" width=\"10\" height=\"7\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.4\" />\n                    <path d=\"M3 5V4a3 3 0 016 0v1\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" />\n                </svg>\n                Exit &amp; Kill Session\n            </button>\n        </div>\n    </div>\n\n    <!-- Hidden file inputs -->\n    <input type=\"file\" id=\"file-input\" multiple>\n    <input type=\"file\" id=\"import-container-input\" accept=\".safenova,.zip,.twc\">\n\n    <!-- ==================== CONTEXT MENU ==================== -->\n    <div class=\"ctx-menu\" id=\"ctx-menu\"></div>\n\n    <!-- ==================== MODAL OVERLAY ==================== -->\n    <div class=\"modal-overlay\" id=\"modal-overlay\">\n\n        <!-- New Container Modal -->\n        <div class=\"modal\" id=\"modal-new-container\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">Create New Container</span>\n                <button class=\"modal-close\" id=\"modal-nc-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <div class=\"form-group\">\n                    <label class=\"form-label\">Container Name</label>\n                    <input type=\"text\" class=\"input\" id=\"nc-name\" placeholder=\"e.g. Personal, Work, Archive\">\n                </div>\n                <div class=\"form-group\">\n                    <label class=\"form-label\">Password</label>\n                    <div class=\"input-wrap\">\n                        <input type=\"password\" class=\"input\" id=\"nc-pw\" placeholder=\"Choose a strong password\">\n                        <button class=\"input-eye\" id=\"nc-pw-eye\" tabindex=\"-1\">\n                            <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                                <path d=\"M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z\" stroke=\"currentColor\" stroke-width=\"1.5\" />\n                                <circle cx=\"8\" cy=\"8\" r=\"2.5\" stroke=\"currentColor\" stroke-width=\"1.5\" />\n                            </svg>\n                        </button>\n                    </div>\n                    <div class=\"pw-strength\" id=\"nc-pw-strength\" style=\"width:0%\"></div>\n                    <div class=\"pw-strength-row\" id=\"nc-pw-strength-label\"></div>\n                </div>\n                <div class=\"form-group\">\n                    <label class=\"form-label\">Confirm Password</label>\n                    <input type=\"password\" class=\"input\" id=\"nc-pw2\" placeholder=\"Repeat password\">\n                </div>\n                <div class=\"notice-box warn\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M7 1L1 13h12z\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linejoin=\"round\" />\n                        <path d=\"M7 5v4\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"round\" />\n                        <circle cx=\"7\" cy=\"10.5\" r=\"0.7\" fill=\"currentColor\" />\n                    </svg>\n                    <span>The password <strong>cannot be recovered</strong>. If lost, all data in this container will be permanently inaccessible.</span>\n                </div>\n                <div class=\"notice-box danger\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <circle cx=\"7\" cy=\"7\" r=\"6\" stroke=\"currentColor\" stroke-width=\"1.3\" />\n                        <path d=\"M7 4v3.5\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"round\" />\n                        <circle cx=\"7\" cy=\"10\" r=\"0.7\" fill=\"currentColor\" />\n                    </svg>\n                    <span><strong>SafeNova has no access to your data and takes no responsibility for data loss.</strong> All containers are stored exclusively in your browser.<br>Clearing browser data or switching devices will permanently destroy all files without the ability to recover them.</span>\n                </div>\n                <label class=\"agree-label\">\n                    <input type=\"checkbox\" id=\"nc-agree\" class=\"agree-cb\">\n                    <span class=\"agree-text\">I understand that I am solely responsible for my data</span>\n                </label>\n            </div>\n            <div class=\"modal-footer\">\n                <button id=\"nc-hwkey-btn\" title=\"Mix passkey entropy into salt\">\n                    <svg width=\"13\" height=\"13\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <rect x=\"1\" y=\"6\" width=\"9\" height=\"7\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\" />\n                        <path d=\"M3.5 6V4a2.5 2.5 0 015 0v2\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"square\" />\n                        <circle cx=\"11.5\" cy=\"5.5\" r=\"1.8\" stroke=\"currentColor\" stroke-width=\"1.2\" />\n                        <path d=\"M11.5 7.3V9.5\" stroke=\"currentColor\" stroke-width=\"1.2\" stroke-linecap=\"round\" />\n                    </svg>\n                    <span>Use passkey for salt</span>\n                </button>\n                <button class=\"btn\" id=\"nc-cancel\">Cancel</button>\n                <button class=\"btn btn-primary\" id=\"nc-create\" disabled>\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M7 1v12M1 7h12\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"square\" />\n                    </svg>\n                    Create Container\n                </button>\n            </div>\n        </div>\n\n        <!-- Text Editor Modal -->\n        <div class=\"modal modal-editor\" id=\"modal-editor\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\" id=\"editor-title\">Text Editor</span>\n                <div class=\"modal-header-actions\">\n                    <span class=\"badge encrypted\">AES-256</span>\n                    <button class=\"btn btn-ghost btn-sm\" id=\"btn-wordwrap\" title=\"Toggle Word Wrap\">\n                        <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path d=\"M1 3h12M1 7h9a2 2 0 010 4H7\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" />\n                            <path d=\"M8 9.5L6 11l2 1.5\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" stroke-linejoin=\"miter\" />\n                        </svg>\n                        Wrap\n                    </button>\n                    <button class=\"btn btn-primary btn-sm\" id=\"btn-save-editor\">\n                        <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <rect x=\"1\" y=\"1\" width=\"10\" height=\"10\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.3\" />\n                            <rect x=\"3\" y=\"1\" width=\"6\" height=\"4\" stroke=\"currentColor\" stroke-width=\"1.3\" />\n                            <rect x=\"3\" y=\"6\" width=\"5\" height=\"4\" stroke=\"currentColor\" stroke-width=\"1.2\" />\n                        </svg>\n                        Save\n                    </button>\n                    <button class=\"modal-close\" id=\"editor-close\">\n                        <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                        </svg>\n                    </button>\n                </div>\n            </div>\n            <div class=\"editor-area\">\n                <div class=\"editor-line-numbers\" id=\"editor-line-numbers\"></div>\n                <textarea class=\"code-editor\" id=\"editor-textarea\" spellcheck=\"false\"></textarea>\n            </div>\n            <div class=\"editor-meta\">\n                <span id=\"editor-meta-chars\">0 chars</span>\n                <span id=\"editor-meta-lines\">0 lines</span>\n                <span id=\"editor-meta-modified\" style=\"display:none\">Modified</span>\n            </div>\n            <!-- Unsaved changes inline confirmation -->\n            <div id=\"editor-unsaved-dialog\" class=\"unsaved-overlay\" style=\"display:none\">\n                <div class=\"unsaved-dialog\">\n                    <div class=\"unsaved-title\">Unsaved Changes</div>\n                    <div class=\"unsaved-msg\">You have unsaved changes. What would you like to do?</div>\n                    <div class=\"unsaved-actions\">\n                        <button class=\"btn\" id=\"editor-unsaved-cancel\">Cancel</button>\n                        <button class=\"btn btn-danger\" id=\"editor-unsaved-discard\">Don't Save</button>\n                        <button class=\"btn btn-primary\" id=\"editor-unsaved-save\">Save &amp; Close</button>\n                    </div>\n                </div>\n            </div>\n        </div>\n\n        <!-- File Viewer Modal -->\n        <div class=\"modal modal-viewer\" id=\"modal-viewer\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\" id=\"viewer-title\">File Viewer</span>\n                <div class=\"modal-header-actions\">\n                    <button class=\"btn btn-ghost btn-sm\" id=\"btn-download-viewer\">\n                        <svg width=\"13\" height=\"13\" viewBox=\"0 0 13 13\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path d=\"M6.5 1v8M3 6l3.5 4 3.5-4M1 12h11\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" />\n                        </svg>\n                        Export\n                    </button>\n                    <button class=\"modal-close\" id=\"viewer-close\">\n                        <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                        </svg>\n                    </button>\n                </div>\n            </div>\n            <div class=\"viewer-content\" id=\"viewer-content\"></div>\n        </div>\n\n        <!-- Properties Modal -->\n        <div class=\"modal modal-props\" id=\"modal-props\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">Properties</span>\n                <button class=\"modal-close\" id=\"props-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\" id=\"props-body\"></div>\n            <div class=\"modal-footer\">\n                <button class=\"btn btn-primary\" id=\"props-ok\">OK</button>\n            </div>\n        </div>\n\n        <!-- Rename Modal -->\n        <div class=\"modal\" id=\"modal-rename\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">Rename</span>\n                <button class=\"modal-close\" id=\"rename-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <div class=\"form-group\">\n                    <label class=\"form-label\">New name</label>\n                    <input type=\"text\" class=\"input\" id=\"rename-input\">\n                </div>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"rename-cancel\">Cancel</button>\n                <button class=\"btn btn-primary\" id=\"rename-ok\">Rename</button>\n            </div>\n        </div>\n\n        <!-- Delete Confirm Modal -->\n        <div class=\"modal\" id=\"modal-delete\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title modal-title-danger\">Confirm Delete</span>\n                <button class=\"modal-close\" id=\"delete-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <p id=\"delete-msg\" class=\"modal-text\"></p>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"delete-cancel\">Cancel</button>\n                <button class=\"btn btn-danger\" id=\"delete-ok\">\n                    <svg width=\"13\" height=\"13\" viewBox=\"0 0 13 13\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M1.5 3.5h10M4 3.5V2H9v1.5M2.5 3.5L3.5 11h6l1-7.5\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"square\" />\n                    </svg>\n                    Delete\n                </button>\n            </div>\n        </div>\n\n        <!-- Session Conflict Modal -->\n        <div class=\"modal\" id=\"modal-session-conflict\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title modal-title-warn\">Container Already Open</span>\n            </div>\n            <div class=\"modal-body\">\n                <p class=\"modal-text\">\n                    <strong id=\"sc-cont-name\"></strong> is currently open in another tab or window.\n                </p>\n                <p class=\"modal-secondary\">\n                    Opening it here will immediately lock the other session. Any unsaved state in that tab will be lost.\n                </p>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"sc-cancel\">Cancel</button>\n                <button class=\"btn btn-danger\" id=\"sc-force\">Force Close &amp; Open Here</button>\n            </div>\n        </div>\n\n        <!-- Disable Activity Logs Confirm Modal -->\n        <div class=\"modal\" id=\"modal-alog-disable\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">Disable Activity Logs?</span>\n            </div>\n            <div class=\"modal-body\">\n                <p class=\"modal-text-dim\">All recorded activity history will be permanently cleared. Are you sure?</p>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"alog-disable-cancel\">Cancel</button>\n                <button class=\"btn btn-danger\" id=\"alog-disable-ok\">Disable &amp; Clear</button>\n            </div>\n        </div>\n\n        <!-- Clear Activity Logs Confirm Modal -->\n        <div class=\"modal\" id=\"modal-alog-clear\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">Clear Activity Logs?</span>\n            </div>\n            <div class=\"modal-body\">\n                <p class=\"modal-text-dim\">All recorded activity history will be permanently cleared. Are you sure?</p>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"alog-clear-cancel\">Cancel</button>\n                <button class=\"btn btn-danger\" id=\"alog-clear-ok\">Clear All</button>\n            </div>\n        </div>\n\n\n\n        <!-- New Text File Modal -->\n        <div class=\"modal\" id=\"modal-new-text\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">New Text File</span>\n                <button class=\"modal-close\" id=\"nf-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <div class=\"form-group\">\n                    <label class=\"form-label\">File name</label>\n                    <input type=\"text\" class=\"input\" id=\"nf-name\" placeholder=\"e.g. notes.txt\">\n                </div>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"nf-cancel\">Cancel</button>\n                <button class=\"btn btn-primary\" id=\"nf-ok\">Create</button>\n            </div>\n        </div>\n\n        <!-- New Folder Modal -->\n        <div class=\"modal\" id=\"modal-new-folder\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">New Folder</span>\n                <button class=\"modal-close\" id=\"nd-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <div class=\"form-group\">\n                    <label class=\"form-label\">Folder name</label>\n                    <input type=\"text\" class=\"input\" id=\"nd-name\" placeholder=\"e.g. Documents\">\n                </div>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"nd-cancel\">Cancel</button>\n                <button class=\"btn btn-primary\" id=\"nd-ok\">Create Folder</button>\n            </div>\n        </div>\n\n        <!-- Delete Container Confirm -->\n        <div class=\"modal\" id=\"modal-del-container\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title modal-title-danger\">Delete Container</span>\n                <button class=\"modal-close\" id=\"dc-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <div class=\"del-container-warning\">\n                    <svg width=\"28\" height=\"28\" viewBox=\"0 0 28 28\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M14 3L2 24h24L14 3z\" fill=\"var(--red)\" opacity=\".18\" stroke=\"var(--red)\" stroke-width=\"1.5\" stroke-linejoin=\"round\" />\n                        <path d=\"M14 11v6\" stroke=\"var(--red)\" stroke-width=\"2\" stroke-linecap=\"square\" />\n                        <circle cx=\"14\" cy=\"20.5\" r=\"1.2\" fill=\"var(--red)\" />\n                    </svg>\n                    <div>\n                        <div class=\"dc-warn-title\">This action is irreversible!</div>\n                        <div class=\"dc-warn-text\">All files inside container <strong id=\"dc-name\"></strong> will be permanently erased. There is no recovery.</div>\n                    </div>\n                </div>\n                <p id=\"dc-msg\"></p>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"dc-cancel\">Cancel</button>\n                <button class=\"btn btn-danger\" id=\"dc-ok\" disabled>\n                    <svg width=\"13\" height=\"13\" viewBox=\"0 0 13 13\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M1.5 3.5h10M4 3.5V2H9v1.5M2.5 3.5L3.5 11h6l1-7.5\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"square\" />\n                    </svg>\n                    <span id=\"dc-ok-label\">Wait… 3</span>\n                </button>\n            </div>\n        </div>\n\n        <!-- ==================== CHANGE PASSWORD MODAL ==================== -->\n        <div class=\"modal\" id=\"modal-change-pw\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">Change Password</span>\n                <button class=\"modal-close\" id=\"cp-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <div class=\"form-group\">\n                    <label class=\"form-label\">Current Password</label>\n                    <div class=\"input-wrap\">\n                        <input type=\"password\" class=\"input\" id=\"cp-old\" placeholder=\"Enter current password\" autocomplete=\"current-password\">\n                        <button class=\"input-eye\" id=\"cp-old-eye\" tabindex=\"-1\">\n                            <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                                <path d=\"M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z\" stroke=\"currentColor\" stroke-width=\"1.5\" />\n                                <circle cx=\"8\" cy=\"8\" r=\"2.5\" stroke=\"currentColor\" stroke-width=\"1.5\" />\n                            </svg>\n                        </button>\n                    </div>\n                </div>\n                <div class=\"form-group\">\n                    <label class=\"form-label\">New Password</label>\n                    <div class=\"input-wrap\">\n                        <input type=\"password\" class=\"input\" id=\"cp-new\" placeholder=\"Enter new password\" autocomplete=\"new-password\">\n                        <button class=\"input-eye\" id=\"cp-new-eye\" tabindex=\"-1\">\n                            <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                                <path d=\"M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z\" stroke=\"currentColor\" stroke-width=\"1.5\" />\n                                <circle cx=\"8\" cy=\"8\" r=\"2.5\" stroke=\"currentColor\" stroke-width=\"1.5\" />\n                            </svg>\n                        </button>\n                    </div>\n                    <div class=\"pw-strength\" id=\"cp-pw-strength\" style=\"width:0%\"></div>\n                    <div class=\"pw-strength-row\" id=\"cp-pw-strength-label\"></div>\n                </div>\n                <div class=\"form-group\">\n                    <label class=\"form-label\">Confirm New Password</label>\n                    <input type=\"password\" class=\"input\" id=\"cp-new2\" placeholder=\"Repeat new password\" autocomplete=\"new-password\">\n                </div>\n                <div class=\"notice-box warn\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M7 1L1 13h12z\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linejoin=\"round\" />\n                        <path d=\"M7 5v4\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"round\" />\n                        <circle cx=\"7\" cy=\"10.5\" r=\"0.7\" fill=\"currentColor\" />\n                    </svg>\n                    <span>All files will be re-encrypted with the new password. <strong>Do not close the browser</strong> during this process.</span>\n                </div>\n                <div class=\"unlock-error\" id=\"cp-error\"></div>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"cp-cancel\">Cancel</button>\n                <button class=\"btn btn-primary\" id=\"cp-ok\">Change Password</button>\n            </div>\n        </div>\n\n        <!-- ==================== RENAME CONTAINER MODAL ==================== -->\n        <div class=\"modal\" id=\"modal-rename-container\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">Rename Container</span>\n                <button class=\"modal-close\" id=\"rc-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <div class=\"form-group\">\n                    <label class=\"form-label\">Container Name</label>\n                    <input type=\"text\" class=\"input\" id=\"rc-name\" placeholder=\"Container name\">\n                </div>\n                <div class=\"unlock-error\" id=\"rc-error\"></div>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"rc-cancel\">Cancel</button>\n                <button class=\"btn btn-primary\" id=\"rc-ok\">Rename</button>\n            </div>\n        </div>\n\n        <!-- ==================== EXPORT CONFIRM MODAL ==================== -->\n        <div class=\"modal\" id=\"modal-export-confirm\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">Cannot Preview File</span>\n                <button class=\"modal-close\" id=\"ec-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <p class=\"modal-text\">This file type cannot be opened in the browser.</p>\n                <div class=\"notice-box\">\n                    <span id=\"ec-filename\"></span> — export this file to your computer?\n                </div>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"ec-cancel\">Cancel</button>\n                <button class=\"btn btn-primary\" id=\"ec-ok\">\n                    <svg width=\"13\" height=\"13\" viewBox=\"0 0 13 13\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M6.5 1v8M3 6l3.5 3.5L10 6M1 11h11\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" />\n                    </svg>\n                    Export File\n                </button>\n            </div>\n        </div>\n\n        <!-- ==================== EXPORT PASSWORD MODAL ==================== -->\n        <div class=\"modal modal-export-pw\" id=\"modal-export-pw\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <div class=\"exp-header\">\n                    <div class=\"exp-icon\">\n                        <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path d=\"M8 10V2M4 5l4-4 4 4M2 13h12\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" />\n                        </svg>\n                    </div>\n                    <div>\n                        <div class=\"modal-title\">Export Container</div>\n                        <div class=\"exp-subtitle\">Confirm your identity to export</div>\n                    </div>\n                </div>\n                <button class=\"modal-close\" id=\"exp-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <div class=\"notice-box accent\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\">\n                        <path d=\"M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM7.25 5h1.5v4h-1.5V5zm0 5h1.5v1.5h-1.5V10z\" fill=\"currentColor\" />\n                    </svg>\n                    <span>Container <strong id=\"exp-cont-name\"></strong> will be exported as a <code class=\"code-tag\">.safenova</code> file. Enter the password to authorize the export.</span>\n                </div>\n                <div class=\"form-group\">\n                    <label class=\"form-label\">Container Password</label>\n                    <div class=\"input-wrap\">\n                        <input type=\"password\" class=\"input\" id=\"exp-pw\" placeholder=\"Enter password…\" autocomplete=\"current-password\">\n                        <button class=\"input-eye\" type=\"button\" id=\"exp-eye\" aria-label=\"Show password\" tabindex=\"-1\">\n                            <svg class=\"eye-open\" width=\"15\" height=\"15\" viewBox=\"0 0 15 15\" fill=\"none\">\n                                <path d=\"M7.5 3C4.5 3 2 7.5 2 7.5S4.5 12 7.5 12 13 7.5 13 7.5 10.5 3 7.5 3z\" stroke=\"currentColor\" stroke-width=\"1.3\" />\n                                <circle cx=\"7.5\" cy=\"7.5\" r=\"1.8\" stroke=\"currentColor\" stroke-width=\"1.3\" />\n                            </svg>\n                            <svg class=\"eye-closed\" width=\"15\" height=\"15\" viewBox=\"0 0 15 15\" fill=\"none\">\n                                <path d=\"M2 2l11 11M6.5 5.5A3 3 0 0112 7.5M3 7.5C4 9.5 5.6 11 7.5 11\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"square\" />\n                            </svg>\n                        </button>\n                    </div>\n                </div>\n                <div class=\"unlock-error\" id=\"exp-error\"></div>\n            </div>\n            <div class=\"modal-footer modal-footer-split\">\n                <span class=\"encrypt-label\">\n                    <svg width=\"11\" height=\"11\" viewBox=\"0 0 12 12\" fill=\"none\">\n                        <rect x=\"1\" y=\"5\" width=\"10\" height=\"6\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.2\" />\n                        <path d=\"M3.5 5V3.5a2.5 2.5 0 015 0V5\" stroke=\"currentColor\" stroke-width=\"1.2\" stroke-linecap=\"square\" />\n                    </svg>\n                    AES-256-GCM encrypted\n                </span>\n                <div class=\"modal-actions\">\n                    <button class=\"btn\" id=\"exp-cancel\">Cancel</button>\n                    <button class=\"btn btn-primary\" id=\"exp-ok\">\n                        <svg width=\"13\" height=\"13\" viewBox=\"0 0 13 13\" fill=\"none\">\n                            <path d=\"M6.5 1v8M3 6l3.5 3.5L10 6M1 11h11\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" />\n                        </svg>\n                        Export\n                    </button>\n                </div>\n            </div>\n        </div>\n\n        <!-- ==================== IMPORT PASSWORD MODAL ==================== -->\n        <div class=\"modal\" id=\"modal-import-pw\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">Import Container</span>\n                <button class=\"modal-close\" id=\"imp-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <p class=\"modal-text\">Container <strong id=\"imp-name\"></strong> has an encrypted file manifest. Enter the password to complete the import.</p>\n                <div class=\"form-group\">\n                    <label class=\"form-label\">Password</label>\n                    <input type=\"password\" class=\"input\" id=\"imp-pw\" placeholder=\"Container password\">\n                </div>\n                <div class=\"unlock-error\" id=\"imp-error\"></div>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"imp-cancel\">Cancel</button>\n                <button class=\"btn btn-primary\" id=\"imp-ok\">Import</button>\n            </div>\n        </div>\n\n        <!-- ==================== SETTINGS MODAL ==================== -->\n        <div class=\"modal modal-settings\" id=\"modal-settings\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">Environment</span>\n                <button class=\"modal-close\" id=\"settings-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"settings-tabs\">\n                <button class=\"settings-tab active\" data-tab=\"personalization\">Settings</button>\n                <button class=\"settings-tab\" data-tab=\"statistics\">Statistics</button>\n                <button class=\"settings-tab\" data-tab=\"activity-logs\">Activity Logs</button>\n            </div>\n            <div class=\"modal-body\">\n                <!-- Personalization Tab -->\n                <div class=\"settings-panel\" id=\"settings-personalization\">\n                    <div class=\"settings-section\">\n                        <div class=\"settings-section-title\">Security</div>\n                        <div class=\"settings-row\">\n                            <span class=\"settings-label\">Require password on export<button class=\"settings-hint\" data-tip=\"When enabled, every export requires password confirmation. Metadata is generated on the fly.When disabled, an encrypted metadata index is pre-generated and cached in the local database, making export instant. The cache contains only file sizes and IVs — no filenames or content — but in theory slightly increases the attack surface.\"><!--?--><svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\">\n                                        <circle cx=\"8\" cy=\"8\" r=\"7\" stroke=\"currentColor\" stroke-width=\"1.3\" />\n                                        <path d=\"M8 7v4.5\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" />\n                                        <circle cx=\"8\" cy=\"5\" r=\"0.8\" fill=\"currentColor\" />\n                                    </svg></button></span>\n                            <label class=\"switch\" id=\"settings-export-pw\">\n                                <input type=\"checkbox\" checked>\n                                <span class=\"switch-slider\"></span>\n                            </label>\n                        </div>\n                        <div class=\"settings-row\">\n                            <span class=\"settings-label\">Activity logs<button class=\"settings-hint\" data-tip=\"Records container events (unlock, file upload, delete, export, etc.) with timestamps, stored encrypted inside the container. Useful for auditing. Disabling clears future log entries but does not erase existing ones.\"><!--?--><svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\">\n                                        <circle cx=\"8\" cy=\"8\" r=\"7\" stroke=\"currentColor\" stroke-width=\"1.3\" />\n                                        <path d=\"M8 7v4.5\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" />\n                                        <circle cx=\"8\" cy=\"5\" r=\"0.8\" fill=\"currentColor\" />\n                                    </svg></button></span>\n                            <label class=\"switch\" id=\"settings-activity-logs-toggle\">\n                                <input type=\"checkbox\" checked>\n                                <span class=\"switch-slider\"></span>\n                            </label>\n                        </div>\n                        <div class=\"settings-row settings-sub-row\" id=\"settings-export-logs-row\">\n                            <span class=\"settings-sub-indicator\"></span>\n                            <span class=\"settings-label\">Export with activity logs<button class=\"settings-hint\" data-tip=\"Activity logs are included in the exported .safenova file, encrypted. Safe to share, but recommended to disable when handing the container to another person — to avoid leaking your access history. Enable for personal backups.\"><!--?--><svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\">\n                                        <circle cx=\"8\" cy=\"8\" r=\"7\" stroke=\"currentColor\" stroke-width=\"1.3\" />\n                                        <path d=\"M8 7v4.5\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" />\n                                        <circle cx=\"8\" cy=\"5\" r=\"0.8\" fill=\"currentColor\" />\n                                    </svg></button></span>\n                            <label class=\"switch\" id=\"settings-export-logs\">\n                                <input type=\"checkbox\">\n                                <span class=\"switch-slider\"></span>\n                            </label>\n                        </div>\n                        <div class=\"settings-row\">\n                            <span class=\"settings-label\">Auto-lock (inactivity)</span>\n                            <div class=\"custom-dd\" id=\"settings-autolock-dd\">\n                                <div class=\"custom-dd-head\">\n                                    <span class=\"custom-dd-val\">1 hour</span>\n                                    <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n                                        <path d=\"M7 10l5 5 5-5z\" />\n                                    </svg>\n                                </div>\n                                <div class=\"custom-dd-menu\">\n                                    <div class=\"custom-dd-opt\" data-value=\"0\">Never</div>\n                                    <div class=\"custom-dd-opt\" data-value=\"5\">5 minutes</div>\n                                    <div class=\"custom-dd-opt\" data-value=\"10\">10 minutes</div>\n                                    <div class=\"custom-dd-opt\" data-value=\"15\">15 minutes</div>\n                                    <div class=\"custom-dd-opt\" data-value=\"20\">20 minutes</div>\n                                    <div class=\"custom-dd-opt\" data-value=\"30\">30 minutes</div>\n                                    <div class=\"custom-dd-opt\" data-value=\"60\">1 hour</div>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n                    <div class=\"settings-section\">\n                        <div class=\"settings-section-title\">Personalization</div>\n                        <div class=\"settings-row\">\n                            <span class=\"settings-label\">Icon size</span>\n                            <div class=\"settings-toggle-group\" id=\"settings-icon-size\">\n                                <button class=\"settings-toggle-btn active\" data-value=\"small\">Small</button>\n                                <button class=\"settings-toggle-btn\" data-value=\"normal\">Normal</button>\n                                <button class=\"settings-toggle-btn\" data-value=\"large\">Large</button>\n                            </div>\n                        </div>\n                        <div class=\"settings-row\">\n                            <span class=\"settings-label\">Show grid dots</span>\n                            <label class=\"switch\" id=\"settings-grid-dots\">\n                                <input type=\"checkbox\" checked>\n                                <span class=\"switch-slider\"></span>\n                            </label>\n                        </div>\n                        <div class=\"settings-row\">\n                            <span class=\"settings-label\">Disable animations</span>\n                            <label class=\"switch\" id=\"settings-animations\">\n                                <input type=\"checkbox\">\n                                <span class=\"switch-slider\"></span>\n                            </label>\n                        </div>\n                        <div class=\"settings-row\">\n                            <span class=\"settings-label\">Show drag position preview</span>\n                            <label class=\"switch\" id=\"settings-snap-highlight\">\n                                <input type=\"checkbox\" checked>\n                                <span class=\"switch-slider\"></span>\n                            </label>\n                        </div>\n                    </div>\n                    <div class=\"settings-section\">\n                        <div class=\"settings-section-title\">Diagnostics</div>\n                        <div class=\"settings-desc\">\n                            Run a full integrity scan of the container's virtual file system and database records. Detects orphaned nodes, broken parent references, duplicate names, missing blobs, and other structural issues — with one-click auto-repair.\n                        </div>\n                        <div class=\"settings-row\">\n                            <span class=\"settings-label\">Container integrity</span>\n                            <button class=\"btn\" id=\"fs-check-open\">\n                                <svg width=\"13\" height=\"13\" viewBox=\"0 0 20 20\" fill=\"none\">\n                                    <path d=\"M10 1.5L3 5.5v4.5c0 4.3 3 8.2 7 9.5 4-1.3 7-5.2 7-9.5V5.5L10 1.5z\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" />\n                                    <path d=\"M7.5 10.5l2 2 3.5-4\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" stroke-linejoin=\"round\" />\n                                </svg>\n                                Run diagnostics\n                            </button>\n                        </div>\n                    </div>\n                    <div class=\"settings-section danger-zone-section\">\n                        <div class=\"settings-section-title danger-zone-title\">Danger Zone</div>\n                        <div class=\"danger-zone-body\">\n                            <div class=\"danger-zone-header\">\n                                <div>\n                                    <div class=\"danger-zone-label\">\n                                        <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\">\n                                            <path d=\"M12 2L3 7v5c0 5.25 3.75 10.15 9 11.25C17.25 22.15 21 17.25 21 12V7L12 2z\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" />\n                                            <path d=\"M12 8v5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" />\n                                            <circle cx=\"12\" cy=\"15.5\" r=\"0.75\" fill=\"currentColor\" />\n                                        </svg>\n                                        Duress password\n                                    </div>\n                                    <div class=\"danger-zone-desc\">\n                                        A secondary password entered under coercion. When used anywhere — unlock, change password, or export — it behaves <strong>exactly like an incorrect password</strong>, but silently and <strong>irreversibly destroys</strong> every encrypted file in the background. The real password still works afterward, but all data inside is already gone — indistinguishable from storage corruption.\n                                    </div>\n                                </div>\n                                <label class=\"switch danger-zone-switch\">\n                                    <input type=\"checkbox\" id=\"settings-duress-cb\">\n                                    <span class=\"switch-slider switch-slider-danger\"></span>\n                                </label>\n                            </div>\n                            <div id=\"duress-form\">\n                                <div class=\"form-group duress-fg\">\n                                    <label class=\"form-label duress-fl\">Duress Password <span class=\"duress-hint\">(min. 4 characters)</span></label>\n                                    <div class=\"input-wrap\">\n                                        <input type=\"password\" class=\"input\" id=\"duress-pw\" placeholder=\"Enter duress password\" autocomplete=\"new-password\">\n                                        <button class=\"input-eye\" id=\"duress-pw-eye\" tabindex=\"-1\"></button>\n                                    </div>\n                                    <div class=\"pw-strength\" id=\"duress-pw-strength\"></div>\n                                    <div class=\"pw-strength-row\" id=\"duress-pw-strength-label\"></div>\n                                </div>\n                                <div class=\"form-group duress-fg\">\n                                    <label class=\"form-label duress-fl\">Confirm</label>\n                                    <input type=\"password\" class=\"input\" id=\"duress-pw2\" placeholder=\"Repeat duress password\" autocomplete=\"new-password\">\n                                </div>\n                                <div class=\"duress-set-error\" id=\"duress-set-error\"></div>\n                                <button class=\"btn btn-danger duress-set-btn\" id=\"duress-set-ok\">Set Duress Password</button>\n                            </div>\n                            <div id=\"duress-active-info\" class=\"duress-active-info\">\n                                <div class=\"duress-active-badge\">\n                                    <span class=\"duress-active-dot\"></span>\n                                    Active — entering this password will silently destroy all files\n                                </div>\n                                <button class=\"btn btn-danger duress-remove-btn\" id=\"duress-remove-btn\">Remove Duress Password</button>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n                <!-- Statistics Tab -->\n                <div class=\"settings-panel\" id=\"settings-statistics\" style=\"display:none\">\n                    <div class=\"settings-section\">\n                        <div class=\"stats-grid\" id=\"stats-grid\"></div>\n                        <div class=\"stats-chart-wrap\">\n                            <span class=\"settings-label settings-label-block\">File Types</span>\n                            <div class=\"stats-bar-chart\" id=\"stats-bar-chart\"></div>\n                        </div>\n                        <div class=\"stats-chart-wrap\">\n                            <span class=\"settings-label settings-label-block\">Storage Usage</span>\n                            <div class=\"stats-storage-bar\" id=\"stats-storage-bar\"></div>\n                            <div class=\"stats-storage-labels\" id=\"stats-storage-labels\"></div>\n                        </div>\n                        <div class=\"stats-chart-wrap\">\n                            <span class=\"settings-label settings-label-block\">Largest Files</span>\n                            <div class=\"stats-top-files\" id=\"stats-top-files\"></div>\n                        </div>\n                    </div>\n                </div>\n                <!-- Activity Logs Tab -->\n                <div class=\"settings-panel\" id=\"settings-activity-logs\" style=\"display:none\">\n                    <div class=\"alog-toolbar\" id=\"alog-toolbar\" style=\"display:none\">\n                        <div class=\"alog-filters\" id=\"alog-filters\"></div>\n                        <button class=\"alog-clear-btn\" id=\"alog-clear-btn\">\n                            <svg width=\"12\" height=\"12\" viewBox=\"0 0 16 16\" fill=\"none\">\n                                <path d=\"M2 4h12\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" />\n                                <path d=\"M5 4V2h6v2M3 4l1 10h8l1-10\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\" />\n                            </svg>\n                            Clear\n                        </button>\n                    </div>\n                    <div class=\"alog-off\" id=\"alog-off\" style=\"display:none\">\n                        <svg width=\"28\" height=\"28\" viewBox=\"0 0 16 16\" fill=\"none\">\n                            <path d=\"M4 3v10M2 5l2-2 2 2M12 13V3M10 11l2 2 2-2\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" />\n                        </svg>\n                        <span class=\"alog-off-text\">Activity Logs are disabled</span>\n                        <button class=\"btn btn-primary\" id=\"alog-enable-btn\">Enable</button>\n                    </div>\n                    <div class=\"alog-empty\" id=\"alog-empty\" style=\"display:none\">\n                        <svg width=\"28\" height=\"28\" viewBox=\"0 0 16 16\" fill=\"none\">\n                            <path d=\"M4 3v10M2 5l2-2 2 2M12 13V3M10 11l2 2 2-2\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" />\n                        </svg>\n                        <span class=\"alog-off-text\">No activity recorded yet</span>\n                    </div>\n                    <div class=\"alog-list\" id=\"alog-list\">\n                        <div class=\"alog-content\" id=\"alog-content\"></div>\n                    </div>\n                </div>\n\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn btn-primary\" id=\"settings-ok\">Done</button>\n            </div>\n        </div>\n\n        <!-- ==================== SCANNER MODAL ==================== -->\n        <div class=\"modal modal-scanner\" id=\"modal-scanner\" style=\"display:none\">\n            <div class=\"modal-header\">\n                <span class=\"modal-title\">Container Integrity Scanner</span>\n                <button class=\"modal-close\" id=\"scanner-close\">\n                    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" />\n                    </svg>\n                </button>\n            </div>\n            <div class=\"modal-body scanner-body\">\n                <div class=\"scanner-desc\">\n                    <svg class=\"scanner-shield\" width=\"32\" height=\"32\" viewBox=\"0 0 24 26\" fill=\"none\">\n                        <path d=\"M12 3L3.5 7.5v4.5c0 5.2 3.6 10 8.5 11.5 4.9-1.5 8.5-6.3 8.5-11.5V7.5L12 3z\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\" />\n                        <path d=\"M9 13l2.5 2.5 4-4.5\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"square\" stroke-linejoin=\"round\" />\n                    </svg>\n                    <div>\n                        <div class=\"scanner-desc-title\">Full container scan</div>\n                        <div class=\"scanner-desc-text\">Deep analysis of the virtual disk image, encrypted file table, folder hierarchy, desktop layout, and workspace environment of this crypto-container.</div>\n                    </div>\n                </div>\n                <div class=\"scanner-log\" id=\"scanner-log\"></div>\n                <div class=\"scanner-summary\" id=\"scanner-summary\" style=\"display:none\"></div>\n            </div>\n            <div class=\"modal-footer\">\n                <button class=\"btn\" id=\"scanner-repair\" style=\"display:none\">\n                    <svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\">\n                        <path d=\"M2 9l3 3 7-8\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"square\" stroke-linejoin=\"round\" />\n                    </svg>\n                    Auto-Repair\n                </button>\n                <button class=\"btn btn-deep-clean\" id=\"scanner-deep-clean\" style=\"display:none\">\n                    <svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\">\n                        <path d=\"M9.5 1L4.5 9H8.5L6.5 15L13 7H9L9.5 1z\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linejoin=\"round\" stroke-linecap=\"round\" fill=\"none\" />\n                    </svg>\n                    Deep Clean\n                </button>\n                <button class=\"btn btn-primary\" id=\"scanner-start\">Start Scan</button>\n            </div>\n        </div>\n\n    </div>\n\n    <!-- ==================== REPAIR CONFIRMATION DIALOG ==================== -->\n    <div class=\"repair-confirm-overlay\" id=\"repair-confirm-overlay\">\n        <div class=\"repair-confirm-dialog\">\n            <div class=\"repair-confirm-icon\">\n                <svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"none\">\n                    <path d=\"M12 2L1 21h22L12 2z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\" fill=\"none\" />\n                    <path d=\"M12 10v4\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" />\n                    <circle cx=\"12\" cy=\"17.5\" r=\"0.8\" fill=\"currentColor\" />\n                </svg>\n            </div>\n            <div class=\"repair-confirm-title\">Export before repair</div>\n            <div class=\"repair-confirm-text\">Auto-Repair may delete unrecoverable files and restructure the virtual disk. It is strongly recommended to export a backup of your container before proceeding.</div>\n            <div class=\"repair-confirm-actions\">\n                <button class=\"btn\" id=\"repair-confirm-export\">\n                    <svg width=\"13\" height=\"13\" viewBox=\"0 0 16 16\" fill=\"none\">\n                        <path d=\"M2 10v3h12v-3\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" stroke-linejoin=\"round\" />\n                        <path d=\"M8 2v8M5 7l3 3 3-3\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" stroke-linejoin=\"round\" />\n                    </svg>\n                    Export\n                </button>\n                <button class=\"btn btn-primary\" id=\"repair-confirm-proceed\">Proceed with repair</button>\n                <button class=\"btn\" id=\"repair-confirm-cancel\">Cancel</button>\n            </div>\n        </div>\n    </div>\n\n    <!-- ==================== TOAST CONTAINER ==================== -->\n    <div id=\"toast-container\"></div>\n\n    <!-- ==================== SCRIPTS (dependency order) ==================== -->\n    <!-- daemon.js already loaded in <head> — runs before any other module -->\n    <script src=\"js/libs/argon2.umd.min.js\"></script>\n    <script src=\"js/initlog.js\"></script>\n    <script src=\"js/constants.js\"></script>\n    <script src=\"js/db.js\"></script>\n    <script src=\"js/crypto.js\"></script>\n    <script src=\"js/vfs.js\"></script>\n    <script src=\"js/state.js\"></script>\n    <script src=\"js/home.js\"></script>\n    <script src=\"js/desktop.js\"></script>\n    <script src=\"js/fileops.js\"></script>\n    <script src=\"js/detectors/incognito.js\"></script>\n    <script src=\"js/main.js\"></script>\n\n</body>\n\n</html>"
  },
  {
    "path": "src/js/constants.js",
    "content": "'use strict';\n\n/* ============================================================\n   CONSTANTS\n   ============================================================ */\nconst DB_NAME = 'SafeNovaEFS',\n    DB_VERSION = 3,\n    CONTAINER_LIMIT = 8 * 1024 * 1024 * 1024,   // 8 GB per container\n    FILE_CHUNK_SIZE = 50 * 1024 * 1024,         // 50 MB per IDB chunk — avoids browser ~2 GB read limit\n    DEVICE_LIMIT = 20 * 1024 * 1024 * 1024,     // 20 GB total device display limit\n\n    ARGON2_MEM = 19456,                         // 19 MB memory cost (OWASP minimum)\n    ARGON2_ITER = 2,                            // time cost (iterations)\n    ARGON2_PAR = 1,                             // parallelism\n    VERIFY_TEXT = 'SafeNovaEFS-VERIFY-OK';\n// Degree of parallelism for AES-GCM operations: cap at 8 to avoid\n// flooding the thread with microtasks on high-core-count machines.\nconst _CRYPTO_CONCURRENCY = Math.min(8, navigator.hardwareConcurrency || 4);\nlet ICON_W = 84,\n    ICON_H = 90;\nlet GRID_X = 96,   // horizontal grid cell size\n    GRID_Y = 96;   // vertical   grid cell size\n\n/* ============================================================\n   UTILITIES\n   ============================================================ */\nfunction uid() {\n    return crypto.randomUUID\n        ? crypto.randomUUID()\n        : Date.now().toString(36) + Math.random().toString(36).slice(2);\n}\n\n/* ============================================================\n   TAB IDENTITY\n   Each browser tab gets a stable UUID so we can detect when\n   the same container is being opened in two different tabs.\n   Stored in sessionStorage so a page refresh keeps the same ID,\n   while a brand-new tab always gets a fresh one.\n   ============================================================ */\nconst _TAB_ID = (() => {\n    let id = sessionStorage.getItem('snv-tab-id');\n    if (!id) { id = uid(); sessionStorage.setItem('snv-tab-id', id); }\n    return id;\n})();\n\nfunction fmtSize(b) {\n    if (!Number.isFinite(b) || b <= 0) return '0 B';\n    const k = 1024, s = ['B', 'KB', 'MB', 'GB', 'TB'],\n        i = Math.min(Math.floor(Math.log(b) / Math.log(k)), s.length - 1);\n    return (b / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0) + ' ' + s[i];\n}\n\nfunction fmtDate(ts) {\n    const d = new Date(ts);\n    return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' +\n        d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });\n}\n\nfunction getExt(name) {\n    const p = name.lastIndexOf('.');\n    return p > 0 ? name.slice(p + 1).toLowerCase() : '';\n}\n\nfunction getMime(name) {\n    const e = getExt(name);\n    return ({\n        txt: 'text/plain', md: 'text/markdown', html: 'text/html', htm: 'text/html',\n        css: 'text/css', js: 'text/javascript', ts: 'text/typescript', json: 'application/json',\n        xml: 'application/xml', csv: 'text/csv', py: 'text/x-python', rs: 'text/x-rust',\n        go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',\n        sh: 'text/x-sh', bat: 'text/x-bat', yaml: 'text/yaml', yml: 'text/yaml',\n        png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',\n        webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',\n        avif: 'image/avif',\n        pdf: 'application/pdf',\n        mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac', m4a: 'audio/m4a',\n        mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', avi: 'video/x-msvideo',\n        zip: 'application/zip', rar: 'application/x-rar-compressed',\n        gz: 'application/gzip', '7z': 'application/x-7z-compressed', tar: 'application/x-tar',\n        doc: 'application/msword',\n        docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n        xls: 'application/vnd.ms-excel',\n        xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n        ppt: 'application/vnd.ms-powerpoint',\n        pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',\n        woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',\n        arj: 'application/x-arj', dbf: 'application/x-dbf',\n        so: 'application/x-sharedlib',\n        arj: 'application/x-arj', dbf: 'application/x-dbf',\n        so: 'application/x-sharedlib',\n        deb: 'application/vnd.debian.binary-package',\n        iso: 'application/x-compressed-iso',\n    })[e] || 'application/octet-stream';\n}\n\nfunction isText(mime, name) {\n    return mime.startsWith('text/') ||\n        ['application/json', 'application/xml', 'application/javascript'].includes(mime);\n}\nfunction isImage(mime) { return mime.startsWith('image/'); }\nfunction isAudio(mime) { return mime.startsWith('audio/'); }\nfunction isVideo(mime) { return mime.startsWith('video/'); }\nfunction isPDF(mime) { return mime === 'application/pdf'; }\n\nfunction buf2b64(buf) {\n    const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf),\n        CHUNK = 8192;\n    let s = '';\n    for (let i = 0; i < u8.length; i += CHUNK)\n        s += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + CHUNK, u8.length)));\n    return btoa(s);\n}\nfunction b642buf(s) {\n    const b = atob(s), u = new Uint8Array(b.length);\n    for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i);\n    return u.buffer;\n}\n\nfunction pwStrength(pw) {\n    let s = 0;\n    if (pw.length >= 8) s++;\n    if (pw.length >= 12) s++;\n    if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++;\n    if (/\\d/.test(pw)) s++;\n    if (/[^a-zA-Z0-9]/.test(pw)) s++;\n    return s; // 0–5\n}\n\nfunction escHtml(str) {\n    return String(str)\n        .replace(/&/g, '&amp;').replace(/</g, '&lt;')\n        .replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n}\n\n/* Shared error/cooldown SVG fragments reused across all password forms */\nconst _ERR_SVG = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM7.25 5h1.5v4h-1.5V5zm0 5h1.5v1.5h-1.5V10z\" fill=\"currentColor\"/></svg>';\nconst _WAIT_SVG = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\"><circle cx=\"7\" cy=\"7\" r=\"6\" stroke=\"currentColor\" stroke-width=\"1.3\"/><path d=\"M7 4v3.5l2.5 1.5\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\"/></svg>';\n\n/* Shared brute-force cooldown — disables btn and shows 3-second countdown in errEl */\nfunction _startAttemptCooldown(errEl, btn, onClear) {\n    let remaining = 3;\n    const upd = s => { errEl.innerHTML = `${_WAIT_SVG} Too many attempts — wait ${s}s`; errEl.style.color = 'var(--orange)'; };\n    upd(remaining);\n    btn.disabled = true;\n    const _t = setInterval(() => {\n        if (--remaining <= 0) {\n            clearInterval(_t);\n            errEl.innerHTML = ''; errEl.style.color = '';\n            btn.disabled = false;\n            onClear?.();\n        } else {\n            upd(remaining);\n        }\n    }, 1000);\n}\n\n/* SHA-256(salt_32 || pw_bytes) — used for duress password detection */\nasync function hashDuress(pw, salt) {\n    const saltU8 = new Uint8Array(salt),\n        pwU8 = new TextEncoder().encode(pw),\n        combined = new Uint8Array(saltU8.length + pwU8.length);\n    combined.set(saltU8);\n    combined.set(pwU8, saltU8.length);\n    return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', combined)));\n}\n\n/* Check whether pw matches the duress hash stored on a container.\n   Returns true if the container has a duress password and pw matches it. */\nasync function checkDuress(pw, container) {\n    if (!container.duressHash || pw.length < 4) return false;\n    const hash = await hashDuress(pw, container.duressHash.salt);\n    return hash.every((b, i) => b === container.duressHash.hash[i]);\n}\n\n/* ============================================================\n   FOLDER COLOR PALETTE\n   ============================================================ */\nconst FOLDER_COLORS = [\n    { label: 'Default (Blue)', color: '#0078d4' },\n    { label: 'Teal', color: '#4ec9b0' },\n    { label: 'Purple', color: '#9b59d0' },\n    { label: 'Orange', color: '#f18800' },\n    { label: 'Red', color: '#e84040' },\n    { label: 'Green', color: '#3cb371' },\n    { label: 'Pink', color: '#e879a0' },\n    { label: 'Yellow', color: '#d4b030' },\n    { label: 'Grey', color: '#7a7a7a' },\n];\n\n/* ============================================================\n   SVG ICON LIBRARY  — 16×16 UI icons\n   ============================================================ */\nconst Icons = {\n    open: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M1 4.5h5l1.5 2H15v8H1z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\"/></svg>`,\n    file: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M3 1h7l3 3v10H3z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\"/><path d=\"M10 1v3h3\" stroke=\"currentColor\" stroke-width=\"1.4\"/></svg>`,\n    folder: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M1 4.5h5l1.5 2H15v8H1z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\"/></svg>`,\n    download: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 2v8M4 7l4 4 4-4M2 14h12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" stroke-linejoin=\"miter\"/></svg>`,\n    upload: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 10V2M4 5l4-4 4 4M2 14h12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" stroke-linejoin=\"miter\"/></svg>`,\n    rename: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M11 2l3 3-8 8H3v-3z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\"/><path d=\"M9 4l3 3\" stroke=\"currentColor\" stroke-width=\"1.4\"/></svg>`,\n    trash: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M2 4h12\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/><path d=\"M5 4V2h6v2\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/><path d=\"M3 4l1 10h8l1-10\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\"/><path d=\"M6 7v4M10 7v4\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/></svg>`,\n    info: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"8\" cy=\"8\" r=\"6.5\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M8 7v4\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"/><circle cx=\"8\" cy=\"5.2\" r=\"0.8\" fill=\"currentColor\"/></svg>`,\n    paste: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><rect x=\"5\" y=\"4\" width=\"9\" height=\"10\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M5 7H2V15h8v-1\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\"/><path d=\"M6 2h4v3H6z\" stroke=\"currentColor\" stroke-width=\"1.4\"/></svg>`,\n    sort: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M2 4h8M2 8h6M2 12h4M12 2v10\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/><path d=\"M9 9l3 4 3-4\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" stroke-linejoin=\"miter\"/></svg>`,\n    unlock: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><rect x=\"2\" y=\"7\" width=\"12\" height=\"8\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M5 7V5a3 3 0 015.8-1\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/></svg>`,\n    copy: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><rect x=\"5\" y=\"5\" width=\"9\" height=\"9\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M4 11H2V2h9v2\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\"/></svg>`,\n    cut: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"4.5\" cy=\"12.5\" r=\"2\" stroke=\"currentColor\" stroke-width=\"1.4\"/><circle cx=\"11.5\" cy=\"12.5\" r=\"2\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M4.5 10.5L11 2\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"round\"/><path d=\"M11.5 10.5L5 2\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"round\"/></svg>`,\n    newfile: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M3 1h7l3 3v10H3z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\"/><path d=\"M10 1v3h3\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M8 7v5M5.5 9.5h5\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/></svg>`,\n    newfolder: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M1 4.5h5l1.5 2H15v8H1z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\"/><path d=\"M8 8.5v4M6 10.5h4\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/></svg>`,\n    warning: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 2L1 14h14z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\"/><path d=\"M8 6v4\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/><circle cx=\"8\" cy=\"12\" r=\"0.8\" fill=\"currentColor\"/></svg>`,\n    lock: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><rect x=\"2\" y=\"7\" width=\"12\" height=\"8\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M5 7V5a3 3 0 016 0v2\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/></svg>`,\n    key: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"5\" cy=\"11\" r=\"3\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M7.2 8.8L14 2M12 2l2 2M10 4l2 2\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/></svg>`,\n    navup: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 12V4M4 8l4-4 4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/></svg>`,\n    fileup: `<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M7 9V1M3 5l4-4 4 4M2 11h10v2H2z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/></svg>`,\n    filedoc: `<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M2 1h7l3 3v9H2z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/><path d=\"M9 1v3h3\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/></svg>`,\n    filedir: `<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M1 3h5l1.5 2H13v7H1z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/></svg>`,\n    plus: `<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M7 1v12M1 7h12\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"square\"/></svg>`,\n    close: `<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M2 2l10 10M12 2L2 12\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/></svg>`,\n    eye: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z\" stroke=\"currentColor\" stroke-width=\"1.5\"/><circle cx=\"8\" cy=\"8\" r=\"2.5\" stroke=\"currentColor\" stroke-width=\"1.5\"/></svg>`,\n    eyeoff: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z\" stroke=\"currentColor\" stroke-width=\"1.5\"/><circle cx=\"8\" cy=\"8\" r=\"2.5\" stroke=\"currentColor\" stroke-width=\"1.5\"/><path d=\"M3 3l10 10\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/></svg>`, save: `<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><rect x=\"1\" y=\"1\" width=\"12\" height=\"12\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.5\"/><rect x=\"3\" y=\"1\" width=\"8\" height=\"5\" stroke=\"currentColor\" stroke-width=\"1.5\"/><rect x=\"4\" y=\"7\" width=\"6\" height=\"5\" stroke=\"currentColor\" stroke-width=\"1.4\"/></svg>`,\n    dlbtn: `<svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M7 9V1M3 6l4 4 4-4M2 12h10\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/></svg>`,\n    sortAsc: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M4 12V4M2 6l2-2 2 2\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/><path d=\"M8 5h6M8 8h4.5M8 11h3\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/></svg>`,\n    sortDesc: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M4 4v8M2 10l2 2 2-2\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/><path d=\"M8 5h3M8 8h4.5M8 11h6\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/></svg>`,\n    sortName: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M3 3h10M5 7h6M7 11h2\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/></svg>`,\n    sortDate: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><rect x=\"2\" y=\"3\" width=\"12\" height=\"11\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M2 6h12M5 1v3M11 1v3\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/></svg>`,\n    sortSize: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><rect x=\"2\" y=\"10\" width=\"4\" height=\"4\" stroke=\"currentColor\" stroke-width=\"1.3\"/><rect x=\"6\" y=\"6\" width=\"4\" height=\"8\" stroke=\"currentColor\" stroke-width=\"1.3\"/><rect x=\"10\" y=\"2\" width=\"4\" height=\"12\" stroke=\"currentColor\" stroke-width=\"1.3\"/></svg>`,\n    sortType: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M2 2h5l2 2v6H2z\" stroke=\"currentColor\" stroke-width=\"1.3\"/><path d=\"M7 2v2h2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><path d=\"M7 8h5l2 2v4H7z\" stroke=\"currentColor\" stroke-width=\"1.3\"/><path d=\"M12 8v2h2\" stroke=\"currentColor\" stroke-width=\"1.3\"/></svg>`,\n    refresh: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M13.5 8a5.5 5.5 0 01-9.8 3.4\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/><path d=\"M2.5 8a5.5 5.5 0 019.8-3.4\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/><path d=\"M12.3 2v3h-3\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" stroke-linejoin=\"miter\"/><path d=\"M3.7 14v-3h3\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\" stroke-linejoin=\"miter\"/></svg>`,\n};\n\n/* ============================================================\n   LARGE (48×48) FILE TYPE ICONS — for desktop thumbnails\n   ============================================================ */\nfunction getFolderSVG(color) {\n    // Validate: only allow CSS hex colors to prevent SVG attribute injection\n    const c = /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : '#0078d4';\n    return `<svg width=\"48\" height=\"48\" viewBox=\"0 0 48 48\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path d=\"M4 14h17l5 7h18v21H4z\" fill=\"${c}\" opacity=\".22\" stroke=\"${c}\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/>\n    <path d=\"M4 21h40v21H4z\" fill=\"${c}\" opacity=\".35\" stroke=\"${c}\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/>\n  </svg>`;\n}\n\nfunction getFileIconSVG(mime, name) {\n    const ext = getExt(name || '');\n    if (isImage(mime)) return _bigIcon('#9cdcfe', _imgPath());\n    if (isAudio(mime)) return _bigIcon('#c678dd', _audioPath());\n    if (isVideo(mime)) return _bigIcon('#c678dd', _videoPath());\n    if (isPDF(mime)) return _bigIcon('#f44747', _pdfPath());\n    if (isText(mime, name)) {\n        if (['js', 'ts', 'py', 'rs', 'go', 'java', 'c', 'cmd', 'cpp', 'cs', 'php', 'rb', 'sh', 'bat', 'ps1', 'vbs'].includes(ext))\n            return _bigIcon('#dcdcaa', _codePath());\n        if (['json', 'yaml', 'yml', 'xml', 'csv'].includes(ext))\n            return _bigIcon('#4ec9b0', _dataPath());\n        return _bigIcon('#d4d4d4', _textPath());\n    }\n    if (['zip', 'rar', 'gz', '7z', 'tar', 'stk', 'itk', 'ltk', 'jtk', 'arj', 'deb', 'iso', 'cso', 'rpm', 'pkg', 'appx', 'msix'].includes(ext)) return _bigIcon('#ce9178', _archivePath());\n    if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) return _bigIcon('#569cd6', _docPath());\n    if (['xls', 'xlsx', 'xlsb', 'xlsm', 'ods'].includes(ext)) return _bigIcon('#4ec9b0', _dataPath());\n    if (['ppt', 'pptx', 'odp'].includes(ext)) return _bigIcon('#ce9178', _slidePath());\n    // Unknown type — show extension label inside icon (≤ 4 chars only)\n    if (ext && ext.length <= 4) return _bigIconExt('#858585', ext.toUpperCase());\n    return _bigIcon('#858585', _filePath());\n}\n\nfunction _bigIcon(color, inner) {\n    return `<svg width=\"48\" height=\"48\" viewBox=\"0 0 48 48\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path d=\"M10 4h18l10 10v30H10z\" fill=\"${color}\" opacity=\".1\" stroke=\"${color}\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/>\n    <path d=\"M28 4v10h10\" stroke=\"${color}\" stroke-width=\"1.5\" stroke-linecap=\"square\"/>\n    ${inner.replace(/COLOR/g, color)}\n  </svg>`;\n}\n\nfunction _filePath() { return `<path d=\"M16 26h16M16 31h12\" stroke=\"COLOR\" stroke-width=\"1.8\" stroke-linecap=\"square\" opacity=\".7\"/>`; }\nfunction _textPath() { return `<path d=\"M16 23h16M16 28h16M16 33h11\" stroke=\"COLOR\" stroke-width=\"1.8\" stroke-linecap=\"square\" opacity=\".7\"/>`; }\nfunction _codePath() { return `<path d=\"M19 22l-5 5 5 5M29 22l5 5-5 5M25 19l-4 14\" stroke=\"COLOR\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" opacity=\".8\"/>`; }\nfunction _dataPath() { return `<path d=\"M15 25h18M21 20v14M27 20v14\" stroke=\"COLOR\" stroke-width=\"1.8\" stroke-linecap=\"square\" opacity=\".7\"/>`; }\nfunction _imgPath() { return `<rect x=\"15\" y=\"20\" width=\"18\" height=\"14\" rx=\"1\" stroke=\"COLOR\" stroke-width=\"1.5\" opacity=\".7\"/><circle cx=\"20\" cy=\"25\" r=\"2\" fill=\"COLOR\" opacity=\".6\"/><path d=\"M15 31l7-5 4 4 3-2 4 3\" stroke=\"COLOR\" stroke-width=\"1.5\" stroke-linejoin=\"round\" opacity=\".7\"/>`; }\nfunction _audioPath() { return `<circle cx=\"24\" cy=\"27\" r=\"6\" stroke=\"COLOR\" stroke-width=\"1.5\" opacity=\".7\"/><circle cx=\"24\" cy=\"27\" r=\"2\" fill=\"COLOR\" opacity=\".6\"/><path d=\"M20 21v6\" stroke=\"COLOR\" stroke-width=\"1.8\" stroke-linecap=\"round\" opacity=\".6\"/>`; }\nfunction _videoPath() { return `<rect x=\"13\" y=\"21\" width=\"15\" height=\"12\" rx=\"1\" stroke=\"COLOR\" stroke-width=\"1.5\" opacity=\".7\"/><path d=\"M28 24l7-3v10l-7-3z\" fill=\"COLOR\" opacity=\".5\" stroke=\"COLOR\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/>`; }\nfunction _pdfPath() { return `<path d=\"M15 23h8M15 28h10M15 33h13\" stroke=\"COLOR\" stroke-width=\"1.8\" stroke-linecap=\"square\" opacity=\".7\"/><path d=\"M30 21v6h5\" stroke=\"COLOR\" stroke-width=\"1.5\" stroke-linecap=\"square\" opacity=\".5\"/>`; }\nfunction _archivePath() { return `<path d=\"M22 4v40M18 12h8M18 18h8M18 24h8M18 30h8\" stroke=\"COLOR\" stroke-width=\"1.8\" stroke-linecap=\"square\" opacity=\".7\"/>`; }\nfunction _docPath() { return `<path d=\"M16 23h16M16 28h16M16 33h10\" stroke=\"COLOR\" stroke-width=\"1.8\" stroke-linecap=\"square\" opacity=\".7\"/><path d=\"M32 21l2 2-2 2\" stroke=\"COLOR\" stroke-width=\"1.5\" stroke-linecap=\"round\" opacity=\".6\"/>`; }\nfunction _slidePath() { return `<rect x=\"14\" y=\"20\" width=\"20\" height=\"14\" rx=\"1\" stroke=\"COLOR\" stroke-width=\"1.5\" opacity=\".7\"/><path d=\"M24 27l-4-4v8z\" fill=\"COLOR\" opacity=\".6\"/>`; }\n\nfunction _bigIconExt(color, extText) {\n    const fs = extText.length <= 2 ? 14 : extText.length === 3 ? 12 : 10;\n    // HTML-escape the extension before inserting into SVG text content\n    const safe = extText.replace(/[&<>\"]/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[ch]));\n    return `<svg width=\"48\" height=\"48\" viewBox=\"0 0 48 48\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path d=\"M10 4h18l10 10v30H10z\" fill=\"${color}\" opacity=\".1\" stroke=\"${color}\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/>\n    <path d=\"M28 4v10h10\" stroke=\"${color}\" stroke-width=\"1.5\" stroke-linecap=\"square\"/>\n    <text x=\"24\" y=\"35\" text-anchor=\"middle\" font-size=\"${fs}\" font-weight=\"700\" font-family=\"Cascadia Code,Consolas,monospace\" fill=\"${color}\" opacity=\".9\">${safe}</text>\n  </svg>`;\n}\n"
  },
  {
    "path": "src/js/crypto.js",
    "content": "'use strict';\n\n/* ============================================================\n   CRYPTO  —  AES-256-GCM + Argon2id (WASM)\n   ============================================================ */\nconst Crypto = (() => {\n\n    // Returns raw 32-byte Argon2id hash as Uint8Array\n    async function deriveRaw(password, salt) {\n        return hashwasm.argon2id({\n            password,\n            salt,\n            parallelism: ARGON2_PAR,\n            iterations: ARGON2_ITER,\n            memorySize: ARGON2_MEM,\n            hashLength: 32,\n            outputType: 'binary',\n        });\n    }\n\n    async function deriveKey(password, salt) {\n        const hash = await deriveRaw(password, salt);\n        return crypto.subtle.importKey(\n            'raw', hash,\n            { name: 'AES-GCM' },\n            false,\n            ['encrypt', 'decrypt']\n        );\n    }\n\n    // Derives both the CryptoKey and the raw bytes in a single Argon2id pass.\n    // Use instead of calling deriveKey + deriveRaw separately to avoid double hashing.\n    async function deriveKeyAndRaw(password, salt) {\n        const raw = await deriveRaw(password, salt),\n            key = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);\n        return { key, raw };\n    }\n\n    // Import a pre-derived 32-byte key (skips Argon2id for session resume)\n    async function importRawKey(rawBytes) {\n        return crypto.subtle.importKey(\n            'raw', rawBytes,\n            { name: 'AES-GCM' },\n            false,\n            ['encrypt', 'decrypt']\n        );\n    }\n\n    async function encrypt(key, data) {\n        const iv = crypto.getRandomValues(new Uint8Array(12));\n        const buf = data instanceof ArrayBuffer\n            ? data\n            : (data instanceof Uint8Array\n                ? data.buffer\n                : new TextEncoder().encode(typeof data === 'string' ? data : JSON.stringify(data)));\n        const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, buf);\n        return { iv: Array.from(iv), blob: buf2b64(ct) };\n    }\n\n    async function decrypt(key, iv, blobB64) {\n        const ivU8 = new Uint8Array(iv),\n            buf = b642buf(blobB64);\n        return crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivU8 }, key, buf);\n    }\n\n    async function encryptBin(key, buf) {\n        const iv = crypto.getRandomValues(new Uint8Array(12)),\n            ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, buf);\n        return { iv: Array.from(iv), blob: ct };\n    }\n\n    async function decryptBin(key, iv, blob) {\n        return crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(iv) }, key, blob);\n    }\n\n    async function makeVerification(key) {\n        const { iv, blob } = await encrypt(key, VERIFY_TEXT);\n        return { iv, blob };\n    }\n\n    async function checkVerification(key, iv, blob) {\n        try {\n            const buf = await decrypt(key, iv, blob);\n            return new TextDecoder().decode(buf) === VERIFY_TEXT;\n        } catch { return false; }\n    }\n\n    return { deriveRaw, deriveKey, deriveKeyAndRaw, importRawKey, encrypt, decrypt, encryptBin, decryptBin, makeVerification, checkVerification };\n})();\n"
  },
  {
    "path": "src/js/db.js",
    "content": "'use strict';\n\n/* ============================================================\n   DATABASE  —  IndexedDB abstraction\n   ============================================================ */\nconst DB = (() => {\n    let _db = null;\n\n    async function init() {\n        InitLog.step('DB open (SafeNovaEFS)');\n        return new Promise((res, rej) => {\n            let settled = false;\n            const timer = setTimeout(() => {\n                if (!settled) { settled = true; InitLog.error('DB open (SafeNovaEFS)', 'timeout'); rej(new Error('SafeNovaEFS open timeout')); }\n            }, 8000);\n            const done = (db) => { if (!settled) { settled = true; clearTimeout(timer); _db = db; InitLog.done('DB open (SafeNovaEFS)'); res(); } };\n            const fail = (e) => { if (!settled) { settled = true; clearTimeout(timer); InitLog.error('DB open (SafeNovaEFS)', e); rej(e); } };\n\n            const req = indexedDB.open(DB_NAME, DB_VERSION);\n            req.onupgradeneeded = e => {\n                InitLog.step('DB schema upgrade');\n                try {\n                    const db = e.target.result;\n                    if (!db.objectStoreNames.contains('containers')) {\n                        db.createObjectStore('containers', { keyPath: 'id' });\n                    }\n                    if (!db.objectStoreNames.contains('files')) {\n                        const fs = db.createObjectStore('files', { keyPath: 'id' });\n                        fs.createIndex('cid', 'cid');\n                    }\n                    if (!db.objectStoreNames.contains('vfs')) {\n                        db.createObjectStore('vfs', { keyPath: 'cid' });\n                    }\n                    if (!db.objectStoreNames.contains('chunks')) {\n                        db.createObjectStore('chunks', { keyPath: 'id' });\n                    }\n                    InitLog.done('DB schema upgrade');\n                } catch (err) { InitLog.error('DB schema upgrade', err); fail(err); }\n            };\n            req.onsuccess = e => done(e.target.result);\n            req.onerror = () => fail(req.error);\n            req.onblocked = () => {\n                // Another connection prevents upgrade; close it by requesting versionchange on self\n                InitLog.error('DB open (SafeNovaEFS)', 'blocked — waiting for other connections to close');\n                // Keep waiting — the blocked event does NOT mean failure, just delay.\n                // If it takes longer than the timeout above, we fail gracefully.\n            };\n        });\n    }\n\n    function rw(store) { return _db.transaction(store, 'readwrite').objectStore(store); }\n    function ro(store) { return _db.transaction(store, 'readonly').objectStore(store); }\n    function wrap(req) { return new Promise((r, j) => { req.onsuccess = () => r(req.result); req.onerror = () => j(req.error); }); }\n\n    // Reassemble a chunked file record: reads N chunks from 'chunks' store,\n    // merges them into a single ArrayBuffer, sets rec.blob, deletes rec._chunked.\n    function _reassemble(rec) {\n        return new Promise((resolve, reject) => {\n            const count = rec._chunked, id = rec.id;\n            const tx = _db.transaction('chunks', 'readonly'),\n                store = tx.objectStore('chunks'),\n                parts = new Array(count);\n            let totalSize = 0, pending = count;\n            for (let i = 0; i < count; i++) {\n                const req = store.get(id + '_' + i);\n                req.onsuccess = () => {\n                    const d = req.result?.data;\n                    if (d) { parts[i] = d; totalSize += d.byteLength; }\n                    if (--pending === 0) {\n                        // Reject if any chunk is missing — silently skipping a missing chunk\n                        // corrupts all subsequent offsets and produces garbled data.\n                        for (let k = 0; k < count; k++) {\n                            if (!parts[k]) { reject(new Error('Missing chunk ' + id + '_' + k)); return; }\n                        }\n                        const merged = new Uint8Array(totalSize);\n                        let off = 0;\n                        for (const p of parts) { merged.set(new Uint8Array(p), off); off += p.byteLength; }\n                        rec.blob = merged.buffer;\n                        delete rec._chunked;\n                        resolve(rec);\n                    }\n                };\n                req.onerror = () => reject(req.error);\n            }\n        });\n    }\n\n    // Returns 2 distinct non-adjacent random indices within [0, size).\n    // Falls back gracefully for very small buffers.\n    function _twoRandPos(size) {\n        if (size < 2) return size ? [0] : [];\n        if (size < 4) return [0, size - 1]; // small buffer — take ends\n        const a = Math.floor(Math.random() * size);\n        let b;\n        do { b = Math.floor(Math.random() * size); } while (Math.abs(b - a) <= 1);\n        return [a, b];\n    }\n\n    // XOR-flips 2 random non-adjacent bytes in the buffer with a random non-zero value.\n    // Position and delta are unknown and unreproducible — guaranteed to change each byte.\n    function _corruptRandBytes(buf) {\n        if (!buf || buf.byteLength < 1) return;\n        const arr = new Uint8Array(buf);\n        for (const p of _twoRandPos(arr.length)) {\n            arr[p] ^= (Math.floor(Math.random() * 255) + 1);\n        }\n    }\n\n    // Cryptographic pre-shredding — makes every encrypted blob irrecoverable:\n    //   • Inline files : XOR-flip 2 random non-adjacent bytes in the ciphertext.\n    //     Any 1-byte change in AES-GCM ciphertext causes GCM auth-tag failure\n    //     for the entire file. Position and XOR delta are unknown and unlogged.\n    //   • Chunked files: zero the IV stored in the file record — no chunk data\n    //     is read at all, making this path maximally fast for large files.\n    //     Without a valid IV, AES-GCM decryption cannot even begin.\n    // Best-effort — always resolves so deletion / duress flow can continue.\n    function _corruptFileBlobs(ids) {\n        return new Promise((resolve) => {\n            if (!ids.length) { resolve(); return; }\n            const tx = _db.transaction(['files', 'chunks'], 'readwrite'),\n                fs = tx.objectStore('files'),\n                cs = tx.objectStore('chunks');\n            tx.oncomplete = () => resolve();\n            tx.onerror = (e) => { e.preventDefault(); resolve(); };\n            tx.onabort = () => resolve();\n            ids.forEach(id => {\n                const req = fs.get(id);\n                req.onsuccess = () => {\n                    const rec = req.result;\n                    if (!rec) return;\n                    if (rec._chunked) {\n                        // Large file: zero the IV — avoids reading any chunk data entirely.\n                        // AES-GCM without a valid IV is unconditionally undecryptable.\n                        // rec.iv is stored as a plain JS Array (via Array.from()), not an ArrayBuffer,\n                        // so we must fill in-place rather than wrapping in a TypedArray view.\n                        if (Array.isArray(rec.iv)) {\n                            for (let _i = 0; _i < rec.iv.length; _i++) rec.iv[_i] = 0;\n                        } else if (rec.iv instanceof ArrayBuffer) {\n                            new Uint8Array(rec.iv).fill(0);\n                        } else if (ArrayBuffer.isView(rec.iv)) {\n                            new Uint8Array(rec.iv.buffer, rec.iv.byteOffset, rec.iv.byteLength).fill(0);\n                        }\n                        fs.put(rec);\n                    } else if (rec.blob) {\n                        // Inline file: XOR-flip 2 random non-adjacent bytes in the ciphertext.\n                        _corruptRandBytes(rec.blob);\n                        fs.put(rec);\n                    }\n                };\n                req.onerror = (e) => e.preventDefault();\n            });\n        });\n    }\n\n    return {\n        init,\n        /* containers */\n        getContainers: () => wrap(ro('containers').getAll()),\n        saveContainer: (c) => wrap(rw('containers').put(c)),\n        deleteContainer: (id) => wrap(rw('containers').delete(id)),\n\n        /* files */\n        saveFile: async (f) => {\n            const blobSize = f.blob ? (f.blob.byteLength ?? 0) : 0;\n            if (blobSize > FILE_CHUNK_SIZE) {\n                const chunkCount = Math.ceil(blobSize / FILE_CHUNK_SIZE),\n                    tx = _db.transaction(['files', 'chunks'], 'readwrite'),\n                    fs = tx.objectStore('files'),\n                    cs = tx.objectStore('chunks');\n                fs.put({ id: f.id, cid: f.cid, iv: f.iv, blob: null, _chunked: chunkCount, _blobSize: blobSize });\n                for (let i = 0; i < chunkCount; i++) {\n                    const start = i * FILE_CHUNK_SIZE;\n                    cs.put({ id: f.id + '_' + i, data: f.blob.slice(start, Math.min(start + FILE_CHUNK_SIZE, blobSize)) });\n                }\n                return new Promise((r, j) => { tx.oncomplete = () => r(); tx.onerror = () => j(tx.error); });\n            }\n            return wrap(rw('files').put(f));\n        },\n        getFile: async (id) => {\n            let rec;\n            try { rec = await wrap(ro('files').get(id)); }\n            catch (e) { console.error('[DB] Failed to read file record:', id, e); return null; }\n            if (!rec) return rec;\n            if (rec._chunked) return _reassemble(rec);\n            return rec;\n        },\n        getFilesByCid: async (cid) => {\n            let recs;\n            try {\n                recs = await wrap(ro('files').index('cid').getAll(cid));\n            } catch {\n                // Fallback: key cursor → individual reads (handles unreadable oversized records)\n                const keys = await new Promise((res, rej) => {\n                    const tx = _db.transaction('files', 'readonly'),\n                        idx = tx.objectStore('files').index('cid'),\n                        r = [],\n                        req = idx.openKeyCursor(IDBKeyRange.only(cid));\n                    req.onsuccess = () => { const c = req.result; if (!c) { res(r); return; } r.push(c.primaryKey); c.continue(); };\n                    req.onerror = () => rej(req.error);\n                });\n                recs = [];\n                for (const key of keys) {\n                    try { const r = await wrap(ro('files').get(key)); if (r) recs.push(r); }\n                    catch (e) { console.error('[DB] Skipping unreadable file:', key, e); }\n                }\n            }\n            const chunked = recs.filter(r => r._chunked);\n            if (chunked.length) await Promise.all(chunked.map(r => _reassemble(r)));\n            return recs;\n        },\n        // Lightweight: returns [{id, iv, sz}] without chunk reassembly\n        getFileMetaByCid: (cid) => new Promise((resolve, reject) => {\n            const tx = _db.transaction('files', 'readonly'),\n                idx = tx.objectStore('files').index('cid'),\n                req = idx.openCursor(IDBKeyRange.only(cid)),\n                results = [];\n            req.onsuccess = () => {\n                const c = req.result;\n                if (c) {\n                    const r = c.value;\n                    results.push({\n                        id: r.id, iv: r.iv,\n                        sz: r._chunked ? (r._blobSize ?? -1) : (r.blob ? (r.blob.byteLength || 0) : 0)\n                    });\n                    c.continue();\n                } else resolve(results);\n            };\n            req.onerror = () => reject(req.error);\n        }),\n        deleteFile: async (id) => {\n            let chunked = 0;\n            try { const rec = await wrap(ro('files').get(id)); if (rec?._chunked) chunked = rec._chunked; }\n            catch { /* unreadable record — not chunked */ }\n            if (chunked) {\n                const tx = _db.transaction(['files', 'chunks'], 'readwrite');\n                tx.objectStore('files').delete(id);\n                const cs = tx.objectStore('chunks');\n                for (let i = 0; i < chunked; i++) cs.delete(id + '_' + i);\n                return new Promise((r, j) => { tx.oncomplete = () => r(); tx.onerror = () => j(tx.error); });\n            }\n            return wrap(rw('files').delete(id));\n        },\n        // Batch-delete multiple file records (and their chunks) in IndexedDB\n        deleteFiles: async (ids) => {\n            if (!ids || !ids.length) return;\n            // Phase 1: check which files are chunked (read-only, safe for broken records)\n            const chunkInfo = new Map();\n            await new Promise((resolve) => {\n                const tx = _db.transaction('files', 'readonly'),\n                    store = tx.objectStore('files');\n                let pending = ids.length;\n                const done = () => { if (--pending === 0) resolve(); };\n                ids.forEach(id => {\n                    const req = store.get(id);\n                    req.onsuccess = () => { if (req.result?._chunked) chunkInfo.set(id, req.result._chunked); done(); };\n                    req.onerror = (e) => { e.preventDefault(); done(); };\n                });\n            });\n            // Phase 2: delete file records + associated chunks\n            const stores = chunkInfo.size ? ['files', 'chunks'] : ['files'],\n                tx = _db.transaction(stores, 'readwrite'),\n                fs = tx.objectStore('files');\n            ids.forEach(id => fs.delete(id));\n            if (chunkInfo.size) {\n                const cs = tx.objectStore('chunks');\n                for (const [id, count] of chunkInfo) {\n                    for (let i = 0; i < count; i++) cs.delete(id + '_' + i);\n                }\n            }\n            return new Promise((r, j) => { tx.oncomplete = () => r(); tx.onerror = () => j(tx.error); });\n        },\n        // Batch-save multiple file records in a single IndexedDB transaction (with chunking for large blobs)\n        saveFiles: (files) => new Promise((res, rej) => {\n            if (!files || !files.length) { res(); return; }\n            const hasChunked = files.some(f => f.blob && (f.blob.byteLength ?? 0) > FILE_CHUNK_SIZE),\n                stores = hasChunked ? ['files', 'chunks'] : ['files'],\n                tx = _db.transaction(stores, 'readwrite'),\n                fileStore = tx.objectStore('files'),\n                chunkStore = hasChunked ? tx.objectStore('chunks') : null;\n            files.forEach(f => {\n                const blobSize = f.blob ? (f.blob.byteLength ?? 0) : 0;\n                if (blobSize > FILE_CHUNK_SIZE) {\n                    const chunkCount = Math.ceil(blobSize / FILE_CHUNK_SIZE);\n                    fileStore.put({ id: f.id, cid: f.cid, iv: f.iv, blob: null, _chunked: chunkCount, _blobSize: blobSize });\n                    for (let i = 0; i < chunkCount; i++) {\n                        const start = i * FILE_CHUNK_SIZE;\n                        chunkStore.put({ id: f.id + '_' + i, data: f.blob.slice(start, Math.min(start + FILE_CHUNK_SIZE, blobSize)) });\n                    }\n                } else {\n                    fileStore.put(f);\n                }\n            });\n            tx.oncomplete = () => res();\n            tx.onerror = () => rej(tx.error);\n        }),\n        // Batch-read specific file records by id array in a single IDB transaction.\n        // Returns a Map<id, record> so callers can look up by id in O(1).\n        getFilesByIds: (ids) => new Promise((res, rej) => {\n            if (!ids || !ids.length) { res(new Map()); return; }\n            const tx = _db.transaction('files', 'readonly'),\n                store = tx.objectStore('files'),\n                result = new Map(),\n                chunkedRecs = [];\n            let pending = ids.length;\n            ids.forEach(id => {\n                const req = store.get(id);\n                req.onsuccess = () => {\n                    if (req.result) {\n                        result.set(id, req.result);\n                        if (req.result._chunked) chunkedRecs.push(req.result);\n                    }\n                    if (--pending === 0) {\n                        if (chunkedRecs.length) {\n                            Promise.all(chunkedRecs.map(r => _reassemble(r))).then(() => res(result)).catch(rej);\n                        } else res(result);\n                    }\n                };\n                req.onerror = (event) => {\n                    console.error('[DB] Failed to read file:', id, req.error);\n                    event.preventDefault();\n                    if (--pending === 0) {\n                        if (chunkedRecs.length) {\n                            Promise.all(chunkedRecs.map(r => _reassemble(r))).then(() => res(result)).catch(rej);\n                        } else res(result);\n                    }\n                };\n            });\n        }),\n\n        /* vfs */\n        saveVFS: (cid, iv, blob) => wrap(rw('vfs').put({ cid, iv, blob })),\n        getVFS: (cid) => wrap(ro('vfs').get(cid)),\n        deleteVFS: (cid) => wrap(rw('vfs').delete(cid)),\n\n        /* nuke container — corrupts blobs, then deletes everything (uses key cursor to avoid deserializing large blobs) */\n        async nukeContainer(cid) {\n            const ids = await new Promise((resolve, reject) => {\n                const tx = _db.transaction('files', 'readonly'),\n                    idx = tx.objectStore('files').index('cid'),\n                    r = [],\n                    req = idx.openKeyCursor(IDBKeyRange.only(cid));\n                req.onsuccess = () => { const c = req.result; if (!c) { resolve(r); return; } r.push(c.primaryKey); c.continue(); };\n                req.onerror = () => reject(req.error);\n            });\n            if (ids.length) {\n                await _corruptFileBlobs(ids); // XOR-flip 2 random bytes (inline) / zero IV (chunked)\n                await this.deleteFiles(ids);\n            }\n            await this.deleteVFS(cid);\n            // Null out lazyWorkspace/heavy blobs before deleting the container record.\n            // Chrome stores large Blobs in external files; IDB delete queues them for\n            // lazy GC but navigator.storage.estimate() still counts them as used.\n            // Overwriting with null forces immediate blob file release.\n            try {\n                const c = await wrap(ro('containers').get(cid));\n                if (c && (c.lazyWorkspace || c._alogZ || c._exportCache)) {\n                    c.lazyWorkspace = null;\n                    c._alogZ = null;\n                    c._exportCache = null;\n                    await wrap(rw('containers').put(c));\n                }\n            } catch { /* read failed — proceed to delete */ }\n            await this.deleteContainer(cid);\n        },\n\n        /* duress trigger — zero-overwrites blobs but does NOT delete anything */\n        async corruptContainerBlobs(cid) {\n            const ids = await new Promise((resolve, reject) => {\n                const tx = _db.transaction('files', 'readonly'),\n                    idx = tx.objectStore('files').index('cid'),\n                    r = [],\n                    req = idx.openKeyCursor(IDBKeyRange.only(cid));\n                req.onsuccess = () => { const c = req.result; if (!c) { resolve(r); return; } r.push(c.primaryKey); c.continue(); };\n                req.onerror = () => reject(req.error);\n            });\n            if (ids.length) await _corruptFileBlobs(ids);\n        }\n    };\n})();\n"
  },
  {
    "path": "src/js/desktop.js",
    "content": "'use strict';\n\n/* ============================================================\n   SAVE VFS\n   ============================================================ */\nasync function saveVFS() {\n    if (!App.key || !App.container) return;\n    try {\n        const jsonBuf = new TextEncoder().encode(JSON.stringify(VFS.toObj())),\n            { iv, blob } = await Crypto.encryptBin(App.key, jsonBuf);\n        await DB.saveVFS(App.container.id, iv, blob);\n        App.container.totalSize = VFS.totalSize();\n        // Strip raw log so only compressed _alogZ gets persisted\n        const _tmpLog = App.container.activityLog;\n        delete App.container.activityLog;\n        await DB.saveContainer(App.container);\n        if (_tmpLog) App.container.activityLog = _tmpLog;\n        Desktop.updateTaskbar();\n    } catch (e) { console.error('saveVFS error', e); }\n}\n\n/* ============================================================\n   ACTIVITY LOGS\n   ============================================================ */\nconst ALOG_MAX = 2048;\nlet _alogSaveTimer = null, _alogRafId = null, _alogFilters = null;\nlet _activityLog = []; // in-memory ring buffer; never stored raw on the container\n\n// ── Compression (deflate, built-in, zero-dependency) ────────\nasync function _compressBytes(bytes) {\n    const cs = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate'));\n    return new Uint8Array(await new Response(cs).arrayBuffer());\n}\nasync function _decompressBytes(bytes) {\n    const ds = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate'));\n    return new Uint8Array(await new Response(ds).arrayBuffer());\n}\n// Compress with a 5 s safety timeout — returns compressed bytes on success,\n// or the original bytes unchanged if compression fails or times out.\nasync function _compressBytesOrRaw(bytes) {\n    try {\n        return await Promise.race([\n            _compressBytes(bytes),\n            new Promise((_, rej) => setTimeout(() => rej(new Error('compress timeout')), 5000))\n        ]);\n    } catch { return bytes; }\n}\nasync function _compressLog(arr) {\n    return _compressBytes(new TextEncoder().encode(JSON.stringify(arr)));\n}\nasync function _decompressLog(bytes) {\n    return JSON.parse(new TextDecoder().decode(await _decompressBytes(bytes)));\n}\nasync function _loadActivityLog() {\n    const pending = _activityLog.length ? _activityLog.slice() : [];\n    _activityLog = [];\n    if (!App.container) return;\n    if (App.container._alogZ) {\n        try {\n            const z = App.container._alogZ;\n            // New format: { iv, blob } — AES-256-GCM encrypted, then zlib-compressed\n            const raw = (z && z.iv && z.blob)\n                ? new Uint8Array(await Crypto.decrypt(App.key, z.iv, z.blob))\n                : z; // legacy: plain compressed bytes (migrate on next flush)\n            _activityLog = await _decompressLog(raw);\n        } catch { }\n    }\n    // Migrate old uncompressed plaintext format\n    if (Array.isArray(App.container.activityLog)) {\n        _activityLog = _activityLog.concat(App.container.activityLog);\n        delete App.container.activityLog;\n    }\n    // Merge any entries pushed during async decompress\n    if (pending.length) _activityLog = _activityLog.concat(pending);\n    if (_activityLog.length > ALOG_MAX) _activityLog.splice(0, _activityLog.length - ALOG_MAX);\n}\nasync function _flushActivityLog() {\n    _alogSaveTimer = null;\n    if (!App.container || !App.key || !_activityLog.length) return;\n    try {\n        // Compress then encrypt — attacker must NOT read activity logs\n        const compressed = await _compressLog(_activityLog);\n        const { iv, blob } = await Crypto.encrypt(App.key, compressed);\n        App.container._alogZ = { iv, blob };\n        delete App.container.activityLog;\n        await DB.saveContainer(App.container);\n    } catch (e) { console.error('_flushActivityLog', e); }\n}\n\n/* ============================================================\n   OPEN-FOLDER GUARD  — shared by drag handlers and file ops\n   Returns the id of the first open-folder in ids, or null.\n   ============================================================ */\nfunction _getOpenFolderIds() {\n    if (typeof WinManager === 'undefined') return new Set();\n    const ids = new Set();\n    WinManager._wins.forEach(w => {\n        let cur = w.folderId;\n        while (cur && cur !== 'root') { ids.add(cur); cur = (VFS.node(cur) || {}).parentId; }\n    });\n    return ids;\n}\nfunction _openFolderGuard(ids) {\n    const open = _getOpenFolderIds();\n    return [...ids].find(id => { const n = VFS.node(id); return n && n.type === 'folder' && open.has(id); }) ?? null;\n}\n\n// ── logActivity ─────────────────────────────────────────────\nfunction logActivity(op, detail, count, itemPath, destPath) {\n    if (!App.container) return;\n    if (_getSettings().activityLogs === false) return;\n    const entry = { t: Date.now(), o: op, d: detail };\n    if (count > 1) entry.n = count;\n    let p = itemPath ?? null;\n    if (!p && App.folder) {\n        p = VFS.fullPath(App.folder);\n    }\n    if (p && p !== '/') entry.p = p;\n    if (destPath && destPath !== '/') entry.p2 = destPath;\n    _activityLog.push(entry);\n    if (_activityLog.length > ALOG_MAX) _activityLog.splice(0, _activityLog.length - ALOG_MAX);\n    if (_alogSaveTimer) clearTimeout(_alogSaveTimer);\n    _alogSaveTimer = setTimeout(_flushActivityLog, 3000);\n}\n\n// ── Helpers ─────────────────────────────────────────────────\nfunction _alogRelTime(ts) {\n    const d = Date.now() - ts;\n    if (d < 60000) return 'Just now';\n    if (d < 3600000) return Math.floor(d / 60000) + 'm ago';\n    if (d < 86400000) return Math.floor(d / 3600000) + 'h ago';\n    if (d < 604800000) return Math.floor(d / 86400000) + 'd ago';\n    return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });\n}\n// Show path compactly: /~/Container/…/parent/name for deep paths\nfunction _alogPathDisplay(p) {\n    if (!p) return '';\n    const segs = p.split('/').filter(Boolean); // ['~', 'Container', 'a', 'b', 'file']\n    if (p.length <= 58 || segs.length <= 4) return p;\n    const prefix = '/~/' + segs[1] + '/\\u2026/',\n        tail = segs.slice(-2).join('/') + (p.endsWith('/') ? '/' : ''),\n        result = prefix + tail;\n    // If still too long, keep only the last segment\n    return result.length <= 62 ? result : prefix + segs[segs.length - 1] + (p.endsWith('/') ? '/' : '');\n}\nfunction _alogOpLabel(op) {\n    const map = {\n        upload: 'Uploaded', delete: 'Deleted', rename: 'Renamed', move: 'Moved',\n        copy: 'Copied', cut: 'Cut', paste: 'Pasted', 'create-file': 'Created',\n        'create-folder': 'New Folder', color: 'Color', edit: 'Saved',\n        download: 'Exported', 'export-zip': 'ZIP Export', sort: 'Sorted',\n        'export-container': 'Container Export'\n    };\n    return map[op] || op;\n}\nconst _ALOG_COLORS = {\n    upload: '#3a8a4f', delete: '#c44040', rename: '#b07a20', move: '#3a6ea0',\n    copy: '#2a8a8a', cut: '#b06020', paste: '#7a309a', 'create-file': '#3a8a4f',\n    'create-folder': '#3a8a4f', color: '#a03060', edit: '#8a7020',\n    download: '#2a6aaa', 'export-zip': '#3a6ea0', sort: '#6a6a6a',\n    'export-container': '#3a6ea0'\n};\nconst _ALOG_ICONS = {\n    upload: Icons.upload,\n    delete: Icons.trash,\n    rename: Icons.rename,\n    move: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M3 8h10M9 4l4 4-4 4\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>`,\n    copy: Icons.copy,\n    cut: Icons.cut,\n    paste: Icons.paste,\n    'create-file': Icons.newfile,\n    'create-folder': Icons.newfolder,\n    color: `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><circle cx=\"8\" cy=\"8\" r=\"5.5\" stroke=\"currentColor\" stroke-width=\"1.4\"/><circle cx=\"8\" cy=\"8\" r=\"2.5\" fill=\"currentColor\"/></svg>`,\n    edit: Icons.save,\n    download: Icons.download,\n    'export-zip': `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M4 2h5l3 3v9H4z\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linejoin=\"round\"/><path d=\"M9 2v3h3\" stroke=\"currentColor\" stroke-width=\"1.3\"/><path d=\"M7 7h2v2H7zM7 10h2v2H7z\" fill=\"currentColor\" opacity=\".85\"/></svg>`,\n    sort: Icons.sort,\n    'export-container': `<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><rect x=\"2\" y=\"2\" width=\"12\" height=\"12\" rx=\"1.5\" stroke=\"currentColor\" stroke-width=\"1.3\"/><path d=\"M8 5v5M5.5 8l2.5 2 2.5-2\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>`\n};\n\n// ── Render: date-grouped list with badge layout ─────────────\nfunction _renderActivityLogs() {\n    const listEl = document.getElementById('alog-list'),\n        offEl = document.getElementById('alog-off'),\n        emptyEl = document.getElementById('alog-empty'),\n        contentEl = document.getElementById('alog-content'),\n        filtersEl = document.getElementById('alog-filters'),\n        toolbarEl = document.getElementById('alog-toolbar'),\n        s = _getSettings(),\n        log = _activityLog;\n\n    if (s.activityLogs === false) {\n        offEl.style.display = 'flex';\n        emptyEl.style.display = 'none';\n        listEl.style.display = 'none';\n        toolbarEl.style.display = 'none';\n        return;\n    }\n    offEl.style.display = 'none';\n    if (!log.length) {\n        emptyEl.style.display = 'flex';\n        listEl.style.display = 'none';\n        toolbarEl.style.display = 'none';\n        return;\n    }\n\n    toolbarEl.style.display = '';\n    emptyEl.style.display = 'none';\n    listEl.style.display = '';\n\n    // Count ops for filter chips\n    const opCounts = {};\n    log.forEach(e => { opCounts[e.o] = (opCounts[e.o] || 0) + 1; });\n    const ops = Object.keys(opCounts).sort((a, b) => opCounts[b] - opCounts[a]);\n    let filterHtml = '';\n    for (const op of ops) {\n        const active = !_alogFilters || _alogFilters.has(op);\n        filterHtml += `<button class=\"alog-filter${active ? ' active' : ''}\" data-op=\"${op}\">${escHtml(_alogOpLabel(op))}<span class=\"alog-filter-count\">${opCounts[op]}</span></button>`;\n    }\n    filtersEl.innerHTML = filterHtml;\n    filtersEl.querySelectorAll('.alog-filter').forEach(btn => {\n        btn.onclick = () => {\n            const op = btn.dataset.op;\n            if (!_alogFilters) {\n                _alogFilters = new Set(ops);\n                _alogFilters.delete(op);\n            } else if (_alogFilters.has(op)) {\n                _alogFilters.delete(op);\n                if (!_alogFilters.size) _alogFilters = null;\n            } else {\n                _alogFilters.add(op);\n                if (_alogFilters.size === ops.length) _alogFilters = null;\n            }\n            _renderActivityLogs();\n        };\n    });\n\n    // Build filtered list (newest first)\n    const items = [];\n    for (let i = log.length - 1; i >= 0; i--) {\n        if (!_alogFilters || _alogFilters.has(log[i].o)) items.push(log[i]);\n    }\n\n    if (!items.length) {\n        listEl.style.display = 'none';\n        emptyEl.style.display = 'flex';\n        return;\n    }\n\n    // Group by date\n    const now = new Date(),\n        todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(),\n        yesterdayStart = todayStart - 86400000,\n        weekStart = todayStart - 6 * 86400000;\n    let html = '', lastGroup = '';\n    for (const it of items) {\n        let group;\n        if (it.t >= todayStart) group = 'Today';\n        else if (it.t >= yesterdayStart) group = 'Yesterday';\n        else if (it.t >= weekStart) group = 'This Week';\n        else group = 'Earlier';\n        if (group !== lastGroup) {\n            html += `<div class=\"alog-group\">${escHtml(group)}</div>`;\n            lastGroup = group;\n        }\n        const color = _ALOG_COLORS[it.o] || '#666',\n            label = _alogOpLabel(it.o),\n            mainText = it.d || '',\n            pathFull = it.p ? (it.n > 1 && !it.p.endsWith('/') ? it.p + '/' : it.p) : '',\n            pathShort = _alogPathDisplay(pathFull),\n            destFull = it.p2 || '',\n            destShort = _alogPathDisplay(destFull),\n            time = _alogRelTime(it.t);\n        let pathHtml = '';\n        if (pathShort && destShort) {\n            pathHtml = `<span class=\"alog-paths\"><code class=\"alog-path-chip\" title=\"${escHtml(pathFull)}\">${escHtml(pathShort)}</code><span class=\"alog-arrow\">\\u2192</span><code class=\"alog-path-chip\" title=\"${escHtml(destFull)}\">${escHtml(destShort)}</code></span>`;\n        } else if (pathShort) {\n            pathHtml = `<code class=\"alog-path-chip\" title=\"${escHtml(pathFull)}\">${escHtml(pathShort)}</code>`;\n        }\n        html += `<div class=\"alog-item\"><span class=\"alog-badge\" style=\"--bc:${color}\">${escHtml(label)}</span><span class=\"alog-detail\"><span class=\"alog-detail-main\" title=\"${escHtml(mainText)}\">${escHtml(mainText)}</span>${pathHtml}</span><span class=\"alog-time\">${escHtml(time)}</span></div>`;\n    }\n    contentEl.innerHTML = html;\n    listEl.onscroll = null;\n}\n\n// ── Clear / export helpers ──────────────────────────────────\nasync function _clearActivityLog() {\n    _activityLog = [];\n    if (App.container) {\n        delete App.container._alogZ;\n        delete App.container.activityLog;\n        await DB.saveContainer(App.container);\n    }\n    _alogFilters = null;\n}\n\n\n\n/* ============================================================\n   CONTEXT MENU\n   ============================================================ */\nlet _activeSubmenu = null;\n\nfunction showCtxMenu(x, y, items) {\n    hideSubmenu();\n    const menu = document.getElementById('ctx-menu');\n    menu.innerHTML = '';\n    items.forEach(item => {\n        if (item.sep) {\n            const d = document.createElement('div'); d.className = 'ctx-sep'; menu.appendChild(d); return;\n        }\n        const li = document.createElement('div');\n        li.className = 'ctx-item' + (item.danger ? ' danger' : '') + (item.disabled ? ' disabled' : '');\n        if (item.submenu) {\n            li.innerHTML = `<span class=\"ctx-item-icon\">${item.icon || ''}</span><span>${escHtml(item.label)}</span><span class=\"ctx-item-arrow\">›</span>`;\n            li.addEventListener('mouseenter', () => showSubmenu(li, item.submenu));\n            li.addEventListener('mouseleave', e => { if (!e.relatedTarget?.closest('#ctx-menu-sub')) hideSubmenu(); });\n        } else if (item.disabled && item._tooltip) {\n            li.innerHTML = `<span class=\"ctx-item-icon\">${item.icon || ''}</span><span>${escHtml(item.label)}</span>${item._keyHint ? `<span class=\"ctx-item-key-hint\">${item._keyHint}</span>` : ''}`;\n            let _tip = null;\n            li.addEventListener('mouseenter', () => {\n                _tip = document.createElement('div');\n                _tip.className = 'ctx-tooltip';\n                _tip.textContent = item._tooltip;\n                document.body.appendChild(_tip);\n                const r = li.getBoundingClientRect();\n                _tip.style.left = r.right + 6 + 'px'; _tip.style.top = r.top + 'px';\n                const tr = _tip.getBoundingClientRect();\n                if (tr.right > window.innerWidth) _tip.style.left = Math.max(0, r.left - tr.width - 6) + 'px';\n            });\n            li.addEventListener('mouseleave', () => { if (_tip) { _tip.remove(); _tip = null; } });\n        } else {\n            li.innerHTML = `<span class=\"ctx-item-icon\">${item.icon || ''}</span><span>${escHtml(item.label)}</span>${item._keyHint ? `<span class=\"ctx-item-key-hint\">${item._keyHint}</span>` : ''}`;\n            li.addEventListener('click', () => { hideCtxMenu(); item.action?.(); });\n            li.addEventListener('mouseenter', hideSubmenu);\n        }\n        menu.appendChild(li);\n    });\n    menu.style.left = x + 'px';\n    menu.style.top = y + 'px';\n    menu.classList.add('show');\n    const r = menu.getBoundingClientRect();\n    // Account for taskbar at the bottom (36px + 1px border)\n    const taskbarH = document.querySelector('.taskbar')?.offsetHeight || 37,\n        maxBottom = window.innerHeight - taskbarH;\n    if (r.right > window.innerWidth) menu.style.left = Math.max(0, x - r.width) + 'px';\n    if (r.bottom > maxBottom) menu.style.top = Math.max(0, y - r.height) + 'px';\n}\n\nfunction showSubmenu(parentEl, items) {\n    hideSubmenu();\n    let sub = document.getElementById('ctx-menu-sub');\n    if (!sub) {\n        sub = document.createElement('div');\n        sub.className = 'ctx-menu'; sub.id = 'ctx-menu-sub';\n        document.body.appendChild(sub);\n    }\n    sub.innerHTML = '';\n    let _activeSub2 = null;\n\n    function hideSub2() {\n        if (_activeSub2) { _activeSub2.remove(); _activeSub2 = null; }\n    }\n\n    items.forEach(item => {\n        if (item.sep) { const d = document.createElement('div'); d.className = 'ctx-sep'; sub.appendChild(d); return; }\n        const li = document.createElement('div');\n        li.className = 'ctx-item' + (item.danger ? ' danger' : '') + (item.disabled ? ' disabled' : '');\n        if (item.submenu) {\n            li.innerHTML = `<span class=\"ctx-item-icon\">${item.icon || ''}</span><span>${escHtml(item.label)}</span><span class=\"ctx-item-arrow\">›</span>`;\n            li.addEventListener('mouseenter', () => {\n                hideSub2();\n                const sub2 = document.createElement('div');\n                sub2.className = 'ctx-menu show';\n                item.submenu.forEach(si => {\n                    if (si.sep) { const d = document.createElement('div'); d.className = 'ctx-sep'; sub2.appendChild(d); return; }\n                    const li2 = document.createElement('div');\n                    li2.className = 'ctx-item' + (si.danger ? ' danger' : '');\n                    li2.innerHTML = `<span class=\"ctx-item-icon\">${si.icon || ''}</span><span>${escHtml(si.label)}</span>`;\n                    li2.addEventListener('click', () => { hideCtxMenu(); si.action?.(); });\n                    sub2.appendChild(li2);\n                });\n                document.body.appendChild(sub2);\n                const pr = li.getBoundingClientRect();\n                sub2.style.position = 'fixed';\n                sub2.style.left = pr.right + 'px'; sub2.style.top = pr.top + 'px';\n                const sr = sub2.getBoundingClientRect();\n                const _taskbarH2 = document.querySelector('.taskbar')?.offsetHeight || 37,\n                    _maxB2 = window.innerHeight - _taskbarH2;\n                if (window.innerWidth <= 640) {\n                    sub2.style.left = Math.max(0, Math.min(pr.left, window.innerWidth - sr.width)) + 'px';\n                    sub2.style.top = Math.min(pr.bottom, _maxB2 - sr.height) + 'px';\n                } else {\n                    if (sr.right > window.innerWidth) sub2.style.left = Math.max(0, pr.left - sr.width) + 'px';\n                    if (sr.bottom > _maxB2) sub2.style.top = Math.max(0, pr.top - (sr.bottom - _maxB2)) + 'px';\n                }\n                _activeSub2 = sub2;\n                sub2.addEventListener('mouseleave', e => {\n                    if (e.relatedTarget && li.contains(e.relatedTarget)) return;\n                    hideSub2();\n                });\n            });\n            li.addEventListener('mouseleave', e => {\n                if (_activeSub2 && _activeSub2.contains(e.relatedTarget)) return;\n                hideSub2();\n            });\n        } else {\n            li.innerHTML = `<span class=\"ctx-item-icon\">${item.icon || ''}</span><span>${escHtml(item.label)}</span>`;\n            li.addEventListener('click', () => { hideCtxMenu(); item.action?.(); });\n            li.addEventListener('mouseenter', hideSub2);\n        }\n        sub.appendChild(li);\n    });\n    sub.classList.add('show');\n    const pr = parentEl.getBoundingClientRect();\n    sub.style.left = pr.right + 'px'; sub.style.top = pr.top + 'px';\n    const sr = sub.getBoundingClientRect();\n    const _taskbarH = document.querySelector('.taskbar')?.offsetHeight || 37,\n        _maxB = window.innerHeight - _taskbarH;\n    if (window.innerWidth <= 640) {\n        // Mobile: open below parent item to prevent horizontal overflow\n        sub.style.left = Math.max(0, Math.min(pr.left, window.innerWidth - sr.width)) + 'px';\n        sub.style.top = Math.min(pr.bottom, _maxB - sr.height) + 'px';\n    } else {\n        if (sr.right > window.innerWidth) sub.style.left = Math.max(0, pr.left - sr.width) + 'px';\n        if (sr.bottom > _maxB) sub.style.top = Math.max(0, pr.top - (sr.bottom - _maxB)) + 'px';\n    }\n    _activeSubmenu = sub;\n}\n\nfunction hideSubmenu() {\n    // Remove any third-level submenus\n    document.querySelectorAll('body > .ctx-menu:not(#ctx-menu):not(#ctx-menu-sub)').forEach(el => el.remove());\n    if (_activeSubmenu) { _activeSubmenu.classList.remove('show'); _activeSubmenu = null; }\n}\n\nfunction hideCtxMenu() {\n    document.getElementById('ctx-menu').classList.remove('show');\n    document.querySelectorAll('body > .ctx-menu:not(#ctx-menu):not(#ctx-menu-sub)').forEach(el => el.remove());\n    document.querySelectorAll('.ctx-tooltip').forEach(el => el.remove());\n    hideSubmenu();\n}\n\n/* ============================================================\n   HOVER TOOLTIP\n   ============================================================ */\nlet _tooltipTimer = null,\n    _tooltipEl = null,\n    _isDragging = false,\n    _touchDragActive = false, // true while touch-drag is active — suppresses contextmenu event\n    _lastTouchTs = 0;     // timestamp of last touchstart — suppresses spurious mouseenter tooltips\n\nfunction _startHoverTooltip(el, node) {\n    if (_isDragging) return;\n    if (Date.now() - _lastTouchTs < 1200) return; // suppress tooltip shortly after any touch\n    _cancelHoverTooltip();\n    _tooltipTimer = setTimeout(() => {\n        _tooltipEl = document.createElement('div');\n        _tooltipEl.className = 'file-tooltip';\n        const mime = node.type === 'folder' ? 'Folder' : (node.mime || getMime(node.name)),\n            childCount = node.type === 'folder' ? VFS.children(node.id).length : null,\n            folderSize = node.type === 'folder' && typeof _folderSize === 'function' ? _folderSize(node.id) : null;\n        _tooltipEl.innerHTML =\n            `<div class=\"ft-name\">${escHtml(node.name)}</div>` +\n            `<div class=\"ft-row\">Path: ${escHtml(VFS.fullPath(node.id))}</div>` +\n            `<div class=\"ft-row\">Type: ${escHtml(node.type === 'folder' ? 'Folder' : mime)}</div>` +\n            (node.size != null ? `<div class=\"ft-row\">Size: ${fmtSize(node.size)}</div>` : '') +\n            (folderSize !== null ? `<div class=\"ft-row\">Size: ${fmtSize(folderSize)}</div>` : '') +\n            (childCount !== null ? `<div class=\"ft-row\">Items: ${childCount}</div>` : '') +\n            `<div class=\"ft-row\">Modified: ${fmtDate(node.mtime)}</div>` +\n            `<div class=\"ft-row\">Created: ${fmtDate(node.ctime)}</div>`;\n        _tooltipEl.style.cssText = 'position:fixed;left:0;top:0;visibility:hidden';\n        document.body.appendChild(_tooltipEl);\n        const rect = el.getBoundingClientRect();\n        // If element was removed from DOM or has zero size, abort\n        if (!document.contains(el) || (rect.width === 0 && rect.height === 0)) {\n            _tooltipEl.remove(); _tooltipEl = null; return;\n        }\n        const tw = _tooltipEl.offsetWidth, th = _tooltipEl.offsetHeight;\n        let left = rect.right + 10, top = rect.top;\n        if (left + tw > window.innerWidth - 8) left = rect.left - tw - 10;\n        if (top + th > window.innerHeight - 8) top = window.innerHeight - th - 8;\n        left = Math.max(4, left);\n        top = Math.max(4, top);\n        _tooltipEl.style.cssText = `position:fixed;left:${left}px;top:${top}px`;\n    }, 750);\n}\n\nfunction _cancelHoverTooltip() {\n    if (_tooltipTimer) { clearTimeout(_tooltipTimer); _tooltipTimer = null; }\n    if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; }\n}\n\n/* ============================================================\n   SETTINGS\n   ============================================================ */\nconst SETTINGS_DEFAULTS = { iconSize: 'normal', gridDots: true, autoLock: '60', disableAnimations: false, requireExportPassword: true, activityLogs: true, exportWithLogs: false, snapHighlight: true };\n\nlet _autoLockTimerId = null;\n\nfunction _resetContainerSettings() {\n    // Cancel any pending auto-lock timer\n    if (_autoLockTimerId) { clearTimeout(_autoLockTimerId); _autoLockTimerId = null; }\n    // Reset body icon-size and animation classes to defaults\n    document.body.classList.remove('icons-small', 'icons-normal', 'icons-large', 'no-animations', 'no-snap-highlight');\n    document.body.classList.add('icons-normal');\n    // Reset grid constants\n    GRID_X = 96;\n    GRID_Y = 96;\n    // Reset desktop grid dots to default (visible)\n    const area = document.getElementById('desktop-area');\n    if (area) area.classList.remove('no-grid-dots');\n}\n\nfunction _getSettings() {\n    const s = App.container?.settings;\n    return { ...SETTINGS_DEFAULTS, ...s };\n}\n\nfunction _applySettings(s, skipRemap = false) {\n    // Icon Size — apply to body so it covers desktop + all folder windows\n    document.body.classList.remove('icons-small', 'icons-normal', 'icons-large');\n    document.body.classList.add('icons-' + (s.iconSize || 'normal'));\n    // Update internal grid size depending on scale\n    const oldGX = GRID_X, oldGY = GRID_Y;\n    let scale = 1;\n    if (s.iconSize === 'small') scale = 0.75;\n    if (s.iconSize === 'large') scale = 1.25;\n    GRID_X = Math.round(96 * scale);\n    GRID_Y = Math.round(96 * scale);\n\n    // Remap all saved positions to the new grid if grid changed.\n    // skipRemap=true is passed on initial container load — positions are already\n    // stored in the correct grid space and must NOT be converted again.\n    if (!skipRemap && (oldGX !== GRID_X || oldGY !== GRID_Y)) {\n        VFS.remapPositions(oldGX, oldGY, GRID_X, GRID_Y);\n        saveVFS();\n        Desktop._renderIcons();\n        if (typeof WinManager !== 'undefined') WinManager.renderAll();\n    }\n\n    // Grid Dots\n    const area = document.getElementById('desktop-area');\n    area.classList.toggle('no-grid-dots', !s.gridDots);\n    document.querySelectorAll('.fw-area').forEach(a => a.classList.toggle('no-grid-dots', !s.gridDots));\n    // Animations\n    document.body.classList.toggle('no-animations', !!s.disableAnimations);\n    // Snap preview highlight\n    document.body.classList.toggle('no-snap-highlight', s.snapHighlight === false);\n}\n\nasync function _saveSettings(s) {\n    if (!App.container) return;\n    App.container.settings = s;\n    await DB.saveContainer(App.container);\n}\n\nfunction _resetAutoLockTimer() {\n    if (_autoLockTimerId) {\n        clearTimeout(_autoLockTimerId);\n        _autoLockTimerId = null;\n    }\n    const s = _getSettings();\n    if (s.autoLock && s.autoLock !== '0') {\n        const min = parseInt(s.autoLock, 10);\n        if (!isNaN(min) && min > 0) {\n            _autoLockTimerId = setTimeout(() => {\n                App.lockContainer();\n            }, min * 60 * 1000);\n        }\n    }\n}\n\nlet _ddCloseListener = null;\nfunction openSettings() {\n    const s = _getSettings();\n    // Populate UI\n    document.querySelectorAll('#settings-icon-size .settings-toggle-btn').forEach(btn => {\n        btn.classList.toggle('active', btn.dataset.value === s.iconSize);\n    });\n    document.querySelector('#settings-grid-dots input').checked = s.gridDots;\n    document.querySelector('#settings-animations input').checked = !!s.disableAnimations;\n\n    // Setup custom dropdown for auto-lock\n    const dd = document.getElementById('settings-autolock-dd'),\n        currentAl = s.autoLock || '60';\n\n    const updateDdUI = (val) => {\n        dd.querySelectorAll('.custom-dd-opt').forEach(opt => {\n            const isSel = opt.dataset.value === val;\n            opt.classList.toggle('selected', isSel);\n            if (isSel) dd.querySelector('.custom-dd-val').textContent = opt.textContent;\n        });\n    };\n\n    // Remove old listeners to prevent duplicates (clone head and menu)\n    const ddHead = dd.querySelector('.custom-dd-head'),\n        newDdHead = ddHead.cloneNode(true);\n    ddHead.parentNode.replaceChild(newDdHead, ddHead);\n\n    // Set initial value AFTER cloning so we update the live DOM element\n    updateDdUI(currentAl);\n\n    newDdHead.onclick = (e) => {\n        e.stopPropagation();\n        document.querySelectorAll('.custom-dd').forEach(d => { if (d !== dd) d.classList.remove('open'); });\n        dd.classList.toggle('open');\n    };\n\n    const ddMenu = dd.querySelector('.custom-dd-menu'),\n        newDdMenu = ddMenu.cloneNode(true);\n    ddMenu.parentNode.replaceChild(newDdMenu, ddMenu);\n\n    newDdMenu.querySelectorAll('.custom-dd-opt').forEach(opt => {\n        opt.onclick = async (e) => {\n            e.stopPropagation();\n            const val = opt.dataset.value;\n            updateDdUI(val);\n            dd.classList.remove('open');\n            const ns = { ..._getSettings(), autoLock: val };\n            _applySettings(ns);\n            await _saveSettings(ns);\n            _resetAutoLockTimer();\n        };\n    });\n\n    // Close dropdowns on outside click — replace previous listener to avoid accumulation\n    if (_ddCloseListener) document.removeEventListener('click', _ddCloseListener);\n    _ddCloseListener = (e) => {\n        if (!e.target.closest('.custom-dd')) {\n            document.querySelectorAll('.custom-dd').forEach(d => d.classList.remove('open'));\n        }\n    };\n    document.addEventListener('click', _ddCloseListener);\n\n    // Tab state\n    document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === 'personalization'));\n    document.getElementById('settings-personalization').style.display = '';\n    document.getElementById('settings-statistics').style.display = 'none';\n    document.getElementById('settings-activity-logs').style.display = 'none';\n    // Bind tabs\n    document.querySelectorAll('.settings-tab').forEach(t => {\n        t.onclick = () => {\n            document.querySelectorAll('.settings-tab').forEach(t2 => t2.classList.remove('active'));\n            t.classList.add('active');\n            document.getElementById('settings-personalization').style.display = t.dataset.tab === 'personalization' ? '' : 'none';\n            document.getElementById('settings-statistics').style.display = t.dataset.tab === 'statistics' ? '' : 'none';\n            document.getElementById('settings-activity-logs').style.display = t.dataset.tab === 'activity-logs' ? '' : 'none';\n            if (t.dataset.tab === 'statistics') _renderStats();\n            if (t.dataset.tab === 'activity-logs') _renderActivityLogs();\n        };\n    });\n    // Bind icon size buttons\n    document.querySelectorAll('#settings-icon-size .settings-toggle-btn').forEach(btn => {\n        btn.onclick = async () => {\n            document.querySelectorAll('#settings-icon-size .settings-toggle-btn').forEach(b => b.classList.remove('active'));\n            btn.classList.add('active');\n            const ns = { ..._getSettings(), iconSize: btn.dataset.value };\n            _applySettings(ns);\n            await _saveSettings(ns);\n        };\n    });\n    // Bind grid dots\n    document.querySelector('#settings-grid-dots input').onchange = async function () {\n        const ns = { ..._getSettings(), gridDots: this.checked };\n        _applySettings(ns);\n        await _saveSettings(ns);\n    };\n    // Bind disabled animations\n    document.querySelector('#settings-animations input').onchange = async function () {\n        const ns = { ..._getSettings(), disableAnimations: this.checked };\n        _applySettings(ns);\n        await _saveSettings(ns);\n    };\n    // Bind snap highlight\n    document.querySelector('#settings-snap-highlight input').checked = s.snapHighlight !== false;\n    document.querySelector('#settings-snap-highlight input').onchange = async function () {\n        const ns = { ..._getSettings(), snapHighlight: this.checked };\n        _applySettings(ns);\n        await _saveSettings(ns);\n    };\n    // Bind require export password\n    document.querySelector('#settings-export-pw input').checked = s.requireExportPassword !== false;\n    document.querySelector('#settings-export-pw input').onchange = async function () {\n        if (!this.checked) {\n            // Disabling password — build export cache first; save setting only on success\n            this.disabled = true;\n            const ok = typeof _updateExportCache === 'function'\n                ? await _updateExportCache(true)\n                : false;\n            this.disabled = false;\n            if (ok) {\n                const ns = { ..._getSettings(), requireExportPassword: false };\n                await _saveSettings(ns);\n            } else {\n                this.checked = true; // revert toggle\n                toast('Failed to generate export cache — setting not changed', 'error');\n            }\n        } else {\n            // Re-enabling password requirement — save immediately and clear any existing cache\n            const ns = { ..._getSettings(), requireExportPassword: true };\n            await _saveSettings(ns);\n            if (typeof _updateExportCache === 'function') await _updateExportCache();\n        }\n    };\n    // Bind duress password toggle + inline form\n    const duressCb = document.getElementById('settings-duress-cb'),\n        duressForm = document.getElementById('duress-form'),\n        duressActiveInfo = document.getElementById('duress-active-info'),\n        hasDuress = !!App.container?.duressHash;\n    duressCb.checked = hasDuress;\n    _updateDuressUI(hasDuress);\n    duressCb.onchange = function () {\n        if (this.checked) {\n            _updateDuressUI(false); // show form with animation\n            _resetDuressForm();\n        } else {\n            // unchecking — if duress is active, remove it; otherwise just collapse\n            if (App.container?.duressHash) {\n                _removeDuressPassword();\n            } else {\n                _updateDuressUI(false);\n            }\n        }\n    };\n    document.getElementById('duress-set-ok').onclick = () => _handleDuressSet();\n    document.getElementById('duress-remove-btn').onclick = () => _removeDuressPassword();\n    document.getElementById('duress-pw').oninput = function () {\n        updatePwStrength(this.value, 'duress-pw-strength', 'duress-pw-strength-label');\n    };\n    // Bind activity logs toggle\n    const alogToggle = document.querySelector('#settings-activity-logs-toggle input'),\n        expLogsToggle = document.querySelector('#settings-export-logs input'),\n        expLogsRow = document.getElementById('settings-export-logs-row');\n    alogToggle.checked = s.activityLogs !== false;\n    expLogsToggle.checked = !!s.exportWithLogs;\n    expLogsRow.classList.toggle('disabled', s.activityLogs === false);\n    expLogsToggle.disabled = s.activityLogs === false;\n    alogToggle.onchange = async function () {\n        if (!this.checked) {\n            // Show confirmation before disabling\n            this.checked = true; // revert, let modal decide\n            Overlay.show('modal-alog-disable');\n            document.getElementById('alog-disable-ok').onclick = async () => {\n                Overlay.hide();\n                alogToggle.checked = false;\n                const ns = { ..._getSettings(), activityLogs: false };\n                await _saveSettings(ns);\n                await _clearActivityLog();\n                expLogsRow.classList.add('disabled');\n                expLogsToggle.disabled = true;\n                Overlay.show('modal-settings');\n            };\n            document.getElementById('alog-disable-cancel').onclick = () => {\n                Overlay.hide();\n                Overlay.show('modal-settings');\n            };\n            return;\n        }\n        const ns = { ..._getSettings(), activityLogs: true };\n        await _saveSettings(ns);\n        expLogsRow.classList.remove('disabled');\n        expLogsToggle.disabled = false;\n    };\n    expLogsToggle.onchange = async function () {\n        const ns = { ..._getSettings(), exportWithLogs: this.checked };\n        await _saveSettings(ns);\n    };\n    document.getElementById('alog-enable-btn').onclick = async () => {\n        const ns = { ..._getSettings(), activityLogs: true };\n        await _saveSettings(ns);\n        alogToggle.checked = true;\n        expLogsRow.classList.remove('disabled');\n        expLogsToggle.disabled = false;\n        _renderActivityLogs();\n    };\n    // Bind clear logs button (with confirmation)\n    document.getElementById('alog-clear-btn').onclick = () => {\n        Overlay.show('modal-alog-clear');\n        document.getElementById('alog-clear-ok').onclick = async () => {\n            Overlay.hide();\n            await _clearActivityLog();\n            Overlay.show('modal-settings');\n            document.querySelectorAll('.settings-tab').forEach(t2 => t2.classList.toggle('active', t2.dataset.tab === 'activity-logs'));\n            document.getElementById('settings-personalization').style.display = 'none';\n            document.getElementById('settings-statistics').style.display = 'none';\n            document.getElementById('settings-activity-logs').style.display = '';\n            _renderActivityLogs();\n        };\n        document.getElementById('alog-clear-cancel').onclick = () => {\n            Overlay.hide();\n            Overlay.show('modal-settings');\n        };\n    };\n    // ── File System check — opens scanner modal ────────────────\n    document.getElementById('fs-check-open').onclick = () => {\n        Overlay.hide();\n        _openScannerModal();\n    };\n    Overlay.show('modal-settings');\n}\n\n/* ── Duress password — inline form helpers ───────────────────── */\nfunction _updateDuressUI(isActive) {\n    const form = document.getElementById('duress-form'),\n        info = document.getElementById('duress-active-info'),\n        cb = document.getElementById('settings-duress-cb');\n    if (isActive) {\n        form.classList.remove('open');\n        info.style.display = 'block';\n        cb.checked = true;\n    } else {\n        info.style.display = '';\n        if (cb.checked) form.classList.add('open');\n        else form.classList.remove('open');\n    }\n}\n\nfunction _resetDuressForm() {\n    const pw = document.getElementById('duress-pw'),\n        pw2 = document.getElementById('duress-pw2'),\n        eye1 = document.getElementById('duress-pw-eye');\n    pw.value = ''; pw2.value = '';\n    pw.type = 'password'; pw2.type = 'password';\n    eye1.style.color = ''; eye1.innerHTML = Icons.eye;\n    document.getElementById('duress-pw-strength').style.width = '0%';\n    document.getElementById('duress-pw-strength').style.height = '0';\n    document.getElementById('duress-pw-strength').style.marginTop = '0';\n    document.getElementById('duress-pw-strength-label').textContent = '';\n    document.getElementById('duress-pw-strength-label').style.display = 'none';\n    document.getElementById('duress-set-error').style.display = '';\n}\n\nasync function _handleDuressSet() {\n    const pw = document.getElementById('duress-pw').value,\n        pw2 = document.getElementById('duress-pw2').value,\n        errEl = document.getElementById('duress-set-error'),\n        okBtn = document.getElementById('duress-set-ok');\n\n    const showErr = msg => { errEl.textContent = msg; errEl.style.display = 'block'; };\n    errEl.style.display = '';\n\n    if (pw.length < 4) { showErr('Duress password must be at least 4 characters'); return; }\n    if (pw !== pw2) { showErr('Passwords do not match'); return; }\n\n    // Verify that duress password differs from the main container password\n    okBtn.disabled = true;\n    try {\n        const c = App.container,\n            testKey = await Crypto.deriveKey(pw, new Uint8Array(c.salt)),\n            isMain = await Crypto.checkVerification(testKey, c.verIv, c.verBlob);\n        if (isMain) { showErr('Duress password must be different from the main password'); okBtn.disabled = false; return; }\n\n        const salt = Array.from(crypto.getRandomValues(new Uint8Array(32))),\n            hash = await hashDuress(pw, salt),\n            updated = { ...c, duressHash: { salt, hash } };\n        await DB.saveContainer(updated);\n        App.container = updated;\n        _updateDuressUI(true);\n        toast('Duress password set', 'success');\n    } catch (e) {\n        showErr(e.message);\n    }\n    okBtn.disabled = false;\n}\n\nasync function _removeDuressPassword() {\n    const c = { ...App.container };\n    delete c.duressHash;\n    await DB.saveContainer(c);\n    App.container = c;\n    document.getElementById('settings-duress-cb').checked = false;\n    _updateDuressUI(false);\n    _resetDuressForm();\n    toast('Duress password removed', 'success');\n}\n\n/* ============================================================\n   CONTAINER INTEGRITY SCANNER MODAL\n   ============================================================ */\nconst _SCAN_ICONS = {\n    pass: '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M4 8l3 3 5-6\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"square\" stroke-linejoin=\"round\"/></svg>',\n    fail: '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M4 4l8 8M12 4l-8 8\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/></svg>',\n    warn: '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M8 3v6M8 11.5v1\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/></svg>',\n    spin: '<div class=\"spinner\"></div>',\n};\n\nfunction _delay(ms) { return new Promise(r => setTimeout(r, ms)); }\n\nfunction _addScanRow(log, name) {\n    const row = document.createElement('div');\n    row.className = 'scanner-step';\n    row.innerHTML = `<span class=\"scanner-step-icon\">${_SCAN_ICONS.spin}</span><span class=\"scanner-step-label\">${escHtml(name)}</span><span class=\"scanner-step-result\">…</span>`;\n    log.appendChild(row);\n    log.scrollTop = log.scrollHeight;\n    return row;\n}\n\nfunction _resolveScanRow(row, status, detail) {\n    row.classList.add(status);\n    row.querySelector('.scanner-step-icon').innerHTML = _SCAN_ICONS[status] || _SCAN_ICONS.pass;\n    row.querySelector('.scanner-step-result').textContent = detail;\n}\n\n/* --- Async DB-level checks (file data, IVs, orphan records, size consistency) --- */\nasync function _runDbChecks(repair, isAborted) {\n    const steps = [];\n    function mkStep(name, iss, fxd) {\n        const hasCrit = iss.some(i => i.sev === 'critical'),\n            status = iss.length === 0 ? 'pass' : hasCrit ? 'fail' : 'warn',\n            detail = iss.length === 0 ? 'OK' : `${iss.length} issue${iss.length !== 1 ? 's' : ''}${repair && fxd.length ? `, ${fxd.length} fixed` : ''}`;\n        steps.push({ name, status, detail, issues: iss, fixed: fxd });\n    }\n\n    // Build the DB file map once (expensive IndexedDB call)\n    const allDbFiles = await DB.getFilesByCid(App.container.id),\n        dbFileMap = new Map(allDbFiles.map(f => [f.id, f]));\n\n    // Snapshot VFS file IDs — refresh after each destructive step\n    let vfsFileIds = new Set(VFS.fileIds());\n    const _abort = () => isAborted?.();\n\n    // 1. File data existence — VFS file nodes whose encrypted data is missing from IndexedDB\n    {\n        const issues = [], fixed = [];\n        for (const id of vfsFileIds) {\n            if (_abort()) break;\n            const node = VFS.node(id);\n            if (!dbFileMap.has(id)) {\n                issues.push({ sev: 'critical', msg: `\"${node?.name || id}\": encrypted data not found in storage` });\n                if (repair) {\n                    VFS.remove(id);\n                    fixed.push(`Removed broken file node \"${node?.name || id}\"`);\n                }\n            }\n        }\n        mkStep('File data existence', issues, fixed);\n        if (repair && fixed.length) vfsFileIds = new Set(VFS.fileIds());\n    }\n\n    // 2. Encryption IV integrity\n    // Files are stored with iv = Array.from(Uint8Array(12)) — plain Array is the canonical format.\n    // Uint8Array / ArrayBuffer / ArrayBufferView are also accepted.\n    // Only flag if iv is missing, wrong length, or an unrecognized type.\n    {\n        if (_abort()) return steps;\n        const issues = [], fixed = [];\n\n        function ivValid(iv) {\n            if (!iv) return false;\n            if (Array.isArray(iv)) return iv.length >= 12;\n            if (iv instanceof Uint8Array || ArrayBuffer.isView(iv)) return iv.byteLength >= 12;\n            if (iv instanceof ArrayBuffer) return iv.byteLength >= 12;\n            return false;\n        }\n\n        for (const [id, rec] of dbFileMap) {\n            if (_abort()) break;\n            if (!vfsFileIds.has(id)) continue;\n            const node = VFS.node(id);\n\n            if (!rec.iv) {\n                issues.push({ sev: 'critical', msg: `\"${node?.name || id}\": missing encryption IV` });\n                if (repair) {\n                    VFS.remove(id);\n                    await DB.deleteFile(id);\n                    fixed.push(`Purged file missing IV: \"${node?.name || id}\"`);\n                }\n                continue;\n            }\n\n            let ok = ivValid(rec.iv);\n\n            // Try to salvage IVs stored as unusual types (e.g. base64 string in very old data)\n            if (!ok && repair) {\n                let coerced = null;\n                const origType = typeof rec.iv;\n                if (typeof rec.iv === 'string') {\n                    try { coerced = new Uint8Array(atob(rec.iv).split('').map(c => c.charCodeAt(0))); } catch { }\n                }\n                if (coerced && coerced.length >= 12) {\n                    rec.iv = Array.from(coerced); // store as canonical Array format\n                    await DB.saveFile(rec);\n                    fixed.push(`Coerced IV for \"${node?.name || id}\" (${origType} → array)`);\n                    ok = true;\n                }\n            }\n\n            if (!ok) {\n                const ivDesc = Array.isArray(rec.iv) ? `array[${rec.iv.length}]` : typeof rec.iv;\n                issues.push({ sev: 'critical', msg: `\"${node?.name || id}\": invalid IV (${ivDesc})` });\n                if (repair) {\n                    VFS.remove(id);\n                    await DB.deleteFile(id);\n                    fixed.push(`Purged file with invalid IV: \"${node?.name || id}\"`);\n                }\n            }\n        }\n        mkStep('Encryption IV integrity', issues, fixed);\n        if (repair && fixed.length) vfsFileIds = new Set(VFS.fileIds());\n    }\n\n    // 3. File blob integrity — sized files must have non-empty blob\n    {\n        if (_abort()) return steps;\n        const issues = [], fixed = [];\n        for (const [id, rec] of dbFileMap) {\n            if (!vfsFileIds.has(id)) continue;\n            const node = VFS.node(id);\n            if (!node) continue;\n            const blobLen = rec.blob ? (rec.blob.byteLength ?? rec.blob.length ?? 0) : 0;\n            if (node.size > 0 && blobLen === 0) {\n                issues.push({ sev: 'warn', msg: `\"${node.name || id}\": expected ${node.size} bytes but blob is empty` });\n                if (repair) {\n                    // Zero the declared size instead of deleting the file — preserves metadata\n                    node.size = 0;\n                    fixed.push(`Reset size to 0 for \"${node.name || id}\"`);\n                }\n            }\n        }\n        mkStep('File blob integrity', issues, fixed);\n    }\n\n    // 4. Orphaned DB records — DB files not referenced by any VFS node\n    {\n        if (_abort()) return steps;\n        const issues = [], fixed = [];\n        const liveIds = new Set(VFS.fileIds());\n        for (const [id] of dbFileMap) {\n            if (_abort()) break;\n            if (!liveIds.has(id)) {\n                issues.push({ sev: 'warn', msg: `Orphaned DB record \"${id}\"` });\n                if (repair) {\n                    await DB.deleteFile(id);\n                    fixed.push(`Deleted orphaned DB record \"${id}\"`);\n                }\n            }\n        }\n        mkStep('Orphaned storage records', issues, fixed);\n    }\n\n    // 5. Record container binding — verify DB records belong to current container\n    {\n        if (_abort()) return steps;\n        const issues = [], fixed = [];\n        for (const [id, rec] of dbFileMap) {\n            if (rec.cid && rec.cid !== App.container.id) {\n                issues.push({ sev: 'warn', msg: `Record \"${id}\": bound to different container` });\n                if (repair) {\n                    rec.cid = App.container.id;\n                    await DB.saveFile(rec);\n                    fixed.push(`Rebound record \"${id}\" to current container`);\n                }\n            }\n        }\n        mkStep('Record container binding', issues, fixed);\n    }\n\n    // 6. Container size consistency\n    {\n        const issues = [], fixed = [];\n        const vfsTotal = VFS.totalSize();\n        const containerTotal = App.container.totalSize || 0;\n        if (Math.abs(vfsTotal - containerTotal) > 1024) {\n            issues.push({ sev: 'warn', msg: `Container reports ${containerTotal} bytes but VFS sums to ${vfsTotal} bytes` });\n            if (repair) {\n                App.container.totalSize = vfsTotal;\n                await DB.saveContainer(App.container);\n                fixed.push(`Corrected container totalSize to ${vfsTotal}`);\n            }\n        }\n        mkStep('Container size consistency', issues, fixed);\n    }\n\n    // 7. File decryption verification — attempt to decrypt each file blob\n    {\n        if (_abort()) return steps;\n        const issues = [], fixed = [];\n        for (const [id, rec] of dbFileMap) {\n            if (_abort()) break;\n            if (!vfsFileIds.has(id)) continue;\n            const node = VFS.node(id);\n            if (!node) continue;\n            const blobLen = rec.blob ? (rec.blob.byteLength ?? rec.blob.length ?? 0) : 0;\n            if (blobLen === 0) continue;\n            try {\n                await Crypto.decryptBin(App.key, rec.iv, rec.blob);\n            } catch {\n                issues.push({ sev: 'critical', msg: `\"${node.name || id}\": decryption failed — data is unreadable` });\n                if (repair) {\n                    VFS.remove(id);\n                    await DB.deleteFile(id);\n                    fixed.push(`Removed unreadable file \"${node.name || id}\"`);\n                }\n            }\n        }\n        mkStep('File decryption verification', issues, fixed);\n        if (repair && fixed.length) vfsFileIds = new Set(VFS.fileIds());\n    }\n\n    return steps;\n}\n\nfunction _openScannerModal() {\n    const log = document.getElementById('scanner-log'),\n        summary = document.getElementById('scanner-summary'),\n        repairBtn = document.getElementById('scanner-repair'),\n        deepCleanBtn = document.getElementById('scanner-deep-clean'),\n        startBtn = document.getElementById('scanner-start'),\n        closeBtn = document.getElementById('scanner-close');\n\n    log.innerHTML = '';\n    summary.style.display = 'none';\n    repairBtn.style.display = 'none';\n    deepCleanBtn.style.display = 'none';\n    startBtn.style.display = '';\n    startBtn.disabled = false;\n    startBtn.textContent = 'Start Scan';\n    let _hasIssues = false, _aborted = false;\n\n    startBtn.onclick = () => {\n        if (_hasIssues || startBtn.textContent === 'Done') {\n            Overlay.hide();\n            return;\n        }\n        startBtn.disabled = true;\n        startBtn.textContent = 'Scanning…';\n        repairBtn.style.display = 'none';\n        deepCleanBtn.style.display = 'none';\n        _runScanAnimated(false);\n    };\n\n    repairBtn.onclick = () => {\n        // Show confirmation dialog over scanner\n        _showRepairConfirm().then(proceed => {\n            if (!proceed) return;\n            repairBtn.style.display = 'none';\n            deepCleanBtn.style.display = 'none';\n            startBtn.style.display = 'none';\n            log.innerHTML = '';\n            summary.style.display = 'none';\n            _runScanAnimated(true);\n        });\n    };\n\n    deepCleanBtn.onclick = () => {\n        _showRepairConfirm().then(proceed => {\n            if (!proceed) return;\n            repairBtn.style.display = 'none';\n            deepCleanBtn.style.display = 'none';\n            startBtn.style.display = 'none';\n            log.innerHTML = '';\n            summary.style.display = 'none';\n            _runDeepCleanAnimated();\n        });\n    };\n\n    closeBtn.onclick = () => { _aborted = true; Overlay.hide(); };\n\n    async function _runDeepCleanAnimated() {\n        log.innerHTML = '';\n        summary.style.display = 'none';\n        _aborted = false;\n\n        // Show 5 progress rows updated via onProgress callback\n        const rowStorage = _addScanRow(log, 'Scanning storage records…'),\n            rowPurge = _addScanRow(log, 'Purging dead nodes…'),\n            rowFlatten = _addScanRow(log, 'Flattening deep folder chains…'),\n            rowMeta = _addScanRow(log, 'Repairing metadata…'),\n            rowClean = _addScanRow(log, 'Cleaning storage records…');\n        log.scrollTop = log.scrollHeight;\n        await _delay(20);\n\n        let phase = 0;\n        const result = await _runDeepClean(() => _aborted, (msg) => {\n            if (phase === 0) { _resolveScanRow(rowStorage, 'pass', 'OK'); phase = 1; }\n            else if (phase === 1) { _resolveScanRow(rowPurge, 'pass', 'OK'); phase = 2; }\n            else if (phase === 2) { _resolveScanRow(rowFlatten, 'pass', 'OK'); phase = 3; }\n            else if (phase === 3) { _resolveScanRow(rowMeta, 'pass', 'OK'); phase = 4; }\n        });\n        if (_aborted) return;\n\n        // Resolve any rows not yet resolved\n        if (phase === 0) _resolveScanRow(rowStorage, 'pass', 'OK');\n        if (phase <= 1) _resolveScanRow(rowPurge, 'pass', 'Clean');\n        _resolveScanRow(rowFlatten, 'pass',\n            result.flattened > 0 ? `${result.flattened} folder${result.flattened !== 1 ? 's' : ''} collapsed` : 'Clean');\n        _resolveScanRow(rowMeta, 'pass',\n            result.metadataFixed > 0 ? `${result.metadataFixed} node${result.metadataFixed !== 1 ? 's' : ''} patched` : 'Clean');\n        _resolveScanRow(rowClean, 'pass', 'OK');\n        log.scrollTop = log.scrollHeight;\n\n        const totalRemoved = (result.removed || 0) + (result.flattened || 0) + (result.metadataFixed || 0);\n        if (totalRemoved > 0) {\n            await saveVFS();\n            Desktop.render();\n            if (typeof _scheduleExportCacheRefresh === 'function') _scheduleExportCacheRefresh();\n            await _delay(600);\n            if (!_aborted) await _runScanAnimated(false);\n        } else {\n            summary.style.display = '';\n            summary.className = 'scanner-summary critical';\n            summary.innerHTML = `<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\"><circle cx=\"10\" cy=\"10\" r=\"8.5\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M10 5.5v5.5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/><circle cx=\"10\" cy=\"14\" r=\"0.9\" fill=\"currentColor\"/></svg>\n                <span class=\"scanner-summary-text\"><strong>Deep Clean found nothing to remove.</strong> All nodes are structurally valid. The remaining warnings are informational and require no action.</span>`;\n            startBtn.style.display = '';\n            startBtn.disabled = false;\n            startBtn.textContent = 'Done';\n        }\n    }\n\n    async function _runScanAnimated(repair) {\n        log.innerHTML = '';\n        summary.style.display = 'none';\n        _hasIssues = false;\n        _aborted = false;\n\n        // Phase 1: VFS structural checks — run synchronously, display per step\n        const vfsSteps = VFS.check(repair);\n        for (const s of vfsSteps) {\n            if (_aborted) return;\n            const row = _addScanRow(log, s.name);\n            await _delay(15);\n            _resolveScanRow(row, s.status, s.detail);\n            log.scrollTop = log.scrollHeight;\n        }\n\n        // Phase 2: DB async checks\n        const dbCheckNames = [\n            'File data existence',\n            'Encryption IV integrity',\n            'File blob integrity',\n            'Orphaned storage records',\n            'Record container binding',\n            'Container size consistency',\n            'File decryption verification',\n        ];\n        const dbRows = dbCheckNames.map(name => _addScanRow(log, name));\n        log.scrollTop = log.scrollHeight;\n\n        if (_aborted) return;\n        const dbSteps = await _runDbChecks(repair, () => _aborted);\n        if (_aborted) return;\n        for (let i = 0; i < dbSteps.length; i++) {\n            if (_aborted) return;\n            await _delay(30);\n            _resolveScanRow(dbRows[i], dbSteps[i].status, dbSteps[i].detail);\n            log.scrollTop = log.scrollHeight;\n        }\n\n        // Combine all steps for summary\n        const allSteps = [...vfsSteps, ...dbSteps];\n        const actionableIssues = allSteps.filter(s => !s.informational).reduce((s, st) => s + st.issues.length, 0),\n            infoIssues = allSteps.filter(s => s.informational).reduce((s, st) => s + st.issues.length, 0),\n            totalIssues = actionableIssues + infoIssues,\n            totalFixed = allSteps.reduce((s, st) => s + st.fixed.length, 0),\n            allPass = allSteps.every(s => s.status === 'pass');\n\n        summary.style.display = '';\n        if (repair && totalFixed > 0) {\n            await saveVFS();\n            Desktop.render();\n            if (typeof _scheduleExportCacheRefresh === 'function') _scheduleExportCacheRefresh();\n            // Auto-re-scan to verify the repair result\n            summary.className = 'scanner-summary repaired';\n            summary.innerHTML = `<svg width=\"20\" height=\"20\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M4 8l3 3 5-6\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"square\" stroke-linejoin=\"round\"/></svg>\n                <span class=\"scanner-summary-text\"><strong>${totalFixed} issue${totalFixed !== 1 ? 's' : ''} repaired.</strong> Running verification scan…</span>`;\n            summary.style.display = '';\n            await _delay(900);\n            if (!_aborted) { await _runScanAnimated(false); }\n            return;\n        } else if (repair && totalFixed === 0 && actionableIssues > 0) {\n            // Repair ran but couldn't fix actionable issues — offer Deep Clean\n            summary.className = 'scanner-summary critical';\n            summary.innerHTML = `<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\"><circle cx=\"10\" cy=\"10\" r=\"8.5\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M10 5.5v5.5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/><circle cx=\"10\" cy=\"14\" r=\"0.9\" fill=\"currentColor\"/></svg>\n                <span class=\"scanner-summary-text\"><strong>${actionableIssues} issue${actionableIssues !== 1 ? 's' : ''} could not be auto-repaired.</strong> Click <em>Deep Clean</em> for aggressive recovery (flattens deep trees, repairs all metadata). A backup will be offered first — no need to exit.</span>`;\n            deepCleanBtn.style.display = '';\n        } else if (repair && totalFixed === 0 && actionableIssues === 0 && infoIssues > 0) {\n            // All remaining issues are informational warnings only — no action needed\n            summary.className = 'scanner-summary healthy';\n            summary.innerHTML = `<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 26\" fill=\"none\"><path d=\"M12 3L3.5 7.5v4.5c0 5.2 3.6 10 8.5 11.5 4.9-1.5 8.5-6.3 8.5-11.5V7.5L12 3z\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\"/><path d=\"M9 13l2.5 2.5 4-4.5\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"square\" stroke-linejoin=\"round\"/></svg>\n                <span class=\"scanner-summary-text\"><strong>All structural issues are resolved.</strong> ${infoIssues} informational warning${infoIssues !== 1 ? 's' : ''} (e.g., folder depth) are noted but require no action.</span>`;\n        } else if (allPass) {\n            summary.className = 'scanner-summary healthy';\n            summary.innerHTML = `<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 26\" fill=\"none\"><path d=\"M12 3L3.5 7.5v4.5c0 5.2 3.6 10 8.5 11.5 4.9-1.5 8.5-6.3 8.5-11.5V7.5L12 3z\" stroke=\"currentColor\" stroke-width=\"1.5\" fill=\"none\"/><path d=\"M9 13l2.5 2.5 4-4.5\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"square\" stroke-linejoin=\"round\"/></svg>\n                <span class=\"scanner-summary-text\"><strong>All checks passed.</strong> Your container's virtual disk image and workspace environment are in perfect condition.</span>`;\n        } else if (actionableIssues === 0 && infoIssues > 0) {\n            // Only informational warnings on first scan — no repair needed\n            summary.className = 'scanner-summary warnings';\n            summary.innerHTML = `<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\"><circle cx=\"10\" cy=\"10\" r=\"8.5\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M10 5.5v5.5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/><circle cx=\"10\" cy=\"14\" r=\"0.9\" fill=\"currentColor\"/></svg>\n                <span class=\"scanner-summary-text\"><strong>${infoIssues} informational warning${infoIssues !== 1 ? 's' : ''} noted.</strong> Advisory only (e.g., folder nesting depth) — no data loss, no action required.</span>`;\n        } else {\n            summary.className = 'scanner-summary issues';\n            summary.innerHTML = `<svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\"><circle cx=\"10\" cy=\"10\" r=\"8.5\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M10 5.5v5.5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/><circle cx=\"10\" cy=\"14\" r=\"0.9\" fill=\"currentColor\"/></svg>\n                <span class=\"scanner-summary-text\"><strong>${actionableIssues} issue${actionableIssues !== 1 ? 's' : ''} detected.</strong> Click <em>Auto-Repair</em> — you'll be offered a backup option first, no need to exit the scanner.</span>`;\n            _hasIssues = true;\n            repairBtn.style.display = '';\n        }\n\n        startBtn.style.display = '';\n        startBtn.disabled = false;\n        startBtn.textContent = 'Done';\n    }\n\n    Overlay.show('modal-scanner');\n}\n\n/* ── Deep Clean — purge all phantom/empty nodes that normal repair can't fix ── */\n// O(n) rebuild: marks ancestors of real files as \"keep\", deletes everything else.\nasync function _runDeepClean(isAborted, onProgress) {\n    const _abort = () => isAborted?.();\n\n    // 1. Load DB records (real data)\n    onProgress?.('Scanning storage records…');\n    const allDbFiles = await DB.getFilesByCid(App.container.id);\n    if (_abort()) return { removed: 0 };\n    const dbIds = new Set(allDbFiles.map(f => f.id));\n\n    // 2. Determine truly live file IDs: exist in VFS AND have a DB record\n    const allVfsFileIds = VFS.fileIds(),\n        liveFileIds = allVfsFileIds.filter(id => dbIds.has(id));\n    if (_abort()) return { removed: 0 };\n\n    // 3. Single-pass bulk purge via VFS.purgeDeadBranches (O(n))\n    onProgress?.(`Purging dead nodes…`);\n    const removed = VFS.purgeDeadBranches(liveFileIds);\n    if (_abort()) return { removed: 0, flattened: 0 };\n\n    // 4. Flatten folders nested deeper than 50 levels — reparents all files to\n    //    their closest depth-≤50 ancestor, then deletes the now-empty deep folders.\n    //    All file data is preserved; only folder hierarchy is truncated.\n    onProgress?.('Flattening deep folder chains…');\n    const flattened = VFS.flattenDeepContent(50);\n    if (_abort()) return { removed, flattened: 0 };\n\n    // 5. Repair all corrupted/missing metadata (timestamps → today's date)\n    onProgress?.('Repairing metadata…');\n    const metadataFixed = VFS.repairMetadata();\n    if (_abort()) return { removed, flattened, metadataFixed: 0 };\n\n    // 6. Remove orphaned DB records in a single IndexedDB transaction\n    onProgress?.('Cleaning storage records…');\n    const liveNow = new Set(VFS.fileIds());\n    const orphanIds = allDbFiles.filter(f => !liveNow.has(f.id)).map(f => f.id);\n    if (orphanIds.length) await DB.deleteFiles(orphanIds);\n\n    return { removed, flattened, metadataFixed };\n}\n\n/* ── Repair confirmation dialog (shown above scanner overlay) ─────── */\nfunction _showRepairConfirm() {\n    return new Promise(resolve => {\n        const ov = document.getElementById('repair-confirm-overlay'),\n            exportBtn = document.getElementById('repair-confirm-export'),\n            proceedBtn = document.getElementById('repair-confirm-proceed'),\n            cancelBtn = document.getElementById('repair-confirm-cancel');\n\n        function close(val) {\n            ov.classList.remove('show');\n            exportBtn.onclick = proceedBtn.onclick = cancelBtn.onclick = null;\n            resolve(val);\n        }\n\n        cancelBtn.onclick = () => close(false);\n        proceedBtn.onclick = () => close(true);\n        exportBtn.onclick = async () => {\n            // Export container via existing exportContainerFile\n            if (typeof exportContainerFile === 'function') {\n                await exportContainerFile(App.container, false);\n            }\n        };\n\n        ov.classList.add('show');\n    });\n}\n\n\nconst STATS_COLORS = ['#569cd6', '#4ec9b0', '#ce9178', '#c586c0', '#6a9955', '#dcdcaa', '#9cdcfe', '#d7ba7d'];\n\nfunction _renderStats() {\n    // Gather all nodes recursively\n    let totalFiles = 0, totalFolders = 0, totalSize = 0;\n    const typeCounts = {}, typeSizes = {};\n    let largestSize = 0, largestName = '';\n    const allFiles = [];\n    function walk(pid) {\n        VFS.children(pid).forEach(n => {\n            if (n.type === 'folder') {\n                totalFolders++;\n                walk(n.id);\n            } else {\n                totalFiles++;\n                const sz = n.size || 0;\n                totalSize += sz;\n                const ext = n.name.includes('.') ? n.name.split('.').pop().toLowerCase() : 'other';\n                typeCounts[ext] = (typeCounts[ext] || 0) + 1;\n                typeSizes[ext] = (typeSizes[ext] || 0) + sz;\n                allFiles.push({ name: n.name, size: sz, ext });\n                if (sz > largestSize) { largestSize = sz; largestName = n.name; }\n            }\n        });\n    }\n    walk('root');\n\n    // ── Stats cards (3×2) ────────────────────────────────────\n    const grid = document.getElementById('stats-grid');\n    grid.innerHTML = '';\n    const _shortDate = ts => ts ? new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';\n    const cards = [\n        { value: totalFiles.toLocaleString(), label: 'Files' },\n        { value: totalFolders.toLocaleString(), label: 'Folders' },\n        { value: fmtSize(totalSize), label: 'Total Size' },\n        { value: totalFiles ? fmtSize(Math.round(totalSize / totalFiles)) : '—', label: 'Avg File Size' },\n        { value: largestSize ? fmtSize(largestSize) : '—', label: largestSize ? (largestName.length > 18 ? largestName.slice(0, 17) + '\\u2026' : largestName) : 'Largest File' },\n        { value: _shortDate(App.container?.createdAt), label: 'Created' },\n    ];\n    cards.forEach(c => {\n        const card = document.createElement('div'); card.className = 'stats-card';\n        card.innerHTML = `<span class=\"stats-card-value\">${escHtml(String(c.value))}</span><span class=\"stats-card-label\">${escHtml(c.label)}</span>`;\n        grid.appendChild(card);\n    });\n\n    // ── File type bar chart (top 6) ──────────────────────────\n    const chart = document.getElementById('stats-bar-chart');\n    chart.innerHTML = '';\n    const sorted = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]).slice(0, 6),\n        maxCount = sorted.length ? sorted[0][1] : 1;\n    sorted.forEach(([ext, count], i) => {\n        const row = document.createElement('div'); row.className = 'stats-bar-row';\n        row.innerHTML =\n            `<span class=\"stats-bar-row-label\">.${escHtml(ext)}</span>` +\n            `<div class=\"stats-bar-row-track\"><div class=\"stats-bar-row-fill\" style=\"width:${Math.round(count / maxCount * 100)}%;background:${STATS_COLORS[i % STATS_COLORS.length]}\"></div></div>` +\n            `<span class=\"stats-bar-row-meta\"><span>${count}</span><span class=\"stats-bar-row-meta-size\">${fmtSize(typeSizes[ext] || 0)}</span></span>`;\n        chart.appendChild(row);\n    });\n    if (!sorted.length) chart.innerHTML = '<span style=\"font-size:12px;color:var(--text-dim)\">No files yet</span>';\n\n    // ── Storage bar ──────────────────────────────────────────\n    const storBar = document.getElementById('stats-storage-bar'),\n        storLabels = document.getElementById('stats-storage-labels'),\n        pctUsed = Math.min(100, Math.round(totalSize / CONTAINER_LIMIT * 100)),\n        fillColor = pctUsed >= 90 ? 'var(--red)' : pctUsed >= 75 ? '#e8a020' : 'linear-gradient(90deg, var(--accent), #5aadff)';\n    storBar.innerHTML =\n        `<div class=\"stats-storage-fill\" style=\"width:${pctUsed}%;background:${fillColor}\"></div>` +\n        `<span class=\"stats-storage-text\">${pctUsed}%</span>`;\n    if (storLabels) {\n        storLabels.innerHTML =\n            `<span>${fmtSize(totalSize)} used</span>` +\n            `<span>${fmtSize(Math.max(0, CONTAINER_LIMIT - totalSize))} free of ${fmtSize(CONTAINER_LIMIT)}</span>`;\n    }\n\n    // ── Top 5 largest files ──────────────────────────────────\n    const topEl = document.getElementById('stats-top-files');\n    if (topEl) {\n        topEl.innerHTML = '';\n        const top5 = allFiles.sort((a, b) => b.size - a.size).slice(0, 5);\n        if (!top5.length) {\n            topEl.innerHTML = '<span style=\"font-size:12px;color:var(--text-dim)\">No files yet</span>';\n        } else {\n            top5.forEach((f, i) => {\n                const row = document.createElement('div'); row.className = 'stats-top-file';\n                row.innerHTML =\n                    `<span class=\"stats-top-file-dot\" style=\"background:${STATS_COLORS[i % STATS_COLORS.length]}\"></span>` +\n                    `<span class=\"stats-top-file-name\" title=\"${escHtml(f.name)}\">${escHtml(f.name)}</span>` +\n                    `<span class=\"stats-top-file-size\">${fmtSize(f.size)}</span>`;\n                topEl.appendChild(row);\n            });\n        }\n    }\n}\n\n/* ============================================================\n   SNAP TO FREE GRID CELL\n   occupied = Map<\"cx_cy\", id>  (cells already taken)\n   ============================================================ */\nfunction _snapFreeCell(rawX, rawY, occupied, extra) {\n    const cx0 = Math.max(0, Math.round((rawX - 8) / GRID_X)),\n        cy0 = Math.max(0, Math.round((rawY - 8) / GRID_Y));\n    for (let r = 0; r <= 80; r++) {\n        for (let dy = -r; dy <= r; dy++) {\n            for (let dx = -r; dx <= r; dx++) {\n                if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;\n                const cx = cx0 + dx, cy = cy0 + dy;\n                if (cx < 0 || cy < 0) continue;\n                const key = `${cx}_${cy}`;\n                if (!occupied.has(key) && !(extra && extra.has(key))) return { x: 8 + cx * GRID_X, y: 8 + cy * GRID_Y };\n            }\n        }\n    }\n    return { x: 8 + cx0 * GRID_X, y: 8 + cy0 * GRID_Y };\n}\n\n/* ============================================================\n   THUMBNAIL QUEUE  —  limits concurrent generateThumb calls to avoid memory spikes\n   ============================================================ */\nconst _thumbQueue = [];\nlet _thumbActive = 0;\nconst THUMB_MAX_CONCURRENT = 8;\n\nfunction _cancelThumbQueue() {\n    _thumbQueue.length = 0;\n    _thumbActive = 0;\n}\n\nfunction _enqueueThumb(node) {\n    _thumbQueue.push(node);\n    _drainThumbQueue();\n}\nfunction _drainThumbQueue() {\n    while (_thumbActive < THUMB_MAX_CONCURRENT && _thumbQueue.length > 0) {\n        const n = _thumbQueue.shift();\n        _thumbActive++;\n        generateThumb(n).then(url => {\n            _thumbActive--;\n            _drainThumbQueue();\n            if (!url) return;\n            App.thumbCache[n.id] = url;\n            const el = document.querySelector(`.file-item[data-id=\"${n.id}\"] .file-thumb`);\n            if (el) { const i = document.createElement('img'); i.src = url; i.draggable = false; el.innerHTML = ''; el.appendChild(i); }\n        });\n    }\n}\n\n/* ============================================================\n   SHARED ICON ELEMENT BUILDER\n   ============================================================ */\nfunction _buildIconEl(node, pos) {\n    const div = document.createElement('div');\n    div.className = 'file-item';\n    div.dataset.id = node.id;\n    div.style.left = pos.x + 'px';\n    div.style.top = pos.y + 'px';\n\n    const thumb = document.createElement('div');\n    if (node.type === 'folder') {\n        thumb.className = 'file-thumb folder-icon';\n        thumb.innerHTML = getFolderSVG(node.color);\n    } else {\n        thumb.className = 'file-thumb';\n        const mime = node.mime || getMime(node.name);\n        if (App.thumbCache[node.id]) {\n            const img = document.createElement('img');\n            img.src = App.thumbCache[node.id];\n            img.draggable = false;\n            thumb.appendChild(img);\n        } else {\n            thumb.innerHTML = getFileIconSVG(mime, node.name);\n            if (isImage(mime)) _enqueueThumb(node);\n        }\n    }\n\n    const name = document.createElement('div');\n    name.className = 'file-name';\n    name.textContent = node.name;\n    div.appendChild(thumb);\n    div.appendChild(name);\n    return div;\n}\n\n/* ============================================================\n   AREA-LEVEL EVENT DELEGATION FOR ICONS\n   Replaces per-element listeners — one set of listeners per container,\n   covering all .file-item[data-id] children regardless of count.\n   owner must implement: _onIconMousedown(e,el,node), _openNode(node), _contextIcon(e,node)\n   ============================================================ */\nfunction _setupAreaDelegation(area, owner) {\n    // Prevent native drag ghost on icons\n    area.addEventListener('dragstart', e => {\n        if (e.target.closest('.file-item[data-id]')) e.preventDefault();\n    });\n    // Hover tooltips via mouseover/mouseout (simulate mouseenter/mouseleave per icon)\n    area.addEventListener('mouseover', e => {\n        const el = e.target.closest('.file-item[data-id]');\n        if (!el) return;\n        if (e.relatedTarget?.closest('.file-item[data-id]') !== el) {\n            const node = VFS.node(el.dataset.id);\n            if (node) _startHoverTooltip(el, node);\n        }\n    });\n    area.addEventListener('mouseout', e => {\n        const el = e.target.closest('.file-item[data-id]');\n        if (!el) return;\n        if (e.relatedTarget?.closest('.file-item[data-id]') !== el) _cancelHoverTooltip();\n    });\n    // Mousedown on icons\n    area.addEventListener('mousedown', e => {\n        const el = e.target.closest('.file-item[data-id]');\n        if (!el) return;\n        const node = VFS.node(el.dataset.id);\n        if (node) owner._onIconMousedown(e, el, node);\n    });\n    // Double-click → open\n    area.addEventListener('dblclick', e => {\n        const el = e.target.closest('.file-item[data-id]');\n        if (!el) return;\n        e.stopPropagation();\n        const node = VFS.node(el.dataset.id);\n        if (node) owner._openNode(node);\n    });\n    // Context menu on icons\n    area.addEventListener('contextmenu', e => {\n        const el = e.target.closest('.file-item[data-id]');\n        if (!el) return;\n        e.preventDefault();\n        if (_touchDragActive || el._tsIsTouch) return;\n        e.stopPropagation();\n        const node = VFS.node(el.dataset.id);\n        if (node) owner._contextIcon(e, node);\n    });\n    // Touch: state stored on element to avoid per-icon closure overhead\n    area.addEventListener('touchstart', e => {\n        const el = e.target.closest('.file-item[data-id]');\n        if (!el) return;\n        e.stopPropagation(); // prevent FW icon touch bubbling to parent Desktop handlers\n        el._tsTime = Date.now(); el._tsMoved = false; el._tsIsTouch = true;\n        _cancelHoverTooltip();\n    }, { passive: true });\n    area.addEventListener('touchmove', e => {\n        const el = e.target.closest('.file-item[data-id]');\n        if (!el) return;\n        el._tsMoved = true;\n        e.stopPropagation();\n    }, { passive: true });\n    area.addEventListener('touchend', e => {\n        const el = e.target.closest('.file-item[data-id]');\n        if (!el) return;\n        e.stopPropagation(); // prevent FW icon touch bubbling to parent Desktop handlers\n        setTimeout(() => { el._tsIsTouch = false; }, 500);\n        if (el._tsMoved || Date.now() - (el._tsTime || 0) > 350) return;\n        e.preventDefault();\n        const now = Date.now(), t = e.changedTouches[0],\n            node = VFS.node(el.dataset.id);\n        if (!node) return;\n        if (now - (el._tsLastTap || 0) < 300) {\n            el._tsLastTap = 0;\n            owner._openNode(node);\n        } else {\n            el._tsLastTap = now;\n            owner._contextIcon({ clientX: t.clientX, clientY: t.clientY, ctrlKey: false, metaKey: false, preventDefault() { }, stopPropagation() { } }, node);\n        }\n    });\n    area.addEventListener('touchcancel', e => {\n        const el = e.target.closest('.file-item[data-id]');\n        if (el) el._tsIsTouch = false;\n    }, { passive: true });\n}\n\n/* ---- Shared touch rubber-band selection on empty area + long-press context menu ----\n   owner implements: selection (Set), _updateStatus(), _contextDesktop(e) */\nfunction _initAreaTouchRubberBand(area, owner) {\n    let _lpTimer = null,\n        _rbBand = null, _rbSX = 0, _rbSY = 0, _rbActive = false, _rbMoved = false, _rbOnEmpty = false;\n\n    area.addEventListener('touchstart', e => {\n        if (e.touches.length !== 1) return;\n        const t = e.touches[0];\n        // BUGFIX: when this handler is on #desktop-area, a touch inside a FolderWindow bubbles up\n        // here too — ignore it so we don't open the Desktop context menu over the FW's own menu.\n        if (!area.closest('.folder-window') && t.target?.closest('.folder-window')) return;\n        if (_lpTimer) { clearTimeout(_lpTimer); _lpTimer = null; }\n        if (_rbBand) { _rbBand.remove(); _rbBand = null; }\n        _rbActive = false; _rbMoved = false;\n        _rbSX = t.clientX; _rbSY = t.clientY;\n        const iconEl = t.target?.closest('.file-item[data-id]');\n        _rbOnEmpty = !iconEl || !area.contains(iconEl);\n        if (_rbOnEmpty) {\n            _lpTimer = setTimeout(() => {\n                if (_rbMoved) return;\n                owner._contextDesktop({ clientX: t.clientX, clientY: t.clientY, preventDefault() { }, stopPropagation() { } });\n            }, 600);\n        }\n    }, { passive: true });\n\n    area.addEventListener('touchmove', e => {\n        if (e.touches.length !== 1) return;\n        const t = e.touches[0],\n            dx = t.clientX - _rbSX, dy = t.clientY - _rbSY;\n        if (!_rbMoved && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) {\n            _rbMoved = true;\n            if (_lpTimer) { clearTimeout(_lpTimer); _lpTimer = null; }\n        }\n        if (!_rbOnEmpty) return;\n        if (!_rbActive && _rbMoved) {\n            _rbActive = true;\n            owner.selection.clear();\n            area.querySelectorAll(':scope > .file-item.selected').forEach(i => i.classList.remove('selected'));\n            owner._updateStatus();\n            const aR = area.getBoundingClientRect();\n            _rbBand = document.createElement('div');\n            _rbBand.className = 'rubberband';\n            _rbBand.style.cssText = `left:${_rbSX - aR.left + area.scrollLeft}px;top:${_rbSY - aR.top + area.scrollTop}px;width:0;height:0`;\n            area.appendChild(_rbBand);\n        }\n        if (_rbActive && _rbBand) {\n            if (e.cancelable) e.preventDefault();\n            const aR = area.getBoundingClientRect(),\n                sx = _rbSX - aR.left + area.scrollLeft, sy = _rbSY - aR.top + area.scrollTop,\n                cx = t.clientX - aR.left + area.scrollLeft, cy = t.clientY - aR.top + area.scrollTop,\n                x = Math.min(sx, cx), y = Math.min(sy, cy),\n                w = Math.abs(cx - sx), h = Math.abs(cy - sy);\n            _rbBand.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px`;\n            const bx2 = x + w, by2 = y + h;\n            for (const item of (area._iconMap?.values() ?? area.querySelectorAll(':scope > .file-item'))) {\n                const ix = parseInt(item.style.left), iy = parseInt(item.style.top),\n                    hit = ix < bx2 && (ix + ICON_W) > x && iy < by2 && (iy + ICON_H) > y;\n                if (hit) { owner.selection.add(item.dataset.id); item.classList.add('selected'); }\n                else { owner.selection.delete(item.dataset.id); item.classList.remove('selected'); }\n            }\n            owner._updateStatus();\n        }\n    }, { passive: false });\n\n    area.addEventListener('touchend', () => {\n        if (_lpTimer) { clearTimeout(_lpTimer); _lpTimer = null; }\n        if (_rbBand) { _rbBand.remove(); _rbBand = null; }\n        _rbActive = false; _rbMoved = false; _rbOnEmpty = false;\n    }, { passive: true });\n}\n\n/* ---- Compute max icon extent and set canvas size for both scrollbars ---- */\nfunction _syncAreaWidth(area) {\n    const canvas = area._canvas ?? (area._canvas = area.querySelector(':scope > .fw-canvas'));\n    if (!canvas) return; // Desktop has no fw-canvas — skip\n    let maxRight = 0, maxBottom = 0;\n    for (const el of (area._iconMap?.values() ?? area.querySelectorAll(':scope > .file-item'))) {\n        const r = parseInt(el.style.left) + (el.offsetWidth || 96);\n        const b = parseInt(el.style.top) + (el.offsetHeight || 96);\n        if (r > maxRight) maxRight = r;\n        if (b > maxBottom) maxBottom = b;\n    }\n    canvas.style.width = maxRight > 0 ? (maxRight + 24) + 'px' : '';\n    canvas.style.height = maxBottom > 0 ? (maxBottom + 24) + 'px' : '';\n}\n\n/* ---- Shared touch-drag for icons (Desktop + FolderWindow) ----\n   owner implements: selection (Set-like), folderId, _updateStatus().\n   opts.showSnap: boolean — true for Desktop (show snap preview dot)\n   opts.afterDrop: () => void — extra callback after drop (e.g. updateTaskbar) */\nfunction _initTouchDragCommon(area, owner, opts = {}) {\n    if (typeof window.ontouchstart === 'undefined' && !navigator.maxTouchPoints) return;\n\n    let _tdNode = null, _tdSelEls = null,\n        _tdSX = 0, _tdSY = 0, _tdOffX = 0, _tdOffY = 0,\n        _tdMoved = false, _tdTimer = null, _tdActive = false,\n        _tdStartPos = {}, _tdHover = null, _tdSnapPrevs = [],\n        _tdOccupied = null, _tdLastCx = -1, _tdLastCy = -1;\n\n    function _tdReset() {\n        if (_tdTimer) { clearTimeout(_tdTimer); _tdTimer = null; }\n        _tdActive = false; _touchDragActive = false;\n        if (_tdSelEls) { _tdSelEls.forEach(el => el.classList.remove('dragging')); _tdSelEls = null; }\n        if (_tdHover) { area.querySelector(`.file-item[data-id=\"${_tdHover}\"]`)?.classList.remove('drag-target'); _tdHover = null; }\n        _tdSnapPrevs.forEach(p => p.remove()); _tdSnapPrevs = [];\n        _tdOccupied = null; _tdLastCx = _tdLastCy = -1;\n        _tdNode = null;\n    }\n\n    area.addEventListener('touchstart', e => {\n        if (e.touches.length !== 1) return;\n        const t = e.touches[0],\n            iconEl = t.target?.closest('.file-item[data-id]');\n        if (!iconEl || !area.contains(iconEl)) return;\n\n        const nodeId = iconEl.dataset.id,\n            node = VFS.node(nodeId);\n        if (!node) return;\n\n        // Prevent native long-press context menu (Android vibration + touchcancel)\n        // Only when touch lands on an icon — scrolling on empty area stays unaffected.\n        e.preventDefault();\n\n        _tdMoved = false; _tdActive = false;\n        _tdSX = t.clientX; _tdSY = t.clientY;\n        const r = iconEl.getBoundingClientRect();\n        _tdOffX = t.clientX - r.left;\n        _tdOffY = t.clientY - r.top;\n\n        _tdTimer = setTimeout(() => {\n            if (_tdMoved) return;\n            // Guard: if a context menu was opened during the hold (e.g. Android fires\n            // contextmenu + touchcancel before our 400ms timer), do not start drag.\n            if (document.getElementById('ctx-menu').classList.contains('show')) return;\n            _tdActive = true;\n            _touchDragActive = true;\n            _tdNode = node;\n            if (!owner.selection.has(nodeId)) {\n                owner.selection.clear();\n                area.querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected'));\n                owner.selection.add(nodeId);\n                iconEl.classList.add('selected');\n                owner._updateStatus();\n            }\n            _tdStartPos = {}; _tdSelEls = new Map();\n            owner.selection.forEach(id => {\n                const el = area._iconMap?.get(id) ?? area.querySelector(`.file-item[data-id=\"${id}\"]`);\n                if (!el) return;\n                _tdStartPos[id] = { x: parseInt(el.style.left), y: parseInt(el.style.top) };\n                _tdSelEls.set(id, el);\n                el.classList.add('dragging');\n            });\n            // Build occupied map once at drag start (avoids O(N) per frame)\n            _tdOccupied = new Map(); _tdLastCx = _tdLastCy = -1;\n            VFS.children(owner.folderId).forEach(n => {\n                if (owner.selection.has(n.id)) return;\n                const p = VFS.getPos(owner.folderId, n.id);\n                if (p) _tdOccupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id);\n            });\n            _cancelHoverTooltip();\n        }, 400);\n    }, { passive: false });\n\n    area.addEventListener('touchmove', e => {\n        if (e.touches.length !== 1) return;\n        const t = e.touches[0];\n        if (Math.abs(t.clientX - _tdSX) + Math.abs(t.clientY - _tdSY) > 5) _tdMoved = true;\n        if ((_tdTimer && !_tdMoved) || _tdActive) { if (e.cancelable) e.preventDefault(); }\n        if (!_tdActive || !_tdNode) return;\n\n        const aR = area.getBoundingClientRect(),\n            mainSp = _tdStartPos[_tdNode.id],\n            rawX = t.clientX - aR.left + area.scrollLeft - _tdOffX,\n            rawY = t.clientY - aR.top + area.scrollTop - _tdOffY,\n            ddx = rawX - mainSp.x, ddy = rawY - mainSp.y;\n\n        _tdSelEls.forEach((el, id) => {\n            const sp = _tdStartPos[id];\n            if (sp) { el.style.left = (sp.x + ddx) + 'px'; el.style.top = (sp.y + ddy) + 'px'; }\n        });\n\n        // Highlight folder under finger\n        _tdSelEls.forEach(el => { el.style.pointerEvents = 'none'; });\n        const hit = document.elementFromPoint(t.clientX, t.clientY);\n        _tdSelEls.forEach(el => { el.style.pointerEvents = ''; });\n        const folderEl = hit?.closest('.file-item[data-id]');\n        const newHover = folderEl && area.contains(folderEl) &&\n            !owner.selection.has(folderEl.dataset.id) &&\n            VFS.node(folderEl.dataset.id)?.type === 'folder' ? folderEl.dataset.id : null;\n        if (newHover !== _tdHover) {\n            if (_tdHover) area.querySelector(`.file-item[data-id=\"${_tdHover}\"]`)?.classList.remove('drag-target');\n            _tdHover = newHover;\n            if (_tdHover && folderEl) folderEl.classList.add('drag-target');\n        }\n\n        // Snap preview — one per selected item (mirrors mouse _showPreviews)\n        if (opts.showSnap) {\n            if (_tdHover || document.body.classList.contains('no-snap-highlight')) {\n                _tdSnapPrevs.forEach(p => { p.style.display = 'none'; });\n                _tdLastCx = _tdLastCy = -1;\n            } else {\n                const _scx = Math.round((rawX - 8) / GRID_X), _scy = Math.round((rawY - 8) / GRID_Y);\n                if (_scx !== _tdLastCx || _scy !== _tdLastCy) {\n                    _tdLastCx = _scx; _tdLastCy = _scy;\n                    const selIds = [...owner.selection];\n                    // grow / shrink pool\n                    while (_tdSnapPrevs.length < selIds.length) {\n                        const p = document.createElement('div'); p.className = 'snap-preview';\n                        area.appendChild(p); _tdSnapPrevs.push(p);\n                    }\n                    while (_tdSnapPrevs.length > selIds.length) _tdSnapPrevs.pop().remove();\n                    const extra = new Map();\n                    selIds.forEach((id, i) => {\n                        const sp = _tdStartPos[id],\n                            offX = sp && mainSp ? sp.x - mainSp.x : 0,\n                            offY = sp && mainSp ? sp.y - mainSp.y : 0,\n                            sn = _snapFreeCell(rawX + offX, rawY + offY, _tdOccupied, extra),\n                            cx = Math.round((sn.x - 8) / GRID_X), cy = Math.round((sn.y - 8) / GRID_Y);\n                        extra.set(`${cx}_${cy}`, id);\n                        _tdSnapPrevs[i].style.left = sn.x + 'px';\n                        _tdSnapPrevs[i].style.top = sn.y + 'px';\n                        _tdSnapPrevs[i].style.display = '';\n                    });\n                }\n            }\n        }\n    }, { passive: false });\n\n    area.addEventListener('touchend', async () => {\n        if (!_tdActive || !_tdNode) { _tdReset(); return; }\n        const hoverTarget = _tdHover;\n        _tdReset();\n\n        if (hoverTarget) {\n            // open-folder guard\n            const blocked = _openFolderGuard(owner.selection);\n            if (blocked) {\n                _snapBack();\n                toast(`\\u201C${VFS.node(blocked)?.name}\\u201D is open in Explorer \\u2014 close the window first`, 'error');\n                return;\n            }\n            const cycled = [...owner.selection].filter(id => VFS.wouldCycle(id, hoverTarget));\n            if (cycled.length) {\n                _snapBack();\n                toast(`Cannot move \"${VFS.node(cycled[0])?.name}\" into itself`, 'error');\n                return;\n            }\n            const tgtChildren = VFS.children(hoverTarget),\n                existing = new Set(tgtChildren.map(n => n.name.toLowerCase())),\n                dupe = [...owner.selection].find(id => id !== hoverTarget && existing.has(VFS.node(id)?.name?.toLowerCase()));\n            if (dupe) {\n                _snapBack();\n                toast(`\"${VFS.node(dupe)?.name}\" already exists in target folder`, 'error');\n                return;\n            }\n            const moved = [];\n            owner.selection.forEach(id => {\n                if (id === hoverTarget) return;\n                if (VFS.move(id, hoverTarget) === 'ok') {\n                    moved.push(id);\n                    area.querySelector(`.file-item[data-id=\"${id}\"]`)?.remove();\n                    area._iconMap?.delete(id);\n                }\n            });\n            if (moved.length) logActivity('move', moved.length === 1 ? (VFS.node(moved[0])?.name ?? '1 item') : `${moved.length} items`, moved.length, VFS.fullPath(owner.folderId), VFS.fullPath(hoverTarget));\n            moved.forEach(id => owner.selection.delete(id));\n        } else {\n            // Snap to grid in place\n            const occupied = new Map();\n            VFS.children(owner.folderId).forEach(n => {\n                if (owner.selection.has(n.id)) return;\n                const p = VFS.getPos(owner.folderId, n.id);\n                if (p) occupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id);\n            });\n            owner.selection.forEach(id => {\n                const el = area.querySelector(`.file-item[data-id=\"${id}\"]`);\n                if (!el) return;\n                const snapped = _snapFreeCell(parseInt(el.style.left), parseInt(el.style.top), occupied),\n                    cx = Math.round((snapped.x - 8) / GRID_X), cy = Math.round((snapped.y - 8) / GRID_Y);\n                occupied.set(`${cx}_${cy}`, id);\n                el.style.transition = 'left .12s ease,top .12s ease';\n                el.style.left = snapped.x + 'px'; el.style.top = snapped.y + 'px';\n                setTimeout(() => { if (el.parentNode) el.style.transition = ''; }, 150);\n                VFS.setPos(owner.folderId, id, snapped.x, snapped.y);\n            });\n        }\n        owner._updateStatus();\n        // Wait for the snap transition to finish (120ms) before the heavy DB write\n        await new Promise(r => setTimeout(r, 130));\n        await saveVFS();\n        if (opts.afterDrop) opts.afterDrop();\n        if (typeof WinManager !== 'undefined') WinManager.renderAll();\n\n        function _snapBack() {\n            Object.entries(_tdStartPos).forEach(([id, sp]) => {\n                const el = area.querySelector(`.file-item[data-id=\"${id}\"]`);\n                if (el && sp) {\n                    el.style.transition = 'left .12s ease,top .12s ease';\n                    el.style.left = sp.x + 'px'; el.style.top = sp.y + 'px';\n                    setTimeout(() => { if (el.parentNode) el.style.transition = ''; }, 150);\n                }\n            });\n        }\n    });\n\n    area.addEventListener('touchcancel', () => { _tdReset(); }, { passive: true });\n\n    // On Android, long-press fires a native contextmenu event (~500-600ms).\n    // If drag is already active, suppress contextmenu so it doesn't kill the drag.\n    // If drag hasn't started yet (timer still pending), reset to avoid ghost state.\n    area.addEventListener('contextmenu', e => {\n        if (_tdActive) { e.preventDefault(); return; }\n        _tdReset();\n    });\n}\n\n/* ---- Shared rubber-band mouse selection ----\n   sel = Set, onUpdate = () => void */\nfunction _rubberBandSelect(e, area, sel, onUpdate) {\n    const rect = area.getBoundingClientRect(),\n        sx = e.clientX - rect.left + area.scrollLeft,\n        sy = e.clientY - rect.top + area.scrollTop,\n        band = document.createElement('div');\n    band.className = 'rubberband';\n    band.style.cssText = `left:${sx}px;top:${sy}px;width:0;height:0`;\n    area.appendChild(band);\n    const onMove = mv => {\n        const cx = mv.clientX - rect.left + area.scrollLeft,\n            cy = mv.clientY - rect.top + area.scrollTop,\n            x = Math.min(sx, cx), y = Math.min(sy, cy),\n            w = Math.abs(cx - sx), h = Math.abs(cy - sy);\n        band.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px`;\n        const bx1 = x, by1 = y, bx2 = x + w, by2 = y + h;\n        for (const item of (area._iconMap?.values() ?? area.querySelectorAll(':scope > .file-item'))) {\n            const ix = parseInt(item.style.left), iy = parseInt(item.style.top),\n                hit = ix < bx2 && (ix + ICON_W) > bx1 && iy < by2 && (iy + ICON_H) > by1;\n            if (hit) { sel.add(item.dataset.id); item.classList.add('selected'); }\n            else if (!e.ctrlKey && !e.metaKey) { sel.delete(item.dataset.id); item.classList.remove('selected'); }\n        }\n        onUpdate();\n    };\n    const onUp = () => { band.remove(); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };\n    document.addEventListener('mousemove', onMove);\n    document.addEventListener('mouseup', onUp);\n}\n\n/* ============================================================\n   UNIFIED ICON DRAG — shared by Desktop and FolderWindow\n   srcCtx = { area, folderId, selection, winEl, updateUI, clearAll }\n   winEl = null  →  source is the desktop\n   winEl = elem  →  source is a folder window\n   ============================================================ */\nfunction _startIconDrag(e, node, el, srcCtx) {\n    e.stopPropagation(); e.preventDefault();\n    _cancelHoverTooltip();\n\n    const wasSelected = srcCtx.selection.has(node.id);\n    if (!e.ctrlKey && !e.metaKey && !wasSelected) srcCtx.clearAll();\n    srcCtx.selection.add(node.id);\n    el.classList.add('selected');\n    srcCtx.updateUI();\n\n    const isDesktop = srcCtx.winEl === null,\n        srcArea = srcCtx.area;\n\n    // Build O(1) element lookup for hot-path drag operations (uses iconMap when available)\n    const selEls = new Map();\n    srcCtx.selection.forEach(id => {\n        const it = srcArea._iconMap?.get(id) ?? srcArea.querySelector(`.file-item[data-id=\"${id}\"]`);\n        if (it) selEls.set(id, it);\n    });\n\n    // Elevate z-index for desktop items during drag\n    if (isDesktop) {\n        selEls.forEach(it => { it.style.zIndex = '7900'; });\n    }\n\n    const areaRect = srcArea.getBoundingClientRect(),\n        elRect = el.getBoundingClientRect(),\n        clickOffX = e.clientX - elRect.left,\n        clickOffY = e.clientY - elRect.top,\n        startX = e.clientX,\n        startY = e.clientY;\n\n    // Snapshot start positions of all selected icons (reuse selEls — no extra querySelector)\n    const startPosMap = {};\n    selEls.forEach((it, id) => { startPosMap[id] = { x: parseInt(it.style.left), y: parseInt(it.style.top) }; });\n\n    // Build occupied map for snap preview excluding dragged items\n    const srcOccupied = new Map();\n    VFS.children(srcCtx.folderId).forEach(n => {\n        if (srcCtx.selection.has(n.id)) return;\n        const p = VFS.getPos(srcCtx.folderId, n.id);\n        if (p) srcOccupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id);\n    });\n\n    let snapPreviewEls = [],      // previews inside the source area\n        deskSnapPreviewEls = [],  // previews on desktop (when FW item escapes to desktop)\n        winSnapPreviewEls = [],  // previews inside a hovered FW\n        ghostEls = [],  // ghost clones on desktop when FW item escapes\n        moved = false,\n        escaped = false,         // FW item currently outside its window\n        hoverFolder = null,\n        hoverWin = null,\n        lastX = e.clientX,\n        lastY = e.clientY,\n        deskOccCached = null,\n        winOccCached = null,\n        lastPrevCx = -1, lastPrevCy = -1, lastPrevMode = '';\n\n    // ---- helpers ----------------------------------------------------------\n\n    function _showPreviews(previewArr, selIds, dropX, dropY, occMap, targetArea) {\n        while (previewArr.length < selIds.length) {\n            const p = document.createElement('div'); p.className = 'snap-preview';\n            targetArea.appendChild(p); previewArr.push(p);\n        }\n        while (previewArr.length > selIds.length) previewArr.pop().remove();\n        const extra = new Map(),\n            mainSp = startPosMap[node.id];\n        selIds.forEach((id, i) => {\n            const sp = startPosMap[id],\n                offX = sp && mainSp ? sp.x - mainSp.x : 0,\n                offY = sp && mainSp ? sp.y - mainSp.y : 0,\n                snapped = _snapFreeCell(dropX + offX, dropY + offY, occMap, extra),\n                cx = Math.round((snapped.x - 8) / GRID_X), cy = Math.round((snapped.y - 8) / GRID_Y);\n            extra.set(`${cx}_${cy}`, id);\n            previewArr[i].style.left = snapped.x + 'px';\n            previewArr[i].style.top = snapped.y + 'px';\n            previewArr[i].style.display = '';\n        });\n    }\n\n    function _snapBackSrc() {\n        srcCtx.selection.forEach(id => {\n            const item = selEls.get(id),\n                sp = startPosMap[id];\n            if (item && sp) {\n                item.style.transition = 'left 0.12s ease, top 0.12s ease';\n                item.style.left = sp.x + 'px'; item.style.top = sp.y + 'px';\n                setTimeout(() => { if (item.parentNode) item.style.transition = ''; }, 150);\n            }\n        });\n    }\n\n    async function _dropIntoFolder(destFid, dropX, dropY) {\n        // pre-check: cycles (includes self-move: wouldCycle(A,A) → true)\n        const cycled = [];\n        srcCtx.selection.forEach(id => {\n            if (VFS.wouldCycle(id, destFid)) cycled.push(VFS.node(id)?.name || id);\n        });\n        if (cycled.length) {\n            _snapBackSrc();\n            toast(`Cannot move \"${cycled[0]}\" into itself or a subfolder`, 'error');\n            return false;\n        }\n        // pre-check: duplicates\n        const existing = new Set(VFS.children(destFid).map(n => n.name.toLowerCase())),\n            conflicts = [];\n        srcCtx.selection.forEach(id => {\n            const n = VFS.node(id); if (!n) return;\n            if (n.parentId !== destFid && existing.has(n.name.toLowerCase())) conflicts.push(n.name);\n        });\n        if (conflicts.length) {\n            _snapBackSrc();\n            toast(`Cannot move: \"${conflicts[0]}\" already exists in target folder`, 'error');\n            return false;\n        }\n        // perform move\n        const movedIds = [],\n            occupied = new Map();\n        VFS.children(destFid).forEach(n => {\n            const p = VFS.getPos(destFid, n.id);\n            if (p) occupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id);\n        });\n        const mainSp = startPosMap[node.id];\n        for (const id of srcCtx.selection) {\n            if (id === destFid) continue;\n            const n = VFS.node(id); if (!n) continue;\n            const result = VFS.move(id, destFid);\n            if (result === 'duplicate') { toast(`\"${n.name}\" already exists in target folder`, 'error'); continue; }\n            if (result === 'cycle') { toast(`Cannot move \"${n.name}\" into itself or a subfolder`, 'error'); continue; }\n            if (result !== 'ok') { continue; }\n            if (dropX !== null) {\n                const sp = startPosMap[id],\n                    offX = sp && mainSp ? sp.x - mainSp.x : 0,\n                    offY = sp && mainSp ? sp.y - mainSp.y : 0,\n                    sn = _snapFreeCell(dropX + offX, dropY + offY, occupied);\n                VFS.setPos(destFid, id, sn.x, sn.y);\n                occupied.set(`${Math.round((sn.x - 8) / GRID_X)}_${Math.round((sn.y - 8) / GRID_Y)}`, id);\n            }\n            movedIds.push(id);\n        }\n        if (movedIds.length) {\n            logActivity('move',\n                movedIds.length === 1 ? (VFS.node(movedIds[0])?.name ?? '1 item') : `${movedIds.length} items`,\n                movedIds.length, VFS.fullPath(srcCtx.folderId), VFS.fullPath(destFid));\n        }\n        return movedIds;\n    }\n\n    // ---- onMove -----------------------------------------------------------\n\n    const onMove = mv => {\n        lastX = mv.clientX; lastY = mv.clientY;\n        if (!moved && (Math.abs(mv.clientX - startX) + Math.abs(mv.clientY - startY)) > 4) {\n            moved = true; _isDragging = true; _cancelHoverTooltip();\n        }\n        if (!moved) return;\n\n        const mainSp = startPosMap[node.id],\n            curAreaRect = srcArea.getBoundingClientRect(),\n            targetMainX = mv.clientX - curAreaRect.left + srcArea.scrollLeft - clickOffX,\n            targetMainY = mv.clientY - curAreaRect.top + srcArea.scrollTop - clickOffY,\n            dx = targetMainX - mainSp.x,\n            dy = targetMainY - mainSp.y;\n\n        // ---- FW-specific: escape / re-enter --------------------------------\n        if (!isDesktop) {\n            const winRect = srcCtx.winEl.getBoundingClientRect();\n            const outsideWindow = mv.clientX < winRect.left || mv.clientX > winRect.right ||\n                mv.clientY < winRect.top || mv.clientY > winRect.bottom;\n\n            if (!outsideWindow && escaped) {\n                // Re-entered source window — cancel escape\n                escaped = false;\n                ghostEls.forEach(g => g.remove()); ghostEls = [];\n                deskSnapPreviewEls.forEach(p => p.remove()); deskSnapPreviewEls = [];\n                winSnapPreviewEls.forEach(p => p.remove()); winSnapPreviewEls = [];\n                selEls.forEach(orig => { orig.style.visibility = ''; });\n            }\n            if (outsideWindow && !escaped) {\n                // Escaping — hide originals, spawn ghosts on desktop\n                escaped = true;\n                selEls.forEach(orig => { orig.style.visibility = 'hidden'; });\n                const deskArea = document.getElementById('desktop-area'),\n                    selIds = [...srcCtx.selection].sort((a, b) => a === node.id ? -1 : b === node.id ? 1 : 0);\n                selIds.forEach(id => {\n                    const n = VFS.node(id); if (!n) return;\n                    const g = _buildIconEl(n, { x: 0, y: 0 });\n                    g.classList.add('selected');\n                    g.style.cssText += ';position:absolute;z-index:7900;opacity:0.7;pointer-events:none;will-change:left,top';\n                    g.dataset.ghostFor = id;\n                    deskArea.appendChild(g);\n                    ghostEls.push(g);\n                });\n            }\n        }\n\n        // ---- position items / ghosts ---------------------------------------\n        if (!escaped) {\n            srcCtx.selection.forEach(id => {\n                const it = selEls.get(id),\n                    sp = startPosMap[id];\n                if (it && sp) { it.style.left = (sp.x + dx) + 'px'; it.style.top = (sp.y + dy) + 'px'; }\n            });\n        } else {\n            const deskArea = document.getElementById('desktop-area'),\n                deskRect = deskArea.getBoundingClientRect(),\n                baseX = mv.clientX - deskRect.left + deskArea.scrollLeft - clickOffX,\n                baseY = mv.clientY - deskRect.top + deskArea.scrollTop - clickOffY;\n            ghostEls.forEach(g => {\n                const sp = startPosMap[g.dataset.ghostFor],\n                    offX = sp && mainSp ? sp.x - mainSp.x : 0,\n                    offY = sp && mainSp ? sp.y - mainSp.y : 0;\n                g.style.left = (baseX + offX) + 'px';\n                g.style.top = (baseY + offY) + 'px';\n            });\n        }\n\n        // ---- hover-folder highlight ----------------------------------------\n        if (!escaped) {\n            selEls.forEach(it => { it.style.pointerEvents = 'none'; });\n        }\n        const target = document.elementFromPoint(mv.clientX, mv.clientY);\n        if (!escaped) {\n            selEls.forEach(it => { it.style.pointerEvents = ''; });\n        }\n        const folderEl = target?.closest('.file-item[data-id]');\n        const newHover = folderEl && !srcCtx.selection.has(folderEl.dataset.id) &&\n            VFS.node(folderEl.dataset.id)?.type === 'folder' ? folderEl.dataset.id : null;\n        if (newHover !== hoverFolder) {\n            if (hoverFolder) document.querySelectorAll(`.file-item[data-id=\"${hoverFolder}\"]`).forEach(i => i.classList.remove('drag-target'));\n            hoverFolder = newHover;\n            if (hoverFolder) document.querySelectorAll(`.file-item[data-id=\"${hoverFolder}\"]`).forEach(i => i.classList.add('drag-target'));\n        }\n\n        // ---- hovered FW (excluding source window) -------------------------\n        const fwElt = !hoverFolder ? target?.closest('.folder-window') : null,\n            curWin = fwElt ? (typeof WinManager !== 'undefined' ? WinManager._wins.find(w => w.el === fwElt) : null) : null,\n            effectiveHoverWin = (curWin && curWin.el !== srcCtx.winEl) ? curWin : null;\n        if (effectiveHoverWin !== hoverWin) {\n            winSnapPreviewEls.forEach(p => p.remove()); winSnapPreviewEls = [];\n            hoverWin = effectiveHoverWin;\n            if (hoverWin) {\n                winOccCached = new Map();\n                VFS.children(hoverWin.folderId).forEach(n => {\n                    const p = VFS.getPos(hoverWin.folderId, n.id);\n                    if (p) winOccCached.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id);\n                });\n            } else { winOccCached = null; }\n            lastPrevMode = '';\n        }\n\n        // ---- snap previews ------------------------------------------------\n        if (!moved) return;\n\n        if (hoverFolder) {\n            snapPreviewEls.forEach(p => p.style.display = 'none');\n            deskSnapPreviewEls.forEach(p => p.style.display = 'none');\n            winSnapPreviewEls.forEach(p => p.style.display = 'none');\n            lastPrevMode = '';\n        } else if (hoverWin) {\n            snapPreviewEls.forEach(p => p.style.display = 'none');\n            deskSnapPreviewEls.forEach(p => p.style.display = 'none');\n            const winArea = hoverWin.el.querySelector('.fw-area'),\n                wRect = winArea.getBoundingClientRect(),\n                dropX = mv.clientX - wRect.left + winArea.scrollLeft - clickOffX,\n                dropY = mv.clientY - wRect.top + winArea.scrollTop - clickOffY,\n                _cx = Math.round((dropX - 8) / GRID_X), _cy = Math.round((dropY - 8) / GRID_Y);\n            if (_cx !== lastPrevCx || _cy !== lastPrevCy || lastPrevMode !== 'win') {\n                lastPrevCx = _cx; lastPrevCy = _cy; lastPrevMode = 'win';\n                _showPreviews(winSnapPreviewEls, [...srcCtx.selection], dropX, dropY, winOccCached, winArea);\n            }\n        } else if (escaped) {\n            // on desktop (FW items that escaped)\n            snapPreviewEls.forEach(p => p.style.display = 'none');\n            winSnapPreviewEls.forEach(p => p.style.display = 'none');\n            const deskArea = document.getElementById('desktop-area'),\n                dRect = deskArea.getBoundingClientRect(),\n                dropX = mv.clientX - dRect.left + deskArea.scrollLeft - clickOffX,\n                dropY = mv.clientY - dRect.top + deskArea.scrollTop - clickOffY,\n                _cx = Math.round((dropX - 8) / GRID_X), _cy = Math.round((dropY - 8) / GRID_Y);\n            if (!deskOccCached) {\n                deskOccCached = new Map();\n                VFS.children(Desktop._desktopFolder).forEach(n => {\n                    const p = VFS.getPos(Desktop._desktopFolder, n.id);\n                    if (p) deskOccCached.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id);\n                });\n            }\n            if (_cx !== lastPrevCx || _cy !== lastPrevCy || lastPrevMode !== 'desk') {\n                lastPrevCx = _cx; lastPrevCy = _cy; lastPrevMode = 'desk';\n                _showPreviews(deskSnapPreviewEls, [...srcCtx.selection], dropX, dropY, deskOccCached, deskArea);\n            }\n        } else {\n            // within source area (desktop or FW)\n            deskSnapPreviewEls.forEach(p => p.style.display = 'none');\n            winSnapPreviewEls.forEach(p => p.style.display = 'none');\n            const _cx = Math.round((mainSp.x + dx - 8) / GRID_X), _cy = Math.round((mainSp.y + dy - 8) / GRID_Y);\n            if (_cx !== lastPrevCx || _cy !== lastPrevCy || lastPrevMode !== 'src') {\n                lastPrevCx = _cx; lastPrevCy = _cy; lastPrevMode = 'src';\n                _showPreviews(snapPreviewEls, [...srcCtx.selection], mainSp.x + dx, mainSp.y + dy, srcOccupied, srcArea);\n            }\n        }\n    };\n\n    // ---- onUp -------------------------------------------------------------\n\n    const onUp = async () => {\n        document.removeEventListener('mousemove', onMove);\n        document.removeEventListener('mouseup', onUp);\n        _isDragging = false;\n        snapPreviewEls.forEach(p => p.remove()); snapPreviewEls = [];\n        deskSnapPreviewEls.forEach(p => p.remove()); deskSnapPreviewEls = [];\n        winSnapPreviewEls.forEach(p => p.remove()); winSnapPreviewEls = [];\n        if (hoverFolder) document.querySelectorAll(`.file-item[data-id=\"${hoverFolder}\"]`).forEach(i => i.classList.remove('drag-target'));\n        ghostEls.forEach(g => g.remove()); ghostEls = [];\n\n        // Restore desktop z-index\n        if (isDesktop) {\n            selEls.forEach(it => { it.style.zIndex = ''; });\n        }\n\n        if (!moved) {\n            // Ctrl+click on already-selected item → deselect it\n            if ((e.ctrlKey || e.metaKey) && wasSelected) {\n                srcCtx.selection.delete(node.id);\n                el.classList.remove('selected');\n                srcCtx.updateUI();\n            }\n            // Click without drag — restore visibility for FW items\n            if (!isDesktop) {\n                selEls.forEach(orig => { orig.style.visibility = ''; });\n            }\n            return;\n        }\n\n        // ---- pre-check: open-folder guard (only when changing folder) ------\n        // Note: for desktop items, `escaped` is never true; desktop→FW drops are caught\n        // by the extra elementFromPoint check below.\n        if (escaped || hoverFolder || document.elementFromPoint(lastX, lastY)?.closest('.folder-window')) {\n            const blocked = _openFolderGuard(srcCtx.selection);\n            if (blocked) {\n                _snapBackSrc();\n                if (!isDesktop) selEls.forEach(orig => { orig.style.visibility = ''; });\n                toast(`\\u201C${VFS.node(blocked)?.name}\\u201D is open in Explorer \\u2014 close the window first`, 'error');\n                return;\n            }\n        }\n\n        // ---- Case 1: FW item escaped → dropped back in same window (race) --\n        if (!isDesktop && escaped) {\n            const srcR = srcCtx.winEl.getBoundingClientRect();\n            if (lastX >= srcR.left && lastX <= srcR.right && lastY >= srcR.top && lastY <= srcR.bottom) {\n                selEls.forEach(orig => { orig.style.visibility = ''; });\n                return;\n            }\n        }\n\n        // Determine actual drop zone\n        const dropTarget = document.elementFromPoint(lastX, lastY),\n            tFwEl = dropTarget?.closest('.folder-window'),\n            tWin = tFwEl ? (typeof WinManager !== 'undefined' ? WinManager._wins.find(w => w.el === tFwEl) : null) : null,\n            actualHoverWin = (tWin && tWin.el !== srcCtx.winEl) ? tWin : null;\n\n        // ---- Case 2: dropped onto a folder icon ----------------------------\n        if (hoverFolder) {\n            const movedIds = await _dropIntoFolder(hoverFolder, null, null);\n            if (movedIds === false) {\n                if (!isDesktop) selEls.forEach(orig => { orig.style.visibility = ''; });\n                return;\n            }\n            const targetWinForFolder = typeof WinManager !== 'undefined' ? WinManager._wins.find(w => w.folderId === hoverFolder) : null;\n            if (targetWinForFolder) targetWinForFolder._clearSelection();\n            movedIds.forEach(id => {\n                srcCtx.selection.delete(id);\n                if (targetWinForFolder) targetWinForFolder.selection.add(id);\n                selEls.get(id)?.remove();\n                srcArea._iconMap?.delete(id);\n            });\n            // snap back failures\n            if (!isDesktop) srcCtx.selection.forEach(id => {\n                const orig = selEls.get(id);\n                if (orig) orig.style.visibility = '';\n            });\n            srcCtx.updateUI();\n            await saveVFS();\n            if (typeof WinManager !== 'undefined') WinManager.renderAll();\n            return;\n        }\n\n        // ---- Case 3: dropped onto a folder window -------------------------\n        if (actualHoverWin || (!isDesktop && escaped && !hoverFolder)) {\n            const targetWin = actualHoverWin;\n            if (targetWin) {\n                const tArea = targetWin.el.querySelector('.fw-area'),\n                    tRect = tArea.getBoundingClientRect(),\n                    dropPosX = lastX - tRect.left + tArea.scrollLeft - clickOffX,\n                    dropPosY = lastY - tRect.top + tArea.scrollTop - clickOffY,\n                    movedIds = await _dropIntoFolder(targetWin.folderId, dropPosX, dropPosY);\n                if (movedIds === false) {\n                    if (!isDesktop) selEls.forEach(orig => { orig.style.visibility = ''; });\n                    return;\n                }\n                const srcWin = !isDesktop ? (typeof WinManager !== 'undefined' ? WinManager._wins.find(w => w.el === srcCtx.winEl) : null) : null;\n                targetWin._clearSelection();\n                movedIds.forEach(id => {\n                    srcCtx.selection.delete(id);\n                    targetWin.selection.add(id);\n                    selEls.get(id)?.remove();\n                    srcArea._iconMap?.delete(id);\n                });\n                // snap back failures\n                if (!isDesktop) srcCtx.selection.forEach(id => {\n                    const orig = selEls.get(id);\n                    if (orig) orig.style.visibility = '';\n                });\n                srcCtx.updateUI();\n                await saveVFS();\n                targetWin.render();\n                return;\n            }\n        }\n\n        // ---- Case 4a: FW item dropped onto desktop ------------------------\n        if (!isDesktop && escaped) {\n            const deskArea = document.getElementById('desktop-area'),\n                dRect = deskArea.getBoundingClientRect(),\n                dropPosX = lastX - dRect.left + deskArea.scrollLeft - clickOffX,\n                dropPosY = lastY - dRect.top + deskArea.scrollTop - clickOffY,\n                deskFid = Desktop._desktopFolder,\n                occupied = new Map();\n            VFS.children(deskFid).forEach(n => {\n                const p = VFS.getPos(deskFid, n.id);\n                if (p) occupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id);\n            });\n            const mainSp = startPosMap[node.id],\n                movedIds = [];\n            for (const id of srcCtx.selection) {\n                const n = VFS.node(id); if (!n) continue;\n                const result = VFS.move(id, deskFid);\n                if (result === 'duplicate') { toast(`\"${n.name}\" already exists on desktop`, 'error'); continue; }\n                if (result === 'cycle') { toast(`Cannot move \"${n.name}\" into itself`, 'error'); continue; }\n                const sp = startPosMap[id],\n                    offX = sp && mainSp ? sp.x - mainSp.x : 0,\n                    offY = sp && mainSp ? sp.y - mainSp.y : 0,\n                    sn = _snapFreeCell(dropPosX + offX, dropPosY + offY, occupied);\n                VFS.setPos(deskFid, id, sn.x, sn.y);\n                occupied.set(`${Math.round((sn.x - 8) / GRID_X)}_${Math.round((sn.y - 8) / GRID_Y)}`, id);\n                movedIds.push(id);\n            }\n            Desktop._sel.clear();\n            document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected'));\n            movedIds.forEach(id => {\n                srcCtx.selection.delete(id);\n                Desktop._sel.add(id);\n                selEls.get(id)?.remove();\n                srcArea._iconMap?.delete(id);\n            });\n            // snap back failures\n            srcCtx.selection.forEach(id => {\n                const orig = selEls.get(id);\n                if (orig) {\n                    orig.style.visibility = '';\n                    const sp = startPosMap[id];\n                    if (sp) {\n                        orig.style.transition = 'left 0.15s ease, top 0.15s ease';\n                        orig.style.left = sp.x + 'px'; orig.style.top = sp.y + 'px';\n                        setTimeout(() => { if (orig.parentNode) orig.style.transition = ''; }, 160);\n                    }\n                }\n            });\n            if (movedIds.length) logActivity('move',\n                movedIds.length === 1 ? (VFS.node(movedIds[0])?.name ?? '1 item') : `${movedIds.length} items`,\n                movedIds.length, VFS.fullPath(srcCtx.folderId), VFS.fullPath(deskFid));\n            srcCtx.updateUI();\n            await saveVFS();\n            Desktop._patchIcons();\n            return;\n        }\n\n        // ---- Case 4b: within-source snap ----------------------------------\n        if (!isDesktop) {\n            // restore visibility first\n            selEls.forEach(orig => { orig.style.visibility = ''; });\n        }\n        // Grid snap within source area\n        const occupied = new Map();\n        VFS.children(srcCtx.folderId).forEach(n => {\n            if (srcCtx.selection.has(n.id)) return;\n            const p = VFS.getPos(srcCtx.folderId, n.id);\n            if (p) occupied.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id);\n        });\n        srcCtx.selection.forEach(id => {\n            const item = selEls.get(id);\n            if (!item) return;\n            const rawX = parseInt(item.style.left), rawY = parseInt(item.style.top),\n                snapped = _snapFreeCell(rawX, rawY, occupied),\n                cx = Math.round((snapped.x - 8) / GRID_X), cy = Math.round((snapped.y - 8) / GRID_Y);\n            occupied.set(`${cx}_${cy}`, id);\n            item.style.transition = 'left 0.12s ease, top 0.12s ease';\n            item.style.left = snapped.x + 'px'; item.style.top = snapped.y + 'px';\n            setTimeout(() => { if (item.parentNode) item.style.transition = ''; }, 150);\n            VFS.setPos(srcCtx.folderId, id, snapped.x, snapped.y);\n        });\n        // Wait for the snap transition to finish (120ms) before the heavy DB write\n        await new Promise(r => setTimeout(r, 130));\n        await saveVFS();\n        if (isDesktop) {\n            srcCtx.updateUI();\n        } else {\n            _syncAreaWidth(srcArea);\n        }\n    };\n\n    document.addEventListener('mousemove', onMove);\n    document.addEventListener('mouseup', onUp);\n}\n\n/* ============================================================\n   SHARED KEYBOARD HANDLER — Desktop & FolderWindow\n   syncCtx: fn to set App.folder/selection/winCtx before an operation\n   withSync: fn(op) to set context → run op → restore context (sync only)\n   opts.area: icon container element\n   opts.refresh: fn() called on F5\n   opts.extraKeys: fn(e) → true if handled (for FW-specific keys like Backspace=navup)\n   ============================================================ */\nfunction _handleKey(e, owner, syncCtx, withSync, opts = {}) {\n    if ((e.key === 'Delete' || e.key === 'Backspace') && owner.selection.size > 0) {\n        syncCtx(); deleteSelected();\n    } else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyC' && owner.selection.size > 0) {\n        withSync(() => copyItems());\n    } else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyX' && owner.selection.size > 0) {\n        withSync(() => cutItems());\n    } else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyV') {\n        syncCtx(); pasteItems();\n    } else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyA') {\n        syncCtx(); selectAll();\n    } else if (e.key === 'Escape') {\n        if (App.clipboard?.op === 'cut') cancelClipboard();\n        owner.selection.clear();\n        (opts.area || document).querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected'));\n        owner._updateStatus();\n    } else if (e.key === 'F2' && owner.selection.size === 1) {\n        renameNode(VFS.node([...owner.selection][0]));\n    } else if (e.key === 'F5') {\n        e.preventDefault();\n        if (opts.refresh) opts.refresh();\n    } else if (opts.extraKeys) {\n        opts.extraKeys(e);\n    }\n}\n\n/* ============================================================\n   SHARED CONTEXT MENU BUILDERS — Desktop & FolderWindow\n   ============================================================ */\nfunction _buildSortSubmenu(sortTarget) {\n    return [\n        {\n            label: 'By Name', icon: Icons.sortName, submenu: [\n                { label: 'A → Z', icon: Icons.sortAsc, action: () => sortIcons('name', 'asc', sortTarget) },\n                { label: 'Z → A', icon: Icons.sortDesc, action: () => sortIcons('name', 'desc', sortTarget) },\n            ]\n        },\n        {\n            label: 'By Date Modified', icon: Icons.sortDate, submenu: [\n                { label: 'Newest first', icon: Icons.sortDesc, action: () => sortIcons('mtime', 'desc', sortTarget) },\n                { label: 'Oldest first', icon: Icons.sortAsc, action: () => sortIcons('mtime', 'asc', sortTarget) },\n            ]\n        },\n        {\n            label: 'By Date Created', icon: Icons.sortDate, submenu: [\n                { label: 'Newest first', icon: Icons.sortDesc, action: () => sortIcons('ctime', 'desc', sortTarget) },\n                { label: 'Oldest first', icon: Icons.sortAsc, action: () => sortIcons('ctime', 'asc', sortTarget) },\n            ]\n        },\n        { sep: true },\n        {\n            label: 'By Size', icon: Icons.sortSize, submenu: [\n                { label: 'Largest first', icon: Icons.sortDesc, action: () => sortIcons('size', 'desc', sortTarget) },\n                { label: 'Smallest first', icon: Icons.sortAsc, action: () => sortIcons('size', 'asc', sortTarget) },\n            ]\n        },\n        { sep: true },\n        { label: 'By Type', icon: Icons.sortType, action: () => sortIcons('type', 'asc', sortTarget) },\n    ];\n}\n\nfunction _buildAreaMenuItems(e, syncFn, sortTarget, refreshFn) {\n    const items = [\n        { label: 'New Text File', icon: Icons.newfile, action: () => { syncFn(); App._ctxScreenPos = { x: e.clientX, y: e.clientY }; newTextFile(); } },\n        { label: 'New Folder', icon: Icons.newfolder, action: () => { syncFn(); App._ctxScreenPos = { x: e.clientX, y: e.clientY }; newFolder(); } },\n        { sep: true },\n        { label: 'Import Files...', icon: Icons.upload, action: () => { syncFn(); document.getElementById('file-input').click(); } },\n    ];\n    if (App.clipboard) {\n        items.push({ sep: true });\n        items.push({ label: 'Paste', icon: Icons.paste, action: () => { syncFn(); App._ctxScreenPos = { x: e.clientX, y: e.clientY }; pasteItems(); } });\n    }\n    items.push({ sep: true });\n    items.push({ label: 'Sort', icon: Icons.sort, submenu: _buildSortSubmenu(sortTarget) });\n    items.push({ sep: true });\n    items.push({ label: 'Refresh', icon: Icons.refresh, action: refreshFn });\n    return items;\n}\n\nfunction _buildIconMenuItems(node, sel, opts) {\n    const items = [];\n    if (node.type === 'folder') {\n        items.push({ label: 'Open', icon: Icons.open, action: () => opts.openFn(node) });\n        items.push({ label: 'Open in New Window', icon: Icons.newfolder, action: () => WinManager.open(node.id) });\n        items.push({\n            label: 'Folder Color', icon: Icons.folder, submenu: FOLDER_COLORS.map(fc => ({\n                label: fc.label,\n                icon: `<span style=\"display:inline-block;width:12px;height:12px;border-radius:2px;background:${fc.color}\"></span>`,\n                action: async () => { node.color = fc.color === '#0078d4' ? undefined : fc.color; await saveVFS(); opts.colorCb(); logActivity('color', `${node.name} → ${fc.label}`, 1, VFS.fullPath(node.id)); }\n            }))\n        });\n    } else {\n        items.push({ label: 'Open', icon: Icons.file, action: () => opts.openFn(node) });\n        items.push({ label: 'Edit as plain text', icon: Icons.rename, action: () => openFileAsText(node) });\n        items.push({ label: 'Export', icon: Icons.download, action: () => downloadFile(node) });\n    }\n    items.push({ label: 'Export as ZIP', icon: Icons.download, action: opts.exportZipFn });\n    items.push({ sep: true });\n    if (opts.hasCopy) items.push({ label: 'Copy', icon: Icons.copy, action: opts.copyFn });\n    items.push({ label: 'Cut', icon: Icons.cut, action: opts.cutFn });\n    items.push({ sep: true });\n    items.push({ label: 'Rename', icon: Icons.rename, action: () => renameNode(node) });\n    items.push({ sep: true });\n    items.push({\n        label: sel.size > 1 ? `Delete ${sel.size} items` : 'Delete', icon: Icons.trash, danger: true,\n        action: opts.deleteFn,\n    });\n    items.push({ sep: true });\n    items.push({ label: 'Properties', icon: Icons.info, action: () => showProps(node) });\n    return items;\n}\n\n/* ---- Shared icon-area render (Desktop + FolderWindow): full rebuild or incremental ---- */\nfunction _renderIconArea(area, folderId, selection, updateStatusFn, forceRebuild) {\n    const items = VFS.children(folderId);\n    items.sort((a, b) => a.type !== b.type ? (a.type === 'folder' ? -1 : 1) : a.name.localeCompare(b.name));\n    area.classList.toggle('no-grid-dots', !_getSettings().gridDots);\n    const iconMap = area._iconMap || (area._iconMap = new Map());\n    const afterRender = () => {\n        updateStatusFn();\n        if (typeof _applyCutStyles !== 'undefined') _applyCutStyles();\n        _syncAreaWidth(area);\n    };\n    if (forceRebuild) {\n        iconMap.forEach(el => el.remove());\n        iconMap.clear();\n        if (!items.length) { afterRender(); return; }\n        const needPos = items.filter(n => !VFS.getPos(folderId, n.id));\n        if (needPos.length) VFS.autoPosBatch(folderId, needPos, area);\n        const useAnim = items.length <= 200;\n        const token = (area._renderToken = (area._renderToken || 0) + 1);\n        const renderChunk = (start) => {\n            if (area._renderToken !== token) return;\n            const frag = document.createDocumentFragment(),\n                end = Math.min(start + 300, items.length);\n            for (let i = start; i < end; i++) {\n                const n = items[i], pos = VFS.getPos(folderId, n.id) || { x: 8, y: 8 },\n                    div = _buildIconEl(n, pos);\n                if (selection.has(n.id)) div.classList.add('selected');\n                if (useAnim) div.style.animation = `iconPop 0.12s ease ${Math.min(i * 15, 200)}ms both`;\n                iconMap.set(n.id, div);\n                frag.appendChild(div);\n            }\n            area.appendChild(frag);\n            if (end < items.length) requestAnimationFrame(() => renderChunk(end));\n            else afterRender();\n        };\n        renderChunk(0);\n        // Immediate status/cut-styles refresh (visible before async chunks complete)\n        updateStatusFn();\n        if (typeof _applyCutStyles !== 'undefined') _applyCutStyles();\n    } else {\n        // Incremental: animate-out removed items, add new ones\n        const nodeMap = new Map(items.map(n => [n.id, n]));\n        iconMap.forEach((el, id) => {\n            if (!nodeMap.has(id)) {\n                if (!el.isConnected) { iconMap.delete(id); }\n                else {\n                    el.style.transition = 'opacity .1s, transform .1s';\n                    el.style.opacity = '0'; el.style.transform = 'scale(.85)';\n                    setTimeout(() => { el.remove(); iconMap.delete(id); }, 110);\n                }\n            }\n        });\n        const needPos = items.filter(n => !VFS.getPos(folderId, n.id));\n        if (needPos.length) VFS.autoPosBatch(folderId, needPos, area);\n        for (const [idx, n] of items.entries()) {\n            let existing = iconMap.get(n.id);\n            if (existing && !existing.isConnected) { iconMap.delete(n.id); existing = null; }\n            if (existing) {\n                const nameEl = existing.querySelector('.file-name');\n                if (nameEl && nameEl.textContent !== n.name) nameEl.textContent = n.name;\n                if (n.type === 'folder') {\n                    const thumbEl = existing.querySelector('.file-thumb.folder-icon');\n                    if (thumbEl) thumbEl.innerHTML = getFolderSVG(n.color);\n                }\n            } else {\n                const pos = VFS.getPos(folderId, n.id) || { x: 8, y: 8 },\n                    div = _buildIconEl(n, pos);\n                if (selection.has(n.id)) div.classList.add('selected');\n                div.style.animation = `iconPop 0.12s ease ${Math.min(idx * 15, 200)}ms both`;\n                iconMap.set(n.id, div);\n                area.appendChild(div);\n            }\n        }\n        afterRender();\n    }\n}\n\n/* ============================================================\n   DESKTOP\n   ============================================================ */\nconst Desktop = {\n    _desktopFolder: 'root',\n    _sel: App.selection,   // main desktop's own selection (same reference as App.selection initially)\n    // Unified interface aliases used by shared helpers (_setupAreaDelegation, _initAreaTouchRubberBand, _rubberBandSelect)\n    get selection() { return this._sel; },\n    get folderId() { return this._desktopFolder; },\n    _updateStatus() { this._updateSelectionBar(); },\n\n    render() {\n        // Restore main desktop's folder + selection as the active App context\n        App._winCtx = null;\n        App.folder = this._desktopFolder;\n        App.selection = this._sel;\n\n        this._renderBreadcrumb();\n        this._renderIcons();\n        this.updateTaskbar();\n        document.title = 'SafeNova — ' + (App.container?.name || 'Container');\n        // Re-render all open folder windows\n        if (typeof WinManager !== 'undefined') WinManager.renderAll();\n        // Load activity log from compressed storage (async)\n        _loadActivityLog();\n    },\n\n    _renderBreadcrumb() {\n        const bc = document.getElementById('breadcrumb'),\n            crumbs = VFS.breadcrumb(this._desktopFolder);\n        bc.innerHTML = '';\n        crumbs.forEach((n, i) => {\n            const span = document.createElement('span');\n            span.className = 'breadcrumb-item' + (i === crumbs.length - 1 ? ' current' : '');\n            span.textContent = n.id === 'root' ? ('/~/' + App.container.name) : n.name;\n            if (i < crumbs.length - 1) {\n                span.addEventListener('click', () => {\n                    this._desktopFolder = n.id;\n                    this._sel.clear();\n                    this.render();\n                });\n            }\n            bc.appendChild(span);\n            if (i < crumbs.length - 1) {\n                const sep = document.createElement('span');\n                sep.className = 'breadcrumb-sep';\n                sep.textContent = ' › ';\n                bc.appendChild(sep);\n            }\n        });\n    },\n\n    _renderIcons() {\n        _renderIconArea(\n            document.getElementById('desktop-area'),\n            this._desktopFolder, this._sel,\n            () => this._updateSelectionBar(), true\n        );\n    },\n\n    // Incremental update: add new icons, remove gone ones, sync names — NO re-animation for existing\n    _patchIcons() {\n        App._winCtx = null;\n        App.folder = this._desktopFolder;\n        App.selection = this._sel;\n        _renderIconArea(\n            document.getElementById('desktop-area'),\n            this._desktopFolder, this._sel,\n            () => { this._updateSelectionBar(); this.updateTaskbar(); }, false\n        );\n        if (typeof WinManager !== 'undefined') WinManager.renderAll();\n    },\n\n    _onIconMousedown(e, el, node) {\n        if (e.button !== 0) return;\n        hideCtxMenu();\n        _startIconDrag(e, node, el, {\n            area: document.getElementById('desktop-area'),\n            folderId: this._desktopFolder,\n            selection: this._sel,\n            winEl: null,\n            updateUI: () => { this._updateSelectionBar(); this.updateTaskbar(); },\n            clearAll: () => {\n                this._sel.clear();\n                document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected'));\n            },\n        });\n    },\n\n\n    /* ---- Touch-drag for mobile: long-press (400ms) + drag icons ---- */\n    _initTouchDrag(area) {\n        _initTouchDragCommon(area, this, { showSnap: true, afterDrop: () => this.updateTaskbar() });\n    },\n\n    _openNode(node) {\n        hideCtxMenu();\n        if (node.type === 'folder') {\n            // Folders always open in a new floating window\n            WinManager.open(node.id);\n        } else {\n            openFile(node);\n        }\n    },\n\n    _contextIcon(e, node) {\n        if (!e.ctrlKey && !e.metaKey && !this._sel.has(node.id)) {\n            this._sel.clear();\n            document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected'));\n        }\n        this._sel.add(node.id);\n        document.querySelector(`#desktop-area > .file-item[data-id=\"${node.id}\"]`)?.classList.add('selected');\n        this._updateSelectionBar();\n        const _sync = () => { App.folder = this._desktopFolder; App.selection = this._sel; App._winCtx = null; };\n        showCtxMenu(e.clientX, e.clientY, _buildIconMenuItems(node, this._sel, {\n            openFn: n => n.type === 'folder' ? WinManager.open(n.id) : this._openNode(n),\n            colorCb: () => Desktop._patchIcons(),\n            hasCopy: true,\n            copyFn: () => { _sync(); copyItems(); },\n            cutFn: () => { _sync(); cutItems(); },\n            exportZipFn: () => { _sync(); exportAsZip([...this._sel]); },\n            deleteFn: () => { _sync(); deleteSelected(); },\n        }));\n    },\n\n    _contextDesktop(e) {\n        this._sel.clear();\n        document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected'));\n        this._updateSelectionBar();\n        const _sync = () => { App.folder = this._desktopFolder; App.selection = this._sel; App._winCtx = null; };\n        showCtxMenu(e.clientX, e.clientY, _buildAreaMenuItems(e, _sync, undefined,\n            () => { Desktop._renderIcons(); if (typeof WinManager !== 'undefined') WinManager.renderAll(); }));\n    },\n\n    // Clear selection: empties both the Set AND removes .selected CSS classes from DOM\n    _clearSelection() {\n        this._sel.clear();\n        document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected'));\n        this._updateSelectionBar();\n    },\n\n    _updateSelectionBar() {\n        const bar = document.getElementById('selection-bar');\n        if (this._sel.size > 0) {\n            const totalSz = [...this._sel].reduce((s, id) => {\n                const n = VFS.node(id); return s + (n && n.size ? n.size : 0);\n            }, 0);\n            bar.textContent = `${this._sel.size} item${this._sel.size !== 1 ? 's' : ''} selected${totalSz > 0 ? ' · ' + fmtSize(totalSz) : ''}`;\n            bar.classList.add('show');\n        } else {\n            bar.classList.remove('show');\n        }\n    },\n\n    updateTaskbar() {\n        if (!App.container) return;\n        const tot = App.container.totalSize || 0,\n            pct = Math.min(tot / CONTAINER_LIMIT * 100, 100),\n            cls = pct > 90 ? 'danger' : pct > 70 ? 'warn' : '';\n        document.getElementById('taskbar-name').textContent = App.container.name;\n        document.getElementById('taskbar-size-text').textContent = `${fmtSize(tot)} / ${fmtSize(CONTAINER_LIMIT)}`;\n        document.getElementById('taskbar-size-pct').textContent = pct.toFixed(1) + '%';\n        const bar = document.getElementById('taskbar-bar-fill');\n        bar.style.width = pct + '%';\n        bar.className = 'taskbar-bar-fill ' + cls;\n    },\n\n    initEvents() {\n        const area = document.getElementById('desktop-area');\n        // Delegated icon events: mousedown, dblclick, contextmenu, touch tap\n        _setupAreaDelegation(area, this);\n        // Mobile touch-drag for icons\n        this._initTouchDrag(area);\n\n        area.addEventListener('contextmenu', e => {\n            if (e.target === area || e.target.classList.contains('drop-overlay') ||\n                e.target.classList.contains('selection-bar')) {\n                e.preventDefault();\n                this._contextDesktop(e);\n            }\n        });\n\n        area.addEventListener('mousedown', e => {\n            if (e.target !== area) return;\n            if (!e.ctrlKey && !e.metaKey) {\n                this._sel.clear();\n                document.querySelectorAll('#desktop-area > .file-item.selected').forEach(i => i.classList.remove('selected'));\n                this._updateSelectionBar();\n            }\n            this._startRubberBand(e);\n        });\n\n        area.addEventListener('keydown', e => this._onKey(e));\n\n        let _deskDndHoverFolder = null;\n        area.addEventListener('dragover', e => {\n            e.preventDefault();\n            const overFW = !!e.target.closest('.folder-window');\n            if (!overFW) {\n                const folderEl = e.target?.closest?.('#desktop-area > .file-item[data-id]'),\n                    newHover = folderEl && VFS.node(folderEl.dataset.id)?.type === 'folder' ? folderEl.dataset.id : null;\n                if (newHover !== _deskDndHoverFolder) {\n                    if (_deskDndHoverFolder) document.querySelector(`#desktop-area > .file-item[data-id=\"${_deskDndHoverFolder}\"]`)?.classList.remove('drag-target');\n                    _deskDndHoverFolder = newHover;\n                    if (_deskDndHoverFolder) document.querySelector(`#desktop-area > .file-item[data-id=\"${_deskDndHoverFolder}\"]`)?.classList.add('drag-target');\n                }\n            }\n            document.getElementById('drop-overlay').classList.toggle('show', !overFW && !_deskDndHoverFolder);\n        });\n        area.addEventListener('dragleave', e => {\n            if (!area.contains(e.relatedTarget)) {\n                if (_deskDndHoverFolder) document.querySelector(`#desktop-area > .file-item[data-id=\"${_deskDndHoverFolder}\"]`)?.classList.remove('drag-target');\n                _deskDndHoverFolder = null;\n                document.getElementById('drop-overlay').classList.remove('show');\n            }\n        });\n        area.addEventListener('drop', e => {\n            e.preventDefault();\n            if (_deskDndHoverFolder) document.querySelector(`#desktop-area > .file-item[data-id=\"${_deskDndHoverFolder}\"]`)?.classList.remove('drag-target');\n            const targetFolderId = _deskDndHoverFolder || this._desktopFolder;\n            _deskDndHoverFolder = null;\n            document.getElementById('drop-overlay').classList.remove('show');\n            App._winCtx = null;\n            App.folder = targetFolderId;\n            App.selection = this._sel;\n            uploadEntries(e.dataTransfer.items, targetFolderId);\n        });\n\n        /* ---- Touch: rubber-band select on empty area + long-press context menu ---- */\n        _initAreaTouchRubberBand(area, this);\n\n        // Global: dismiss context menu on any LMB click outside the menu\n        document.addEventListener('mousedown', e => {\n            if (e.button !== 0) return;\n            if (e.target.closest('#ctx-menu, #ctx-menu-sub, body > .ctx-menu')) return;\n            hideCtxMenu();\n        });\n        // Track last touch to suppress spurious mouseenter tooltips fired by the browser after touchend\n        document.addEventListener('touchstart', () => { _lastTouchTs = Date.now(); }, { passive: true, capture: true });\n    },\n\n    _startRubberBand(e) {\n        _rubberBandSelect(e, document.getElementById('desktop-area'), this._sel, () => this._updateSelectionBar());\n    },\n\n    _onKey(e) {\n        const sync = () => { App.folder = this._desktopFolder; App.selection = this._sel; App._winCtx = null; };\n        _handleKey(e, this, sync, fn => { sync(); fn(); }, {\n            area: document.getElementById('desktop-area'),\n            refresh: () => { Desktop._renderIcons(); if (typeof WinManager !== 'undefined') WinManager.renderAll(); },\n        });\n    }\n};\n\n/* ============================================================\n   FOLDER WINDOW MANAGER\n   ============================================================ */\nconst WinManager = {\n    _wins: [],\n    _z: 300,\n\n    open(folderId) {\n        hideCtxMenu();\n        // Auto-cancel cut if the opened folder (or its ancestor) is in the clipboard\n        if (App.clipboard?.op === 'cut') {\n            const cutIds = new Set(App.clipboard.ids);\n            let cur = folderId;\n            while (cur && cur !== 'root') {\n                if (cutIds.has(cur)) { cancelClipboard(); break; }\n                cur = (VFS.node(cur) || {}).parentId;\n            }\n        }\n        // Bring existing window to front if already open\n        const existing = this._wins.find(w => w.folderId === folderId && !w._navStack.length);\n        if (existing) { existing.bringToFront(); return existing; }\n        const win = new FolderWindow(folderId);\n        this._wins.push(win);\n        return win;\n    },\n\n    close(win) {\n        this._wins = this._wins.filter(w => w !== win);\n        win.el.remove();\n    },\n\n    closeAll() {\n        this._wins.forEach(w => w.el.remove());\n        this._wins = [];\n    },\n\n    renderAll() {\n        this._wins.forEach(w => w.render());\n    },\n\n    nextZ() { return ++this._z; }\n};\n\n/* ============================================================\n   FOLDER WINDOW  (floating explorer)\n   ============================================================ */\nclass FolderWindow {\n    constructor(folderId) {\n        this.folderId = folderId;\n        this.selection = new Set();\n        this._navStack = [];  // for back navigation (not used in default: navigate in window)\n        this.el = null;\n        this._build();\n    }\n\n    /* ---- DOM BUILD ---- */\n    _build() {\n        const node = VFS.node(this.folderId),\n            el = document.createElement('div');\n        el.className = 'folder-window';\n        el.style.zIndex = WinManager.nextZ();\n\n        // Cascade position\n        const area = document.getElementById('desktop-area'),\n            count = WinManager._wins.length,\n            defW = 680, defH = 440,\n            cx = Math.max(20, Math.min((area.clientWidth - defW) / 2 + count * 28, area.clientWidth - defW - 10)),\n            cy = Math.max(20, Math.min((area.clientHeight - defH) / 2 + count * 28, area.clientHeight - defH - 10));\n        el.style.left = cx + 'px';\n        el.style.top = cy + 'px';\n        el.style.width = defW + 'px';\n        el.style.height = defH + 'px';\n\n        el.innerHTML = `\n      <div class=\"fw-titlebar\">\n        <div class=\"fw-drag-area\">\n          <span class=\"fw-folder-icon\">${getFolderSVG(node.color)}</span>\n          <span class=\"fw-title\">${escHtml(node.name)}</span>\n        </div>\n        <div class=\"fw-controls\">\n          <button class=\"fw-btn fw-btn-navup\" title=\"Go up\">\n            <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\">\n              <path d=\"M7 11V3M3 7l4-4 4 4\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/>\n            </svg>\n          </button>\n          <button class=\"fw-btn fw-btn-close close\" title=\"Close\">\n            <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\">\n              <path d=\"M2 2l8 8M10 2L2 10\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/>\n            </svg>\n          </button>\n        </div>\n      </div>\n      <div class=\"fw-toolbar\">\n        <button class=\"btn btn-ghost btn-sm fw-btn-upload\">\n          <svg width=\"13\" height=\"13\" viewBox=\"0 0 13 13\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path d=\"M6.5 8V1M3 4.5l3.5-3.5 3.5 3.5M1 10h11v2H1z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/>\n          </svg>\n          Import\n        </button>\n        <button class=\"btn btn-ghost btn-sm fw-btn-newfile\">\n          <svg width=\"13\" height=\"13\" viewBox=\"0 0 13 13\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path d=\"M2 1h6l3 3v8H2z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/>\n            <path d=\"M8 1v3h3\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/>\n            <path d=\"M6.5 6v3M5 7.5h3\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/>\n          </svg>\n          New File\n        </button>\n        <button class=\"btn btn-ghost btn-sm fw-btn-newfolder\">\n          <svg width=\"13\" height=\"13\" viewBox=\"0 0 13 13\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path d=\"M1 3h4l1.5 2H12v7H1z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/>\n            <path d=\"M6.5 6.5v2.5M5.2 7.8h2.6\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\"/>\n          </svg>\n          New Folder\n        </button>\n        <div class=\"fw-breadcrumb\" id=\"fw-bc-${this.folderId}\"></div>\n      </div>\n      <div class=\"fw-area\" tabindex=\"0\">\n        <div class=\"fw-canvas\"></div>\n        <div class=\"fw-drop-overlay\">\n          <svg width=\"36\" height=\"36\" viewBox=\"0 0 48 48\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M24 8v24M12 20l12-12 12 12M8 36h32v4H8z\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"square\"/></svg>\n          Drop files to import\n        </div>\n      </div>\n      <div class=\"fw-statusbar\">\n        <span class=\"fw-status-text\">0 items</span>\n      </div>\n      <div class=\"fw-resize-handle\"></div>\n    `;\n\n        this.el = el;\n        area.appendChild(el);\n        this._bindEvents();\n        this.render();\n    }\n\n    /* ---- EVENTS ---- */\n    _bindEvents() {\n        const el = this.el;\n        el.addEventListener('mousedown', () => this.bringToFront(), true);\n\n        // Title bar drag (move window)\n        this._makeDraggable(el.querySelector('.fw-drag-area'));\n\n        // Buttons\n        el.querySelector('.fw-btn-close').addEventListener('click', e => {\n            e.stopPropagation(); WinManager.close(this);\n        });\n        el.querySelector('.fw-btn-navup').addEventListener('click', e => {\n            e.stopPropagation();\n            const n = VFS.node(this.folderId);\n            if (n && n.parentId && n.parentId !== 'root') { this.folderId = n.parentId; this.selection.clear(); this.render(); }\n        });\n        el.querySelector('.fw-btn-upload').addEventListener('click', e => {\n            e.stopPropagation();\n            this._setCtx();\n            document.getElementById('file-input').click();\n        });\n        el.querySelector('.fw-btn-newfile').addEventListener('click', e => {\n            e.stopPropagation(); App._ctxScreenPos = null; this._setCtx(); newTextFile();\n        });\n        el.querySelector('.fw-btn-newfolder').addEventListener('click', e => {\n            e.stopPropagation(); App._ctxScreenPos = null; this._setCtx(); newFolder();\n        });\n\n        // Content area events\n        const area = el.querySelector('.fw-area');\n        // Delegated icon events: mousedown, dblclick, contextmenu, touch tap\n        _setupAreaDelegation(area, this);\n        area.addEventListener('contextmenu', e => {\n            if (e.target === area) { e.preventDefault(); e.stopPropagation(); this._contextDesktop(e); }\n        });\n        area.addEventListener('mousedown', e => {\n            if (e.target !== area) return;\n            hideCtxMenu();\n            area.focus();\n            if (!e.ctrlKey && !e.metaKey) {\n                this.selection.clear();\n                area.querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected'));\n                this._updateStatus();\n            }\n            this._startRubberBand(e);\n        });\n        area.addEventListener('keydown', e => this._onKey(e));\n        const fwDropOv = area.querySelector('.fw-drop-overlay');\n        let _fwDndHoverFolder = null;\n        area.addEventListener('dragover', e => {\n            e.preventDefault();\n            const folderEl = e.target?.closest?.('.file-item[data-id]'),\n                newHover = folderEl && VFS.node(folderEl.dataset.id)?.type === 'folder' ? folderEl.dataset.id : null;\n            if (newHover !== _fwDndHoverFolder) {\n                if (_fwDndHoverFolder) area.querySelector(`.file-item[data-id=\"${_fwDndHoverFolder}\"]`)?.classList.remove('drag-target');\n                _fwDndHoverFolder = newHover;\n                if (_fwDndHoverFolder) area.querySelector(`.file-item[data-id=\"${_fwDndHoverFolder}\"]`)?.classList.add('drag-target');\n            }\n            if (fwDropOv) fwDropOv.classList.toggle('show', !_fwDndHoverFolder);\n        });\n        area.addEventListener('dragleave', e => {\n            if (!area.contains(e.relatedTarget)) {\n                if (_fwDndHoverFolder) area.querySelector(`.file-item[data-id=\"${_fwDndHoverFolder}\"]`)?.classList.remove('drag-target');\n                _fwDndHoverFolder = null;\n                if (fwDropOv) fwDropOv.classList.remove('show');\n            }\n        });\n        area.addEventListener('drop', e => {\n            e.preventDefault();\n            e.stopPropagation(); // prevent desktop from also receiving this drop\n            if (_fwDndHoverFolder) area.querySelector(`.file-item[data-id=\"${_fwDndHoverFolder}\"]`)?.classList.remove('drag-target');\n            const targetFolderId = _fwDndHoverFolder || this.folderId;\n            _fwDndHoverFolder = null;\n            if (fwDropOv) fwDropOv.classList.remove('show');\n            App._winCtx = this;\n            App.folder = targetFolderId;\n            App.selection = this.selection;\n            uploadEntries(e.dataTransfer.items, targetFolderId);\n        });\n\n        /* ---- Touch: rubber-band select on empty area + long-press context menu ---- */\n        _initAreaTouchRubberBand(area, this);\n\n        this._initFwTouchDrag(area);\n        this._addResizeHandle();\n    }\n\n    /* ---- SET CONTEXT for modal-based and async ops ---- */\n    // Clear selection: empties both the Set AND removes .selected CSS classes from DOM\n    _clearSelection() {\n        this.selection.clear();\n        this.el.querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected'));\n        this._updateStatus();\n    }\n\n    _setCtx() {\n        App._winCtx = this;\n        App.folder = this.folderId;\n        App.selection = this.selection;\n    }\n\n    /* ---- SET CONTEXT for sync ops (save+restore immediately) ---- */\n    _withCtxSync(fn) {\n        const pF = App.folder, pS = App.selection, pW = App._winCtx;\n        App.folder = this.folderId; App.selection = this.selection; App._winCtx = this;\n        try { fn(); } finally { App.folder = pF; App.selection = pS; App._winCtx = pW; }\n    }\n\n    /* ---- WINDOW DRAG ---- */\n    _makeDraggable(handle) {\n        handle.addEventListener('mousedown', e => {\n            if (e.button !== 0) return;\n            e.preventDefault();\n            const startMouseX = e.clientX, startMouseY = e.clientY,\n                startLeft = parseInt(this.el.style.left) || 0,\n                startTop = parseInt(this.el.style.top) || 0;\n            const onMove = mv => {\n                const area = document.getElementById('desktop-area'),\n                    maxL = area.clientWidth - this.el.offsetWidth,\n                    maxT = area.clientHeight - this.el.offsetHeight;\n                this.el.style.left = Math.max(0, Math.min(maxL, startLeft + mv.clientX - startMouseX)) + 'px';\n                this.el.style.top = Math.max(0, Math.min(maxT, startTop + mv.clientY - startMouseY)) + 'px';\n            };\n            const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };\n            document.addEventListener('mousemove', onMove);\n            document.addEventListener('mouseup', onUp);\n        });\n    }\n\n    bringToFront() { this.el.style.zIndex = WinManager.nextZ(); }\n\n    /* ---- RENDER ---- */\n    render() {\n        const node = VFS.node(this.folderId);\n        if (!node) { WinManager.close(this); return; }\n\n        // Update title — full path\n        this.el.querySelector('.fw-title').textContent = VFS.fullPath(this.folderId);\n        // Hide navup button when at top-level folder (parent is root)\n        const _navB = this.el.querySelector('.fw-btn-navup');\n        if (_navB) _navB.style.display = (node.parentId && node.parentId !== 'root') ? '' : 'none';\n        // Update title bar folder icon (reflects current color)\n        const _folderIconEl = this.el.querySelector('.fw-folder-icon');\n        if (_folderIconEl) _folderIconEl.innerHTML = getFolderSVG(node.color);\n\n        // Update breadcrumb inside toolbar\n        const bcId = `fw-bc-${this.el.querySelector('.fw-breadcrumb').id.replace('fw-bc-', '')}`,\n            bc = this.el.querySelector('.fw-breadcrumb');\n        bc.innerHTML = '';\n        VFS.breadcrumb(this.folderId).forEach((n, i, arr) => {\n            if (i === 0) return; // skip root\n            const sp = document.createElement('span');\n            sp.className = 'fw-bc-item' + (i === arr.length - 1 ? ' current' : '');\n            sp.textContent = n.name;\n            if (i < arr.length - 1) sp.addEventListener('click', () => { this.folderId = n.id; this.selection.clear(); this.render(); });\n            bc.appendChild(sp);\n            if (i < arr.length - 1) { const s = document.createElement('span'); s.className = 'fw-bc-sep'; s.textContent = ' › '; bc.appendChild(s); }\n        });\n\n        // Render icons — incremental when same folder to avoid flash, full rebuild on navigation\n        const area = this.el.querySelector('.fw-area'),\n            folderChanged = this._renderedFolderId !== this.folderId;\n        this._renderedFolderId = this.folderId;\n        _renderIconArea(area, this.folderId, this.selection, () => this._updateStatus(), folderChanged);\n    }\n\n    /* ---- ICON DRAG (within window + escape to desktop/other windows) ---- */\n    _onIconMousedown(e, el, node) {\n        if (e.button !== 0) return;\n        hideCtxMenu();\n        _startIconDrag(e, node, el, {\n            area: this.el.querySelector('.fw-area'),\n            folderId: this.folderId,\n            selection: this.selection,\n            winEl: this.el,\n            updateUI: () => this._updateStatus(),\n            clearAll: () => {\n                this.selection.clear();\n                this.el.querySelectorAll('.fw-area > .file-item.selected').forEach(i => i.classList.remove('selected'));\n            },\n        });\n    }\n\n    /* ---- OPEN NODE: default = navigate within window ---- */\n    _openNode(node) {\n        hideCtxMenu();\n        if (node.type === 'folder') {\n            // Auto-cancel cut if navigating into a cut folder\n            if (App.clipboard?.op === 'cut') {\n                const cutIds = new Set(App.clipboard.ids);\n                let cur = node.id;\n                while (cur && cur !== 'root') {\n                    if (cutIds.has(cur)) { cancelClipboard(); break; }\n                    cur = (VFS.node(cur) || {}).parentId;\n                }\n            }\n            this.folderId = node.id; this.selection.clear(); this.render();\n        } else {\n            openFile(node);\n        }\n    }\n\n    /* ---- TOUCH DRAG for mobile (inside folder window) ---- */\n    _initFwTouchDrag(area) {\n        _initTouchDragCommon(area, this, { showSnap: true });\n    }\n\n    /* ---- RUBBER BAND selection ---- */\n    _startRubberBand(e) {\n        _rubberBandSelect(e, this.el.querySelector('.fw-area'), this.selection, () => this._updateStatus());\n    }\n\n    /* ---- CONTEXT MENUS ---- */\n    _contextDesktop(e) {\n        this.selection.clear();\n        this.el.querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected'));\n        this._updateStatus();\n        const syncFn = () => this._setCtx();\n        showCtxMenu(e.clientX, e.clientY, _buildAreaMenuItems(e, syncFn, this,\n            () => { this._renderedFolderId = null; this.render(); }));\n    }\n\n    _contextIcon(e, node) {\n        if (!e.ctrlKey && !e.metaKey && !this.selection.has(node.id)) {\n            this.selection.clear();\n            this.el.querySelectorAll('.file-item.selected').forEach(i => i.classList.remove('selected'));\n        }\n        this.selection.add(node.id);\n        this.el.querySelector(`.file-item[data-id=\"${node.id}\"]`)?.classList.add('selected');\n        this._updateStatus();\n        showCtxMenu(e.clientX, e.clientY, _buildIconMenuItems(node, this.selection, {\n            openFn: n => n.type === 'folder' ? this._openNode(n) : openFile(n),\n            colorCb: () => this.render(),\n            hasCopy: false,\n            copyFn: null,\n            cutFn: () => this._withCtxSync(() => cutItems()),\n            exportZipFn: () => this._withCtxSync(() => exportAsZip([...this.selection])),\n            deleteFn: () => { this._setCtx(); deleteSelected(); },\n        }));\n    }\n\n    /* ---- RESIZE HANDLE ---- */\n    _addResizeHandle() {\n        const handle = this.el.querySelector('.fw-resize-handle');\n        if (!handle) return;\n        handle.addEventListener('mousedown', e => {\n            if (e.button !== 0) return;\n            e.preventDefault(); e.stopPropagation();\n            const startX = e.clientX, startY = e.clientY,\n                startW = this.el.offsetWidth, startH = this.el.offsetHeight;\n            const onMove = mv => {\n                this.el.style.width = Math.max(420, Math.min(1400, startW + mv.clientX - startX)) + 'px';\n                this.el.style.height = Math.max(260, Math.min(900, startH + mv.clientY - startY)) + 'px';\n            };\n            const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };\n            document.addEventListener('mousemove', onMove);\n            document.addEventListener('mouseup', onUp);\n        });\n    }\n\n    /* ---- STATUS BAR & KEYBOARD ---- */\n    _updateStatus() {\n        const count = VFS.children(this.folderId).length,\n            sel = this.selection.size;\n        this.el.querySelector('.fw-status-text').textContent =\n            sel > 0 ? `${sel} of ${count} selected` : `${count} item${count !== 1 ? 's' : ''}`;\n    }\n\n    _onKey(e) {\n        _handleKey(e, this, () => this._setCtx(), fn => this._withCtxSync(fn), {\n            area: this.el,\n            refresh: () => { this.render(); this.el.querySelector('.fw-area')?.focus(); },\n            extraKeys: ev => {\n                if (ev.key === 'Backspace' && !ev.ctrlKey && !ev.metaKey) {\n                    const n = VFS.node(this.folderId);\n                    if (n && n.parentId && n.parentId !== 'root') { this.folderId = n.parentId; this.selection.clear(); this.render(); }\n                    return true;\n                }\n            },\n        });\n    }\n}\n"
  },
  {
    "path": "src/js/detectors/incognito.js",
    "content": "'use strict';\n\n/* ============================================================\n   INCOGNITO DETECTOR  v1.6.2 (adapted)\n\n   Algorithm ported from:\n   https://github.com/Joe12387/detectIncognito\n   MIT License — Copyright (c) 2021-2025 Joe Rutkowski <Joe@dreggle.com>\n\n   Re-implemented as plain ES6 (no TypeScript, no bundler).\n   Covers: Chrome 76+, Edge, Brave, Opera, Safari 13+, Firefox, IE.\n   ============================================================ */\n\n/**\n * Detect whether the current tab is running in a private / incognito context.\n * @returns {Promise<{isPrivate: boolean, browserName: string}>}\n */\nfunction detectIncognito() {\n    return new Promise(function (resolve, reject) {\n        let browserName = 'Unknown';\n        let callbackSettled = false;\n\n        function __callback(isPrivate) {\n            if (callbackSettled) return;\n            callbackSettled = true;\n            resolve({ isPrivate, browserName });\n        }\n\n        // ── Engine fingerprint ────────────────────────────────────\n        // Each JS engine produces a unique error.message.length for (-1).toFixed(-1):\n        //   V8 (Chrome/Edge/…) → 51\n        //   JavaScriptCore (Safari) → 44 or 43\n        //   SpiderMonkey (Firefox) → 25\n        function feid() {\n            let id = 0;\n            try { (-1).toFixed(-1); } catch (e) { id = e.message.length; }\n            return id;\n        }\n\n        function isSafari() { const f = feid(); return f === 44 || f === 43; }\n        function isChrome() { return feid() === 51; }\n        function isFirefox() { return feid() === 25; }\n        function isMSIE() { return navigator.msSaveBlob !== undefined; }\n\n        function identifyChromium() {\n            const ua = navigator.userAgent;\n            if (ua.match(/Chrome/)) {\n                if (navigator.brave !== undefined) return 'Brave';\n                if (ua.match(/Edg/)) return 'Edge';\n                if (ua.match(/OPR/)) return 'Opera';\n                return 'Chrome';\n            }\n            return 'Chromium';\n        }\n\n        // ── Safari ───────────────────────────────────────────────\n\n        async function currentSafariTest() {\n            // Modern Safari private mode: getDirectory() throws \"unknown transient reason\"\n            try {\n                await navigator.storage.getDirectory();\n                __callback(false);\n            } catch (e) {\n                const msg = (e instanceof Error) ? e.message : String(e);\n                __callback(msg.includes('unknown transient reason'));\n            }\n        }\n\n        function safari13to18Test() {\n            // Safari 13-18: storing a Blob in IDB throws \"are not yet supported\" in private mode\n            const tmp = String(Math.random());\n            try {\n                const dbReq = indexedDB.open(tmp, 1);\n                dbReq.onupgradeneeded = (ev) => {\n                    const db = ev.target.result;\n                    const finish = (priv) => { __callback(priv); };\n                    try {\n                        db.createObjectStore('t', { autoIncrement: true }).put(new Blob());\n                        finish(false);\n                    } catch (err) {\n                        const msg = (err instanceof Error) ? err.message : String(err);\n                        finish(msg.includes('are not yet supported'));\n                    } finally {\n                        db.close();\n                        indexedDB.deleteDatabase(tmp);\n                    }\n                };\n                dbReq.onerror = () => __callback(false);\n            } catch {\n                __callback(false);\n            }\n        }\n\n        function oldSafariTest() {\n            const openDB = window.openDatabase;\n            const storage = window.localStorage;\n            try { openDB(null, null, null, null); } catch { __callback(true); return; }\n            try { storage.setItem('test', '1'); storage.removeItem('test'); } catch { __callback(true); return; }\n            __callback(false);\n        }\n\n        async function safariPrivateTest() {\n            if (typeof navigator.storage?.getDirectory === 'function') {\n                await currentSafariTest();\n            } else if (navigator.maxTouchPoints !== undefined) {\n                safari13to18Test();\n            } else {\n                oldSafariTest();\n            }\n        }\n\n        // ── Chrome / Chromium ─────────────────────────────────────\n\n        function getQuotaLimit() {\n            return window?.performance?.memory?.jsHeapSizeLimit ?? 1073741824;\n        }\n\n        // Chrome 76+: private mode caps webkitTemporaryStorage quota to ~2× jsHeapSizeLimit\n        function storageQuotaChromePrivateTest() {\n            navigator.webkitTemporaryStorage.queryUsageAndQuota(\n                function (_used, quota) {\n                    const quotaInMib = Math.round(quota / (1024 * 1024));\n                    const quotaLimitInMib = Math.round(getQuotaLimit() / (1024 * 1024)) * 2;\n                    __callback(quotaInMib < quotaLimitInMib);\n                },\n                function (e) {\n                    reject(new Error('detectIncognito failed to query storage quota: ' + e.message));\n                }\n            );\n        }\n\n        // Chrome 50-75: webkitRequestFileSystem fails in private mode\n        function oldChromePrivateTest() {\n            window.webkitRequestFileSystem(0, 1, () => __callback(false), () => __callback(true));\n        }\n\n        function chromePrivateTest() {\n            if (self.Promise !== undefined && self.Promise.allSettled !== undefined) {\n                storageQuotaChromePrivateTest();\n            } else {\n                oldChromePrivateTest();\n            }\n        }\n\n        // ── Firefox ──────────────────────────────────────────────\n\n        async function firefoxPrivateTest() {\n            if (typeof navigator.storage?.getDirectory === 'function') {\n                // Modern Firefox private mode: getDirectory() throws \"Security error\"\n                try {\n                    await navigator.storage.getDirectory();\n                    __callback(false);\n                } catch (e) {\n                    const msg = (e instanceof Error) ? e.message : String(e);\n                    __callback(msg.includes('Security error'));\n                }\n            } else {\n                // Older Firefox: IDB open fails immediately in private mode\n                const req = indexedDB.open('inPrivate');\n                req.onerror = (event) => {\n                    if (req.error && req.error.name === 'InvalidStateError') event.preventDefault();\n                    __callback(true);\n                };\n                req.onsuccess = () => {\n                    indexedDB.deleteDatabase('inPrivate');\n                    __callback(false);\n                };\n            }\n        }\n\n        // ── IE ───────────────────────────────────────────────────\n\n        function msiePrivateTest() {\n            __callback(window.indexedDB === undefined);\n        }\n\n        // ── Main ─────────────────────────────────────────────────\n\n        async function main() {\n            if (isSafari()) {\n                browserName = 'Safari';\n                await safariPrivateTest();\n            } else if (isChrome()) {\n                browserName = identifyChromium();\n                chromePrivateTest();\n            } else if (isFirefox()) {\n                browserName = 'Firefox';\n                await firefoxPrivateTest();\n            } else if (isMSIE()) {\n                browserName = 'Internet Explorer';\n                msiePrivateTest();\n            } else {\n                reject(new Error('detectIncognito cannot determine the browser'));\n            }\n        }\n\n        main().catch(reject);\n    });\n}\n\n/* ============================================================\n   INCOGNITO WARNING UI\n   ============================================================ */\n\n/**\n * Show the full-screen incognito warning and wait for the user to dismiss it.\n * Uses a 3-second countdown before \"Continue\" is enabled — same pattern\n * as confirmDeleteContainer() in home.js.\n * @returns {Promise<void>} resolves when the user clicks Continue.\n */\nfunction showIncognitoWarning() {\n    return new Promise((resolve) => {\n        const el = document.getElementById('incognito-warning');\n        const btn = document.getElementById('incognito-continue-btn');\n        const lbl = document.getElementById('incognito-continue-lbl');\n\n        el.style.display = 'flex';\n\n        let remaining = 3;\n        lbl.textContent = `Wait\\u2026 ${remaining}`;\n        btn.disabled = true;\n\n        const timer = setInterval(() => {\n            remaining--;\n            if (remaining > 0) {\n                lbl.textContent = `Wait\\u2026 ${remaining}`;\n            } else {\n                clearInterval(timer);\n                btn.disabled = false;\n                lbl.textContent = 'I understand, Continue';\n            }\n        }, 1000);\n\n        btn.onclick = () => {\n            clearInterval(timer);\n            btn.onclick = null;\n            el.style.display = 'none';\n            resolve();\n        };\n    });\n}\n"
  },
  {
    "path": "src/js/docmode.js",
    "content": "if (localStorage.getItem('snv-doc-hide') === '1') document.documentElement.classList.add('snv-doc-hidden');\n"
  },
  {
    "path": "src/js/fileops.js",
    "content": "﻿'use strict';\n\n/* ============================================================\n   FILENAME SANITIZATION\n   ============================================================ */\nfunction sanitizeFilename(name) {\n    // Strip null bytes, path separators, HTML/XML special chars, and prevent . / .. as names\n    const s = (name || 'unnamed')\n        .replace(/[\\x00-\\x1f\\\\/]/g, '_')\n        .replace(/[<>&\"']/g, '_')\n        .trim();\n    return /^\\.{1,2}$/.test(s) || s === '' ? 'unnamed' : s;\n}\n\n/* ============================================================\n   UPLOAD FILES  (from OS drag-drop or file picker — flat list)\n   ============================================================ */\nasync function uploadFiles(files) {\n    if (!App.key || !App.container) return;\n    if (!files || !files.length) return;\n\n    // Container size check\n    const remaining = CONTAINER_LIMIT - VFS.totalSize();\n    let totalNew = 0;\n    for (const f of files) totalNew += f.size;\n    if (totalNew > remaining) {\n        toast(`Not enough space in container. Need ${fmtSize(totalNew)}, have ${fmtSize(remaining)}`, 'error');\n        return;\n    }\n\n    // Device storage check\n    const spCheck = await checkStorageSpace(totalNew * 1.1); // +10% for encryption overhead\n    if (!spCheck.ok) {\n        toast(\n            `Not enough device storage. Need ~${fmtSize(totalNew)}, only ${fmtSize(spCheck.available)} free.`,\n            'error'\n        );\n        return;\n    }\n\n    showLoading(`Encrypting ${files.length} file${files.length > 1 ? 's' : ''}...`);\n    let ok = 0, _okIds = [];\n    const fileArr = Array.from(files);\n    const BATCH = _CRYPTO_CONCURRENCY;\n    for (let i = 0; i < fileArr.length; i += BATCH) {\n        const batch = fileArr.slice(i, i + BATCH);\n        // Read all file buffers in this batch concurrently before encrypting\n        const bufs = await Promise.all(batch.map(f => f.arrayBuffer()));\n        const results = await Promise.allSettled(batch.map(async (f, bi) => {\n            const name = sanitizeFilename(f.name),\n                mime = f.type || getMime(name),\n                { iv, blob } = await Crypto.encryptBin(App.key, bufs[bi]),\n                nodeId = uid();\n            VFS.add({\n                id: nodeId, type: 'file', name, mime, size: f.size,\n                parentId: App.folder, ctime: Date.now(), mtime: Date.now()\n            });\n            return { nodeId, rec: { id: nodeId, cid: App.container.id, iv: Array.from(iv), blob } };\n        }));\n        // Batch-save all encrypted records in a single IDB transaction\n        const recs = [];\n        for (let j = 0; j < results.length; j++) {\n            if (results[j].status === 'fulfilled') {\n                ok++;\n                _okIds.push(results[j].value.nodeId);\n                recs.push(results[j].value.rec);\n            } else {\n                console.error('upload error', batch[j].name, results[j].reason);\n                toast('Failed to encrypt: ' + batch[j].name, 'error');\n            }\n        }\n        if (recs.length) await DB.saveFiles(recs);\n        showLoading(`Encrypting... ${Math.min(i + BATCH, fileArr.length)}/${fileArr.length}`);\n    }\n    await saveVFS();\n    Desktop._patchIcons();\n    hideLoading();\n    if (ok > 0) {\n        toast(`${ok} file${ok > 1 ? 's' : ''} imported`, 'success');\n        logActivity('upload', ok === 1 ? files[0].name : `${ok} files`, ok, ok === 1 && _okIds[0] ? VFS.fullPath(_okIds[0]) : null);\n        _scheduleExportCacheRefresh();\n    }\n}\n\n/* ============================================================\n   UPLOAD ENTRIES  (from OS drag-drop — supports folders)\n   Handles DataTransferItemList containing files AND directories.\n   ============================================================ */\n\n// Read all entries from a FileSystemDirectoryReader.\n// The API only returns up to 100 entries per call — must batch until empty.\nfunction _readAllEntries(reader) {\n    return new Promise(resolve => {\n        const results = [];\n        function batch() {\n            reader.readEntries(entries => {\n                if (!entries.length) { resolve(results); return; }\n                results.push(...entries);\n                batch();\n            }, () => resolve(results)); // on error, return what we have\n        }\n        batch();\n    });\n}\n\n// Encrypt a single FileSystemFileEntry and add it to the VFS under targetFolderId.\nasync function _uploadFileEntry(fileEntry, targetFolderId) {\n    const file = await new Promise((res, rej) => fileEntry.file(res, rej));\n    const name = sanitizeFilename(file.name);\n    if (VFS.hasChildNamed(targetFolderId, name)) {\n        toast(`\"${name}\" already exists — skipped`, 'warn');\n        return false;\n    }\n    if (VFS.totalSize() + file.size > CONTAINER_LIMIT) {\n        _uploadLimitHit = true;\n        return false;\n    }\n    const spCheck = await checkStorageSpace(file.size * 1.1);\n    if (!spCheck.ok) {\n        _uploadDeviceFullHit = true;\n        return false;\n    }\n    const buf = await file.arrayBuffer(),\n        mime = file.type || getMime(name),\n        { iv, blob } = await Crypto.encryptBin(App.key, buf),\n        nodeId = uid(), now = Date.now();\n    VFS.add({\n        id: nodeId, type: 'file', name, mime, size: file.size,\n        parentId: targetFolderId, ctime: now, mtime: now\n    });\n    await DB.saveFile({ id: nodeId, cid: App.container.id, iv: Array.from(iv), blob });\n    return true;\n}\n\n// Recursively upload a FileSystemDirectoryEntry into the VFS under targetFolderId.\nasync function _uploadDirEntry(dirEntry, targetFolderId, depth) {\n    if (depth > 32) { toast('Folder nesting too deep — stopped at 32 levels', 'warn'); return false; }\n    const name = sanitizeFilename(dirEntry.name);\n    if (VFS.hasChildNamed(targetFolderId, name)) {\n        toast(`Folder \"${name}\" already exists — skipped`, 'warn');\n        return false;\n    }\n    const folderId = uid(), now = Date.now();\n    VFS.add({ id: folderId, type: 'folder', name, parentId: targetFolderId, ctime: now, mtime: now });\n    const entries = await _readAllEntries(dirEntry.createReader());\n    const fileEntries = entries.filter(e => e.isFile);\n    const subDirEntries = entries.filter(e => e.isDirectory);\n    // Encrypt files in this directory in parallel batches\n    const BATCH = _CRYPTO_CONCURRENCY;\n    for (let i = 0; i < fileEntries.length; i += BATCH) {\n        await Promise.allSettled(\n            fileEntries.slice(i, i + BATCH).map(e => _uploadFileEntry(e, folderId))\n        );\n    }\n    // Recurse into subdirectories sequentially\n    for (const subDir of subDirEntries) {\n        await _uploadDirEntry(subDir, folderId, depth + 1);\n    }\n    return true;\n}\n\nlet _uploadLimitHit = false;\nlet _uploadDeviceFullHit = false;\n\n// Main drop entry point for desktop and folder-window drop events.\n// Accepts DataTransferItemList (supports both files and folders).\nasync function uploadEntries(dataTransferItems, targetFolderId) {\n    if (!App.key || !App.container) return;\n    const itemArr = Array.from(dataTransferItems || []);\n    if (!itemArr.length) return;\n    _uploadLimitHit = false;\n    _uploadDeviceFullHit = false;\n\n    const entries = itemArr.map(i => i.webkitGetAsEntry?.()).filter(Boolean);\n    if (!entries.length) {\n        // Fallback: no Entry API support — treat all as flat files\n        const files = itemArr.map(i => i.getAsFile?.()).filter(Boolean);\n        if (files.length) await uploadFiles(files);\n        return;\n    }\n\n    const fileEntries = entries.filter(e => e.isFile),\n        folderEntries = entries.filter(e => e.isDirectory);\n    const label = [\n        fileEntries.length && `${fileEntries.length} file${fileEntries.length !== 1 ? 's' : ''}`,\n        folderEntries.length && `${folderEntries.length} folder${folderEntries.length !== 1 ? 's' : ''}`,\n    ].filter(Boolean).join(' and ');\n\n    showLoading(`Encrypting ${label}…`);\n    let ok = 0;\n    for (const entry of entries) {\n        try {\n            const added = entry.isDirectory\n                ? await _uploadDirEntry(entry, targetFolderId, 0)\n                : await _uploadFileEntry(entry, targetFolderId);\n            if (added) ok++;\n        } catch (err) {\n            console.error('upload entry error', entry.name, err);\n            toast(`Failed to import: ${entry.name}`, 'error');\n        }\n    }\n    await saveVFS();\n    Desktop._patchIcons();\n    if (typeof WinManager !== 'undefined') WinManager.renderAll();\n    hideLoading();\n    if (ok > 0) {\n        toast(`${ok} item${ok !== 1 ? 's' : ''} imported`, 'success');\n        {\n            const _sn = ok === 1 ? VFS.children(targetFolderId).find(n => n.name === sanitizeFilename(entries[0]?.name || '')) : null;\n            logActivity('upload', ok === 1 ? (entries[0]?.name ?? '1 item') : `${ok} items`, ok, _sn ? VFS.fullPath(_sn.id) : null);\n        }\n        _scheduleExportCacheRefresh();\n    }\n    if (_uploadLimitHit) toast(`Container is full (${fmtSize(CONTAINER_LIMIT)}) — some files were not imported`, 'error');\n    if (_uploadDeviceFullHit) toast('Not enough device storage — some files were not imported', 'error');\n}\n\n/* ============================================================\n   OPEN / DOWNLOAD FILE\n   ============================================================ */\nasync function openFile(node) {\n    if (!App.key || !App.container) return;\n    showLoading('Decrypting file...');\n    try {\n        const rec = await DB.getFile(node.id);\n        if (!rec) { toast('File data not found', 'error'); hideLoading(); return; }\n        const mime = node.mime || getMime(node.name);\n        let buf;\n        // Empty file: blob may be missing or decrypt may fail for 0-byte content\n        if (!rec.blob || (rec.blob instanceof ArrayBuffer && rec.blob.byteLength === 0)) {\n            buf = new ArrayBuffer(0);\n        } else {\n            buf = await Crypto.decryptBin(App.key, rec.iv, rec.blob);\n        }\n        hideLoading();\n\n        if (isText(mime, node.name)) {\n            openEditor(node, buf);\n        } else if (isImage(mime) || isAudio(mime) || isVideo(mime) || isPDF(mime)) {\n            openViewer(node, buf, mime);\n        } else {\n            _confirmExport(node, buf, mime);\n        }\n    } catch (e) { hideLoading(); toast('Decryption failed: ' + e.message, 'error'); console.error(e); }\n}\n\nasync function downloadFile(node) {\n    if (!App.key || !App.container) return;\n    showLoading('Decrypting...');\n    try {\n        const rec = await DB.getFile(node.id);\n        if (!rec) { toast('File data not found', 'error'); hideLoading(); return; }\n        let buf;\n        if (!rec.blob || (rec.blob instanceof ArrayBuffer && rec.blob.byteLength === 0)) {\n            buf = new ArrayBuffer(0);\n        } else {\n            buf = await Crypto.decryptBin(App.key, rec.iv, rec.blob);\n        }\n        downloadBuf(buf, node.name, node.mime || getMime(node.name));\n        toast('Exported: ' + node.name, 'success');\n        logActivity('download', node.name, 1, VFS.fullPath(node.id));\n    } catch (e) { toast('Decryption failed: ' + e.message, 'error'); }\n    hideLoading();\n}\n\nfunction downloadBuf(buf, name, mime) {\n    const blob = new Blob(Array.isArray(buf) ? buf : [buf], { type: mime });\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url; a.download = name;\n    // Append to DOM before click — some Chrome builds require a connected\n    // element for the download attribute to trigger blob saves correctly.\n    document.body.appendChild(a);\n    a.click();\n    a.remove();\n    setTimeout(() => URL.revokeObjectURL(url), 5000);\n}\n\nfunction _confirmExport(node, buf, mime) {\n    const fnEl = document.getElementById('ec-filename');\n    if (fnEl) fnEl.textContent = node.name;\n    Overlay.show('modal-export-confirm');\n    document.getElementById('ec-ok').onclick = () => {\n        Overlay.hide();\n        downloadBuf(buf, node.name, mime);\n        toast('Exported: ' + node.name, 'success');\n        logActivity('download', node.name, 1, VFS.fullPath(node.id));\n    };\n}\n\n/* ============================================================\n   DELETE SELECTED\n   ============================================================ */\nasync function deleteSelected() {\n    if (!App.selection.size) return;\n    const selRef = App.selection;           // capture the active selection Set at call time\n    const ids = [...selRef];\n\n    // Prevent deleting folders currently open in Explorer windows\n    const blocked = _openFolderGuard(ids);\n    if (blocked) {\n        toast(`\"${VFS.node(blocked)?.name}\" is open in Explorer — close the window first`, 'error');\n        return;\n    }\n\n    const names = ids.map(id => VFS.node(id)?.name || '').filter(Boolean);\n    const msg = ids.length === 1\n        ? `Delete \"${names[0]}\"? This action cannot be undone.`\n        : `Delete ${ids.length} items? This action cannot be undone.`;\n\n    document.getElementById('delete-msg').textContent = msg;\n    Overlay.show('modal-delete');\n    document.getElementById('delete-ok').onclick = async () => {\n        Overlay.hide();\n        showLoading('Deleting...');\n        const allFileIds = [];\n        for (const id of ids) {\n            const n = VFS.node(id); if (!n) continue;\n            if (n.type === 'file') {\n                allFileIds.push(id);\n            } else {\n                const _walkSeen = new Set();\n                const walk = fid => {\n                    if (_walkSeen.has(fid)) return;\n                    _walkSeen.add(fid);\n                    VFS.children(fid).forEach(c => { if (c.type === 'file') allFileIds.push(c.id); else walk(c.id); });\n                };\n                walk(id);\n            }\n        }\n        if (allFileIds.length) await DB.deleteFiles(allFileIds).catch(() => { });\n        allFileIds.forEach(fid => { delete App.thumbCache[fid]; });\n        const _delSinglePath = ids.length === 1 ? VFS.fullPath(ids[0]) : null;\n        for (const id of ids) VFS.remove(id);\n        selRef.clear();\n        await saveVFS();\n        Desktop._patchIcons();\n        if (typeof WinManager !== 'undefined') WinManager.renderAll();\n        hideLoading();\n        toast('Deleted', 'info');\n        logActivity('delete', ids.length === 1 ? names[0] : `${ids.length} items`, ids.length, _delSinglePath);\n        _scheduleExportCacheRefresh();\n    };\n}\n\n/* ============================================================\n   NEW TEXT FILE  —  BUG FIX: just creates the file, does NOT open editor\n   ============================================================ */\nfunction newTextFile() {\n    const targetFolder = App.folder;\n    let name = 'Document.txt';\n    if (VFS.hasChildNamed(targetFolder, name)) {\n        let i = 2;\n        while (VFS.hasChildNamed(targetFolder, `Document (${i}).txt`)) i++;\n        name = `Document (${i}).txt`;\n    }\n    document.getElementById('nf-name').value = name;\n    Overlay.show('modal-new-text');\n    setTimeout(() => {\n        const inp = document.getElementById('nf-name');\n        inp.focus();\n        const dot = name.lastIndexOf('.');\n        if (dot > 0) inp.setSelectionRange(0, dot); else inp.select();\n    }, 100);\n}\n\nasync function createTextFile() {\n    const name = sanitizeFilename(document.getElementById('nf-name').value.trim());\n    if (!name || name === 'unnamed') { toast('Enter a valid file name', 'error'); return; }\n    // Capture context BEFORE Overlay.hide() clears it\n    const targetFolder = App.folder,\n        winCtx = App._winCtx;\n    // Duplicate name check\n    if (VFS.hasChildNamed(targetFolder, name)) {\n        toast(`“${name}” already exists in this folder`, 'error'); return;\n    }\n    Overlay.hide();\n\n    const nodeId = uid();\n    const mime = getMime(name);\n    const emptyBuf = new ArrayBuffer(0);\n    const { iv, blob } = await Crypto.encryptBin(App.key, emptyBuf);\n    VFS.add({\n        id: nodeId, type: 'file', name, mime, size: 0,\n        parentId: targetFolder, ctime: Date.now(), mtime: Date.now()\n    });\n    // Position at context menu cursor if available\n    if (App._ctxScreenPos) {\n        const area2 = winCtx ? winCtx.el.querySelector('.fw-area') : document.getElementById('desktop-area');\n        const rect2 = area2.getBoundingClientRect();\n        const rawX = App._ctxScreenPos.x - rect2.left + area2.scrollLeft;\n        const rawY = App._ctxScreenPos.y - rect2.top + area2.scrollTop;\n        const occ = new Map();\n        VFS.children(targetFolder).forEach(n => {\n            if (n.id === nodeId) return;\n            const p = VFS.getPos(targetFolder, n.id);\n            if (p) occ.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id);\n        });\n        const snapped = _snapFreeCell(rawX, rawY, occ);\n        VFS.setPos(targetFolder, nodeId, snapped.x, snapped.y);\n        App._ctxScreenPos = null;\n    }\n    await DB.saveFile({ id: nodeId, cid: App.container.id, iv: Array.from(iv), blob });\n    await saveVFS();\n    if (winCtx) winCtx.render(); else Desktop._patchIcons();\n    toast(`File “${name}” created`, 'success');\n    logActivity('create-file', name, 1, VFS.fullPath(nodeId));\n}\n\n/* ============================================================\n   NEW FOLDER\n   ============================================================ */\nfunction newFolder() {\n    const targetFolder = App.folder;\n    let name = 'New Folder';\n    if (VFS.hasChildNamed(targetFolder, name)) {\n        let i = 2;\n        while (VFS.hasChildNamed(targetFolder, `New Folder (${i})`)) i++;\n        name = `New Folder (${i})`;\n    }\n    document.getElementById('nd-name').value = name;\n    Overlay.show('modal-new-folder');\n    setTimeout(() => {\n        const inp = document.getElementById('nd-name');\n        inp.focus(); inp.select();\n    }, 100);\n}\n\nasync function createFolder() {\n    const name = sanitizeFilename(document.getElementById('nd-name').value.trim());\n    if (!name || name === 'unnamed') { toast('Enter a valid folder name', 'error'); return; }\n    // Capture context BEFORE Overlay.hide() clears it\n    const targetFolder = App.folder,\n        winCtx = App._winCtx;\n    // Duplicate name check\n    if (VFS.hasChildNamed(targetFolder, name)) {\n        toast(`“${name}” already exists in this folder`, 'error'); return;\n    }\n    Overlay.hide();\n    const nodeId = uid();\n    VFS.add({ id: nodeId, type: 'folder', name, parentId: targetFolder, ctime: Date.now(), mtime: Date.now() });\n    // Position at context menu cursor if available\n    if (App._ctxScreenPos) {\n        const area2 = winCtx ? winCtx.el.querySelector('.fw-area') : document.getElementById('desktop-area');\n        const rect2 = area2.getBoundingClientRect();\n        const rawX = App._ctxScreenPos.x - rect2.left + area2.scrollLeft;\n        const rawY = App._ctxScreenPos.y - rect2.top + area2.scrollTop;\n        const occ = new Map();\n        VFS.children(targetFolder).forEach(n => {\n            if (n.id === nodeId) return;\n            const p = VFS.getPos(targetFolder, n.id);\n            if (p) occ.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id);\n        });\n        const snapped = _snapFreeCell(rawX, rawY, occ);\n        VFS.setPos(targetFolder, nodeId, snapped.x, snapped.y);\n        App._ctxScreenPos = null;\n    }\n    await saveVFS();\n    if (winCtx) winCtx.render(); else Desktop._patchIcons();\n    toast(`Folder \"${name}\" created`, 'success');\n    logActivity('create-folder', name, 1, VFS.fullPath(nodeId));\n}\n\n/* ============================================================\n   RENAME\n   ============================================================ */\nfunction renameNode(node) {\n    if (!node) return;\n\n    // Prevent renaming folders currently open in Explorer windows\n    if (node.type === 'folder') {\n        const blocked = _openFolderGuard([node.id]);\n        if (blocked) {\n            toast(`“${node.name}” is open in Explorer — close the window first`, 'error');\n            return;\n        }\n    }\n\n    document.getElementById('rename-input').value = node.name;\n    const capturedWinCtx = App._winCtx;\n    Overlay.show('modal-rename');\n    setTimeout(() => {\n        const i = document.getElementById('rename-input');\n        i.focus();\n        const dot = i.value.lastIndexOf('.');\n        if (dot > 0) i.setSelectionRange(0, dot); else i.select();\n    }, 100);\n    document.getElementById('rename-ok').onclick = async () => {\n        const newName = sanitizeFilename(document.getElementById('rename-input').value.trim());\n        if (!newName || newName === 'unnamed') { toast('Enter a valid name', 'error'); return; }\n        // Duplicate check (ignore if same name, case-insensitive)\n        const pid = VFS.node(node.id)?.parentId;\n        if (pid && newName.toLowerCase() !== node.name.toLowerCase() && VFS.hasChildNamed(pid, newName)) {\n            toast(`“${newName}” already exists in this folder`, 'error'); return;\n        }\n        Overlay.hide();\n        const _oldName = node.name;\n        VFS.rename(node.id, newName);\n        await saveVFS();\n\n        // Patch only the affected icon(s) in-place — no full desktop re-render needed.\n        // node.mime was already updated by VFS.rename; recompute for the thumb.\n        const _patchIconEl = (el) => {\n            if (!el) return;\n            const nameEl = el.querySelector('.file-name');\n            if (nameEl) nameEl.textContent = newName;\n            if (node.type === 'file') {\n                const newMime = node.mime || getMime(newName);\n                const thumb = el.querySelector('.file-thumb');\n                if (thumb) {\n                    // If extension changed to a non-image type, drop the thumbnail cache\n                    if (!isImage(newMime) && App.thumbCache[node.id]) {\n                        delete App.thumbCache[node.id];\n                    }\n                    if (App.thumbCache[node.id]) {\n                        const img = document.createElement('img');\n                        img.src = App.thumbCache[node.id];\n                        img.draggable = false;\n                        thumb.innerHTML = '';\n                        thumb.appendChild(img);\n                    } else {\n                        thumb.innerHTML = getFileIconSVG(newMime, newName);\n                        if (isImage(newMime)) _enqueueThumb(node);\n                    }\n                }\n            }\n        };\n        document.querySelectorAll(`.file-item[data-id=\"${node.id}\"]`).forEach(_patchIconEl);\n\n        logActivity('rename', `${_oldName} → ${newName}`, 1, VFS.fullPath(node.id));\n    };\n}\n\n/* ============================================================\n   COPY / CUT / PASTE\n   ============================================================ */\nfunction copyItems() {\n    App.clipboard = { op: 'copy', ids: [...App.selection] };\n    toast(`${App.clipboard.ids.length} item(s) copied`, 'info');\n    logActivity('copy', App.clipboard.ids.length === 1 ? (VFS.node(App.clipboard.ids[0])?.name ?? '1 item') : `${App.clipboard.ids.length} items`, App.clipboard.ids.length, App.clipboard.ids.length === 1 ? VFS.fullPath(App.clipboard.ids[0]) : null);\n}\nfunction cutItems() {\n    // Prevent cutting folders currently open in Explorer windows\n    const blocked = _openFolderGuard(App.selection);\n    if (blocked) {\n        toast(`“${VFS.node(blocked)?.name}” is open in Explorer — close the window first`, 'error');\n        return;\n    }\n\n    App.clipboard = { op: 'cut', ids: [...App.selection] };\n    toast(`${App.clipboard.ids.length} item(s) cut`, 'info');\n    logActivity('cut', App.clipboard.ids.length === 1 ? (VFS.node(App.clipboard.ids[0])?.name ?? '1 item') : `${App.clipboard.ids.length} items`, App.clipboard.ids.length, App.clipboard.ids.length === 1 ? VFS.fullPath(App.clipboard.ids[0]) : null);\n    _applyCutStyles();\n}\n\nfunction _dedupName(folderId, name) {\n    const dot = name.lastIndexOf('.');\n    const hasExt = dot > 0;\n    const base = hasExt ? name.slice(0, dot) : name;\n    const ext = hasExt ? name.slice(dot) : '';\n    let i = 2;\n    while (VFS.hasChildNamed(folderId, `${base} (${i})${ext}`)) i++;\n    return `${base} (${i})${ext}`;\n}\n\nasync function pasteItems() {\n    if (!App.clipboard) return;\n    const { op, ids } = App.clipboard;\n\n    // Prevent pasting a cut folder if it's currently open in Explorer windows\n    if (op === 'cut') {\n        const blocked = _openFolderGuard(ids);\n        if (blocked) {\n            toast(`“${VFS.node(blocked)?.name}” is open in Explorer — close the window first`, 'error');\n            // Abort the entire paste operation to prevent partial moves\n            return;\n        }\n    }\n\n    const _srcParent = VFS.node(ids[0])?.parentId,\n        _srcFolderPath = _srcParent ? VFS.fullPath(_srcParent) : null;\n    let _pastedSn = null;\n    const _pastedIds = [];\n    for (const id of ids) {\n        const n = VFS.node(id); if (!n) continue;\n        if (op === 'cut') {\n            if (n.parentId === App.folder) continue;\n            const result = VFS.move(id, App.folder);\n            if (result === 'duplicate') { toast(`\"${n.name}\" already exists in this folder`, 'error'); continue; }\n            if (result === 'cycle') { toast(`Cannot paste \"${n.name}\" into itself or a subfolder`, 'error'); continue; }\n            _pastedSn = n.name;\n            _pastedIds.push(id);\n        } else {\n            let name = n.name;\n            if (VFS.hasChildNamed(App.folder, name)) name = _dedupName(App.folder, name);\n            const newId = await deepCopy(id, App.folder, name !== n.name ? name : undefined);\n            _pastedSn = name;\n            if (newId) _pastedIds.push(newId);\n        }\n    }\n    // Position pasted items at the context-menu cursor (set by right-click / long-press Paste)\n    if (App._ctxScreenPos && _pastedIds.length > 0) {\n        const winCtx = App._winCtx;\n        const area2 = winCtx ? winCtx.el.querySelector('.fw-area') : document.getElementById('desktop-area');\n        const rect2 = area2.getBoundingClientRect();\n        const rawX = App._ctxScreenPos.x - rect2.left + area2.scrollLeft;\n        const rawY = App._ctxScreenPos.y - rect2.top + area2.scrollTop;\n        const occ = new Map();\n        VFS.children(App.folder).forEach(n => {\n            if (_pastedIds.includes(n.id)) return;\n            const p = VFS.getPos(App.folder, n.id);\n            if (p) occ.set(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`, n.id);\n        });\n        _pastedIds.forEach(id => {\n            const snapped = _snapFreeCell(rawX, rawY, occ);\n            VFS.setPos(App.folder, id, snapped.x, snapped.y);\n            occ.set(`${Math.round((snapped.x - 8) / GRID_X)}_${Math.round((snapped.y - 8) / GRID_Y)}`, id);\n        });\n        App._ctxScreenPos = null;\n    }\n    if (op === 'cut') App.clipboard = null;\n    _applyCutStyles();\n    await saveVFS();\n    // Refresh all open views so both source and target folders update\n    Desktop._patchIcons();\n    if (typeof WinManager !== 'undefined') WinManager.renderAll();\n    logActivity('paste', ids.length === 1 ? (_pastedSn ?? VFS.node(ids[0])?.name ?? '1 item') : `${ids.length} items`, ids.length, _srcFolderPath, VFS.fullPath(App.folder));\n    // Copy paste creates new file blobs — refresh the export cache\n    if (op === 'copy') _scheduleExportCacheRefresh();\n}\n\nasync function deepCopy(nodeId, newParent, newName, _depth = 0) {\n    if (_depth > 64 || nodeId === 'root') return null;\n    const n = VFS.node(nodeId); if (!n) return null;\n    const newId = uid();\n    const name = newName || n.name;\n    if (n.type === 'file') {\n        VFS.add({ ...n, id: newId, name, parentId: newParent, ctime: Date.now(), mtime: Date.now() });\n        const rec = await DB.getFile(nodeId);\n        if (rec) await DB.saveFile({ ...rec, id: newId, cid: App.container.id });\n    } else {\n        VFS.add({ ...n, id: newId, name, parentId: newParent, ctime: Date.now(), mtime: Date.now() });\n        for (const child of VFS.children(nodeId)) await deepCopy(child.id, newId, undefined, _depth + 1);\n    }\n    return newId;\n}\n\n/* ============================================================\n   SELECT ALL / SORT\n   ============================================================ */\nfunction selectAll() {\n    VFS.children(App.folder).forEach(n => {\n        App.selection.add(n.id);\n        // Look in both main desktop and any folder windows\n        const el = document.querySelector(`.file-item[data-id=\"${n.id}\"]`);\n        if (el) el.classList.add('selected');\n    });\n    if (App._winCtx) App._winCtx._updateStatus();\n    else if (typeof Desktop !== 'undefined') Desktop._updateSelectionBar();\n}\n\nfunction sortIcons(by = 'name', dir = 'asc', winCtx = null) {\n    const fid = winCtx ? winCtx.folderId : Desktop._desktopFolder;\n    const area = winCtx ? winCtx.el.querySelector('.fw-area') : document.getElementById('desktop-area');\n    const items = VFS.children(fid);\n    items.sort((a, b) => {\n        // Folders always come first\n        if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;\n        let va, vb;\n        switch (by) {\n            case 'mtime': va = a.mtime || 0; vb = b.mtime || 0; break;\n            case 'ctime': va = a.ctime || 0; vb = b.ctime || 0; break;\n            case 'size': va = a.size || 0; vb = b.size || 0; break;\n            case 'type': va = getExt(a.name) || ''; vb = getExt(b.name) || ''; break;\n            default: va = a.name.toLowerCase(); vb = b.name.toLowerCase();\n        }\n        const cmp = va < vb ? -1 : va > vb ? 1 : 0;\n        return dir === 'desc' ? -cmp : cmp;\n    });\n    // Compute sequential grid positions directly (don't use autoPos which sees old positions as occupied)\n    const W = (area && area.clientWidth) || 800,\n        cols = Math.max(1, Math.floor((W - 16) / GRID_X));\n    items.forEach((n, i) => {\n        const col = i % cols, row = Math.floor(i / cols);\n        const x = 8 + col * GRID_X, y = 8 + row * GRID_Y;\n        VFS.setPos(fid, n.id, x, y);\n        const el = area._iconMap?.get(n.id) ?? area.querySelector(`.file-item[data-id=\"${n.id}\"]`);\n        if (el) {\n            el.style.transition = 'left 0.12s ease, top 0.12s ease';\n            el.style.left = x + 'px'; el.style.top = y + 'px';\n            setTimeout(() => { if (el.parentNode) el.style.transition = ''; }, 150);\n        }\n    });\n    saveVFS();\n    logActivity('sort', `by ${by} (${dir})`);\n}\n\n/* ============================================================\n   CAN EDIT AS PLAIN TEXT  (whitelist of text-ish types)\n   ============================================================ */\nfunction canEditAsText(node) {\n    if (node.type === 'folder') return false;\n    const mime = node.mime || getMime(node.name);\n    if (mime.startsWith('text/')) return true;\n    if (['application/json', 'application/xml', 'application/javascript',\n        'application/x-yaml', 'application/sql'].includes(mime)) return true;\n    const ext = getExt(node.name).toLowerCase();\n    return ['txt', 'md', 'log', 'logs', 'conf', 'config', 'cfg', 'ini', 'env',\n        'sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1',\n        'js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs',\n        'py', 'pyw', 'rb', 'php', 'phps',\n        'java', 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp',\n        'cs', 'fs', 'fsx', 'vb',\n        'go', 'rs', 'swift', 'kt', 'kts', 'groovy', 'scala',\n        'lua', 'pl', 'r', 'sql', 'graphql', 'gql',\n        'json', 'xml', 'yaml', 'yml', 'toml', 'csv', 'tsv',\n        'html', 'htm', 'css', 'scss', 'sass', 'less',\n        'vue', 'svelte', 'astro',\n        'dockerfile', 'makefile', 'cmake',\n        'gitignore', 'gitattributes', 'editorconfig',\n        'prettierrc', 'eslintrc', 'babelrc', 'map',\n        'csproj'].includes(ext);\n}\n\n/* ============================================================\n   OPEN FILE AS PLAIN TEXT  (force text editor, any file type)\n   ============================================================ */\nasync function openFileAsText(node) {\n    if (!App.key || !App.container) return;\n    showLoading('Decrypting file...');\n    const TIMEOUT_MS = 5000;\n    try {\n        const rec = await DB.getFile(node.id);\n        if (!rec) { toast('File data not found', 'error'); hideLoading(); return; }\n        let buf;\n        if (!rec.blob || (rec.blob instanceof ArrayBuffer && rec.blob.byteLength === 0)) {\n            buf = new ArrayBuffer(0);\n        } else {\n            const decryptPromise = Crypto.decryptBin(App.key, rec.iv, rec.blob);\n            const timeoutPromise = new Promise((_, reject) =>\n                setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS)\n            );\n            buf = await Promise.race([decryptPromise, timeoutPromise]);\n        }\n        hideLoading();\n        openEditor(node, buf);\n    } catch (e) {\n        hideLoading();\n        if (e.message === 'timeout') {\n            toast('Operation timed out after 5 seconds', 'warn');\n        } else {\n            toast('Decryption failed: ' + e.message, 'error');\n        }\n    }\n}\n\n/* ============================================================\n   FOLDER SIZE  (recursive sum of all file descendants)\n   ============================================================ */\nfunction _folderSize(folderId, _visited = new Set()) {\n    if (_visited.has(folderId)) return 0;\n    _visited.add(folderId);\n    let size = 0;\n    VFS.children(folderId).forEach(n => {\n        size += n.type === 'file' ? (n.size || 0) : _folderSize(n.id, _visited);\n    });\n    return size;\n}\n\n/* ============================================================\n   PROPERTIES\n   ============================================================ */\nfunction showProps(node) {\n    const body = document.getElementById('props-body');\n    const icon = node.type === 'folder' ? getFolderSVG(node.color) : getFileIconSVG(node.mime || getMime(node.name), node.name);\n    const folderSz = node.type === 'folder' ? _folderSize(node.id) : null;\n    body.innerHTML = `\n    <div class=\"props-icon\">${icon}</div>\n    <table class=\"props-table\">\n      <tr><td>Name</td><td>${escHtml(node.name)}</td></tr>\n      <tr><td>Path</td><td>${escHtml(VFS.fullPath(node.id))}</td></tr>\n      <tr><td>Type</td><td>${escHtml(node.type === 'folder' ? 'Folder' : (node.mime || getMime(node.name)))}</td></tr>\n      ${node.size != null ? `<tr><td>Size</td><td>${fmtSize(node.size)}</td></tr>` : ''}\n      ${folderSz !== null ? `<tr><td>Size</td><td>${fmtSize(folderSz)}</td></tr>` : ''}\n      <tr><td>Created</td><td>${fmtDate(node.ctime)}</td></tr>\n      <tr><td>Modified</td><td>${fmtDate(node.mtime)}</td></tr>\n      ${node.type === 'folder' ? `<tr><td>Items</td><td>${VFS.children(node.id).length}</td></tr>` : ''}\n      <tr><td>Encrypted</td><td style=\"color:var(--accent)\">AES-256-GCM ✓</td></tr>\n    </table>\n  `;\n    Overlay.show('modal-props');\n}\n\n/* ============================================================\n   LINE NUMBER HELPERS\n   ============================================================ */\nlet _lineNumCanvas = null;\nlet _lineNumTimer = null;\n\nfunction _measureWrappedLineHeights(ta, lines) {\n    const cs = window.getComputedStyle(ta);\n    const lh = parseFloat(cs.lineHeight);\n    const taW = ta.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);\n    if (taW <= 0) return lines.map(() => lh);\n    if (!_lineNumCanvas) _lineNumCanvas = document.createElement('canvas');\n    const ctx = _lineNumCanvas.getContext('2d');\n    const tabSize = parseInt(cs.tabSize) || 2;\n    const tabStr = '\\u00a0'.repeat(tabSize);\n    ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;\n    return lines.map(line => {\n        if (!line) return lh;\n        const expanded = line.replace(/\\t/g, tabStr);\n        const w = ctx.measureText(expanded).width;\n        return Math.max(1, Math.ceil(w / taW)) * lh;\n    });\n}\n\nfunction _updateLineNumbers() {\n    const ta = document.getElementById('editor-textarea');\n    const gutter = document.getElementById('editor-line-numbers');\n    if (!gutter || !ta) return;\n    const lines = ta.value.split('\\n');\n    const isWrapped = ta.classList.contains('word-wrap');\n    const lh = parseFloat(window.getComputedStyle(ta).lineHeight) || 20.8;\n    const heights = isWrapped ? _measureWrappedLineHeights(ta, lines) : null;\n    const frag = document.createDocumentFragment();\n    for (let i = 0; i < lines.length; i++) {\n        const d = document.createElement('div');\n        d.textContent = i + 1;\n        d.style.height = (heights ? heights[i] : lh) + 'px';\n        frag.appendChild(d);\n    }\n    gutter.innerHTML = '';\n    gutter.appendChild(frag);\n    gutter.scrollTop = ta.scrollTop;\n}\n\nfunction _scheduleLineNumberUpdate() {\n    clearTimeout(_lineNumTimer);\n    _lineNumTimer = setTimeout(_updateLineNumbers, 80);\n}\n\n/* ============================================================\n   TEXT EDITOR\n   ============================================================ */\nlet _editorNode = null;\nlet _editorOriginal = '';\n\nfunction openEditor(node, buf) {\n    _editorNode = node;\n    const raw = new TextDecoder().decode(buf);\n    // Normalize line endings to \\n to match what <textarea> returns\n    const text = raw.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n    _editorOriginal = text;\n    const ta = document.getElementById('editor-textarea');\n    ta.value = text;\n    document.getElementById('editor-title').textContent = node.name;\n    // Word wrap toggle\n    const wrapBtn = document.getElementById('btn-wordwrap');\n    wrapBtn.classList.toggle('active', ta.classList.contains('word-wrap'));\n    wrapBtn.onclick = () => {\n        ta.classList.toggle('word-wrap');\n        wrapBtn.classList.toggle('active', ta.classList.contains('word-wrap'));\n        _updateLineNumbers();\n    };\n    ta.oninput = () => {\n        document.getElementById('editor-meta-chars').textContent = ta.value.length + ' chars';\n        document.getElementById('editor-meta-lines').textContent = ta.value.split('\\n').length + ' lines';\n        const mod = document.getElementById('editor-meta-modified');\n        mod.style.display = ta.value !== _editorOriginal ? '' : 'none';\n        _updateLineNumbers();\n    };\n    ta.oninput();\n    ta.onscroll = () => {\n        const gutter = document.getElementById('editor-line-numbers');\n        if (gutter) gutter.scrollTop = ta.scrollTop;\n    };\n    // Custom context menu for text editor (works on desktop + mobile)\n    ta.oncontextmenu = e => {\n        e.preventDefault();\n        const hasSel = ta.selectionStart !== ta.selectionEnd;\n        const _undoIcon = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M3 6l-2 2 2 2\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" stroke-linejoin=\"miter\"/><path d=\"M1 8h9a4 4 0 000-8\" stroke=\"currentColor\" stroke-width=\"1.4\"/></svg>';\n        const _redoIcon = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M13 6l2 2-2 2\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"square\" stroke-linejoin=\"miter\"/><path d=\"M15 8H6a4 4 0 010-8\" stroke=\"currentColor\" stroke-width=\"1.4\"/></svg>';\n        const _selAllIcon = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><rect x=\"2\" y=\"2\" width=\"12\" height=\"12\" rx=\"1\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M5 8h6M5 5.5h6M5 10.5h4\" stroke=\"currentColor\" stroke-width=\"1.2\" stroke-linecap=\"square\"/></svg>';\n        showCtxMenu(e.clientX || e.pageX, e.clientY || e.pageY, [\n            { label: 'Undo', icon: _undoIcon, action: () => { ta.focus(); document.execCommand('undo'); if (ta.oninput) ta.oninput(); } },\n            { label: 'Redo', icon: _redoIcon, action: () => { ta.focus(); document.execCommand('redo'); if (ta.oninput) ta.oninput(); } },\n            { sep: true },\n            { label: 'Cut', icon: Icons.cut, action: () => { ta.focus(); document.execCommand('cut'); if (ta.oninput) ta.oninput(); }, disabled: !hasSel },\n            { label: 'Copy', icon: Icons.copy, action: () => { ta.focus(); document.execCommand('copy'); }, disabled: !hasSel },\n            {\n                label: 'Paste', icon: Icons.paste, action: async () => {\n                    ta.focus();\n                    try {\n                        const txt = await navigator.clipboard.readText();\n                        const ss = ta.selectionStart, se = ta.selectionEnd;\n                        ta.setRangeText(txt, ss, se, 'end');\n                        if (ta.oninput) ta.oninput();\n                    } catch (_) { /* clipboard read may be blocked by browser */ }\n                }\n            },\n            { sep: true },\n            { label: 'Select All', icon: _selAllIcon, action: () => { ta.focus(); ta.select(); } }\n        ]);\n    };\n    _updateLineNumbers();\n    Overlay.show('modal-editor');\n    setTimeout(() => { ta.focus(); _updateLineNumbers(); }, 100);\n}\n\nasync function saveEditor() {\n    if (!_editorNode || !App.key) return;\n\n    const text = document.getElementById('editor-textarea').value;\n    const buf = new TextEncoder().encode(text);\n    // Container limit check: compare projected total against CONTAINER_LIMIT.\n    // VFS.totalSize() still contains the old size for this node at this point.\n    const _oldSize = _editorNode.size || 0;\n    const _delta = buf.byteLength - _oldSize;\n    if (_delta > 0 && VFS.totalSize() + _delta > CONTAINER_LIMIT) {\n        toast(`Cannot save: container limit reached (${fmtSize(CONTAINER_LIMIT)})`, 'error');\n        return;\n    }\n    const needed = buf.byteLength * 1.1;\n    const spCheck = await checkStorageSpace(needed);\n    if (!spCheck.ok) {\n        toast(`Cannot save: not enough device storage (${fmtSize(spCheck.available)} free)`, 'error');\n        return;\n    }\n\n    let saved = false;\n    showLoading('Saving...');\n    try {\n        const { iv, blob } = await Crypto.encryptBin(App.key, buf.buffer);\n        _editorNode.size = buf.byteLength;\n        _editorNode.mtime = Date.now();\n        VFS.add(_editorNode);\n        await DB.saveFile({ id: _editorNode.id, cid: App.container.id, iv: Array.from(iv), blob });\n        _editorOriginal = text;\n        document.getElementById('editor-meta-modified').style.display = 'none';\n        await saveVFS();\n        Desktop._patchIcons();\n        toast('File saved', 'success');\n        logActivity('edit', _editorNode.name, 1, VFS.fullPath(_editorNode.id));\n        _scheduleExportCacheRefresh();\n        saved = true;\n    } catch (e) { toast('Save failed: ' + e.message, 'error'); console.error(e); }\n    hideLoading();\n    return saved;\n}\n\nfunction _clearEditorMemory() {\n    const ta = document.getElementById('editor-textarea');\n    const gutter = document.getElementById('editor-line-numbers');\n    if (ta) { ta.value = ''; ta.onscroll = null; }\n    if (gutter) gutter.innerHTML = '';\n    _editorOriginal = '';\n}\n\nfunction closeEditor() {\n    const ta = document.getElementById('editor-textarea');\n    const modified = ta.value !== _editorOriginal;\n    if (modified) {\n        const dlg = document.getElementById('editor-unsaved-dialog');\n        dlg.style.display = 'flex';\n        return;\n    }\n    Overlay.hide();\n    _editorNode = null;\n    _clearEditorMemory();\n}\n\nfunction discardEditor() {\n    Overlay.hide();\n    _editorNode = null;\n    _clearEditorMemory();\n}\n\nasync function saveAndCloseEditor() {\n    const ok = await saveEditor();\n    if (ok) { Overlay.hide(); _editorNode = null; _clearEditorMemory(); }\n}\n\n/* ============================================================\n   FILE VIEWER\n   ============================================================ */\nlet _viewerBlob = null;\n\nfunction openViewer(node, buf, mime) {\n    const content = document.getElementById('viewer-content');\n    content.innerHTML = '';\n    const blobObj = new Blob([buf], { type: mime }),\n        url = URL.createObjectURL(blobObj);\n    _viewerBlob = { url, node };\n\n    document.getElementById('viewer-title').textContent = node.name;\n    document.getElementById('btn-download-viewer').onclick = () => {\n        const a = document.createElement('a'); a.href = url; a.download = node.name;\n        document.body.appendChild(a); a.click(); a.remove();\n    };\n\n    if (isImage(mime)) {\n        const img = document.createElement('img');\n        img.src = url; img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain';\n        content.appendChild(img);\n    } else if (isAudio(mime)) {\n        content.appendChild(_buildCustomPlayer(url, 'audio'));\n    } else if (isVideo(mime)) {\n        content.appendChild(_buildCustomPlayer(url, 'video'));\n    } else if (isPDF(mime)) {\n        const fr = document.createElement('iframe');\n        fr.src = url; fr.style.cssText = 'width:100%;height:100%;border:none';\n        content.appendChild(fr);\n    }\n    Overlay.show('modal-viewer');\n}\n\n/* ---- Custom media player builder ---- */\nfunction _buildCustomPlayer(url, kind) {\n    const wrap = document.createElement('div');\n    wrap.className = 'twc-player' + (kind === 'audio' ? ' audio-only' : '');\n\n    const media = document.createElement(kind === 'audio' ? 'audio' : 'video');\n    media.src = url;\n    media.preload = 'metadata';\n    media.volume = 1;\n    media.muted = false;\n    if (kind !== 'audio') media.setAttribute('playsinline', '');\n\n    if (kind === 'audio') {\n        media.style.display = 'none';  // keep in DOM so closeViewer() can find and pause it\n        wrap.appendChild(media);\n        const vis = document.createElement('div');\n        vis.className = 'audio-vis';\n        vis.innerHTML = `<svg viewBox=\"0 0 64 64\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M10 44V20l20-10v44L10 44z\" fill=\"currentColor\" opacity=\".3\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/>\n      <path d=\"M34 22c4 2 7 6 7 10s-3 8-7 10\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/>\n      <path d=\"M38 16c6 3 10 10 10 16s-4 13-10 16\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/>\n    </svg>`;\n        wrap.appendChild(vis);\n    } else {\n        wrap.appendChild(media);\n    }\n\n    const controls = document.createElement('div');\n    controls.className = 'player-controls';\n\n    // Play/Pause\n    const btnPlay = document.createElement('button');\n    btnPlay.className = 'player-btn';\n    const playIcon = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M4 2l10 6-10 6z\" fill=\"currentColor\"/></svg>';\n    const pauseIcon = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><rect x=\"3\" y=\"2\" width=\"3.5\" height=\"12\" rx=\".5\" fill=\"currentColor\"/><rect x=\"9.5\" y=\"2\" width=\"3.5\" height=\"12\" rx=\".5\" fill=\"currentColor\"/></svg>';\n    btnPlay.innerHTML = playIcon;\n    btnPlay.addEventListener('click', () => { media.paused ? media.play() : media.pause(); });\n\n    // Seek bar\n    const seek = document.createElement('input');\n    seek.type = 'range'; seek.className = 'player-seek'; seek.min = 0; seek.max = 100; seek.value = 0; seek.step = 0.1;\n\n    // Time label\n    const timeLabel = document.createElement('span');\n    timeLabel.className = 'player-time';\n    timeLabel.textContent = '0:00 / 0:00';\n\n    function fmtTime(s) {\n        if (!isFinite(s)) return '0:00';\n        const m = Math.floor(s / 60), sec = Math.floor(s % 60);\n        return m + ':' + (sec < 10 ? '0' : '') + sec;\n    }\n\n    media.addEventListener('loadedmetadata', () => {\n        seek.max = media.duration || 100;\n        timeLabel.textContent = fmtTime(0) + ' / ' + fmtTime(media.duration);\n    });\n    media.addEventListener('timeupdate', () => {\n        seek.value = media.currentTime;\n        timeLabel.textContent = fmtTime(media.currentTime) + ' / ' + fmtTime(media.duration);\n    });\n    media.addEventListener('play', () => { btnPlay.innerHTML = pauseIcon; });\n    media.addEventListener('pause', () => { btnPlay.innerHTML = playIcon; });\n    media.addEventListener('ended', () => { btnPlay.innerHTML = playIcon; });\n\n    let _seeking = false;\n    seek.addEventListener('mousedown', () => { _seeking = true; });\n    seek.addEventListener('touchstart', () => { _seeking = true; }, { passive: true });\n    seek.addEventListener('input', () => { if (_seeking) media.currentTime = seek.value; });\n    seek.addEventListener('mouseup', () => { _seeking = false; media.currentTime = seek.value; });\n    seek.addEventListener('touchend', () => { _seeking = false; media.currentTime = seek.value; });\n\n    // Volume\n    const btnVol = document.createElement('button');\n    btnVol.className = 'player-btn';\n    const volIcon = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M2 6h3l4-3v10l-4-3H2z\" fill=\"currentColor\"/><path d=\"M12 4.5c1.3 1 2 2.3 2 3.5s-.7 2.5-2 3.5\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"round\"/></svg>';\n    const muteIcon = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M2 6h3l4-3v10l-4-3H2z\" fill=\"currentColor\"/><path d=\"M12 5l4 6M16 5l-4 6\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\"/></svg>';\n    btnVol.innerHTML = volIcon;\n    btnVol.addEventListener('click', () => {\n        media.muted = !media.muted;\n        btnVol.innerHTML = media.muted ? muteIcon : volIcon;\n        vol.value = media.muted ? 0 : media.volume;\n    });\n\n    const vol = document.createElement('input');\n    vol.type = 'range'; vol.className = 'player-vol'; vol.min = 0; vol.max = 1; vol.step = 0.01; vol.value = 1;\n    vol.addEventListener('input', () => {\n        media.volume = vol.value;\n        media.muted = vol.value == 0;\n        btnVol.innerHTML = media.muted ? muteIcon : volIcon;\n    });\n\n    controls.appendChild(btnPlay);\n    controls.appendChild(seek);\n    controls.appendChild(timeLabel);\n    controls.appendChild(btnVol);\n    controls.appendChild(vol);\n\n    // Fullscreen button (video only)\n    if (kind !== 'audio') {\n        const btnFs = document.createElement('button');\n        btnFs.className = 'player-btn';\n        btnFs.title = 'Fullscreen (F)';\n        const fsEnterIcon = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M1 1h4M1 1v4M15 1h-4M15 1v4M1 15h4M1 15v-4M15 15h-4M15 15v-4\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/></svg>';\n        const fsExitIcon = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M5 1v4H1M11 1v4h4M5 15v-4H1M11 15v-4h4\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/></svg>';\n        btnFs.innerHTML = fsEnterIcon;\n        btnFs.addEventListener('click', () => {\n            if (!document.fullscreenElement) wrap.requestFullscreen?.();\n            else document.exitFullscreen?.();\n        });\n        const _onFsChange = () => { btnFs.innerHTML = document.fullscreenElement ? fsExitIcon : fsEnterIcon; };\n        document.addEventListener('fullscreenchange', _onFsChange);\n        wrap._cleanupFs = () => document.removeEventListener('fullscreenchange', _onFsChange);\n        controls.appendChild(btnFs);\n\n        // Click on video = play/pause; double-click = toggle fullscreen\n        let _lastClick = 0;\n        media.addEventListener('click', () => {\n            const now = Date.now();\n            if (now - _lastClick < 300) {\n                // Double-click: toggle fullscreen\n                if (!document.fullscreenElement) wrap.requestFullscreen?.();\n                else document.exitFullscreen?.();\n            } else {\n                media.paused ? media.play() : media.pause();\n            }\n            _lastClick = now;\n        });\n    }\n\n    // Keyboard shortcuts: Space = play/pause, ←/→ = ±5s, F = fullscreen, M = mute\n    const _onKey = e => {\n        if (!wrap.isConnected) return;\n        const tag = e.target?.tagName;\n        if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON') return;\n        if (e.code === 'Space') { e.preventDefault(); media.paused ? media.play() : media.pause(); }\n        if (e.code === 'ArrowLeft') { e.preventDefault(); media.currentTime = Math.max(0, media.currentTime - 5); }\n        if (e.code === 'ArrowRight') { e.preventDefault(); media.currentTime = Math.min(media.duration || Infinity, media.currentTime + 5); }\n        if (e.code === 'KeyF' && kind !== 'audio') {\n            e.preventDefault();\n            if (!document.fullscreenElement) wrap.requestFullscreen?.();\n            else document.exitFullscreen?.();\n        }\n        if (e.code === 'KeyM') { e.preventDefault(); media.muted = !media.muted; btnVol.innerHTML = media.muted ? muteIcon : volIcon; vol.value = media.muted ? 0 : media.volume; }\n    };\n    document.addEventListener('keydown', _onKey);\n    wrap._cleanupKeyboard = () => document.removeEventListener('keydown', _onKey);\n\n    wrap.appendChild(controls);\n    return wrap;\n}\n\nfunction closeViewer() {\n    // Stop any playing media, cleanup event listeners\n    const content = document.getElementById('viewer-content');\n    content.querySelectorAll('audio, video').forEach(el => { el.pause(); el.src = ''; });\n    content.querySelectorAll('.twc-player').forEach(p => { p._cleanupKeyboard?.(); p._cleanupFs?.(); });\n    if (_viewerBlob) { URL.revokeObjectURL(_viewerBlob.url); _viewerBlob = null; }\n    content.innerHTML = '';\n    Overlay.hide();\n}\n\n/* ============================================================\n   CLIPBOARD VISUAL / CUT FEEDBACK\n   ============================================================ */\nfunction _applyCutStyles() {\n    document.querySelectorAll('.file-item.cut-item').forEach(el => el.classList.remove('cut-item'));\n    if (App.clipboard?.op === 'cut') {\n        App.clipboard.ids.forEach(id => {\n            document.querySelectorAll(`.file-item[data-id=\"${id}\"]`).forEach(el => el.classList.add('cut-item'));\n        });\n    }\n}\n\nfunction cancelClipboard() {\n    App.clipboard = null;\n    _applyCutStyles();\n}\n\n/* ============================================================\n   ZIP EXPORT  (pure JS, no compression — stored only)\n   ============================================================ */\nfunction _escXml(s) {\n    return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n}\n\nfunction _readZip(buffer) {\n    const view = new DataView(buffer);\n    const u8 = new Uint8Array(buffer);\n    const size = buffer.byteLength;\n    let eocdOffset = -1;\n    for (let i = size - 22; i >= Math.max(0, size - 65558); i--) {\n        if (view.getUint32(i, true) === 0x06054b50) { eocdOffset = i; break; }\n    }\n    if (eocdOffset < 0) throw new Error('Not a valid ZIP file');\n    const cdCount = view.getUint16(eocdOffset + 8, true),\n        cdOffset = view.getUint32(eocdOffset + 16, true),\n        dec = new TextDecoder('utf-8'),\n        entries = {};\n    let pos = cdOffset;\n    for (let i = 0; i < cdCount; i++) {\n        if (view.getUint32(pos, true) !== 0x02014b50) break;\n        const fnLen = view.getUint16(pos + 28, true),\n            exLen = view.getUint16(pos + 30, true),\n            cmLen = view.getUint16(pos + 32, true),\n            lhOff = view.getUint32(pos + 42, true),\n            fn = dec.decode(u8.subarray(pos + 46, pos + 46 + fnLen)),\n            lhFnLen = view.getUint16(lhOff + 26, true),\n            lhExLen = view.getUint16(lhOff + 28, true),\n            dataOff = lhOff + 30 + lhFnLen + lhExLen,\n            dataLen = view.getUint32(lhOff + 22, true);\n        entries[fn] = u8.slice(dataOff, dataOff + dataLen);\n        pos += 46 + fnLen + exLen + cmLen;\n    }\n    return entries;\n}\nasync function _readZipFromFile(file) {\n    const size = file.size;\n    // Read EOCD from the tail (last 65 KB is sufficient)\n    const tailSize = Math.min(65558, size);\n    const tailBuf = await file.slice(size - tailSize, size).arrayBuffer();\n    const tailView = new DataView(tailBuf);\n    let eocdOffset = -1;\n    for (let i = tailBuf.byteLength - 22; i >= 0; i--) {\n        if (tailView.getUint32(i, true) === 0x06054b50) { eocdOffset = i; break; }\n    }\n    if (eocdOffset < 0) throw new Error('Not a valid ZIP file');\n    const cdCount = tailView.getUint16(eocdOffset + 8, true),\n        cdSize = tailView.getUint32(eocdOffset + 12, true),\n        cdOffset = tailView.getUint32(eocdOffset + 16, true);\n    // Read central directory\n    const cdBuf = await file.slice(cdOffset, cdOffset + cdSize).arrayBuffer();\n    const cdView = new DataView(cdBuf);\n    const cdU8 = new Uint8Array(cdBuf);\n    const dec = new TextDecoder('utf-8');\n    const infos = [];\n    let pos = 0;\n    for (let i = 0; i < cdCount; i++) {\n        if (cdView.getUint32(pos, true) !== 0x02014b50) break;\n        const fnLen = cdView.getUint16(pos + 28, true),\n            exLen = cdView.getUint16(pos + 30, true),\n            cmLen = cdView.getUint16(pos + 32, true),\n            compSize = cdView.getUint32(pos + 20, true),\n            lhOff = cdView.getUint32(pos + 42, true),\n            fn = dec.decode(cdU8.subarray(pos + 46, pos + 46 + fnLen));\n        infos.push({ name: fn, lhOff, compSize });\n        pos += 46 + fnLen + exLen + cmLen;\n    }\n    // Read each entry: small ones into Uint8Array, workspace.bin as Blob (no full-file RAM load)\n    const entries = {};\n    for (const info of infos) {\n        const lhBuf = await file.slice(info.lhOff, info.lhOff + 30).arrayBuffer();\n        const lhView = new DataView(lhBuf);\n        const lhFnLen = lhView.getUint16(26, true),\n            lhExLen = lhView.getUint16(28, true);\n        const dataOff = info.lhOff + 30 + lhFnLen + lhExLen;\n        if (info.name === 'safenova_efs/workspace.bin') {\n            entries[info.name] = file.slice(dataOff, dataOff + info.compSize);\n        } else {\n            const buf = await file.slice(dataOff, dataOff + info.compSize).arrayBuffer();\n            entries[info.name] = new Uint8Array(buf);\n        }\n    }\n    return entries;\n}\nfunction _crc32(data) {\n    if (!_crc32._t) {\n        const t = new Uint32Array(256);\n        for (let n = 0; n < 256; n++) {\n            let c = n;\n            for (let k = 0; k < 8; k++) c = (c & 1) ? 0xEDB88320 ^ (c >>> 1) : (c >>> 1);\n            t[n] = c;\n        }\n        _crc32._t = t;\n    }\n    const table = _crc32._t;\n    let crc = 0xFFFFFFFF;\n    for (let i = 0; i < data.length; i++) crc = table[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8);\n    return (crc ^ 0xFFFFFFFF) >>> 0;\n}\n// Incremental CRC32 over multiple chunks (avoids concatenating large buffers)\nfunction _crc32multi(chunks) {\n    if (!_crc32._t) _crc32(new Uint8Array(0));\n    const table = _crc32._t;\n    let crc = 0xFFFFFFFF;\n    for (const chunk of chunks)\n        for (let i = 0; i < chunk.length; i++) crc = table[(crc ^ chunk[i]) & 0xFF] ^ (crc >>> 8);\n    return (crc ^ 0xFFFFFFFF) >>> 0;\n}\n\nfunction _buildZip(entries) {\n    // entries: [ { name: string, data: Uint8Array|Uint8Array[], mtime?: number } ]\n    // Returns an array of Uint8Array parts (Blob-friendly, no single giant allocation)\n    const enc = new TextEncoder();\n    const parts = [], centralDir = [];\n    let offset = 0;\n\n    function dosDT(ts) {\n        const d = new Date(ts || Date.now());\n        return {\n            t: ((d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2)),\n            d: (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate())\n        };\n    }\n\n    for (const entry of entries) {\n        const nm = enc.encode(entry.name);\n        const chunks = Array.isArray(entry.data) ? entry.data\n            : [entry.data instanceof Uint8Array ? entry.data : new Uint8Array(entry.data)];\n        const crc = chunks.length === 1 ? _crc32(chunks[0]) : _crc32multi(chunks);\n        let sz = 0;\n        for (const ch of chunks) sz += ch.length;\n        const { t: mt, d: md } = dosDT(entry.mtime);\n\n        const lh = new Uint8Array(30 + nm.length);\n        const lv = new DataView(lh.buffer);\n        lv.setUint32(0, 0x04034b50, true); lv.setUint16(4, 20, true);\n        lv.setUint16(6, 0x0800, true); lv.setUint16(8, 0, true);  // 0x0800 = UTF-8 filename flag\n        lv.setUint16(10, mt, true); lv.setUint16(12, md, true);\n        lv.setUint32(14, crc, true); lv.setUint32(18, sz, true);\n        lv.setUint32(22, sz, true); lv.setUint16(26, nm.length, true);\n        lv.setUint16(28, 0, true); lh.set(nm, 30);\n        parts.push(lh);\n        for (const ch of chunks) parts.push(ch);\n\n        const cd = new Uint8Array(46 + nm.length);\n        const cv = new DataView(cd.buffer);\n        cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true);\n        cv.setUint16(8, 0x0800, true); cv.setUint16(10, 0, true);  // 0x0800 = UTF-8 filename flag\n        cv.setUint16(12, mt, true); cv.setUint16(14, md, true);\n        cv.setUint32(16, crc, true); cv.setUint32(20, sz, true); cv.setUint32(24, sz, true);\n        cv.setUint16(28, nm.length, true); cv.setUint16(30, 0, true); cv.setUint16(32, 0, true);\n        cv.setUint16(34, 0, true); cv.setUint16(36, 0, true); cv.setUint32(38, 0, true);\n        cv.setUint32(42, offset, true); cd.set(nm, 46);\n        centralDir.push(cd);\n\n        offset += 30 + nm.length + sz;\n    }\n\n    const cdSz = centralDir.reduce((s, a) => s + a.length, 0);\n    for (const cd of centralDir) parts.push(cd);\n    const eocd = new Uint8Array(22);\n    const ev = new DataView(eocd.buffer);\n    ev.setUint32(0, 0x06054b50, true); ev.setUint16(4, 0, true); ev.setUint16(6, 0, true);\n    ev.setUint16(8, entries.length, true); ev.setUint16(10, entries.length, true);\n    ev.setUint32(12, cdSz, true); ev.setUint32(16, offset, true); ev.setUint16(20, 0, true);\n    parts.push(eocd);\n    return parts;\n}\n\nasync function exportAsZip(nodeIds, zipName) {\n    if (!App.key || !App.container) return;\n    if (!zipName) {\n        zipName = nodeIds.length === 1\n            ? (VFS.node(nodeIds[0])?.name?.replace(/\\.[^.]+$/, '') || 'export') + '.zip'\n            : 'export.zip';\n    }\n    showLoading('Preparing ZIP…');\n    try {\n        const entries = [], _zipSeen = new Set(), _flat = [];\n        function _collectFlat(nodeId, prefix, _depth = 0) {\n            if (_zipSeen.has(nodeId) || _depth > 64) return;\n            _zipSeen.add(nodeId);\n            const n = VFS.node(nodeId); if (!n) return;\n            if (n.type === 'folder') {\n                for (const c of VFS.children(nodeId)) _collectFlat(c.id, prefix + n.name + '/', _depth + 1);\n            } else {\n                _flat.push({ id: nodeId, name: prefix + n.name, mtime: n.mtime || n.ctime });\n            }\n        }\n        for (const id of nodeIds) _collectFlat(id, '');\n        // Fetch all file records in one IDB transaction, then decrypt fully in parallel\n        const fileMap = await DB.getFilesByIds(_flat.map(f => f.id));\n        const decResults = await Promise.allSettled(_flat.map(async item => {\n            const rec = fileMap.get(item.id); if (!rec) return null;\n            const buf = await Crypto.decryptBin(App.key, rec.iv, rec.blob);\n            return { name: item.name, data: new Uint8Array(buf), mtime: item.mtime };\n        }));\n        decResults.forEach(r => { if (r.status === 'fulfilled' && r.value) entries.push(r.value); });\n        if (!entries.length) { toast('Nothing to export', 'warn'); hideLoading(); return; }\n        const zipParts = _buildZip(entries);\n        downloadBuf(zipParts, zipName, 'application/zip');\n        toast(`Exported ${entries.length} file${entries.length !== 1 ? 's' : ''} as ZIP`, 'success');\n        logActivity('export-zip', nodeIds.length === 1 ? (VFS.node(nodeIds[0])?.name ?? entries[0]?.name ?? '1 file') : `${entries.length} files`, entries.length, nodeIds.length === 1 ? VFS.fullPath(nodeIds[0]) : null);\n    } catch (e) { toast('ZIP export failed: ' + e.message, 'error'); console.error(e); }\n    hideLoading();\n}\n\n/* ============================================================\n   CONTAINER IMPORT / EXPORT\n   ============================================================ */\n\n// _buf2b64Safe removed — buf2b64 is now chunked and equivalent\n\n/** Brute-force attempt tracker for export password prompts */\nconst _expFailCounts = new Map();\n\n/** Prompt for password to derive the container key before exporting a locked container */\nfunction _askExportPassword(c) {\n    return new Promise(resolve => {\n        const expKey = 'exp:' + c.id;\n        const errEl = document.getElementById('exp-error');\n        const btnOk = document.getElementById('exp-ok');\n        document.getElementById('exp-cont-name').textContent = c.name;\n        document.getElementById('exp-pw').value = '';\n        errEl.innerHTML = '';\n        errEl.style.color = '';\n        btnOk.disabled = false;\n\n        const prevFails = _expFailCounts.get(expKey);\n        if (prevFails?.lockUntil > Date.now()) {\n            _startAttemptCooldown(errEl, btnOk, () => { if (prevFails) prevFails.lockUntil = 0; });\n        }\n\n        Overlay.show('modal-export-pw');\n        setTimeout(() => document.getElementById('exp-pw').focus(), 100);\n\n        const cleanup = () => {\n            Overlay.hide();\n            btnOk.onclick = null;\n            document.getElementById('exp-cancel').onclick = null;\n            document.getElementById('exp-close').onclick = null;\n            document.getElementById('exp-pw').onkeydown = null;\n        };\n\n        const doExport = async () => {\n            const fails = _expFailCounts.get(expKey) || { count: 0, lockUntil: 0 };\n            if (fails.lockUntil > Date.now()) return;\n            const pw = document.getElementById('exp-pw').value;\n            if (!pw) { errEl.innerHTML = _ERR_SVG + ' Enter password'; return; }\n            errEl.innerHTML = '';\n            btnOk.disabled = true;\n            try {\n                const key = await Crypto.deriveKey(pw, new Uint8Array(c.salt)),\n                    ok = await Crypto.checkVerification(key, c.verIv, c.verBlob);\n                if (!ok) {\n                    // Duress check — silently corrupts data, then falls through to \"wrong password\"\n                    if (await checkDuress(pw, c)) await _executeDuress(c);\n                    fails.count++;\n                    _expFailCounts.set(expKey, fails);\n                    if (fails.count > 3) {\n                        fails.lockUntil = Date.now() + 3000;\n                        _startAttemptCooldown(errEl, btnOk, () => { fails.lockUntil = 0; });\n                    } else {\n                        errEl.innerHTML = _ERR_SVG + ' Incorrect password';\n                        btnOk.disabled = false;\n                    }\n                    return;\n                }\n                _expFailCounts.delete(expKey);\n                cleanup();\n                resolve(key);\n            } catch (e) {\n                errEl.textContent = 'Error: ' + e.message;\n                btnOk.disabled = false;\n            }\n        };\n\n        btnOk.onclick = doExport;\n        document.getElementById('exp-cancel').onclick = () => { cleanup(); resolve(null); };\n        document.getElementById('exp-close').onclick = () => { cleanup(); resolve(null); };\n        document.getElementById('exp-pw').onkeydown = e => { if (e.key === 'Enter') doExport(); };\n    });\n}\n\n/* ── Binary ↔ base64 helpers: chunked to avoid spread stack-overflow on large buffers ── */\nfunction _u8ToB64(u8) {\n    let s = '';\n    for (let i = 0; i < u8.length; i += 8192)\n        s += String.fromCharCode.apply(null, u8.subarray(i, i + 8192));\n    return btoa(s);\n}\nfunction _b64ToU8(b64) {\n    const s = atob(b64), u8 = new Uint8Array(s.length);\n    for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i);\n    return u8;\n}\n\n/* ── Export-cache key: HKDF-SHA-256 from container salt (browser-independent, no extra storage) ── */\nasync function _deriveExportCacheKey(salt) {\n    const raw = salt instanceof Uint8Array ? salt : new Uint8Array(salt);\n    const hkdf = await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']);\n    return crypto.subtle.deriveKey(\n        { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: new TextEncoder().encode('snv-export-cache-v1') },\n        hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']\n    );\n}\nasync function _wrapExportCache(salt, data) {\n    const key = await _deriveExportCacheKey(salt),\n        iv = crypto.getRandomValues(new Uint8Array(12)),\n        ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data),\n        out = new Uint8Array(12 + ct.byteLength);\n    out.set(iv); out.set(new Uint8Array(ct), 12);\n    return out;\n}\nasync function _unwrapExportCache(salt, data) {\n    const key = await _deriveExportCacheKey(salt),\n        iv = data.slice(0, 12), ct = data.slice(12);\n    return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct));\n}\n\n/* ── Pre-generate encrypted manifest for passwordless export ──\n   generateNow=true  — bypasses the saved-settings check (toggle just switched).\n   silent=true       — no loading overlay; fire-and-forget from file operations.\n   Returns true on success, false on failure. */\nasync function _updateExportCache(generateNow = false, silent = false) {\n    if (!App.container || !App.key) return false;\n    const shouldGenerate = generateNow || (App.container.settings || {}).requireExportPassword === false;\n    if (!shouldGenerate) {\n        // password is (or will be) required — clear any stale cache\n        if (App.container._exportCache) {\n            delete App.container._exportCache;\n            await DB.saveContainer(App.container);\n        }\n        return true;\n    }\n    const _setMsg = msg => { if (!silent) document.getElementById('loading-msg').textContent = msg; };\n    if (!silent) showLoading('Generating export cache database…');\n    try {\n        _setMsg('Export cache: reading file list…');\n        const meta = await DB.getFileMetaByCid(App.container.id);\n        let entries = meta;\n        if (meta.some(m => m.sz < 0)) {\n            _setMsg('Export cache: loading file data…');\n            const recs = await DB.getFilesByCid(App.container.id);\n            entries = recs.map(r => ({\n                id: r.id, iv: r.iv,\n                sz: r.blob instanceof ArrayBuffer ? r.blob.byteLength : (r.blob?.byteLength || 0)\n            }));\n        }\n        _setMsg(`Export cache: building manifest (${entries.length} file${entries.length === 1 ? '' : 's'})…`);\n        const order = [], manifest = [];\n        let offset = 0;\n        for (let _i = 0; _i < entries.length; _i += 500) {\n            for (let _j = _i, _end = Math.min(_i + 500, entries.length); _j < _end; _j++) {\n                const m = entries[_j];\n                const ivArr = m.iv instanceof Array ? m.iv : Array.from(new Uint8Array(m.iv));\n                order.push({ id: m.id, sz: m.sz });\n                manifest.push({ id: m.id, ivB64: btoa(String.fromCharCode(...ivArr)), offset, size: m.sz });\n                offset += m.sz;\n            }\n            if (_i + 500 < entries.length) await new Promise(r => setTimeout(r, 0));\n        }\n        _setMsg('Export cache: encrypting manifest…');\n        await new Promise(r => setTimeout(r, 0));\n        const enc = await Crypto.encryptBin(App.key, new TextEncoder().encode(JSON.stringify(manifest)));\n        _setMsg('Export cache: saving to database…');\n        await new Promise(r => setTimeout(r, 0));\n        const cacheJson = new TextEncoder().encode(JSON.stringify({\n            mIv: enc.iv,\n            mBlob: _u8ToB64(new Uint8Array(enc.blob)),\n            order\n        }));\n        const wrapped = await _wrapExportCache(App.container.salt, cacheJson);\n        App.container._exportCache = { wrapped: _u8ToB64(wrapped) };\n        await DB.saveContainer(App.container);\n        return true;\n    } catch (e) {\n        console.error('[SafeNova] Export cache generation failed:', e);\n        // Clean up any partial cache that may have been written before the failure\n        if (App.container._exportCache) {\n            delete App.container._exportCache;\n            try { await DB.saveContainer(App.container); } catch { /* non-critical */ }\n        }\n        return false;\n    } finally {\n        if (!silent) hideLoading();\n    }\n}\n\n/* ── Schedule a silent cache refresh after a file operation, only when the\n   passwordless-export setting is active. Fire-and-forget; never throws. ── */\nfunction _scheduleExportCacheRefresh() {\n    if ((App.container?.settings || {}).requireExportPassword !== false) return;\n    _updateExportCache(false, true).catch(() => { });\n}\n\n/* ── Remove orphaned export cache: cache stored in IDB but setting was never\n   saved (e.g. app crashed mid-generation). Called on every container open. ── */\nasync function _cleanOrphanedExportCache() {\n    if (!App.container) return;\n    if ((App.container.settings || {}).requireExportPassword !== false && App.container._exportCache) {\n        delete App.container._exportCache;\n        try { await DB.saveContainer(App.container); } catch { /* non-critical */ }\n    }\n}\n\nasync function exportContainerFile(c, requirePassword = true) {\n    showLoading('Exporting container…');\n    try {\n        const vfsRec = await DB.getVFS(c.id);\n        let fileRecs = await DB.getFilesByCid(c.id);\n        const now = Date.now();\n\n        let key = (App.container?.id === c.id) ? App.key : null,\n            encManifestIv, encManifestBlob, usedCache = false;\n\n        // If the extra-confirmation setting is off, silently try the saved session\n        if (!requirePassword && !key) {\n            const rawKeyBytes = await loadSession(c.id);\n            if (rawKeyBytes) {\n                try {\n                    const sk = await Crypto.importRawKey(rawKeyBytes);\n                    if (await Crypto.checkVerification(sk, c.verIv, c.verBlob)) key = sk;\n                } catch { /* corrupt session — will prompt below */ }\n            }\n        }\n\n        // Try pre-generated export cache (no key/session needed)\n        if (!requirePassword && !key && c._exportCache?.wrapped) {\n            try {\n                const plainBytes = await _unwrapExportCache(c.salt,\n                    typeof c._exportCache.wrapped === 'string' ? _b64ToU8(c._exportCache.wrapped) : new Uint8Array(c._exportCache.wrapped));\n                const cache = JSON.parse(new TextDecoder().decode(plainBytes));\n                const fileMap = new Map(fileRecs.map(r => [r.id, r]));\n                let valid = cache.order.length === fileRecs.length;\n                if (valid) {\n                    for (const o of cache.order) {\n                        const rec = fileMap.get(o.id);\n                        if (!rec) { valid = false; break; }\n                        const sz = rec.blob instanceof ArrayBuffer ? rec.blob.byteLength : (rec.blob?.byteLength || 0);\n                        if (sz !== o.sz) { valid = false; break; }\n                    }\n                }\n                if (valid) {\n                    fileRecs = cache.order.map(o => fileMap.get(o.id));\n                    encManifestIv = new Uint8Array(cache.mIv);\n                    encManifestBlob = typeof cache.mBlob === 'string' ? _b64ToU8(cache.mBlob) : new Uint8Array(cache.mBlob);\n                    usedCache = true;\n                }\n            } catch { /* fingerprint changed or cache corrupted — fall through to password prompt */ }\n        }\n\n        if (!usedCache && !key) {\n            hideLoading();\n            key = await _askExportPassword(c);\n            if (!key) return;\n            showLoading('Exporting container…');\n        }\n\n        // Build file data — keep blobs as individual chunks (no giant single allocation)\n        const fileChunks = fileRecs.map(f => new Uint8Array(f.blob instanceof ArrayBuffer ? f.blob : f.blob));\n\n        if (!usedCache) {\n            let offset = 0;\n            const fileManifest = fileRecs.map((f, fi) => {\n                const ivArr = f.iv instanceof Array ? f.iv : Array.from(new Uint8Array(f.iv));\n                const ivB64 = btoa(String.fromCharCode(...ivArr));\n                const entry = { id: f.id, ivB64, offset, size: fileChunks[fi].length };\n                offset += fileChunks[fi].length;\n                return entry;\n            });\n            const encManifest = await Crypto.encryptBin(key, new TextEncoder().encode(JSON.stringify(fileManifest)));\n            encManifestIv = new Uint8Array(encManifest.iv);\n            encManifestBlob = new Uint8Array(encManifest.blob);\n        }\n\n        // Rebuild export cache in IDB if setting is off but cache was absent (e.g. after password-prompted export)\n        if (c.settings?.requireExportPassword === false && !usedCache && key) {\n            try {\n                const order = fileRecs.map((f, fi) => ({ id: f.id, sz: fileChunks[fi].length }));\n                const cacheJson = new TextEncoder().encode(JSON.stringify({\n                    mIv: Array.from(encManifestIv),\n                    mBlob: _u8ToB64(encManifestBlob),\n                    order\n                }));\n                const wrapped = await _wrapExportCache(c.salt, cacheJson);\n                c._exportCache = { wrapped: _u8ToB64(wrapped) };\n                DB.saveContainer(c).catch(() => { }); // async — non-critical\n            } catch { /* non-critical — cache rebuilt on next lock */ }\n        }\n\n        // VFS bytes → meta/0 (iv raw), meta/1 (blob raw)\n        const vfsIvData = vfsRec ? new Uint8Array(vfsRec.iv) : new Uint8Array(0),\n            vfsBlobData = vfsRec ? new Uint8Array(typeof vfsRec.blob === 'string' ? b642buf(vfsRec.blob) : vfsRec.blob) : new Uint8Array(0);\n\n        // container.xml — file manifest is encrypted, no <files> in plaintext\n        const saltB64 = btoa(String.fromCharCode(...new Uint8Array(c.salt))),\n            verIvB64 = btoa(String.fromCharCode(...new Uint8Array(c.verIv)));\n        const settingsToExport = { ...SETTINGS_DEFAULTS, ...(c.settings || {}) };\n        const xmlLines = [\n            '<?xml version=\"1.0\" encoding=\"UTF-8\"?>',\n            `<safenova version=\"3\" exportedAt=\"${now}\">`,\n            '  <container>',\n            `    <name>${_escXml(c.name)}</name>`,\n            `    <createdAt>${c.createdAt}</createdAt>`,\n            `    <salt>${saltB64}</salt>`,\n            `    <verIv>${verIvB64}</verIv>`,\n            `    <verBlob>${c.verBlob}</verBlob>`,\n            `    <totalSize>${c.totalSize || 0}</totalSize>`,\n            '  </container>',\n            '  <settings>',\n            ...Object.entries(settingsToExport).map(([k, v]) => `    <${k}>${_escXml(String(v))}</${k}>`),\n            '  </settings>',\n            '  <files encrypted=\"true\"/>',\n            '</safenova>',\n        ];\n        const xmlData = new TextEncoder().encode(xmlLines.join('\\n'));\n\n        const entries = [\n            { name: 'container.xml', data: xmlData, mtime: now },\n            { name: 'meta/0', data: vfsIvData, mtime: now },\n            { name: 'meta/1', data: vfsBlobData, mtime: now },\n            { name: 'meta/2', data: encManifestIv, mtime: now },\n            { name: 'meta/3', data: encManifestBlob, mtime: now },\n            { name: 'safenova_efs/workspace.bin', data: fileChunks, mtime: now },\n        ];\n\n        // Optionally include activity log (encrypted)\n        if (c.settings?.exportWithLogs === true) {\n            let alogEnc;\n            if (App.container?.id === c.id && typeof _activityLog !== 'undefined' && _activityLog.length) {\n                const compressed = await _compressLog(_activityLog);\n                alogEnc = await Crypto.encrypt(App.key, compressed);\n            } else if (c._alogZ && c._alogZ.iv && c._alogZ.blob) {\n                alogEnc = c._alogZ;\n            }\n            if (alogEnc) {\n                const alogBytes = new TextEncoder().encode(JSON.stringify(alogEnc));\n                entries.push({ name: 'meta/activity_logs/0', data: alogBytes, mtime: now });\n            }\n        }\n        const zipParts = _buildZip(entries);\n        const dateStr = new Date(now).toISOString().slice(0, 10);\n        downloadBuf(zipParts, `SafeNova_${c.name}_${dateStr}.safenova`, 'application/octet-stream');\n        toast(`Container \"${c.name}\" exported`, 'success');\n        logActivity('export-container', c.name);\n    } catch (e) { toast('Export failed: ' + e.message, 'error'); console.error(e); }\n    hideLoading();\n}\n\nasync function importContainerFile(file) {\n    if (!file) return;\n    const MAX_IMPORT_SIZE = CONTAINER_LIMIT; // matches per-container 8 GB limit\n    if (file.size > MAX_IMPORT_SIZE) {\n        toast(`Import file too large (max ${fmtSize(MAX_IMPORT_SIZE)})`, 'error');\n        return;\n    }\n    showLoading('Importing container…');\n    try {\n        const headerBuf = await file.slice(0, 2).arrayBuffer(),\n            u8first = new Uint8Array(headerBuf),\n            isZip = u8first[0] === 0x50 && u8first[1] === 0x4B;\n\n        if (isZip) {\n            const entries = await _readZipFromFile(file);\n            if (!entries['container.xml'] || !entries['meta/0'] || !entries['meta/1'] || !entries['safenova_efs/workspace.bin'])\n                throw new Error('Invalid SafeNova file: missing required entries');\n\n            const xmlText = new TextDecoder('utf-8').decode(entries['container.xml']),\n                doc = new DOMParser().parseFromString(xmlText, 'text/xml');\n            const getText = (parent, sel) => { const el = parent.querySelector(sel); return el ? el.textContent.trim() : null; };\n\n            const nameRaw = getText(doc, 'container > name'),\n                createdAt = parseInt(getText(doc, 'container > createdAt') || '0', 10),\n                saltB64 = getText(doc, 'container > salt'),\n                verIvB64 = getText(doc, 'container > verIv'),\n                verBlob = getText(doc, 'container > verBlob'),\n                totalSize = parseInt(getText(doc, 'container > totalSize') || '0', 10);\n\n            if (!nameRaw || !saltB64 || !verIvB64 || !verBlob)\n                throw new Error('Invalid container.xml: missing required fields');\n\n            // Parse settings block if present\n            const settingsEl = doc.querySelector('settings');\n            let importedSettings;\n            if (settingsEl) {\n                importedSettings = {};\n                Array.from(settingsEl.children).forEach(el => {\n                    const v = el.textContent.trim();\n                    importedSettings[el.tagName] = v === 'true' ? true : v === 'false' ? false : v;\n                });\n            }\n\n            const salt = Array.from(Uint8Array.from(atob(saltB64), ch => ch.charCodeAt(0))),\n                verIv = Array.from(Uint8Array.from(atob(verIvB64), ch => ch.charCodeAt(0)));\n\n            if (entries['meta/2'] && entries['meta/3']) {\n                // v3: import without password — encrypted workspace stored as-is, expanded on first unlock\n                const existing2 = await DB.getContainers();\n                let name = nameRaw, suffix = 2;\n                while (existing2.find(x => x.name.toLowerCase() === name.toLowerCase()))\n                    name = nameRaw + ' (' + suffix++ + ')';\n\n                const newCid = uid();\n                const cObj = {\n                    id: newCid, name, createdAt, salt, verIv, verBlob, totalSize,\n                    settings: importedSettings || undefined,\n                    lazyWorkspace: {\n                        bin: entries['safenova_efs/workspace.bin'],\n                        mIv: entries['meta/2'],\n                        mBlob: entries['meta/3'],\n                    }\n                };\n                // Restore encrypted activity log (if present)\n                if (entries['meta/activity_logs/0']) {\n                    try { cObj._alogZ = JSON.parse(new TextDecoder().decode(entries['meta/activity_logs/0'])); } catch { }\n                } else if (entries['meta/activity_log.zlib']) {\n                    // Legacy: plain compressed bytes — store raw, will be encrypted on first flush\n                    cObj._alogZ = entries['meta/activity_log.zlib'];\n                }\n                await DB.saveContainer(cObj);\n                await DB.saveVFS(newCid, Array.from(entries['meta/0']), entries['meta/1'].buffer);\n                hideLoading();\n                toast(`Container \"${name}\" imported`, 'success');\n                await Home.render();\n                _highlightCard(newCid);\n                return;\n            }\n\n            throw new Error('Unsupported container format. Only SafeNova v3 (.safenova) exports are supported.');\n\n        } else {\n            throw new Error('Unsupported file format. Please use a .safenova export.');\n        }\n    } catch (e) {\n        hideLoading();\n        toast('Import failed: ' + e.message, 'error');\n        console.error(e);\n    }\n}\n\n/* ============================================================\n   THUMBNAIL GENERATION  (async, cached)\n   ============================================================ */\nasync function generateThumb(node) {\n    if (!App.key || !App.container) return null;\n    try {\n        const rec = await DB.getFile(node.id); if (!rec) return null;\n        const buf = await Crypto.decryptBin(App.key, rec.iv, rec.blob);\n        const mime = node.mime || getMime(node.name);\n        if (!isImage(mime)) return null;\n\n        const blob = new Blob([buf], { type: mime }),\n            url = URL.createObjectURL(blob);\n        return new Promise(res => {\n            const img = new Image();\n            img.onload = () => {\n                const canvas = document.createElement('canvas');\n                canvas.width = canvas.height = 56;\n                const ctx = canvas.getContext('2d');\n                const scale = Math.min(56 / img.width, 56 / img.height),\n                    w = img.width * scale,\n                    h = img.height * scale;\n                ctx.fillStyle = '#2d2d30'; ctx.fillRect(0, 0, 56, 56);\n                ctx.drawImage(img, (56 - w) / 2, (56 - h) / 2, w, h);\n                URL.revokeObjectURL(url);\n                res(canvas.toDataURL('image/jpeg', 0.75));\n            };\n            img.onerror = () => { URL.revokeObjectURL(url); res(null); };\n            img.src = url;\n        });\n    } catch { return null; }\n}\n"
  },
  {
    "path": "src/js/home.js",
    "content": "'use strict';\n\n/* ============================================================\n   HOME VIEW\n   ============================================================ */\n\nfunction _loadCardOrder() {\n    try { return JSON.parse(localStorage.getItem('snv-card-order') || '[]'); } catch { return []; }\n}\nfunction _saveCardOrder(ids) {\n    localStorage.setItem('snv-card-order', JSON.stringify(ids));\n}\nfunction _highlightCard(cid) {\n    const card = document.querySelector(`.container-card[data-id=\"${CSS.escape(cid)}\"]`);\n    if (!card) return;\n    card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n    requestAnimationFrame(() => {\n        card.classList.add('highlight');\n        card.addEventListener('animationend', () => card.classList.remove('highlight'), { once: true });\n    });\n}\n\nconst Home = {\n    async render() {\n        InitLog.step('Home.render');\n        const grid = document.getElementById('container-grid'),\n            empty = document.getElementById('container-empty'),\n            containers = await DB.getContainers();\n\n        grid.querySelectorAll('.container-card').forEach(c => c.remove());\n\n        if (containers.length === 0) {\n            empty.style.display = 'flex';\n        } else {\n            empty.style.display = 'none';\n            // Re-validate stored sessions: clear stale/expired ones before rendering badges\n            for (const c of containers) {\n                if (hasSession(c.id)) await loadSession(c.id);\n            }\n            const savedOrder = _loadCardOrder();\n            containers.sort((a, b) => {\n                const ia = savedOrder.indexOf(a.id), ib = savedOrder.indexOf(b.id);\n                if (ia === -1 && ib === -1) return b.createdAt - a.createdAt;\n                if (ia === -1) return 1;\n                if (ib === -1) return -1;\n                return ia - ib;\n            });\n            containers.forEach(c => grid.appendChild(this._makeCard(c)));\n        }\n        await updateStorageInfo();\n\n        // ---- Persistent visibility: warning box (only when 1+ container exists) ----\n        const warnBox = document.getElementById('home-warning-box'),\n            dismissBtn = document.getElementById('warning-dismiss');\n        if (warnBox) {\n            const shouldShow = containers.length > 0 && localStorage.getItem('snv-warn-hide') !== '1';\n            warnBox.classList.toggle('hidden', !shouldShow);\n            if (dismissBtn) {\n                dismissBtn.onclick = () => {\n                    warnBox.classList.add('hidden');\n                    localStorage.setItem('snv-warn-hide', '1');\n                };\n            }\n        }\n\n        // ---- Persistent visibility: doc block ----\n        const docEl = document.getElementById('home-doc'),\n            docTab = document.getElementById('home-doc-tab'),\n            collapseBtn = document.getElementById('home-doc-collapse');\n        const _applyDoc = (hidden) => {\n            if (!docEl || !docTab) return;\n            docEl.classList.toggle('collapsed', hidden);\n            docTab.classList.toggle('visible', hidden);\n        };\n        // Disable transition during initial render to avoid animating on page load\n        if (docEl) docEl.style.transition = 'none';\n        _applyDoc(localStorage.getItem('snv-doc-hide') === '1');\n        // Remove the pre-hide html class now that the correct class is set\n        document.documentElement.classList.remove('snv-doc-hidden');\n        // Re-enable transitions after layout settles\n        if (docEl) requestAnimationFrame(() => { docEl.style.transition = ''; });\n        if (collapseBtn) {\n            collapseBtn.onclick = () => {\n                localStorage.setItem('snv-doc-hide', '1');\n                _applyDoc(true);\n            };\n        }\n        if (docTab) {\n            docTab.onclick = () => {\n                localStorage.setItem('snv-doc-hide', '0');\n                _applyDoc(false);\n            };\n        }\n        InitLog.done('Home.render', containers.length + ' container(s)');\n    },\n\n    _makeCard(c) {\n        const pct = Math.min((c.totalSize || 0) / CONTAINER_LIMIT * 100, 100),\n            fill = pct > 90 ? 'danger' : pct > 70 ? 'warn' : '',\n            hasSess = hasSession(c.id),\n            card = document.createElement('div');\n        card.className = 'container-card';\n        card.dataset.id = c.id;\n        card.innerHTML = `\n      <div class=\"container-drag-handle\" title=\"Drag to reorder\">\n        <svg width=\"10\" height=\"14\" viewBox=\"0 0 10 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n          <circle cx=\"3\" cy=\"2.5\"  r=\"1.2\" fill=\"currentColor\"/>\n          <circle cx=\"7\" cy=\"2.5\"  r=\"1.2\" fill=\"currentColor\"/>\n          <circle cx=\"3\" cy=\"7\"    r=\"1.2\" fill=\"currentColor\"/>\n          <circle cx=\"7\" cy=\"7\"    r=\"1.2\" fill=\"currentColor\"/>\n          <circle cx=\"3\" cy=\"11.5\" r=\"1.2\" fill=\"currentColor\"/>\n          <circle cx=\"7\" cy=\"11.5\" r=\"1.2\" fill=\"currentColor\"/>\n        </svg>\n      </div>\n      <div class=\"container-card-header\">\n        <div class=\"container-card-icon\">\n          <img src=\"favicon.png\" width=\"20\" height=\"20\" alt=\"\" style=\"object-fit:contain;display:block;\">\n        </div>\n        <div>\n          <div class=\"container-card-name\">${escHtml(c.name)}</div>\n          <div class=\"container-card-date\">${fmtDate(c.createdAt)}</div>\n        </div>\n      </div>\n      <div class=\"container-card-body\">\n        <div class=\"container-bar-wrap\">\n          <div class=\"container-bar-fill ${fill}\" style=\"width:${pct.toFixed(1)}%\"></div>\n        </div>\n        <div class=\"container-card-sizes\">\n          <span>${fmtSize(c.totalSize || 0)} used</span>\n          <span>${fmtSize(Math.max(0, CONTAINER_LIMIT - (c.totalSize || 0)))} free</span>\n        </div>\n      </div>\n      ${hasSess ? `<div class=\"session-badge\" title=\"Active session — click to resume\">\n        <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n          <circle cx=\"6\" cy=\"6\" r=\"5\" stroke=\"currentColor\" stroke-width=\"1.3\"/>\n          <path d=\"M4 6l1.5 1.5L8.5 4\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"square\"/>\n        </svg>\n        Session active\n      </div>` : ''}\n      <div class=\"container-card-menu\" data-id=\"${c.id}\" title=\"Options\">\n        <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n          <circle cx=\"8\" cy=\"3\"  r=\"1.2\" fill=\"currentColor\"/>\n          <circle cx=\"8\" cy=\"8\"  r=\"1.2\" fill=\"currentColor\"/>\n          <circle cx=\"8\" cy=\"13\" r=\"1.2\" fill=\"currentColor\"/>\n        </svg>\n      </div>\n    `;\n\n        // Drag-to-reorder via handle — whole card used as ghost image\n        const handle = card.querySelector('.container-drag-handle');\n        handle.setAttribute('draggable', 'true');\n        handle.addEventListener('dragstart', e => {\n            e.dataTransfer.setData('text/plain', c.id);\n            e.dataTransfer.effectAllowed = 'move';\n            // Use the full card as ghost image so it looks like the whole card is being dragged\n            const rect = card.getBoundingClientRect();\n            e.dataTransfer.setDragImage(card, e.clientX - rect.left, e.clientY - rect.top);\n            // Delay adding source class so ghost is captured before card fades\n            requestAnimationFrame(() => card.classList.add('drag-reorder-source'));\n            e.stopPropagation();\n        });\n        handle.addEventListener('dragend', () => card.classList.remove('drag-reorder-source'));\n        card.addEventListener('dragover', e => {\n            e.preventDefault();\n            e.dataTransfer.dropEffect = 'move';\n            card.classList.add('drag-reorder-over');\n        });\n        card.addEventListener('dragleave', e => {\n            if (!card.contains(e.relatedTarget)) card.classList.remove('drag-reorder-over');\n        });\n        card.addEventListener('drop', e => {\n            e.preventDefault();\n            card.classList.remove('drag-reorder-over');\n            const sourceId = e.dataTransfer.getData('text/plain');\n            if (!sourceId || sourceId === c.id) return;\n            const grid2 = document.getElementById('container-grid'),\n                sourceCard = grid2.querySelector(`.container-card[data-id=\"${CSS.escape(sourceId)}\"]`);\n            if (!sourceCard) return;\n\n            // Remove drag-source styling before FLIP snapshot so layout is clean\n            sourceCard.classList.remove('drag-reorder-source');\n\n            const all = [...grid2.querySelectorAll('.container-card')],\n                fromIdx = all.indexOf(sourceCard),\n                toIdx = all.indexOf(card);\n            if (fromIdx === toIdx) return;\n\n            // FLIP — record positions before DOM change\n            const beforeRects = new Map(all.map(el => [el, el.getBoundingClientRect()]));\n\n            // Perform DOM reorder\n            if (fromIdx < toIdx) card.after(sourceCard);\n            else card.before(sourceCard);\n\n            // Force reflow so new positions are calculated\n            grid2.getBoundingClientRect();\n\n            // Apply inverse transforms (no transition yet — jump to old visual position)\n            const movedCards = [];\n            all.forEach(el => {\n                const bef = beforeRects.get(el), aft = el.getBoundingClientRect();\n                const dx = bef.left - aft.left, dy = bef.top - aft.top;\n                if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {\n                    el.style.transition = 'none';\n                    el.style.transform = `translate(${dx}px,${dy}px)`;\n                    movedCards.push(el);\n                }\n            });\n\n            // Second reflow to commit the inverse transforms before animating\n            grid2.getBoundingClientRect();\n\n            // Animate each card to its final (natural) position\n            movedCards.forEach(el => {\n                el.style.transition = 'transform 280ms cubic-bezier(0.25,0.46,0.45,0.94)';\n                el.style.transform = '';\n                el.addEventListener('transitionend', () => { el.style.transition = ''; }, { once: true });\n            });\n\n            const newOrder = [...grid2.querySelectorAll('.container-card')].map(el => el.dataset.id).filter(Boolean);\n            _saveCardOrder(newOrder);\n        });\n\n        card.addEventListener('click', async e => {\n            if (e.target.closest('.container-card-menu') || e.target.closest('.container-drag-handle')) return;\n            _tryOpenContainer(c);\n        });\n        card.querySelector('.container-card-menu').addEventListener('click', e => {\n            e.stopPropagation();\n            showContainerMenu(e, c);\n        });\n        return card;\n    }\n};\n\n/* ---- Container context menu ---- */\nfunction showContainerMenu(e, c) {\n    const hasSess = hasSession(c.id),\n        isOpen = App.container && App.container.id === c.id,\n        reqExpPw = c.settings?.requireExportPassword !== false || !c._exportCache?.wrapped,\n        items = [];\n\n    // Open — resume if session exists, otherwise go to unlock view\n    items.push({\n        label: 'Open', icon: Icons.unlock,\n        _keyHint: !hasSess ? '<span class=\"auth-dot\"></span>' : null,\n        action: () => _tryOpenContainer(c)\n    });\n    items.push({ sep: true });\n\n    if (hasSess) {\n        items.push({ label: 'Kill Session', icon: Icons.lock, action: () => killSession(c) });\n    }\n\n    // Change Password — always visible, disabled when session active or container open\n    const cpDisabled = hasSess || isOpen;\n    items.push({\n        label: 'Change Password…', icon: Icons.key,\n        _keyHint: !cpDisabled ? '<span class=\"auth-dot\"></span>' : null,\n        disabled: cpDisabled,\n        _tooltip: cpDisabled ? 'End the active session first' : null,\n        action: cpDisabled ? null : () => openChangePasswordModal(c)\n    });\n\n    // Rename Container — disabled when session active or container open\n    const rnDisabled = hasSess || isOpen;\n    items.push({\n        label: 'Rename Container…', icon: Icons.rename, disabled: rnDisabled,\n        _tooltip: rnDisabled ? 'End the active session first' : null,\n        action: rnDisabled ? null : () => openRenameContainerModal(c)\n    });\n\n    items.push({ sep: true });\n    items.push({\n        label: 'Export Container', icon: Icons.download,\n        _keyHint: reqExpPw ? '<span class=\"auth-dot\"></span>' : null,\n        action: () => exportContainerFile(c, reqExpPw)\n    });\n    items.push({ sep: true });\n    items.push({ label: 'Delete Container...', icon: Icons.trash, danger: true, action: () => confirmDeleteContainer(c) });\n    showCtxMenu(e.clientX, e.clientY, items);\n}\n\n/* ---- Kill Session ---- */\nfunction killSession(c) {\n    // Notify any tab that has this container open — they will call lockContainer() via the\n    // storage event listener.  forceClaimSession writes kick=true; we then immediately clean\n    // up the claim entry (we're on the home page, not opening this container ourselves).\n    _forceClaimSession(c.id);\n    _stopContainerSession(c.id); // removes the kick entry we just wrote\n    clearSession(c.id);\n    toast(`Session for \"${c.name}\" terminated`, 'info');\n    Home.render();\n}\n\n/* ---- Change Password ---- */\nfunction openChangePasswordModal(c) {\n    ['cp-old', 'cp-new', 'cp-new2'].forEach(id => {\n        const el = document.getElementById(id); el.value = ''; el.type = 'password';\n    });\n    ['cp-old-eye', 'cp-new-eye'].forEach(id => {\n        const btn = document.getElementById(id); if (btn) { btn.style.color = ''; btn.innerHTML = Icons.eye; }\n    });\n    document.getElementById('cp-pw-strength').style.width = '0%';\n    document.getElementById('cp-pw-strength').style.height = '0';\n    document.getElementById('cp-pw-strength').style.marginTop = '0';\n    document.getElementById('cp-pw-strength-label').textContent = '';\n    document.getElementById('cp-pw-strength-label').style.display = 'none';\n    document.getElementById('cp-error').innerHTML = '';\n    document.getElementById('cp-ok')._container = c;\n    Overlay.show('modal-change-pw');\n    setTimeout(() => document.getElementById('cp-old').focus(), 100);\n}\n\nasync function doChangePassword() {\n    const okBtn = document.getElementById('cp-ok'),\n        c = okBtn._container;\n    if (!c) return;\n    const oldPw = document.getElementById('cp-old').value,\n        newPw = document.getElementById('cp-new').value,\n        newPw2 = document.getElementById('cp-new2').value,\n        errEl = document.getElementById('cp-error');\n\n    if (!oldPw) { errEl.textContent = 'Enter current password'; return; }\n    if (newPw.length < 4) { errEl.textContent = 'New password must be at least 4 characters'; return; }\n    if (newPw !== newPw2) { errEl.textContent = 'Passwords do not match'; return; }\n    if (oldPw === newPw) { errEl.textContent = 'New password must differ from current'; return; }\n\n    // Rate-limit check\n    const cpKey = 'cp:' + c.id,\n        cpFails = _failCounts.get(cpKey) || { count: 0, lockUntil: 0 };\n    if (cpFails.lockUntil > Date.now()) return;\n\n    errEl.innerHTML = '';\n    okBtn.disabled = true;\n    let _cpInModal = true;\n\n    try {\n        // Verify old password\n        const oldKey = await Crypto.deriveKey(oldPw, new Uint8Array(c.salt)),\n            ok = await Crypto.checkVerification(oldKey, c.verIv, c.verBlob);\n        if (!ok) {\n            // Duress check — silently corrupts data, then falls through to \"wrong password\"\n            if (await checkDuress(oldPw, c)) await _executeDuress(c);\n            cpFails.count++;\n            _failCounts.set(cpKey, cpFails);\n            if (cpFails.count > 3) {\n                cpFails.lockUntil = Date.now() + 3000;\n                _startAttemptCooldown(errEl, okBtn, () => { cpFails.lockUntil = 0; });\n            } else {\n                errEl.innerHTML = `${_ERR_SVG} Incorrect current password`;\n                okBtn.disabled = false;\n            }\n            return;\n        }\n        _cpInModal = false;\n        _failCounts.delete(cpKey);\n        Overlay.hide();\n        showLoading('Deriving new key…');\n\n        const newSalt = Array.from(crypto.getRandomValues(new Uint8Array(32))),\n            newKey = await Crypto.deriveKey(newPw, new Uint8Array(newSalt));\n\n        // Re-encrypt VFS\n        showLoading('Re-encrypting VFS…');\n        const vfsRec = await DB.getVFS(c.id);\n        if (vfsRec) {\n            const vfsBuf = typeof vfsRec.blob === 'string'\n                ? await Crypto.decrypt(oldKey, vfsRec.iv, vfsRec.blob)\n                : await Crypto.decryptBin(oldKey, vfsRec.iv, vfsRec.blob);\n            const { iv: newVfsIv, blob: newVfsBlob } = await Crypto.encryptBin(newKey, vfsBuf);\n            await DB.saveVFS(c.id, newVfsIv, newVfsBlob);\n        }\n\n        // Expand lazy workspace (if never unlocked) so file blobs enter the re-encryption pass below.\n        // The blobs are encrypted with oldKey — expanding them into IDB first is required\n        // so the per-file loop below can decrypt and re-encrypt each one with newKey.\n        if (c.lazyWorkspace) {\n            showLoading('Restoring files from import\\u2026');\n            const { bin, mIv, mBlob } = c.lazyWorkspace;\n            const mBlobBuf = mBlob instanceof Blob ? await mBlob.arrayBuffer() : (mBlob.buffer || mBlob);\n            const decBuf = await Crypto.decryptBin(oldKey, Array.from(new Uint8Array(mIv)), mBlobBuf);\n            const manifest = JSON.parse(new TextDecoder().decode(decBuf));\n            const filesToExpand = await Promise.all(manifest.map(async m => {\n                const raw = bin.slice(m.offset, m.offset + m.size);\n                return {\n                    id: m.id, cid: c.id,\n                    iv: Array.from(Uint8Array.from(atob(m.ivB64), ch => ch.charCodeAt(0))),\n                    blob: raw instanceof Blob ? await raw.arrayBuffer() : (raw.buffer ?? raw)\n                };\n            }));\n            await DB.saveFiles(filesToExpand);\n            delete c.lazyWorkspace;\n        }\n\n        // Re-encrypt all files fully in parallel (Web Crypto is async/hardware-accelerated;\n        // browser schedules concurrent SubtleCrypto calls across available cores)\n        const files = await DB.getFilesByCid(c.id);\n        showLoading(`Re-encrypting files\\u2026 0/${files.length}`);\n        let _reencDone = 0;\n        const reencResults = await Promise.allSettled(files.map(async f => {\n            const buf = await Crypto.decryptBin(oldKey, f.iv, f.blob);\n            const { iv, blob } = await Crypto.encryptBin(newKey, buf);\n            _reencDone++;\n            if (_reencDone % 4 === 0 || _reencDone === files.length)\n                showLoading(`Re-encrypting files\\u2026 ${_reencDone}/${files.length}`);\n            return { id: f.id, cid: f.cid, iv: Array.from(iv), blob };\n        }));\n        const reencFiles = reencResults\n            .filter(r => r.status === 'fulfilled')\n            .map(r => r.value);\n        if (reencFiles.length) await DB.saveFiles(reencFiles);\n\n\n        // New verification blob\n        showLoading('Finalizing…');\n        const { iv: verIv, blob: verBlob } = await Crypto.makeVerification(newKey);\n\n        // Update container metadata\n        c.salt = newSalt;\n        c.verIv = verIv;\n        c.verBlob = verBlob;\n        delete c._exportCache;\n        await DB.saveContainer(c);\n\n        // Clear any stored sessions (password changed)\n        clearSession(c.id);\n\n        hideLoading();\n        toast(`Password for \"${c.name}\" changed successfully`, 'success');\n        Home.render();\n    } catch (e) {\n        if (_cpInModal) {\n            okBtn.disabled = false;\n            errEl.innerHTML = `${_ERR_SVG} ${e.message}`;\n        } else {\n            hideLoading();\n            toast('Change password failed: ' + e.message, 'error');\n        }\n        console.error(e);\n    }\n}\n\n/* ---- Rename Container ---- */\nfunction openRenameContainerModal(c) {\n    document.getElementById('rc-name').value = c.name;\n    document.getElementById('rc-error').textContent = '';\n    document.getElementById('rc-ok')._container = c;\n    Overlay.show('modal-rename-container');\n    setTimeout(() => {\n        const inp = document.getElementById('rc-name');\n        inp.focus(); inp.select();\n    }, 100);\n}\n\nasync function doRenameContainer() {\n    const okBtn = document.getElementById('rc-ok'),\n        c = okBtn._container;\n    if (!c) return;\n    const name = document.getElementById('rc-name').value.trim(),\n        errEl = document.getElementById('rc-error');\n\n    if (!name) { errEl.textContent = 'Enter a name'; return; }\n    const nameRegex = /^[a-zA-Zа-яА-ЯёЁ0-9 _\\-.,!()@#$%&+=]+$/;\n    if (!nameRegex.test(name)) { errEl.textContent = 'Invalid characters in name'; return; }\n    if (name.toLowerCase() === c.name.toLowerCase()) { Overlay.hide(); return; }\n\n    const existing = await DB.getContainers();\n    if (existing.find(x => x.id !== c.id && x.name.toLowerCase() === name.toLowerCase())) {\n        errEl.textContent = 'A container with this name already exists'; return;\n    }\n\n    c.name = name;\n    await DB.saveContainer(c);\n    Overlay.hide();\n    toast(`Container renamed to \"${name}\"`, 'success');\n    Home.render();\n}\n\nfunction confirmDeleteContainer(c) {\n    document.getElementById('dc-name').textContent = c.name;\n    document.getElementById('dc-msg').textContent =\n        `Container \"${c.name}\" and ALL its files will be permanently erased. This cannot be undone.`;\n    const okBtn = document.getElementById('dc-ok'),\n        okLabel = document.getElementById('dc-ok-label');\n    okBtn._container = c;\n    okBtn.disabled = true;\n    Overlay.show('modal-del-container');\n\n    // 3-second countdown before allowing delete\n    let remaining = 3;\n    okLabel.textContent = `Wait\\u2026 ${remaining}`;\n    const timer = setInterval(() => {\n        remaining--;\n        if (remaining > 0) {\n            okLabel.textContent = `Wait\\u2026 ${remaining}`;\n        } else {\n            clearInterval(timer);\n            okBtn.disabled = false;\n            okLabel.textContent = 'Delete Forever';\n        }\n    }, 1000);\n\n    // Cancel countdown if modal is dismissed\n    okBtn._countdownTimer = timer;\n}\n\n/* ============================================================\n   NEW CONTAINER\n   ============================================================ */\nlet _hwSaltData = null;\n\n/** CRC-32 of a string (for stable UA fingerprint) */\nfunction _crc32str(str) {\n    let crc = 0xFFFFFFFF;\n    for (let i = 0; i < str.length; i++) {\n        crc ^= str.charCodeAt(i);\n        for (let j = 0; j < 8; j++) crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);\n    }\n    return ((crc ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');\n}\n\n/** Derived UA fingerprint as hex string */\nfunction _uaCrc() { return _crc32str(navigator.userAgent); }\n\nconst _KEY_ICON = `<svg width=\"13\" height=\"13\" viewBox=\"0 0 14 14\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><rect x=\"1\" y=\"6\" width=\"9\" height=\"7\" rx=\"1.2\" stroke=\"currentColor\" stroke-width=\"1.3\"/><path d=\"M3.5 6V4a2.5 2.5 0 015 0v2\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"square\"/><circle cx=\"11.5\" cy=\"5.5\" r=\"1.8\" stroke=\"currentColor\" stroke-width=\"1.2\"/><path d=\"M11.5 7.3V9.5\" stroke=\"currentColor\" stroke-width=\"1.2\" stroke-linecap=\"round\"/></svg>`;\n\nconst _HW_STATES = {\n    idle: () => `${_KEY_ICON}<span>Use passkey for salt</span>`,\n    loading: () => `<div class=\"spinner\" style=\"width:11px;height:11px;border-width:1.5px;flex-shrink:0\"></div><span>Connecting…</span>`,\n    ok: () => `<svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\" style=\"color:var(--green);flex-shrink:0\"><path d=\"M1.5 6l3 3 6-6\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"square\"/></svg><span style=\"color:var(--green)\">Salt secured</span>`,\n    fail: () => `${_KEY_ICON}<span>Use passkey for salt</span>`,\n    unavailable: () => `${_KEY_ICON}<span>Use passkey for salt</span>`,\n};\n\n/**\n * Returns a human-readable reason why WebAuthn cannot be used,\n * or null if the API appears available (sync checks only).\n */\nfunction _webAuthnUnavailableReason() {\n    if (!window.isSecureContext)\n        return 'Requires a secure context (HTTPS or localhost)';\n    if (typeof window.PublicKeyCredential === 'undefined')\n        return 'WebAuthn is not supported in this browser';\n    if (!navigator.credentials || typeof navigator.credentials.create !== 'function')\n        return 'Credentials API is not available in this browser';\n    return null;\n}\n\nfunction _setHwBtn(state) {\n    const btn = document.getElementById('nc-hwkey-btn');\n    if (!btn) return;\n    btn.innerHTML = _HW_STATES[state]();\n    btn.disabled = (state === 'loading' || state === 'ok' || state === 'unavailable');\n}\n\nasync function _hwKeyBtnClick() {\n    _setHwBtn('loading');\n    try {\n        _hwSaltData = await _webAuthnSalt();\n        _setHwBtn('ok');\n    } catch (e) {\n        _hwSaltData = null;\n        _setHwBtn('idle');\n    }\n}\n\nfunction openNewContainerModal() {\n    document.getElementById('nc-name').value = '';\n    document.getElementById('nc-pw').value = '';\n    document.getElementById('nc-pw2').value = '';\n    document.getElementById('nc-pw-strength').style.width = '0%';\n    document.getElementById('nc-pw-strength').style.height = '0';\n    document.getElementById('nc-pw-strength').style.marginTop = '0';\n    document.getElementById('nc-pw-strength-label').textContent = '';\n    document.getElementById('nc-pw-strength-label').style.display = 'none';\n    document.getElementById('nc-agree').checked = false;\n    document.getElementById('nc-create').disabled = true;\n    // Reset hardware key button\n    _hwSaltData = null;\n    const hwBtn = document.getElementById('nc-hwkey-btn');\n    if (hwBtn) {\n        hwBtn.classList.add('show');\n        const unavailReason = _webAuthnUnavailableReason();\n        if (unavailReason) {\n            _setHwBtn('unavailable');\n            hwBtn.title = unavailReason;\n        } else {\n            _setHwBtn('idle');\n            hwBtn.title = 'Mix passkey entropy into salt';\n            // Async check: does this device actually have a platform authenticator?\n            if (typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {\n                PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()\n                    .then(ok => {\n                        if (!ok) {\n                            _setHwBtn('unavailable');\n                            hwBtn.title = 'No platform authenticator available on this device';\n                        }\n                    })\n                    .catch(() => {\n                        _setHwBtn('unavailable');\n                        hwBtn.title = 'Platform authenticator check failed';\n                    });\n            }\n        }\n    }\n    Overlay.show('modal-new-container');\n    setTimeout(() => document.getElementById('nc-name').focus(), 100);\n}\n\n/** Generate salt using WebAuthn passkey mixed with CSPRNG */\nasync function _webAuthnSalt() {\n    const challenge = crypto.getRandomValues(new Uint8Array(32)),\n        userId = crypto.getRandomValues(new Uint8Array(16));\n    const cred = await navigator.credentials.create({\n        publicKey: {\n            challenge,\n            rp: { name: 'SafeNova' },\n            user: { id: userId, name: `safenova-${_uaCrc()}`, displayName: 'SafeNova' },\n            pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],\n            authenticatorSelection: { userVerification: 'required' },\n            attestation: 'none',\n            timeout: 60000,\n        }\n    });\n    // Mix authenticatorData with CSPRNG for the final 32-byte salt\n    const authData = new Uint8Array(cred.response.authenticatorData || cred.response.clientDataJSON),\n        rng = crypto.getRandomValues(new Uint8Array(32)),\n        combined = new Uint8Array(authData.length + rng.length);\n    combined.set(authData, 0);\n    combined.set(rng, authData.length);\n    const hashBuf = await crypto.subtle.digest('SHA-256', combined);\n    return Array.from(new Uint8Array(hashBuf));\n}\n\nasync function createContainer() {\n    const name = document.getElementById('nc-name').value.trim(),\n        pw = document.getElementById('nc-pw').value,\n        pw2 = document.getElementById('nc-pw2').value;\n\n    if (!name) { toast('Enter a container name', 'error'); return; }\n    // Allow only letters (Latin + Cyrillic), digits, spaces, and safe filename chars\n    const nameRegex = /^[a-zA-Zа-яА-ЯёЁ0-9 _\\-.,!()@#$%&+=]+$/;\n    if (!nameRegex.test(name)) {\n        toast('Container name contains invalid characters. Use letters, digits, spaces, and safe symbols only', 'error'); return;\n    } if (!document.getElementById('nc-agree').checked) { toast('Please accept the data responsibility terms', 'error'); return; } if (pw.length < 4) { toast('Password must be at least 4 characters', 'error'); return; }\n    if (pw !== pw2) { toast('Passwords do not match', 'error'); return; }\n\n    const existing = await DB.getContainers();\n    if (existing.find(c => c.name.toLowerCase() === name.toLowerCase())) {\n        toast('A container with this name already exists', 'error'); return;\n    }\n\n    // Check device storage before creating\n    const spCheck = await checkStorageSpace(1024 * 1024); // minimal overhead\n    if (!spCheck.ok) {\n        toast(`Not enough device storage (${fmtSize(spCheck.available)} available)`, 'error'); return;\n    }\n\n    showLoading('Creating container and deriving key...');\n    Overlay.hide();\n    try {\n        const salt = _hwSaltData || Array.from(crypto.getRandomValues(new Uint8Array(32))),\n            key = await Crypto.deriveKey(pw, new Uint8Array(salt));\n        const { iv, blob } = await Crypto.makeVerification(key);\n\n        VFS.init();\n        const vfsStr = JSON.stringify(VFS.toObj()),\n            { iv: vfsIv, blob: vfsBlobB64 } = await Crypto.encrypt(key, vfsStr);\n\n        const container = {\n            id: uid(), name, createdAt: Date.now(),\n            salt, verIv: iv, verBlob: blob,\n            totalSize: 0\n        };\n        await DB.saveContainer(container);\n        await DB.saveVFS(container.id, vfsIv, vfsBlobB64);\n        toast(`Container \"${name}\" created`, 'success');\n        await Home.render();\n        _highlightCard(container.id);\n    } catch (e) { toast('Error: ' + e.message, 'error'); console.error(e); }\n    hideLoading();\n}\n\nfunction updatePwStrength(pw, barId = 'nc-pw-strength', lblId = 'nc-pw-strength-label') {\n    const s = pwStrength(pw),\n        pct = [0, 20, 40, 60, 80, 100][s],\n        colors = ['#555', '#f44747', '#ce9178', '#dcdcaa', '#6a9955', '#4ec9b0'],\n        labels = ['', 'Very Weak', 'Weak', 'Fair', 'Strong', 'Very Strong'],\n        bar = document.getElementById(barId),\n        lbl = document.getElementById(lblId);\n    bar.style.width = pct + '%';\n    bar.style.background = colors[s];\n    bar.style.height = pct ? '3px' : '0';\n    bar.style.marginTop = pct ? '4px' : '0';\n    lbl.textContent = pw.length ? labels[s] : '';\n    lbl.style.color = colors[s];\n    lbl.style.display = pw.length ? '' : 'none';\n}\n\n/* ============================================================\n   UNLOCK VIEW\n   ============================================================ */\nlet _unlockContainer = null;\nconst _failCounts = new Map(); // containerId → { count, lockUntil }\n\nfunction _startUnlockCooldown(c) {\n    const fails = _failCounts.get(c.id);\n    _startAttemptCooldown(\n        document.getElementById('unlock-error'),\n        document.getElementById('btn-unlock'),\n        () => { if (fails) fails.lockUntil = 0; }\n    );\n}\n\nfunction openUnlockView(c) {\n    _unlockContainer = c;\n    document.getElementById('unlock-name').textContent = c.name;\n    document.getElementById('unlock-pw').value = '';\n    document.getElementById('unlock-error').innerHTML = '';\n    document.getElementById('unlock-spinner').classList.remove('show');\n    // Pre-fill checkbox and scope based on saved session\n    const hasSessT = !!sessionStorage.getItem('snv-s-' + c.id),\n        hasSessW = !!localStorage.getItem('snv-sb-' + c.id),\n        remEl = document.getElementById('unlock-remember'),\n        opts = document.getElementById('remember-opts'),\n        tabEl = document.getElementById('remember-tab'),\n        brwEl = document.getElementById('remember-browser');\n    if (remEl) {\n        const remembered = hasSessT || hasSessW;\n        remEl.checked = remembered;\n        if (opts) {\n            const radios = opts.querySelectorAll('input[type=\"radio\"]'),\n                labels = opts.querySelectorAll('.remember-opt');\n            radios.forEach(r => r.disabled = !remembered);\n            labels.forEach(l => l.classList.toggle('disabled', !remembered));\n        }\n        if (hasSessW && brwEl) brwEl.checked = true;\n        else if (tabEl) tabEl.checked = true;\n    }\n    App.showView('unlock');\n    setTimeout(() => document.getElementById('unlock-pw').focus(), 100);\n}\n\n// ── Tab deduplication guard ───────────────────────────────────\n\n/** Show the session-conflict modal; `onConfirm` is called if the user\n *  chooses to force-close the other session. */\nfunction _showSessionConflict(c, onConfirm) {\n    document.getElementById('sc-cont-name').textContent = c.name;\n    document.getElementById('sc-force').onclick = () => {\n        _forceClaimSession(c.id);\n        Overlay.hide();\n        onConfirm?.();\n    };\n    document.getElementById('sc-cancel').onclick = () => Overlay.hide();\n    Overlay.show('modal-session-conflict');\n}\n\n/**\n * Single entry point for opening any container.\n * Checks for cross-tab conflict FIRST (before any password prompt),\n * then resumes an existing session or shows the unlock view.\n */\nasync function _tryOpenContainer(c) {\n    if (_checkContainerSession(c.id)) {\n        _showSessionConflict(c, async () => {\n            // Other tab has been kicked — proceed with open\n            const savedPw = await loadSession(c.id);\n            if (savedPw) _resumeSession(c, savedPw); else openUnlockView(c);\n        });\n        return;\n    }\n    const savedPw = await loadSession(c.id);\n    if (savedPw) _resumeSession(c, savedPw); else openUnlockView(c);\n}\n// ────────────────────────────────────────────────────────────────\n\n/* Duress execution — silently corrupts all encrypted file blobs and erases\n   duress traces.  The caller continues with the normal \"wrong password\" flow\n   so the response is indistinguishable from a genuine incorrect attempt.\n   When the real password is later entered the container opens normally but\n   every file decryption will fail (AES-GCM auth tag mismatch). */\nasync function _executeDuress(c) {\n    // 1. Corrupt every encrypted file blob (zeros first 8 bytes → AES-GCM auth tag fails)\n    try { await DB.corruptContainerBlobs(c.id); } catch (e) { console.error('[SafeNova] Duress corruption error:', e); }\n\n    // 2. Erase all duress traces + invalidate export cache\n    delete c.duressHash;\n    delete c._exportCache;\n    await DB.saveContainer(c);\n}\n\nasync function doUnlock() {\n    const c = _unlockContainer; if (!c) return;\n    const pw = document.getElementById('unlock-pw').value;\n    if (!pw) { document.getElementById('unlock-error').innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM7.25 5h1.5v4h-1.5V5zm0 5h1.5v1.5h-1.5V10z\" fill=\"currentColor\"/></svg> Enter password'; return; }\n\n    // Brute-force protection: check if currently locked out\n    const fails = _failCounts.get(c.id) || { count: 0, lockUntil: 0 };\n    if (fails.lockUntil > Date.now()) return; // button is already disabled during lockout\n\n    document.getElementById('unlock-error').innerHTML = '';\n    document.getElementById('unlock-spinner').classList.add('show');\n    document.getElementById('btn-unlock').disabled = true;\n\n    try {\n        const { key, raw: _sessionRawKey } = await Crypto.deriveKeyAndRaw(pw, new Uint8Array(c.salt)),\n            ok = await Crypto.checkVerification(key, c.verIv, c.verBlob);\n\n        if (!ok) {\n            // Duress check — SHA-256 is fast, runs before incrementing fail count.\n            // Silently corrupts data then falls through to normal \"wrong password\" flow.\n            if (await checkDuress(pw, c)) await _executeDuress(c);\n            fails.count++;\n            _failCounts.set(c.id, fails);\n            document.getElementById('unlock-spinner').classList.remove('show');\n            if (fails.count > 3) {\n                fails.lockUntil = Date.now() + 3000;\n                _startUnlockCooldown(c);\n            } else {\n                document.getElementById('unlock-error').innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM7.25 5h1.5v4h-1.5V5zm0 5h1.5v1.5h-1.5V10z\" fill=\"currentColor\"/></svg> Incorrect password';\n                document.getElementById('btn-unlock').disabled = false;\n            }\n            return;\n        }\n\n        // Load VFS (detect binary vs legacy base64 format)\n        const vfsRec = await DB.getVFS(c.id);\n        if (vfsRec) {\n            try {\n                const buf = typeof vfsRec.blob === 'string'\n                    ? await Crypto.decrypt(key, vfsRec.iv, vfsRec.blob)\n                    : await Crypto.decryptBin(key, vfsRec.iv, vfsRec.blob);\n                const json = JSON.parse(new TextDecoder().decode(buf));\n                VFS.fromObj(json);\n            } catch { VFS.init(); }\n        } else { VFS.init(); }\n\n        // Expand lazy workspace if this container was imported without a password\n        let _activeCont = c;\n        if (c.lazyWorkspace) {\n            showLoading('Restoring files from import\\u2026');\n            try {\n                const { bin, mIv, mBlob } = c.lazyWorkspace;\n                const mBlobBuf = mBlob instanceof Blob ? await mBlob.arrayBuffer() : (mBlob.buffer || mBlob);\n                const decBuf = await Crypto.decryptBin(key, Array.from(new Uint8Array(mIv)), mBlobBuf);\n                const manifest = JSON.parse(new TextDecoder().decode(decBuf));\n                const filesToSave = await Promise.all(manifest.map(async m => {\n                    const raw = bin.slice(m.offset, m.offset + m.size);\n                    return {\n                        id: m.id, cid: c.id,\n                        iv: Array.from(Uint8Array.from(atob(m.ivB64), ch => ch.charCodeAt(0))),\n                        blob: raw instanceof Blob ? await raw.arrayBuffer() : (raw.buffer ?? raw)\n                    };\n                }));\n                await DB.saveFiles(filesToSave);\n                const cleanCont = Object.assign({}, c);\n                delete cleanCont.lazyWorkspace;\n                await DB.saveContainer(cleanCont);\n                _activeCont = cleanCont;\n                _unlockContainer = cleanCont;\n            } catch (expandErr) {\n                console.error('Lazy expand failed', expandErr);\n                toast('Could not restore files from import', 'error');\n            }\n            hideLoading();\n        }\n\n        _failCounts.delete(c.id); // reset fail count on successful unlock\n\n        App.container = _activeCont;\n        App.key = key;\n        App.folder = 'root';\n        App.selection.clear();\n        _startContainerSession(_activeCont.id);\n        App.showView('desktop');\n        if (typeof _applySettings === 'function') _applySettings(_getSettings(), true);\n        if (typeof _resetAutoLockTimer === 'function') _resetAutoLockTimer();\n        Desktop.render();\n        if (typeof _cleanOrphanedExportCache === 'function') _cleanOrphanedExportCache().catch(() => { });\n        toast(`Container \"${c.name}\" unlocked`, 'success');\n        // Save or clear session based on checkbox — uses rawKey already derived above (no second Argon2)\n        const remEl = document.getElementById('unlock-remember');\n        if (remEl && remEl.checked) {\n            const scope = document.querySelector('input[name=\"remember-scope\"]:checked')?.value || 'tab';\n            try {\n                await saveSession(c.id, _sessionRawKey, scope);\n            } catch (sessErr) {\n                console.error('[SafeNova] saveSession failed:', sessErr);\n                toast('Session could not be saved — you will need to re-enter the password next time', 'warn');\n            }\n        } else {\n            // Checkbox unchecked — clear any previously saved session\n            clearSession(c.id);\n        }\n    } catch (e) {\n        document.getElementById('unlock-error').innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\"><path d=\"M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM7.25 5h1.5v4h-1.5V5zm0 5h1.5v1.5h-1.5V10z\" fill=\"currentColor\"/></svg> ' + escHtml(e.message);\n        console.error(e);\n    }\n    document.getElementById('unlock-spinner').classList.remove('show');\n    document.getElementById('btn-unlock').disabled = false;\n}\n\n/* ============================================================\n   DELETE CONTAINER (confirmed with password)\n   ============================================================ */\nasync function deleteContainerConfirmed() {\n    const okBtn = document.getElementById('dc-ok');\n    const c = okBtn._container; if (!c) return;\n    if (okBtn.disabled) return;\n\n    // Clear countdown timer if still running\n    if (okBtn._countdownTimer) { clearInterval(okBtn._countdownTimer); okBtn._countdownTimer = null; }\n\n    showLoading('Erasing container...');\n    Overlay.hide();\n    try {\n        await DB.nukeContainer(c.id);\n        // Clear all session tokens and the tab-coordinator claim for this container\n        _stopContainerSession(c.id);\n        clearSession(c.id);\n        hideLoading();\n        await Home.render();\n        toast(`Container \\\"${c.name}\\\" deleted`, 'info');\n    } catch (e) {\n        hideLoading();\n        toast('Delete failed: ' + e.message, 'error');\n    }\n}\n\n/* ============================================================\n   SESSION RESUME  —  auto-unlock using stored sessionStorage password\n   ============================================================ */\nasync function _resumeSession(c, rawKeyBytes) {\n    showLoading('Restoring session...');\n    try {\n        const key = await Crypto.importRawKey(rawKeyBytes),\n            ok = await Crypto.checkVerification(key, c.verIv, c.verBlob);\n        if (!ok) {\n            // Stored session is invalid (password changed?) — clear it and open unlock view\n            clearSession(c.id);\n            hideLoading();\n            openUnlockView(c);\n            return;\n        }\n        const vfsRec = await DB.getVFS(c.id);\n        if (vfsRec) {\n            try {\n                const buf = typeof vfsRec.blob === 'string'\n                    ? await Crypto.decrypt(key, vfsRec.iv, vfsRec.blob)\n                    : await Crypto.decryptBin(key, vfsRec.iv, vfsRec.blob);\n                const json = JSON.parse(new TextDecoder().decode(buf));\n                VFS.fromObj(json);\n            } catch { VFS.init(); }\n        } else { VFS.init(); }\n\n        // Defensive: expand lazyWorkspace if somehow present at session resume\n        if (c.lazyWorkspace) {\n            try {\n                const { bin, mIv, mBlob } = c.lazyWorkspace;\n                const mBlobBuf = mBlob instanceof Blob ? await mBlob.arrayBuffer() : (mBlob.buffer || mBlob);\n                const decBuf = await Crypto.decryptBin(key, Array.from(new Uint8Array(mIv)), mBlobBuf);\n                const manifest = JSON.parse(new TextDecoder().decode(decBuf));\n                const filesToSave = await Promise.all(manifest.map(async m => {\n                    const raw = bin.slice(m.offset, m.offset + m.size);\n                    return {\n                        id: m.id, cid: c.id,\n                        iv: Array.from(Uint8Array.from(atob(m.ivB64), ch => ch.charCodeAt(0))),\n                        blob: raw instanceof Blob ? await raw.arrayBuffer() : (raw.buffer ?? raw)\n                    };\n                }));\n                await DB.saveFiles(filesToSave);\n                const cleanCont = Object.assign({}, c);\n                delete cleanCont.lazyWorkspace;\n                await DB.saveContainer(cleanCont);\n                c = cleanCont;\n            } catch (expandErr) {\n                console.error('Lazy expand in resume failed', expandErr);\n            }\n        }\n\n        App.container = c;\n        App.key = key;\n        App.folder = 'root';\n        App.selection.clear();\n        _startContainerSession(c.id);\n        App.showView('desktop');\n        if (typeof _applySettings === 'function') _applySettings(_getSettings(), true);\n        if (typeof _resetAutoLockTimer === 'function') _resetAutoLockTimer();\n        Desktop.render();\n        if (typeof _cleanOrphanedExportCache === 'function') _cleanOrphanedExportCache().catch(() => { });\n        toast(`Session for \"${c.name}\" restored`, 'success');\n    } catch (e) {\n        hideLoading();\n        toast('Resume failed: ' + e.message, 'error');\n        openUnlockView(c);\n        return;\n    }\n    hideLoading();\n}\n"
  },
  {
    "path": "src/js/initlog.js",
    "content": "'use strict';\n\n/* ============================================================\n   INITLOG  —  Initialization stage console logger\n\n   Usage:\n     InitLog.start()            — open grouped output, start timer\n     InitLog.step('label')      — mark stage start (prints offset from boot)\n     InitLog.done('label', d?)  — mark stage done  (prints elapsed for stage)\n     InitLog.error('label', err)— mark stage failed\n     InitLog.finish()           — close group, print total boot time\n   ============================================================ */\nconst InitLog = (() => {\n    let _t0 = null;\n    const _timers = {};\n\n    const _S = {\n        badge: 'font-size:11px;font-weight:700;color:#1e1e1e;background:#0078d4;padding:1px 6px;border-radius:2px;font-family:Consolas,monospace',\n        title: 'font-size:11px;font-weight:700;color:#0078d4;font-family:Consolas,monospace',\n        step: 'font-size:11px;color:#4ec9b0;font-family:Consolas,monospace',\n        lbl: 'font-size:11px;color:#d4d4d4;font-family:Consolas,monospace',\n        done: 'font-size:11px;color:#89d185;font-family:Consolas,monospace',\n        err: 'font-size:11px;font-weight:700;color:#f44747;font-family:Consolas,monospace',\n        dim: 'font-size:11px;color:#858585;font-family:Consolas,monospace',\n        time: 'font-size:11px;color:#ce9178;font-family:Consolas,monospace',\n    };\n\n    function _ts() { return _t0 != null ? '+' + (performance.now() - _t0).toFixed(1) + 'ms' : ''; }\n    function _elapsed(label) { const t = _timers[label]; return t != null ? (performance.now() - t).toFixed(1) + 'ms' : ''; }\n\n    function start() {\n        _t0 = performance.now();\n        console.groupCollapsed('%c SNV %c Initialization', _S.badge, _S.title);\n    }\n\n    function step(label) {\n        _timers[label] = performance.now();\n        console.log('%c ▶ %c' + label + ' %c' + _ts(), _S.step, _S.lbl, _S.dim);\n    }\n\n    function done(label, detail) {\n        const elapsed = _elapsed(label);\n        const suffix = detail ? '  · ' + detail : '';\n        console.log('%c ✓ %c' + label + suffix + ' %c' + elapsed, _S.done, _S.lbl, _S.time);\n    }\n\n    function error(label, err) {\n        const msg = err instanceof Error ? err.message : String(err ?? '');\n        console.log('%c ✗ %c' + label + ' %c' + msg, _S.err, _S.lbl, _S.err);\n    }\n\n    function finish() {\n        const total = _t0 != null ? (performance.now() - _t0).toFixed(1) : '?';\n        console.log('%c ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', _S.dim);\n        console.log('%c ✔ %cReady  %c' + total + 'ms', _S.done, _S.lbl, _S.time);\n        console.groupEnd();\n    }\n\n    return { start, step, done, error, finish };\n})();\n"
  },
  {
    "path": "src/js/main.js",
    "content": "'use strict';\n\n/* ============================================================\n   CONSOLE SECURITY WARNING\n   ============================================================ */\n(function consoleSecurityWarning() {\n    const W = 60,\n        pad = s => s + ' '.repeat(Math.max(0, W - s.length)),\n        row = s => `║ ${pad(s)} ║`,\n        top = `╔${'═'.repeat(W + 2)}╗`,\n        bot = `╚${'═'.repeat(W + 2)}╝`,\n        sep = `╠${'═'.repeat(W + 2)}╣`,\n        _ = row('');\n\n    const box = [\n        top,\n        _,\n        row('  DO NOT paste any code or commands into this console.'),\n        row('  Not from the internet. Not from anyone. For any reason.'),\n        _,\n        sep,\n        _,\n        row('  A single malicious snippet can silently:'),\n        row('    \\u203a  intercept and exfiltrate your encryption keys'),\n        row('    \\u203a  dump the entire local file storage in plaintext'),\n        row('    \\u203a  steal your container password as you type'),\n        row('    \\u203a  re-encrypt your files with an attacker-controlled key'),\n        _,\n        sep,\n        _,\n        row('  [!] Only use this console if you know what you are doing.'),\n        row('      If someone told you to paste something here \\u2014'),\n        row('      you are being socially engineered.'),\n        _,\n        bot,\n    ].join('\\n');\n\n    const show = () => console.log(\n        '%c STOP %c SafeNova \\u2014 Security Warning\\n%c\\n' + box,\n        'font-size:13px;font-weight:900;color:#1e1e1e;background:#f44747;padding:2px 10px;border-radius:2px;font-family:Consolas,monospace',\n        'font-size:13px;font-weight:700;color:#f44747;font-family:Consolas,monospace',\n        'font-size:12px;color:#d4d4d4;line-height:1;font-family:Consolas,monospace'\n    );\n    show();\n    setInterval(show, 5_000);\n})();\n\n/* ============================================================\n   PASSWORD EYE TOGGLE\n   ============================================================ */\nfunction togglePwEye(inputId, btnId) {\n    const input = document.getElementById(inputId),\n        btn = document.getElementById(btnId);\n    if (input.type === 'password') {\n        input.type = 'text';\n        btn.style.color = 'var(--accent)';\n        btn.innerHTML = Icons.eyeoff;\n    } else {\n        input.type = 'password';\n        btn.style.color = '';\n        btn.innerHTML = Icons.eye;\n    }\n}\n\n/* ============================================================\n   EVENT LISTENERS\n   ============================================================ */\nfunction initEvents() {\n\n    /* ---- Home ---- */\n    document.getElementById('btn-new-container').addEventListener('click', openNewContainerModal);\n    document.getElementById('btn-import-container').addEventListener('click', () => document.getElementById('import-container-input').click());\n    document.getElementById('import-container-input').addEventListener('change', e => {\n        const file = e.target.files[0];\n        if (file) importContainerFile(file);\n        e.target.value = '';\n    });\n\n    /* ---- New Container Modal ---- */\n    document.getElementById('nc-pw').addEventListener('input', e => updatePwStrength(e.target.value));\n    document.getElementById('nc-pw-eye').addEventListener('click', () => togglePwEye('nc-pw', 'nc-pw-eye'));\n    document.getElementById('nc-create').addEventListener('click', createContainer);\n    document.getElementById('nc-cancel').addEventListener('click', () => Overlay.hide());\n    document.getElementById('modal-nc-close').addEventListener('click', () => Overlay.hide());\n    document.getElementById('nc-agree').addEventListener('change', e => {\n        document.getElementById('nc-create').disabled = !e.target.checked;\n    });\n    document.getElementById('nc-hwkey-btn')?.addEventListener('click', _hwKeyBtnClick);\n    document.getElementById('nc-name').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('nc-pw').focus(); });\n    document.getElementById('nc-pw').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('nc-pw2').focus(); });\n    document.getElementById('nc-pw2').addEventListener('keydown', e => { if (e.key === 'Enter') createContainer(); });\n\n    /* ---- Unlock ---- */\n    document.getElementById('btn-back').addEventListener('click', () => App.showView('home'));\n    document.getElementById('btn-unlock').addEventListener('click', doUnlock);\n    document.getElementById('unlock-pw').addEventListener('keydown', e => { if (e.key === 'Enter') doUnlock(); });\n    document.getElementById('unlock-pw-eye').addEventListener('click', () => togglePwEye('unlock-pw', 'unlock-pw-eye'));\n\n    /* ---- Export password modal eye toggle ---- */\n    document.getElementById('exp-eye')?.addEventListener('click', () => {\n        const inp = document.getElementById('exp-pw');\n        const show = inp.type === 'password';\n        inp.type = show ? 'text' : 'password';\n        document.querySelector('#exp-eye .eye-open').style.display = show ? 'none' : '';\n        document.querySelector('#exp-eye .eye-closed').style.display = show ? '' : 'none';\n    });\n\n    /* ---- Remember scope toggle ---- */\n    document.getElementById('unlock-remember').addEventListener('change', e => {\n        const opts = document.getElementById('remember-opts');\n        if (!opts) return;\n        const radios = opts.querySelectorAll('input[type=\"radio\"]'),\n            labels = opts.querySelectorAll('.remember-opt');\n        radios.forEach(r => r.disabled = !e.target.checked);\n        labels.forEach(l => l.classList.toggle('disabled', !e.target.checked));\n    });\n\n    /* ---- Desktop toolbar ---- */\n    document.getElementById('btn-lock').addEventListener('click', () => App.backToMenu());\n    document.getElementById('btn-lock-taskbar').addEventListener('click', () => App.lockContainer());\n\n    document.getElementById('btn-upload-toolbar').addEventListener('click', () => document.getElementById('file-input').click());\n    document.getElementById('btn-new-file-toolbar').addEventListener('click', newTextFile);\n    document.getElementById('btn-new-folder-toolbar').addEventListener('click', newFolder);\n    document.getElementById('btn-settings').addEventListener('click', openSettings);\n    document.getElementById('settings-close').addEventListener('click', () => Overlay.hide());\n    document.getElementById('settings-ok').addEventListener('click', () => Overlay.hide());\n    document.getElementById('file-input').addEventListener('change', e => {\n        uploadFiles(Array.from(e.target.files));\n        e.target.value = '';\n    });\n\n    /* ---- Text Editor ---- */\n    document.getElementById('btn-save-editor').addEventListener('click', saveEditor);\n    document.getElementById('editor-close').addEventListener('click', closeEditor);\n    document.getElementById('editor-textarea').addEventListener('keydown', e => {\n        if (e.ctrlKey && e.code === 'KeyS') { e.preventDefault(); saveEditor(); }\n    });\n\n    /* ---- Unsaved-changes dialog buttons ---- */\n    document.getElementById('editor-unsaved-cancel').addEventListener('click', () => {\n        document.getElementById('editor-unsaved-dialog').style.display = 'none';\n    });\n    document.getElementById('editor-unsaved-discard').addEventListener('click', () => {\n        document.getElementById('editor-unsaved-dialog').style.display = 'none';\n        discardEditor();\n    });\n    document.getElementById('editor-unsaved-save').addEventListener('click', async () => {\n        document.getElementById('editor-unsaved-dialog').style.display = 'none';\n        await saveAndCloseEditor();\n    });\n\n    /* ---- File Viewer ---- */\n    document.getElementById('viewer-close').addEventListener('click', closeViewer);\n\n    /* ---- Properties ---- */\n    document.getElementById('props-close').addEventListener('click', () => Overlay.hide());\n    document.getElementById('props-ok').addEventListener('click', () => Overlay.hide());\n\n    /* ---- Rename ---- */\n    document.getElementById('rename-close').addEventListener('click', () => Overlay.hide());\n    document.getElementById('rename-cancel').addEventListener('click', () => Overlay.hide());\n    document.getElementById('rename-input').addEventListener('keydown', e => {\n        if (e.key === 'Enter') document.getElementById('rename-ok').click();\n    });\n\n    /* ---- Delete confirm ---- */\n    document.getElementById('delete-close').addEventListener('click', () => Overlay.hide());\n    document.getElementById('delete-cancel').addEventListener('click', () => Overlay.hide());\n\n    /* ---- New Text File ---- */\n    document.getElementById('nf-close').addEventListener('click', () => Overlay.hide());\n    document.getElementById('nf-cancel').addEventListener('click', () => Overlay.hide());\n    document.getElementById('nf-ok').addEventListener('click', createTextFile);\n    document.getElementById('nf-name').addEventListener('keydown', e => { if (e.key === 'Enter') createTextFile(); });\n\n    /* ---- New Folder ---- */\n    document.getElementById('nd-close').addEventListener('click', () => Overlay.hide());\n    document.getElementById('nd-cancel').addEventListener('click', () => Overlay.hide());\n    document.getElementById('nd-ok').addEventListener('click', createFolder);\n    document.getElementById('nd-name').addEventListener('keydown', e => { if (e.key === 'Enter') createFolder(); });\n\n    /* ---- Delete Container ---- */\n    document.getElementById('dc-close').addEventListener('click', () => {\n        const t = document.getElementById('dc-ok')._countdownTimer;\n        if (t) { clearInterval(t); document.getElementById('dc-ok')._countdownTimer = null; }\n        Overlay.hide();\n    });\n    document.getElementById('dc-cancel').addEventListener('click', () => {\n        const t = document.getElementById('dc-ok')._countdownTimer;\n        if (t) { clearInterval(t); document.getElementById('dc-ok')._countdownTimer = null; }\n        Overlay.hide();\n    });\n    document.getElementById('dc-ok').addEventListener('click', deleteContainerConfirmed);\n\n    /* ---- Change Password ---- */\n    document.getElementById('cp-close').addEventListener('click', () => Overlay.hide());\n    document.getElementById('cp-cancel').addEventListener('click', () => Overlay.hide());\n    document.getElementById('cp-ok').addEventListener('click', doChangePassword);\n    document.getElementById('cp-old-eye').addEventListener('click', () => togglePwEye('cp-old', 'cp-old-eye'));\n    document.getElementById('cp-new-eye').addEventListener('click', () => togglePwEye('cp-new', 'cp-new-eye'));\n    document.getElementById('cp-new').addEventListener('input', e => updatePwStrength(e.target.value, 'cp-pw-strength', 'cp-pw-strength-label'));\n\n    /* ---- Duress password eyes ---- */\n    document.getElementById('duress-pw-eye').innerHTML = Icons.eye;\n    document.getElementById('duress-pw-eye').addEventListener('click', () => togglePwEye('duress-pw', 'duress-pw-eye'));\n\n    document.getElementById('cp-old').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('cp-new').focus(); });\n    document.getElementById('cp-new').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('cp-new2').focus(); });\n    document.getElementById('cp-new2').addEventListener('keydown', e => { if (e.key === 'Enter') doChangePassword(); });\n\n    /* ---- Rename Container ---- */\n    document.getElementById('rc-close').addEventListener('click', () => Overlay.hide());\n    document.getElementById('rc-cancel').addEventListener('click', () => Overlay.hide());\n    document.getElementById('rc-ok').addEventListener('click', doRenameContainer);\n    document.getElementById('rc-name').addEventListener('keydown', e => { if (e.key === 'Enter') doRenameContainer(); });\n\n    /* ---- Export Confirm ---- */\n    document.getElementById('ec-close').addEventListener('click', () => Overlay.hide());\n    document.getElementById('ec-cancel').addEventListener('click', () => Overlay.hide());\n\n    /* ---- Overlay background click ---- */\n    let _overlayMousedownOnBg = false;\n    const _overlayEl = document.getElementById('modal-overlay');\n    _overlayEl.addEventListener('mousedown', e => {\n        _overlayMousedownOnBg = (e.target === _overlayEl);\n    });\n    _overlayEl.addEventListener('click', e => {\n        if (!_overlayMousedownOnBg || e.target !== _overlayEl) return;\n        _overlayMousedownOnBg = false;\n        const active = Overlay.current;\n        if (active === 'modal-editor') closeEditor();\n        else if (active === 'modal-viewer') closeViewer();\n        else {\n            // Clear delete-container countdown if running\n            const t = document.getElementById('dc-ok')._countdownTimer;\n            if (t) { clearInterval(t); document.getElementById('dc-ok')._countdownTimer = null; }\n            Overlay.hide();\n        }\n    });\n\n    /* ---- Block Ctrl+S system save globally (editor handles its own Ctrl+S) ---- */\n    document.addEventListener('keydown', e => {\n        if (e.ctrlKey && e.code === 'KeyS' && Overlay.current !== 'modal-editor') {\n            e.preventDefault();\n        }\n    }, true);\n\n    /* ---- Mobile burger menu ---- */\n    (function initBurger() {\n        const burger = document.getElementById('topbar-burger');\n        const dd = document.getElementById('topbar-dropdown');\n        if (!burger || !dd) return;\n\n        const _close = () => dd.classList.remove('open'),\n            _toggle = () => dd.classList.toggle('open');\n\n        burger.addEventListener('click', e => { e.stopPropagation(); _toggle(); });\n\n        // Proxy clicks in dropdown to real toolbar buttons\n        document.getElementById('topbar-dd-settings')?.addEventListener('click', () => { _close(); document.getElementById('btn-settings').click(); });\n        document.getElementById('topbar-dd-upload')?.addEventListener('click', () => { _close(); document.getElementById('btn-upload-toolbar').click(); });\n        document.getElementById('topbar-dd-newfile')?.addEventListener('click', () => { _close(); document.getElementById('btn-new-file-toolbar').click(); });\n        document.getElementById('topbar-dd-newfolder')?.addEventListener('click', () => { _close(); document.getElementById('btn-new-folder-toolbar').click(); });\n\n        // Close dropdown on tap outside\n        document.addEventListener('touchstart', e => {\n            if (!e.target.closest('#topbar-dropdown') && !e.target.closest('#topbar-burger')) _close();\n        }, { passive: true });\n        document.addEventListener('mousedown', e => {\n            if (!e.target.closest('#topbar-dropdown') && !e.target.closest('#topbar-burger')) _close();\n        });\n        // Close on Escape\n        document.addEventListener('keydown', e => { if (e.key === 'Escape') _close(); });\n    })();\n\n    /* ---- Dismiss context menu ---- */\n    document.addEventListener('mousedown', e => { if (!e.target.closest('.ctx-menu')) hideCtxMenu(); });\n    document.addEventListener('touchstart', e => { if (!e.target.closest('.ctx-menu')) hideCtxMenu(); }, { passive: true });\n    document.addEventListener('keydown', e => { if (e.key === 'Escape') hideCtxMenu(); });\n\n    /* ---- End key: lock container (only on desktop/folder view, not in text fields) ---- */\n    document.addEventListener('keydown', e => {\n        if (e.key !== 'End') return;\n        const tag = document.activeElement?.tagName;\n        if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;\n        if (!App.key || !App.container) return;\n        App.lockContainer();\n    });\n\n    /* ---- Block native browser context menu globally ---- */\n    document.addEventListener('contextmenu', e => { e.preventDefault(); });\n\n    /* ---- Desktop area events ---- */\n    Desktop.initEvents();\n}\n\n/* ============================================================\n   BOOT\n   ============================================================ */\nwindow.addEventListener('DOMContentLoaded', async () => {\n    // SafeNova Proactive must be loaded and active before the app starts.\n    // daemon.js is injected in <head> before all other scripts; if it\n    // failed to load for any reason the guard token will be absent.\n    // v4: __snvVerify() does canary cross-check (guards against pre-defined __snvGuard).\n    // Legacy fallback: if __snvVerify is absent (v3), fall back to __snvGuard.active alone.\n    const _snvOk = typeof window.__snvVerify === 'function'\n        ? window.__snvVerify() === true\n        : window.__snvGuard?.active === true;\n    if (!_snvOk) {\n        const ol = document.getElementById('loading-overlay');\n        if (ol) {\n            ol.innerHTML = `\n              <div style=\"text-align:center;max-width:380px;padding:0 24px\">\n                <svg width=\"44\" height=\"44\" viewBox=\"0 0 24 24\" fill=\"none\" style=\"color:#f44747;margin-bottom:16px\" xmlns=\"http://www.w3.org/2000/svg\">\n                  <path d=\"M12 2L3 7v5c0 5.25 3.75 10.15 9 11.35C17.25 22.15 21 17.25 21 12V7z\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linejoin=\"round\"/>\n                  <path d=\"M12 8v5\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"/>\n                  <circle cx=\"12\" cy=\"16\" r=\"1\" fill=\"currentColor\"/>\n                </svg>\n                <div style=\"color:var(--text);font-size:16px;font-weight:600;margin-bottom:8px\">SafeNova Proactive failed to initialize</div>\n                <div style=\"color:var(--text-dim);font-size:13px;line-height:1.7\">\n                  The runtime protection module did not load.<br>\n                  The application cannot start without it.\n                </div>\n              </div>`;\n            ol.style.cssText += 'display:flex;opacity:1;pointer-events:all;';\n        }\n        return;\n    }\n    InitLog.start();\n    InitLog.step('initEvents');\n    initEvents();\n    InitLog.done('initEvents');\n    try {\n        await App.init();\n        // Incognito / private-mode check — show warning once per session\n        if (!sessionStorage.getItem('snv-incognito-warned')) {\n            const { isPrivate } = await detectIncognito();\n            if (isPrivate) {\n                await showIncognitoWarning();\n                sessionStorage.setItem('snv-incognito-warned', '1');\n            }\n        }\n    } catch (err) {\n        InitLog.error('App.init', err);\n        // Show a visible error instead of leaving the user on a grey screen\n        const ol = document.getElementById('loading-overlay');\n        if (ol) {\n            ol.innerHTML = `\n              <div style=\"text-align:center;max-width:380px;padding:0 24px\">\n                <svg width=\"44\" height=\"44\" viewBox=\"0 0 24 24\" fill=\"none\" style=\"color:#f44747;margin-bottom:16px\" xmlns=\"http://www.w3.org/2000/svg\">\n                  <path d=\"M12 2L2 20h20z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/>\n                  <path d=\"M12 9v5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n                  <circle cx=\"12\" cy=\"16.5\" r=\"0.8\" fill=\"currentColor\"/>\n                </svg>\n                <div style=\"color:var(--text);font-size:16px;font-weight:600;margin-bottom:8px\">Failed to initialize</div>\n                <div style=\"color:var(--text-dim);font-size:13px;line-height:1.7;margin-bottom:16px\">${escHtml(String(err?.message || err))}</div>\n                <button class=\"btn btn-primary\" onclick=\"location.reload()\">Reload</button>\n              </div>`;\n            ol.style.cssText += 'display:flex;opacity:1;pointer-events:all;';\n        }\n    }\n    InitLog.finish();\n});\n\n/* ============================================================\n   SAFENOVA PROACTIVE — FORCE LOCK\n   ============================================================ */\n// Fired by daemon.js whenever a security threat is detected.\n// F2: Call __snvEmergencyLock first — directly nukes storage and zeroes app state\n// regardless of whether App.lockContainer was patched. Then also try lockContainer\n// for full UI cleanup (close file editor, show lock screen).\n// The reason from e.detail is shown as a toast so the user always sees why the\n// container locked — even when _showAlert is blocked by its 10 s rate-limit.\nwindow.addEventListener('snv:lock', e => {\n    try { window.__snvEmergencyLock?.(); } catch { }\n    if (typeof App !== 'undefined' && App.container) {\n        const reason = e?.detail?.reason;\n        if (typeof reason === 'string' && reason) toast(reason, 'warn');\n        App.lockContainer();\n    }\n});\n\n/* ============================================================\n   SAFENOVA PROACTIVE — DEAD MAN'S SWITCH  (C1)\n   ============================================================ */\n// daemon.js dispatches 'snv:alive' every watchdog tick (~1 s).\n// If the heartbeat goes silent for >3 s the watchdog was killed —\n// lock all containers immediately so keys are not left in memory.\n(() => {\n    let _lastAlive = Date.now(), _lastHbN = 0;\n    window.addEventListener('snv:alive', e => {\n        // Only accept events with a strictly increasing monotonic counter (E5)\n        // capped to a maximum jump of 15 per event.\n        // Without the upper bound an attacker (Self-XSS / extension) can dispatch\n        // snv:alive with n = Number.MAX_SAFE_INTEGER, permanently advancing _lastHbN\n        // so all real daemon ticks (n=1,2,3…) are rejected → forced lockout in 3 s.\n        //\n        // The +15 bound was sized for \"three slow mechanisms × ~1 tick/s = ~3 ticks/s\"\n        // (mechanisms 2/3/4). Mechanism 1 (setInterval 50 ms = 20 ticks/s) was added\n        // later, bringing the real rate to ~23 ticks/s. At that rate, +15 covers only\n        // ~650 ms of startup gap — far too small for a fresh browser session where the\n        // ~485 KB of bottom-of-body scripts must be fetched over the network. During\n        // those network fetches the main thread is idle and the daemon's 50 ms\n        // setInterval fires freely, pushing _heartbeatN past 15 before this listener\n        // is registered. Every subsequent real heartbeat then fails n <= _lastHbN + 15,\n        // _lastAlive is never refreshed, and the DMS fires 3 s after unlock.\n        //\n        // Fix (bootstrap exemption): on the very first accepted event (_lastHbN === 0)\n        // skip the upper-bound check entirely — any n > 0 is accepted. The daemon is\n        // the only source of snv:alive events with a monotonically increasing counter,\n        // so the first event is always genuine. After this single bootstrap event the\n        // strict +15 window is restored, preserving the security guarantee that a\n        // post-load fake-n jump > 15 cannot permanently silence the real heartbeat.\n        const n = e?.detail?.n;\n        const isBootstrap = (_lastHbN === 0);\n        if (typeof n === 'number' && n > _lastHbN && (isBootstrap || n <= _lastHbN + 15)) {\n            _lastHbN = n;\n            _lastAlive = Date.now();\n        }\n    });\n    let _dmsLocking = false;\n    setInterval(() => {\n        if (Date.now() - _lastAlive > 3000 && typeof App !== 'undefined' && App.container && !_dmsLocking) {\n            // F2: __snvEmergencyLock nukes storage and zeroes app state directly,\n            // bypassing a patched App.lockContainer. Then still try lockContainer\n            // for UI cleanup (close editor, show lock screen).\n            // _dmsLocking prevents re-entrant fires while lockContainer is still\n            // running asynchronously (interval period < async completion time).\n            _dmsLocking = true;\n            // BUG-FIX: Pass the reason string so __snvEmergencyLock shows the alert\n            // overlay (previously the user saw the animated veil with no explanation).\n            try { window.__snvEmergencyLock?.('Security guard stopped responding \\u2014 container locked for safety.'); } catch { }\n            toast('Security guard stopped responding — container locked for safety.', 'warn');\n            App.lockContainer().finally(() => { _dmsLocking = false; });\n        }\n    }, 1000);\n})();\n\n/* ============================================================\n   CROSS-TAB SESSION GUARD\n   ============================================================ */\n// When another tab claims (or force-kicks) our container, lock immediately.\nwindow.addEventListener('storage', e => {\n    // ── Kick: another tab force-claimed our container ──────────\n    if (App.container && e.key === 'snv-open-' + App.container.id) {\n        try {\n            const d = e.newValue ? JSON.parse(e.newValue) : null;\n            if (d && d.tab !== _TAB_ID && d.kick) {\n                App.lockContainer();\n                toast('This container was opened in another tab — session ended.', 'warn');\n            }\n        } catch { /* ignore corrupt value */ }\n    }\n\n    // ── Session badge live-update: any session blob change ─────\n    // When another tab saves or clears a remembered session, refresh the home\n    // view so the \"Session active\" badge is immediately up-to-date.\n    if (App.view === 'home' && e.key && (e.key.startsWith('snv-sb-') || e.key.startsWith('snv-s-'))) {\n        Home.render();\n    }\n});\n\n// Release the session claim on tab close / navigation.\n// beforeunload (desktop) shows a dialog when an operation is in progress.\n// pagehide (mobile / bfcache) fires on navigation but is NOT cancelable —\n// calling e.preventDefault() or setting e.returnValue here is a spec no-op,\n// so it only handles session cleanup.\nfunction _onTabUnload(e) {\n    if (_appBusy > 0) { e.preventDefault(); e.returnValue = ''; }\n    if (App.container?.id) _stopContainerSession(App.container.id);\n}\nwindow.addEventListener('beforeunload', _onTabUnload);\nwindow.addEventListener('pagehide', () => {\n    if (App.container?.id) _stopContainerSession(App.container.id);\n});\n\n/* ============================================================\n   SETTINGS HINT TOOLTIPS\n   ============================================================ */\n(function () {\n    let _stip = null;\n\n    document.addEventListener('mouseover', e => {\n        const hint = e.target.closest('.settings-hint');\n        if (!hint || !hint.dataset.tip) return;\n        if (_stip) return;\n        _stip = document.createElement('div');\n        _stip.className = 'settings-tip';\n        // Use \\u2028 in data-tip as line separator — render as <br>\n        _stip.innerHTML = hint.dataset.tip.split('\\u2028').map(s => escHtml(s)).join('<br>');\n        _stip.style.cssText = 'visibility:hidden';\n        document.body.appendChild(_stip);\n        const r = hint.getBoundingClientRect(),\n            tw = _stip.offsetWidth,\n            th = _stip.offsetHeight;\n        let left = r.right + 8,\n            top = r.top + (r.height / 2) - (th / 2);\n        // Flip left if not enough room on the right\n        if (left + tw > window.innerWidth - 8) left = r.left - tw - 8;\n        // Clamp vertically\n        top = Math.min(Math.max(top, 6), window.innerHeight - th - 6);\n        _stip.style.cssText = `left:${left}px;top:${top}px`;\n    });\n\n    document.addEventListener('mouseout', e => {\n        const hint = e.target.closest('.settings-hint');\n        if (!hint) return;\n        // Cursor moved to a child element of the same hint — don't destroy\n        if (hint.contains(e.relatedTarget)) return;\n        if (_stip) { _stip.remove(); _stip = null; }\n    });\n})();\n"
  },
  {
    "path": "src/js/proactive/daemon.js",
    "content": "'use strict';\n\n/* ============================================================\n   SafeNova Proactive — Anti-Tamper Runtime Integrity Guard\n   Version 6\n\n   Loads BEFORE all other application scripts. Every other\n   module checks window.__snvGuard.active at boot; if the\n   guard is absent or inactive the app refuses to start.\n\n   Responsibilities\n   ─────────────────\n   1. Capture all security-critical native function references\n      at the earliest possible moment (before any extension or\n      injected script can tamper), including typed arrays, Blob,\n      URL, timers, and requestAnimationFrame\n\n   2. Validate captured references — confirm every just-captured\n      function is still truly native before trusting it; if any\n      captured reference is already non-native the app refuses\n      to start (pre-capture tampering guard)\n\n   3. Verify that those natives are still native on every tick\n      (1 s interval): crypto.subtle.*, crypto.getRandomValues,\n      IDBFactory, Storage, btoa/atob, TextEncoder, Uint8Array,\n      ArrayBuffer, DataView, Blob, URL, TextDecoder,\n      CompressionStream, DecompressionStream\n\n   4. Install protective hooks on outbound network, DOM, and eval APIs:\n        • fetch / XMLHttpRequest.open / navigator.sendBeacon\n        • WebSocket / window.open / EventSource\n        • Worker / SharedWorker (data: + blob: + external URL blocked)\n        • window.eval / new Function() constructor (E15/E16)\n        • setTimeout / setInterval string callbacks (E14)\n        • Element.setAttribute / innerHTML / outerHTML\n        • insertAdjacentHTML / document.write / document.writeln\n        • Location navigation (assign / replace / href setter)\n        • HTMLFormElement.submit / resource property setters on\n          img / script / iframe / video / audio / embed / object /\n          link / anchor / area prototypes\n        → MutationObserver defense-in-depth on entire document tree\n\n   5. Watchdog resilience — four independent timer mechanisms\n      (setInterval, recursive setTimeout, rAF chain, MessageChannel\n      self-ping) make it impossible to kill the watchdog without\n      page-level control. clearInterval/clearTimeout are guarded —\n      attempts to clear watchdog timer IDs are silently ignored.\n\n   6. Dead man's switch — every tick dispatches 'snv:alive'.\n      main.js monitors the heartbeat; if >3 s silence → auto-lock.\n\n   7. On any real threat (native crypto/storage/encoding/array\n      tamper, or external network request):\n        a. Immediately wipe all snv-* keys from localStorage\n           and sessionStorage using captured native references\n        b. Show a dismissible overlay warning the user\n\n   8. Visibility-change fast check — when tab becomes visible,\n      an immediate full tick runs so attacks during background\n      cannot exploit the ~1 s window.\n\n   9. App function integrity (G1) — critical Crypto module methods\n      (encrypt, decrypt, encryptBin, decryptBin, deriveKey,\n      deriveKeyAndRaw, importRawKey, checkVerification,\n      makeVerification), App.lockContainer, VFS.init, and\n      WinManager.closeAll are wrapped through _mkProxy at window load\n      so toString() reveals only the thin forwarder body (same double-\n      hook pattern as network/DOM hooks).  Raw references are kept\n      closure-private for _wipeAppState.  Proxied references are\n      compared by identity on every tick — replacing any of them via\n      the DevTools console triggers an immediate alert and key-wipe.\n\n  10. Debugger trap (G3) — on threat detection a 'debugger' statement\n      fires every 50 ms for up to 5 minutes. If DevTools are open this\n      pauses the JS engine, blocking follow-up console commands. Has\n      zero cost when DevTools are closed (native no-op).\n\n  11. Console threat log (G2) — every detected threat emits a styled\n      red console.error, providing a forensic trace even if the visual\n      overlay is later dismissed.\n\n   Intentionally excluded from checks:\n   • console namespace — overrides are common and benign\n   • Function.prototype.toString — protected via captured ref\n     (_fnToString); live checks cause false positives from\n     extensions that legitimately wrap it\n   • document.createElement — extensions create elements\n     (including <script>) for their own content scripts;\n     blocking this causes widespread false positives.\n     Note: <script> elements with an external src= injected\n     dynamically after page load ARE silently removed via\n     MutationObserver (section 8b) without triggering a\n     full alert — console trace only\n   • JSON.stringify/parse — DevTools and frameworks patch these\n   • Promise / Promise.prototype.then — polyfills wrap these\n   • performance.now — privacy extensions add jitter\n   • Object.defineProperty — too many legitimate uses\n   • window.location setter — [Unforgeable]; cannot be intercepted\n   Note: eval / new Function() constructor ARE blocked (E15/E16).\n\n   Design philosophy\n   ─────────────────\n   This daemon's primary goal is to protect as many JS primitives and\n   APIs as possible. At the same time the daemon itself uses JS to an\n   absolute minimum: all internal calls go through the frozen `_N`\n   snapshot rather than live globals, loop counters use indexed `for`\n   instead of iterator-based `for…of`, property lookups use the `in`\n   operator instead of hookable Set/Map methods, and string operations\n   use pure operator-level reimplementations (`_pureToLower`,\n   `_pureIndexOf`, `_pureSlice`) built entirely from bracket indexing,\n   `.length`, `+` concatenation, and comparison operators — zero\n   prototype method calls, immune to ANY prototype poisoning.\n   The original captured references (`_strSlice`, `_strToLower`,\n   `_strIndexOf`) are retained solely for boot-time and per-tick\n   native validation — they are never used for actual string operations.\n   As a direct result, the integrity-checking core is well-isolated\n   and resistant to most hook-based attacks: replacing window.fetch,\n   Array.prototype.push, String.prototype.toLowerCase, or other live\n   globals after page load cannot change daemon behaviour — the daemon\n   uses only pure operators for string processing, validates captured\n   references on every tick, and would detect the replacement before\n   an attacker could leverage it.\n   ============================================================ */\n\n(() => {\n\n    // DEBUG: set to true to disable all protection mechanisms\n    // (hooks, alerts, nukeStorage, native checks, debugger trap)\n    // while keeping timers and heartbeat alive. Used to isolate\n    // whether daemon.js is the cause of session invalidation.\n    const _DISABLE_PROACTIVE_ANTITAMPER = false;\n\n    /* ──────────────────────────────────────────────────────────\n       0.  Earliest possible capture — before anything else runs\n       ────────────────────────────────────────────────────────── */\n\n    // BUG-A/B/C/D/F/J/K: Capture security-critical Object/Array/String/RegExp methods at\n    // the very first line — before any code can replace them.  These are IIFE-private\n    // const bindings (non-reassignable) used as safe alternatives to live prototype\n    // calls throughout the guard.\n    //   _freeze     → Object.freeze — used to freeze _N (BUG-D)\n    //   _reTest     → RegExp.prototype.test — used in _isNative & bootstrap (BUG-A)\n    //   _arrPush    → Array.prototype.push — safe array append (BUG-F)\n    //   _strSlice   → String.prototype.slice — prefix check in _nukeStorage (BUG-C)\n    //   _strToLower → String.prototype.toLowerCase — tag/attribute name normalization in\n    //                 DOM exfiltration hooks; replacing with identity passes ONCLICK/SRC\n    //                 uppercase through every on* and resource-attribute check (BUG-K)\n    //   _strIndexOf → String.prototype.indexOf — HTML threat scanner early-exit and\n    //                 attribute extraction; replacing with () => -1 disables the entire\n    //                 scanner and breaks Worker data:/blob: URL blocking (BUG-J)\n    const _freeze = Object.freeze;\n    const _reTest = RegExp.prototype.test;\n    const _arrPush = Array.prototype.push;\n    const _strSlice = String.prototype.slice;\n    const _strToLower = String.prototype.toLowerCase;\n    const _strIndexOf = String.prototype.indexOf;\n\n    // ── Pure operator-level string utilities ──────────────────────\n    // Built entirely from language-level operators: bracket indexing (s[i]),\n    // .length (string internal slot — non-overridable), concatenation (+),\n    // comparison (===, >=, <=), and arithmetic.  ZERO prototype method calls.\n    //\n    // Why not just _reflectApply(_strSlice, …)?\n    //   That chain has two dependencies: captured Reflect.apply + captured\n    //   String.prototype.slice.  Both are validated native at boot and on\n    //   every tick, so the practical risk is near-zero.  But the pure\n    //   implementations remove even this theoretical dependency: they have\n    //   NO external call targets — the JS engine resolves bracket indexing\n    //   and `+` at the bytecode level, entirely outside the prototype\n    //   lookup mechanism.  An attacker who controls every prototype in the\n    //   runtime still cannot affect these functions.\n    //\n    // _LOWER_MAP — frozen A-Z → a-z lookup.  Own-property access via []\n    // uses the engine's internal hash table, NOT Object.prototype.\n    const _LOWER_MAP = _freeze({\n        A: 'a', B: 'b', C: 'c', D: 'd', E: 'e', F: 'f', G: 'g', H: 'h', I: 'i',\n        J: 'j', K: 'k', L: 'l', M: 'm', N: 'n', O: 'o', P: 'p', Q: 'q', R: 'r',\n        S: 's', T: 't', U: 'u', V: 'v', W: 'w', X: 'x', Y: 'y', Z: 'z'\n    });\n\n    // _pureToLower(s) — ASCII toLowerCase.\n    // Only A-Z (U+0041-U+005A) are mapped; all other code points pass through.\n    // Sufficient for daemon.js: HTML tag names, attribute names, URL protocols\n    // and hosts are always pure ASCII per the relevant W3C / WHATWG specs.\n    const _pureToLower = s => {\n        let r = '';\n        for (let i = 0, len = s.length; i < len; i++) {\n            const c = s[i];\n            r += (c >= 'A' && c <= 'Z') ? _LOWER_MAP[c] : c;\n        }\n        return r;\n    };\n\n    // _pureIndexOf(s, needle [, from]) — substring search via nested indexed loop.\n    // Returns first index of needle in s starting at from (default 0), or -1.\n    const _pureIndexOf = (s, needle, from) => {\n        const sLen = s.length, nLen = needle.length;\n        if (nLen === 0) return 0;\n        const start = (from !== void 0 && from > 0) ? from : 0;\n        const limit = sLen - nLen;\n        outer: for (let i = start; i <= limit; i++) {\n            for (let j = 0; j < nLen; j++) {\n                if (s[i + j] !== needle[j]) continue outer;\n            }\n            return i;\n        }\n        return -1;\n    };\n\n    // _pureSlice(s, start [, end]) — substring extraction via bracket + concatenation.\n    const _pureSlice = (s, start, end) => {\n        const len = s.length;\n        let a = start < 0 ? (len + start > 0 ? len + start : 0) : (start > len ? len : start);\n        let b = end === void 0 ? len : (end < 0 ? (len + end > 0 ? len + end : 0) : (end > len ? len : end));\n        let r = '';\n        for (let i = a; i < b; i++) r += s[i];\n        return r;\n    };\n\n    // _pureCollapseAttrSpaces(s) — removes ASCII spaces/tabs around '=' in HTML\n    // attribute assignments (e.g. 'src = \"url\"' → 'src=\"url\"').\n    // Per the HTML5 tokenizer spec an attribute name may be separated from its\n    // value by optional whitespace around the '=' sign; without normalisation\n    // a search for the literal 'src=' misses 'src =' and 'src ='.\n    // Built entirely from bracket indexing, .length, comparison and concatenation —\n    // ZERO prototype method calls, immune to String.prototype poisoning.\n    const _pureCollapseAttrSpaces = s => {\n        let r = '';\n        const len = s.length;\n        let i = 0;\n        // FIX-QUO: track whether we are inside a quoted attribute value so that\n        // '=' characters and surrounding spaces WITHIN a value (e.g. '?w=10&h = 5')\n        // are not collapsed.  Without this, URLs with ' = ' inside their query\n        // string are silently mutated, corrupting the URL shown in alert messages.\n        // 0 = outside quotes, 34 = inside \"-quote, 39 = inside '-quote.\n        let inQuote = 0;\n        while (i < len) {\n            const c = s[i];\n            if (inQuote !== 0) {\n                // Inside a quoted value — pass everything through unchanged.\n                if ((inQuote === 34 && c === '\"') || (inQuote === 39 && c === \"'\")) inQuote = 0;\n                r += c; i++;\n            } else if (c === '\"') {\n                inQuote = 34; r += c; i++;\n            } else if (c === \"'\") {\n                inQuote = 39; r += c; i++;\n            } else if (c === ' ' || c === '\\t') {\n                // Peek ahead: if the first non-space char is '=', these are\n                // pre-'=' spaces — skip them entirely.\n                let j = i;\n                while (j < len && (s[j] === ' ' || s[j] === '\\t')) j++;\n                if (j < len && s[j] === '=') { i = j; continue; }\n                r += c; i++;\n            } else if (c === '=') {\n                r += c; i++;\n                // Skip post-'=' spaces so value starts immediately after '='.\n                while (i < len && (s[i] === ' ' || s[i] === '\\t')) i++;\n            } else {\n                r += c; i++;\n            }\n        }\n        return r;\n    };\n\n    // Capture Function.prototype.toString before anyone can spoof it.\n    // All subsequent \"is this native?\" checks use this reference directly.\n    const _fnToString = Function.prototype.toString;\n\n    // CRIT-1: _isNative MUST use Reflect.apply, NOT _fnToString.call().\n    // Reason: _fnToString.call(fn) resolves .call via the live\n    // Function.prototype.call property.  An attacker who replaces\n    // Function.prototype.call AFTER daemon boot (Self-XSS) with a fake that\n    // always returns '{ [native code] }' makes _isNative return true for ANY\n    // function — including freshly-injected crypto replacements — bypassing\n    // every native check on every tick.\n    // Reflect.apply(fn, thisArg, args) goes directly to the C++ [[Call]]\n    // internal method without touching Function.prototype.call at all.\n    // _reflectApply is captured here at the very top (before _N is built)\n    // so an early replacement of Reflect.apply is still caught by the\n    // pre-capture validation below.\n    const _reflectApply = Reflect.apply;\n\n    // A genuine native function's body is exactly `{ [native code] }` with\n    // nothing else inside — no source lines, no comments.\n    // A simple .includes('[native code]') test is fooled by:\n    //   function fake() { // [native code]\\n  return 1; }\n    // The regex requires [native code] to be the ONLY content of the body,\n    // which covers both Chrome (single-line) and Firefox (indented) formats.\n    // BUG-A: _nativeRe is a const binding; _isNative calls _reTest via _reflectApply\n    // so a post-boot live RegExp.prototype.test replacement cannot make _isNative\n    // return true for every function, bypassing all watchdog native checks.\n    const _nativeRe = /\\{\\s*\\[native code\\]\\s*\\}\\s*$/;\n    const _isNative = fn =>\n        typeof fn === 'function' &&\n        _reflectApply(_reTest, _nativeRe, [_reflectApply(_fnToString, fn, [])]);\n\n    // HEX-1: nibble → hex-char lookup — pure array index operator, zero function calls.\n    // Used for canary generation and _ALERT_HOST_CLS; defined here so both can use it.\n    const _HEX_CHARS = '0123456789abcdef';\n\n    const _origin = window.location.origin;\n\n    // Capture direct object references to the storage instances.\n    // This is done BEFORE building _N because window.localStorage and\n    // window.sessionStorage are live getters — if an attacker later\n    // replaces them with Object.defineProperty, our saved refs still\n    // point to the real storage objects, so _nukeStorage cannot be\n    // fooled by a getter-level interception.\n    const _ls = (() => { try { return window.localStorage; } catch { return null; } })();\n    const _ss = (() => { try { return window.sessionStorage; } catch { return null; } })();\n    // Early capture of CacheStorage and ServiceWorkerContainer instances —\n    // same rationale as _ls/_ss: if an attacker later replaces window.caches\n    // or navigator.serviceWorker via Object.defineProperty, _nukeCachesAndWorkers\n    // still operates on the real objects.\n    const _caches = (() => { try { return (typeof caches !== 'undefined' ? caches : null); } catch { return null; } })();\n    const _sw = (() => { try { return (navigator && navigator.serviceWorker ? navigator.serviceWorker : null); } catch { return null; } })();\n\n    /* ──────────────────────────────────────────────────────────\n       0b. Pre-existence guard-token check\n           If __snvGuard already exists on window an attacker\n           pre-defined it (e.g. via MV2 document_start) to hold\n           active:true while blocking our Object.defineProperty.\n           We record this; __snvVerify will return false.\n       ────────────────────────────────────────────────────────── */\n    const _guardPreexisted = (function () {\n        // V14: Use `in` operator (unhookable) instead of live\n        // Object.prototype.hasOwnProperty.call which goes through the prototype chain.\n        try { return '__snvGuard' in window; } catch { return true; }\n    }());\n\n    /* ──────────────────────────────────────────────────────────\n       0c. Bootstrap-validate Function.prototype.toString and .call\n           BEFORE building _N — avoids circular dependency.\n           Uses structural checks (.name, .length) and String()\n           coercion (a different code path from _fnToString.call)\n           to cross-verify that the foundational helpers are genuine.\n           BUG-A: All regex tests use _reflectApply(_reTest, ...) so a live\n           RegExp.prototype.test replacement at MV2 document_start cannot\n           cause _fnToStringValid / _fnCallValid to return true for fakes.\n           _nativeRe (defined in section 0) is reused — no duplicate.\n       ────────────────────────────────────────────────────────── */\n    const _fnToStringValid =\n        typeof _fnToString === 'function' &&\n        _fnToString.name === 'toString' &&\n        _fnToString.length === 0 &&\n        (function () { try { return _reflectApply(_reTest, _nativeRe, ['' + _fnToString]); } catch { return false; } }());\n    const _fnCallValid =\n        typeof Function.prototype.call === 'function' &&\n        Function.prototype.call.name === 'call' &&\n        Function.prototype.call.length === 1 &&\n        (function () { try { return _reflectApply(_reTest, _nativeRe, ['' + Function.prototype.call]); } catch { return false; } }());\n\n    /* ──────────────────────────────────────────────────────────\n       0d. Native restoration via about:blank iframe\n           MV2 extensions (run_at:document_start) can wrap or\n           replace globals and prototype methods on the main\n           window before daemon.js evaluates.  A programmatically-\n           created about:blank iframe has a completely fresh\n           contentWindow that extensions have never touched.\n           We restore as many security-critical natives as possible\n           from the iframe onto the main window, then immediately\n           remove the iframe from the DOM.  By the time section 1\n           builds _N, it sees the restored native references.\n\n           Restoration order matters:\n             1. Object / Reflect / Array / String / RegExp primitives\n                — restored first so all subsequent Object.defineProperty\n                  calls in this block use the native version.\n             2. Window-level globals (fetch, XHR, timers, URL, …)\n             3. Crypto — window.crypto is [Unforgeable]; methods are\n                restored via Object.defineProperty individually.\n             4. Prototype methods (XHR, EventTarget, Element, Node,\n                Document, Storage, IDB, Location, Navigator, …)\n             5. Console reference (for _N.consoleError)\n\n           Security: Document.prototype.createElement and\n           Node.prototype.appendChild/.removeChild are validated as\n           native via _isNative (from section 0) before use.\n           If any of them is tampered, _canUseIframe is false and\n           section 1b _captureClean checks will refuse boot.\n       ────────────────────────────────────────────────────────── */\n    const _docCreateEl = Document.prototype.createElement;\n    const _nodeAppend = Node.prototype.appendChild;\n    const _nodeRemove = Node.prototype.removeChild;\n    // Verify the DOM creation primitives are native before trusting them.\n    const _canUseIframe = _isNative(_docCreateEl) && _isNative(_nodeAppend) && _isNative(_nodeRemove);\n\n    let _ifrConsoleErr = null;\n    // D3: set to true after the init-phase iframe is removed — enables post-init\n    // <iframe> creation block in _createElementImpl.\n    let _iframeRestoreDone = false;\n\n    if (_canUseIframe) {\n        try {\n            const _ifr = _reflectApply(_docCreateEl, document, ['iframe']);\n            _ifr.style.cssText = 'display:none;width:0;height:0;position:absolute;left:-9999px;top:-9999px';\n            // document.body does not exist when <head> scripts run — use documentElement.\n            _reflectApply(_nodeAppend, document.documentElement, [_ifr]);\n            const _iwin = _ifr.contentWindow;\n\n            if (_iwin && typeof _iwin === 'object') {\n                // Restore target[prop] from iframe value, but only if the value is a\n                // native function — guards against a nested-poison iframe scenario.\n                // Uses simple assignment (not Object.defineProperty) to bypass any\n                // setter-level interception that may still be in place at call time.\n                const _rst = (target, prop, val) => {\n                    if (typeof val === 'function' && _isNative(val)) {\n                        try { target[prop] = val; } catch { }\n                    }\n                };\n\n                // ── 1. Core language primitives ───────────────────────\n                // Restore Object.defineProperty first — all subsequent descriptor-\n                // based restorations (innerHTML, href, storage.length, …) will then\n                // call the native version.\n                if (_iwin.Object) {\n                    _rst(Object, 'defineProperty', _iwin.Object.defineProperty);\n                    _rst(Object, 'getOwnPropertyDescriptor', _iwin.Object.getOwnPropertyDescriptor);\n                    _rst(Object, 'getOwnPropertyDescriptors', _iwin.Object.getOwnPropertyDescriptors);\n                    _rst(Object, 'freeze', _iwin.Object.freeze);\n                    _rst(Object, 'keys', _iwin.Object.keys);\n                    _rst(Object, 'assign', _iwin.Object.assign);\n                }\n                if (_iwin.Reflect) {\n                    _rst(Reflect, 'apply', _iwin.Reflect.apply);\n                    _rst(Reflect, 'construct', _iwin.Reflect.construct);\n                    _rst(Reflect, 'defineProperty', _iwin.Reflect.defineProperty);\n                    _rst(Reflect, 'ownKeys', _iwin.Reflect.ownKeys);\n                }\n                if (_iwin.RegExp) _rst(RegExp.prototype, 'test', _iwin.RegExp.prototype.test);\n                if (_iwin.Array) {\n                    _rst(Array.prototype, 'push', _iwin.Array.prototype.push);\n                    _rst(Array.prototype, 'slice', _iwin.Array.prototype.slice);\n                    _rst(Array, 'from', _iwin.Array.from);\n                    _rst(Array, 'isArray', _iwin.Array.isArray);\n                }\n                if (_iwin.String) {\n                    _rst(String.prototype, 'slice', _iwin.String.prototype.slice);\n                    _rst(String.prototype, 'indexOf', _iwin.String.prototype.indexOf);\n                    _rst(String.prototype, 'toLowerCase', _iwin.String.prototype.toLowerCase);\n                }\n\n                // ── 2. Window-level globals ───────────────────────────\n                _rst(window, 'fetch', _iwin.fetch);\n                _rst(window, 'XMLHttpRequest', _iwin.XMLHttpRequest);\n                _rst(window, 'WebSocket', _iwin.WebSocket);\n                _rst(window, 'EventSource', _iwin.EventSource);\n                _rst(window, 'Worker', _iwin.Worker);\n                _rst(window, 'MutationObserver', _iwin.MutationObserver);\n                _rst(window, 'CustomEvent', _iwin.CustomEvent);\n                // URL and Blob constructors are intentionally NOT restored from\n                // the iframe.  After iframe DOM removal the browsing context is\n                // destroyed; constructors and static methods that depend on the\n                // originating realm (Blob storage, blob-URL registry) produce\n                // objects in a dead context — URL.createObjectURL returns URLs\n                // the browser cannot serve, causing downloads to fall back to\n                // the current HTML page (same class of bug as the crypto .bind()\n                // hang).  The daemon still captures these in _N and validates\n                // nativity every watchdog tick; pre-load tampering is detected\n                // at boot and the app refuses to start.\n                // _rst(window, 'URL',  _iwin.URL);\n                // _rst(window, 'Blob', _iwin.Blob);\n                _rst(window, 'Uint8Array', _iwin.Uint8Array);\n                _rst(window, 'ArrayBuffer', _iwin.ArrayBuffer);\n                _rst(window, 'DataView', _iwin.DataView);\n                _rst(window, 'TextEncoder', _iwin.TextEncoder);\n                _rst(window, 'TextDecoder', _iwin.TextDecoder);\n                _rst(window, 'btoa', _iwin.btoa);\n                _rst(window, 'atob', _iwin.atob);\n                _rst(window, 'setTimeout', _iwin.setTimeout);\n                _rst(window, 'clearTimeout', _iwin.clearTimeout);\n                _rst(window, 'setInterval', _iwin.setInterval);\n                _rst(window, 'clearInterval', _iwin.clearInterval);\n                _rst(window, 'requestAnimationFrame', _iwin.requestAnimationFrame);\n                _rst(window, 'cancelAnimationFrame', _iwin.cancelAnimationFrame);\n                // eval / Function — restored to native here; E15/E16 hooks block them later\n                _rst(window, 'eval', _iwin.eval);\n                _rst(window, 'Function', _iwin.Function);\n                if (_iwin.SharedWorker) _rst(window, 'SharedWorker', _iwin.SharedWorker);\n                if (_iwin.CompressionStream) _rst(window, 'CompressionStream', _iwin.CompressionStream);\n                if (_iwin.DecompressionStream) _rst(window, 'DecompressionStream', _iwin.DecompressionStream);\n\n                // ── 3. Crypto ─────────────────────────────────────────\n                // window.crypto is [Unforgeable] — cannot be replaced as a whole.\n                // Restore getRandomValues and all SubtleCrypto methods individually.\n                const _iCrypto = _iwin.crypto;\n                if (_iCrypto && typeof _iCrypto === 'object' && window.crypto) {\n                    if (typeof _iCrypto.getRandomValues === 'function' && _isNative(_iCrypto.getRandomValues)) {\n                        try {\n                            Object.defineProperty(window.crypto, 'getRandomValues', {\n                                value: _iCrypto.getRandomValues,\n                                writable: true, configurable: true, enumerable: true\n                            });\n                        } catch { }\n                    }\n                    const _iSubtle = _iCrypto.subtle;\n                    if (_iSubtle && typeof _iSubtle === 'object' && window.crypto.subtle) {\n                        const _sM = ['encrypt', 'decrypt', 'importKey', 'exportKey',\n                            'deriveKey', 'deriveBits', 'digest', 'sign',\n                            'verify', 'generateKey', 'wrapKey', 'unwrapKey'];\n                        for (let _si = 0; _si < _sM.length; _si++) {\n                            const _sv = _iSubtle[_sM[_si]];\n                            if (typeof _sv === 'function' && _isNative(_sv)) {\n                                try {\n                                    Object.defineProperty(window.crypto.subtle, _sM[_si], {\n                                        value: _sv,\n                                        writable: true, configurable: true, enumerable: true\n                                    });\n                                } catch { }\n                            }\n                        }\n                    }\n                }\n\n                // ── 4. XHR prototype ──────────────────────────────────\n                if (_iwin.XMLHttpRequest) {\n                    const _iXP = _iwin.XMLHttpRequest.prototype;\n                    _rst(XMLHttpRequest.prototype, 'open', _iXP.open);\n                    _rst(XMLHttpRequest.prototype, 'send', _iXP.send);\n                }\n\n                // ── 5. EventTarget prototype ──────────────────────────\n                if (_iwin.EventTarget) {\n                    const _iETP = _iwin.EventTarget.prototype;\n                    _rst(EventTarget.prototype, 'addEventListener', _iETP.addEventListener);\n                    _rst(EventTarget.prototype, 'dispatchEvent', _iETP.dispatchEvent);\n                    _rst(EventTarget.prototype, 'removeEventListener', _iETP.removeEventListener);\n                }\n\n                // ── 6. Element / Node / Document prototypes ───────────\n                if (_iwin.Element) {\n                    const _iElP = _iwin.Element.prototype;\n                    _rst(Element.prototype, 'setAttribute', _iElP.setAttribute);\n                    _rst(Element.prototype, 'getAttribute', _iElP.getAttribute);\n                    _rst(Element.prototype, 'removeAttribute', _iElP.removeAttribute);\n                    _rst(Element.prototype, 'insertAdjacentHTML', _iElP.insertAdjacentHTML);\n                    _rst(Element.prototype, 'querySelector', _iElP.querySelector);\n                    _rst(Element.prototype, 'querySelectorAll', _iElP.querySelectorAll);\n                    _rst(Element.prototype, 'animate', _iElP.animate);\n                    // innerHTML / outerHTML are accessor descriptors — require defineProperty\n                    const _iInD = Object.getOwnPropertyDescriptor(_iwin.Element.prototype, 'innerHTML');\n                    const _iOuD = Object.getOwnPropertyDescriptor(_iwin.Element.prototype, 'outerHTML');\n                    if (_iInD && typeof _iInD.set === 'function') {\n                        try { Object.defineProperty(Element.prototype, 'innerHTML', _iInD); } catch { }\n                    }\n                    if (_iOuD && typeof _iOuD.set === 'function') {\n                        try { Object.defineProperty(Element.prototype, 'outerHTML', _iOuD); } catch { }\n                    }\n                }\n                if (_iwin.Node) {\n                    const _iNP = _iwin.Node.prototype;\n                    _rst(Node.prototype, 'appendChild', _iNP.appendChild);\n                    _rst(Node.prototype, 'removeChild', _iNP.removeChild);\n                    _rst(Node.prototype, 'insertBefore', _iNP.insertBefore);\n                }\n                if (_iwin.Document) {\n                    const _iDP = _iwin.Document.prototype;\n                    _rst(Document.prototype, 'createElement', _iDP.createElement);\n                    _rst(Document.prototype, 'getElementById', _iDP.getElementById);\n                    _rst(Document.prototype, 'querySelector', _iDP.querySelector);\n                    _rst(Document.prototype, 'querySelectorAll', _iDP.querySelectorAll);\n                    if (_iDP.write) _rst(Document.prototype, 'write', _iDP.write);\n                    if (_iDP.writeln) _rst(Document.prototype, 'writeln', _iDP.writeln);\n                }\n\n                // ── 7. Storage prototype ──────────────────────────────\n                if (_iwin.Storage) {\n                    const _iSP = _iwin.Storage.prototype;\n                    _rst(Storage.prototype, 'getItem', _iSP.getItem);\n                    _rst(Storage.prototype, 'setItem', _iSP.setItem);\n                    _rst(Storage.prototype, 'removeItem', _iSP.removeItem);\n                    _rst(Storage.prototype, 'clear', _iSP.clear);\n                    _rst(Storage.prototype, 'key', _iSP.key);\n                    const _iLD = Object.getOwnPropertyDescriptor(_iwin.Storage.prototype, 'length');\n                    if (_iLD && typeof _iLD.get === 'function') {\n                        try { Object.defineProperty(Storage.prototype, 'length', _iLD); } catch { }\n                    }\n                }\n\n                // ── 8. IDBFactory prototype ───────────────────────────\n                if (_iwin.IDBFactory) {\n                    _rst(IDBFactory.prototype, 'open', _iwin.IDBFactory.prototype.open);\n                }\n\n                // ── 9. HTMLFormElement / Location / Navigator ─────────\n                if (_iwin.HTMLFormElement) {\n                    _rst(HTMLFormElement.prototype, 'submit', _iwin.HTMLFormElement.prototype.submit);\n                }\n                if (_iwin.Location) {\n                    const _iLP = _iwin.Location.prototype;\n                    if (typeof _iLP.assign === 'function') _rst(Location.prototype, 'assign', _iLP.assign);\n                    if (typeof _iLP.replace === 'function') _rst(Location.prototype, 'replace', _iLP.replace);\n                    const _iHD = Object.getOwnPropertyDescriptor(_iwin.Location.prototype, 'href');\n                    if (_iHD && typeof _iHD.set === 'function') {\n                        try { Object.defineProperty(Location.prototype, 'href', _iHD); } catch { }\n                    }\n                }\n                if (_iwin.Navigator && typeof _iwin.Navigator.prototype.sendBeacon === 'function') {\n                    _rst(Navigator.prototype, 'sendBeacon', _iwin.Navigator.prototype.sendBeacon);\n                }\n\n                // ── 10. Typed array prototype methods ─────────────────\n                if (_iwin.Uint8Array) {\n                    const _iU8P = _iwin.Uint8Array.prototype;\n                    _rst(Uint8Array.prototype, 'set', _iU8P.set);\n                    _rst(Uint8Array.prototype, 'subarray', _iU8P.subarray);\n                    _rst(Uint8Array.prototype, 'slice', _iU8P.slice);\n                }\n                if (_iwin.ArrayBuffer) {\n                    _rst(ArrayBuffer.prototype, 'slice', _iwin.ArrayBuffer.prototype.slice);\n                }\n\n                // ── 11. URL static methods ────────────────────────────\n                // createObjectURL / revokeObjectURL are NOT restored for the\n                // same reason as URL and Blob above: calling static methods\n                // whose [[Realm]] is the destroyed iframe produces blob URLs\n                // that cannot be served by the browser.\n                // if (_iwin.URL) {\n                //     _rst(URL, 'createObjectURL', _iwin.URL.createObjectURL);\n                //     _rst(URL, 'revokeObjectURL', _iwin.URL.revokeObjectURL);\n                // }\n\n                // ── 12. Console (for _N.consoleError) ─────────────────\n                if (_iwin.console && typeof _iwin.console.error === 'function') {\n                    _ifrConsoleErr = _iwin.console.error.bind(_iwin.console);\n                }\n            }\n\n            // Remove iframe from DOM — JS references remain valid, node is gone\n            _reflectApply(_nodeRemove, document.documentElement, [_ifr]);\n        } catch { /* frame-src CSP blocked, DOM unavailable — fall back to direct captures */ }\n    }\n    // D3: all init-phase iframe work is complete — block future iframe creation\n    _iframeRestoreDone = true;\n\n    /* ──────────────────────────────────────────────────────────\n       1.  Lock in native references\n       ────────────────────────────────────────────────────────── */\n    // BUG-D: Use captured _freeze (not live Object.freeze) so that replacing\n    // Object.freeze = (x) => x before daemon.js loads cannot leave _N mutable.\n    const _N = _freeze({\n        // Network\n        fetch: window.fetch,\n        xhrOpen: XMLHttpRequest.prototype.open,\n        xhrSend: XMLHttpRequest.prototype.send,\n        sendBeacon: navigator.sendBeacon,\n\n        // DOM\n        createElement: Document.prototype.createElement,\n        appendChild: Element.prototype.appendChild,\n\n        // Crypto\n        getRandomValues: crypto.getRandomValues,\n        subtleEncrypt: crypto.subtle.encrypt,\n        subtleDecrypt: crypto.subtle.decrypt,\n        subtleImportKey: crypto.subtle.importKey,\n        subtleExportKey: crypto.subtle.exportKey,\n        subtleDeriveKey: crypto.subtle.deriveKey,\n        subtleDigest: crypto.subtle.digest,\n\n        // IndexedDB\n        idbOpen: IDBFactory.prototype.open,\n\n        // Storage (prototype-level, covers both localStorage and sessionStorage)\n        storageGetItem: Storage.prototype.getItem,\n        storageSetItem: Storage.prototype.setItem,\n        storageRemoveItem: Storage.prototype.removeItem,\n        storageClear: Storage.prototype.clear,\n        storageKey: Storage.prototype.key,\n        storageLength: Object.getOwnPropertyDescriptor(Storage.prototype, 'length')?.get,\n\n        // Encoding\n        btoa: window.btoa,\n        atob: window.atob,\n        textEncode: TextEncoder.prototype.encode,\n        textDecode: TextDecoder.prototype.decode,\n\n        // Typed Arrays & ArrayBuffer\n        Uint8Array: window.Uint8Array,\n        Uint8ArraySet: Uint8Array.prototype.set,\n        Uint8ArraySubarray: Uint8Array.prototype.subarray,\n        Uint8ArraySlice: Uint8Array.prototype.slice,\n        ArrayBuffer: window.ArrayBuffer,\n        ArrayBufferSlice: ArrayBuffer.prototype.slice,\n        DataView: window.DataView,\n\n        // Blob & URL\n        Blob: window.Blob,\n        URL: window.URL,\n        createObjectURL: URL.createObjectURL,\n        revokeObjectURL: URL.revokeObjectURL,\n\n        // Compression (may be absent in older browsers)\n        CompressionStream: window.CompressionStream ?? null,\n        DecompressionStream: window.DecompressionStream ?? null,\n\n        // Timers — captured BEFORE any code can replace them\n        _setInterval: window.setInterval,\n        _clearInterval: window.clearInterval,\n        _setTimeout: window.setTimeout,\n        _clearTimeout: window.clearTimeout,\n        _requestAnimationFrame: window.requestAnimationFrame,\n\n        // Document.cookie descriptor (validate getter/setter not replaced)\n        cookieDesc: Object.getOwnPropertyDescriptor(Document.prototype, 'cookie'),\n\n        // Function meta-methods — hardening for internal .call()/.apply() usages\n        fnCall: Function.prototype.call,\n        fnApply: Function.prototype.apply,\n\n        // CRIT-1: Reflect.apply — bypasses Function.prototype.call entirely.\n        // Used in _isNative so a live Function.prototype.call replacement cannot\n        // make every native check pass.  Captured here so a pre-load replacement\n        // is caught in the pre-capture validation block.\n        reflectApply: Reflect.apply,\n\n        // CRIT-3: EventTarget.prototype.addEventListener — used for ALL\n        // daemon-internal event subscriptions.  If a MV2 extension replaces this\n        // at document_start before daemon.js runs, our 'load' listener (which\n        // populates G1 refs) would never fire.  Capturing it here and validating\n        // it as native ensures we detect the replacement at boot and use our own\n        // reference for every internal addEventListener call.\n        addEventListener: EventTarget.prototype.addEventListener,\n\n        // CRIT-4: EventTarget.prototype.dispatchEvent — used for heartbeat\n        // (snv:alive) and threat events (snv:lock).  window.dispatchEvent is a\n        // live property; replacing it with a no-op silently drops both events.\n        dispatchEvent: EventTarget.prototype.dispatchEvent,\n\n        // CRIT-4: CustomEvent constructor — captured so an attacker replacing\n        // window.CustomEvent cannot intercept or suppress heartbeat payloads.\n        CustomEvent: window.CustomEvent,\n\n        // cancelAnimationFrame — needed for E4 guard\n        _cancelAnimationFrame: window.cancelAnimationFrame ?? null,\n\n        // MutationObserver — needed for E7 self-healing alert overlay\n        MutationObserver: window.MutationObserver ?? null,\n\n        // DOM access — captured to harden against attacker hooking getElementById/querySelector\n        // after daemon.js loads. Used exclusively inside _wipeAppState() to reach\n        // sensitive UI elements without going through potentially-patched window methods.\n        docGetElementById: Document.prototype.getElementById ?? null,\n        docQuerySelector: Document.prototype.querySelector ?? null,\n        docQuerySelectorAll: Document.prototype.querySelectorAll ?? null,\n        elQuerySelectorAll: Element.prototype.querySelectorAll ?? null,\n\n        // Value setters — wipe decrypted plaintext from editor/input elements\n        // without relying on the script-accessible .value property path which\n        // could be intercepted via Object.defineProperty on the prototype.\n        taValueSetter: Object.getOwnPropertyDescriptor(HTMLTextAreaElement?.prototype, 'value')?.set ?? null,\n        inputValueSetter: Object.getOwnPropertyDescriptor(HTMLInputElement?.prototype, 'value')?.set ?? null,\n\n        // Web Animations API — used to animate the security barrier veil without\n        // a <style> injection (no injectable keyframe name to target via CSS).\n        elementAnimate: Element.prototype.animate ?? null,\n\n        // Diagnostics — prefer iframe-sourced console.error (immune to main-window\n        // wrapping by MV2 extensions at document_start); fall back to direct capture.\n        consoleError: _ifrConsoleErr ?? (console.error?.bind(console) ?? null),\n\n        // DOM exfiltration defense — element methods (D2)\n        setAttribute: Element.prototype.setAttribute,\n        getAttribute: Element.prototype.getAttribute,\n        removeAttribute: Element.prototype.removeAttribute,\n        insertAdjacentHTML: Element.prototype.insertAdjacentHTML ?? null,\n        formSubmit: HTMLFormElement.prototype.submit,\n\n        // DOM exfiltration — property descriptors for src/href/data/innerHTML/outerHTML\n        imgSrcDesc: Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src'),\n        scriptSrcDesc: Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, 'src'),\n        iframeSrcDesc: Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src'),\n        videoSrcDesc: Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'src'),\n        audioSrcDesc: Object.getOwnPropertyDescriptor(HTMLAudioElement.prototype, 'src'),\n        embedSrcDesc: Object.getOwnPropertyDescriptor(HTMLEmbedElement.prototype, 'src'),\n        objectDataDesc: Object.getOwnPropertyDescriptor(HTMLObjectElement.prototype, 'data'),\n        linkHrefDesc: Object.getOwnPropertyDescriptor(HTMLLinkElement.prototype, 'href'),\n        // <a href> and <area href> are navigation-only — NOT auto-loading resources;\n        // hooking them blocks legitimate external links in the app UI (false positives).\n        // ping= on anchors is still blocked via _RESOURCE_ATTRS in setAttribute/MO.\n        innerHTMLDesc: Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML'),\n        outerHTMLDesc: Object.getOwnPropertyDescriptor(Element.prototype, 'outerHTML'),\n\n        // Location defense — captured to block external navigation\n        locAssign: Location.prototype.assign ?? null,\n        locReplace: Location.prototype.replace ?? null,\n        locHrefDesc: Object.getOwnPropertyDescriptor(Location.prototype, 'href') ?? null,\n\n        // Document write defense\n        docWrite: Document.prototype.write ?? null,\n        docWriteln: Document.prototype.writeln ?? null,\n\n        // UI — captured so our alert overlay cannot itself be intercepted\n        alert: window.alert,\n\n        // Location reload — captured so an attacker replacing window.location.reload\n        // after daemon.js boots cannot prevent the post-alert page reload.\n        locationReload: Location.prototype.reload ?? null,\n\n        // Date.now — captured so rate-limiting (_ALERT_COOLDOWN_MS) and healer\n        // deadline cannot be bypassed by replacing Date.now after boot.\n        dateNow: Date.now,\n\n        // CacheStorage and ServiceWorkerContainer prototype methods — captured so\n        // _nukeCachesAndWorkers uses native methods even if the live globals are\n        // later replaced via Object.defineProperty.  Optional: may be null in\n        // environments that lack the Cache API or Service Worker support.\n        cacheStorageKeys: (typeof CacheStorage !== 'undefined' && CacheStorage.prototype && typeof CacheStorage.prototype.keys === 'function') ? CacheStorage.prototype.keys : null,\n        cacheStorageDelete: (typeof CacheStorage !== 'undefined' && CacheStorage.prototype && typeof CacheStorage.prototype.delete === 'function') ? CacheStorage.prototype.delete : null,\n        swGetRegistrations: (typeof ServiceWorkerContainer !== 'undefined' && ServiceWorkerContainer.prototype && typeof ServiceWorkerContainer.prototype.getRegistrations === 'function') ? ServiceWorkerContainer.prototype.getRegistrations : null,\n\n        // Promise.prototype.then — used in _nukeCachesAndWorkers to chain async\n        // cache/SW wipe callbacks.  If an attacker replaces Promise.prototype.then\n        // after boot, the entire cache and SW wipe silently becomes a no-op.\n        // Must be validated native at both boot and on every tick.\n        promiseThen: Promise.prototype.then,\n\n        // ServiceWorkerRegistration.prototype.unregister — called per-registration\n        // in _nukeCachesAndWorkers.  Captured so a post-boot replacement of this\n        // prototype method cannot prevent SW unregistration during threat response.\n        swUnregister: (typeof ServiceWorkerRegistration !== 'undefined' && ServiceWorkerRegistration.prototype && typeof ServiceWorkerRegistration.prototype.unregister === 'function') ? ServiceWorkerRegistration.prototype.unregister : null,\n\n        // MessagePort.prototype.postMessage — used in the MessageChannel (mechanism 4)\n        // watchdog self-ping.  If an attacker replaces this after boot, the fourth\n        // watchdog mechanism silently dies; the other three remain active but the\n        // defence-in-depth guarantee is weakened.\n        portPostMessage: (typeof MessagePort !== 'undefined' && MessagePort.prototype && typeof MessagePort.prototype.postMessage === 'function') ? MessagePort.prototype.postMessage : null,\n    });\n\n    /* ──────────────────────────────────────────────────────────\n       1b. Pre-capture validation — confirm EVERY captured ref is\n           truly native before we trust it.  If malicious code ran\n           before daemon.js (e.g. via a MV2 document_start content\n           script), any of the just-captured references could\n           already be non-native wrappers.  We fail hard here:\n           set __snvGuard.active = false so the app refuses to boot.\n       ────────────────────────────────────────────────────────── */\n    const _CAPTURE_MUST_BE_NATIVE = [\n        _N.fetch, _N.xhrOpen, _N.xhrSend,\n        _N.getRandomValues,\n        _N.subtleEncrypt, _N.subtleDecrypt, _N.subtleImportKey,\n        _N.subtleExportKey, _N.subtleDeriveKey, _N.subtleDigest,\n        _N.idbOpen,\n        _N.storageGetItem, _N.storageSetItem, _N.storageRemoveItem,\n        _N.storageClear, _N.storageKey,\n        _N.btoa, _N.atob,\n        _N.textEncode, _N.textDecode,\n        _N.Uint8ArraySet, _N.Uint8ArraySubarray, _N.Uint8ArraySlice,\n        _N.ArrayBufferSlice,\n        _N.createObjectURL, _N.revokeObjectURL,\n        _N._setInterval, _N._clearInterval, _N._setTimeout,\n        _N._clearTimeout, _N._requestAnimationFrame,\n        _N.fnCall, _N.fnApply,\n        // CRIT-1: Reflect.apply must be native (see _isNative comment above)\n        _N.reflectApply,\n        // CRIT-3/4: event subscription and dispatch must be native\n        _N.addEventListener, _N.dispatchEvent,\n        // BUG-A/C/D/F/J/K: newly-captured Array/String/Object/RegExp methods\n        _reTest, _freeze, _arrPush, _strSlice, _strToLower, _strIndexOf,\n        // DOM exfiltration — core methods (D2)\n        _N.setAttribute, _N.getAttribute, _N.formSubmit,\n        // Date.now — used for alert rate-limiting and healer deadline\n        _N.dateNow,\n        // Promise.prototype.then — used for async cache/SW wipe callbacks\n        _N.promiseThen,\n    ];\n    // MessagePort.prototype.postMessage — optional (absent in very old browsers)\n    if (_N.portPostMessage) _CAPTURE_MUST_BE_NATIVE[_CAPTURE_MUST_BE_NATIVE.length] = _N.portPostMessage;\n    // Constructors are native but toString prints differently — check them\n    // with _isNative which already handles \"function Uint8Array() { [native code] }\"\n    const _CAPTURE_MUST_BE_NATIVE_CTORS = [\n        _N.Uint8Array, _N.ArrayBuffer, _N.DataView, _N.Blob, _N.URL,\n        // CRIT-4: CustomEvent ctor must be native (see comment above)\n        _N.CustomEvent,\n    ];\n    // Optional: CompressionStream / DecompressionStream may be null in older browsers\n    // BUG-F: index assignment avoids live Array.prototype.push\n    if (_N.CompressionStream) _CAPTURE_MUST_BE_NATIVE_CTORS[_CAPTURE_MUST_BE_NATIVE_CTORS.length] = _N.CompressionStream;\n    if (_N.DecompressionStream) _CAPTURE_MUST_BE_NATIVE_CTORS[_CAPTURE_MUST_BE_NATIVE_CTORS.length] = _N.DecompressionStream;\n    // DOM exfiltration — optional method captures (null in older browsers)\n    if (_N.insertAdjacentHTML) _CAPTURE_MUST_BE_NATIVE[_CAPTURE_MUST_BE_NATIVE.length] = _N.insertAdjacentHTML;\n    if (_N.locAssign) _CAPTURE_MUST_BE_NATIVE[_CAPTURE_MUST_BE_NATIVE.length] = _N.locAssign;\n    if (_N.locReplace) _CAPTURE_MUST_BE_NATIVE[_CAPTURE_MUST_BE_NATIVE.length] = _N.locReplace;\n    if (_N.docWrite) _CAPTURE_MUST_BE_NATIVE[_CAPTURE_MUST_BE_NATIVE.length] = _N.docWrite;\n    if (_N.docWriteln) _CAPTURE_MUST_BE_NATIVE[_CAPTURE_MUST_BE_NATIVE.length] = _N.docWriteln;\n    // CacheStorage / ServiceWorkerContainer methods — optional (absent in some browsers)\n    if (_N.cacheStorageKeys) _CAPTURE_MUST_BE_NATIVE[_CAPTURE_MUST_BE_NATIVE.length] = _N.cacheStorageKeys;\n    if (_N.cacheStorageDelete) _CAPTURE_MUST_BE_NATIVE[_CAPTURE_MUST_BE_NATIVE.length] = _N.cacheStorageDelete;\n    if (_N.swGetRegistrations) _CAPTURE_MUST_BE_NATIVE[_CAPTURE_MUST_BE_NATIVE.length] = _N.swGetRegistrations;\n    // locationReload — optional (may be null in non-standard environments)\n    if (_N.locationReload) _CAPTURE_MUST_BE_NATIVE[_CAPTURE_MUST_BE_NATIVE.length] = _N.locationReload;\n    // swUnregister — optional (absent in browsers without Service Worker support)\n    if (_N.swUnregister) _CAPTURE_MUST_BE_NATIVE[_CAPTURE_MUST_BE_NATIVE.length] = _N.swUnregister;\n\n    // BUG-E: for...of relies on Array.prototype[Symbol.iterator]; replacing it\n    // before daemon.js loads makes both loops iterate zero elements, so\n    // _captureClean stays true even when every capture is already tainted.\n    // Indexed for-loops access elements by numeric index — immune to any\n    // prototype or Symbol replacement.\n    let _captureClean = true;\n    for (let _ci = 0; _ci < _CAPTURE_MUST_BE_NATIVE.length; _ci++) {\n        if (!_isNative(_CAPTURE_MUST_BE_NATIVE[_ci])) { _captureClean = false; break; }\n    }\n    if (_captureClean) {\n        for (let _ci = 0; _ci < _CAPTURE_MUST_BE_NATIVE_CTORS.length; _ci++) {\n            if (!_isNative(_CAPTURE_MUST_BE_NATIVE_CTORS[_ci])) { _captureClean = false; break; }\n        }\n    }\n    // storageLength is a getter, not a plain function — validate separately\n    if (_captureClean && _N.storageLength && typeof _N.storageLength !== 'function') {\n        _captureClean = false;\n    }\n    // Factor in bootstrap validation results\n    if (_captureClean && (!_fnToStringValid || !_fnCallValid)) {\n        _captureClean = false;\n    }\n    // Structural validation for early-captured methods (cannot use _isNative —\n    // _reTest is itself one of them, creating a circular dependency).\n    // .name checks catch naive spoofing attempts that forget to copy the name.\n    if (_captureClean &&\n        (typeof _reTest !== 'function' || _reTest.name !== 'test' ||\n            typeof _freeze !== 'function' || _freeze.name !== 'freeze' ||\n            typeof _arrPush !== 'function' || _arrPush.name !== 'push' ||\n            typeof _strSlice !== 'function' || _strSlice.name !== 'slice' ||\n            typeof _strToLower !== 'function' || _strToLower.name !== 'toLowerCase' ||\n            typeof _strIndexOf !== 'function' || _strIndexOf.name !== 'indexOf')) {\n        _captureClean = false;\n    }\n    // Factor in pre-existence of __snvGuard — indicates attacker setup\n    if (_captureClean && _guardPreexisted) {\n        _captureClean = false;\n    }\n\n    // Session canary — CRIT-2: generated with CSPRNG, NOT Math.random().\n    // V8’s Math.random() uses xorshift128+: observing 5–7 subsequent outputs\n    // allows an attacker to recover the PRNG state and reverse-compute past\n    // values, making the canary predictable and __snvVerify forgeable.\n    // _N.getRandomValues (already captured & validated above) uses the\n    // browser’s OS-level CSPRNG — its output is irreversible.\n    let _canary;\n    try {\n        const _cb = new _N.Uint8Array(16); // 128 bits of entropy\n        _reflectApply(_N.getRandomValues, crypto, [_cb]);\n        let _cs = '';\n        for (let _i = 0; _i < 16; _i++) {\n            const _b = _cb[_i];\n            // HEX-1: bitwise nibble extraction — no Number.prototype.toString call\n            _cs += _HEX_CHARS[_b >> 4] + _HEX_CHARS[_b & 15];\n        }\n        _canary = _cs;\n    } catch {\n        // Only reachable if crypto is tampered — _captureClean is false in that\n        // case so the app refuses to boot regardless of the canary value.\n        // HEX-1: inline hex from bitwise ops — avoids Number.prototype.toString\n        const _r0 = Math.random() * 0xffffffff >>> 0;\n        const _r1 = Math.random() * 0xffffffff >>> 0;\n        let _fb = '';\n        for (let _fi = 28; _fi >= 0; _fi -= 4) _fb += _HEX_CHARS[(_r0 >>> _fi) & 15];\n        for (let _fi = 28; _fi >= 0; _fi -= 4) _fb += _HEX_CHARS[(_r1 >>> _fi) & 15];\n        _canary = _fb;\n    }\n\n    /* ──────────────────────────────────────────────────────────\n       2.  Functions that must remain native (we never hook them)\n           Each entry: [display name, live-reference getter]\n\n           Function.prototype.toString is deliberately excluded:\n           it is protected by the captured _fnToString reference\n           and extensions (Adblock, Dark Reader) routinely wrap\n           it, causing false positives on every tick.\n       ────────────────────────────────────────────────────────── */\n    const _NATIVE_CHECKS = [\n        // Crypto\n        ['crypto.getRandomValues', () => crypto.getRandomValues],\n        ['crypto.subtle.encrypt', () => crypto.subtle.encrypt],\n        ['crypto.subtle.decrypt', () => crypto.subtle.decrypt],\n        ['crypto.subtle.importKey', () => crypto.subtle.importKey],\n        ['crypto.subtle.exportKey', () => crypto.subtle.exportKey],\n        ['crypto.subtle.deriveKey', () => crypto.subtle.deriveKey],\n        ['crypto.subtle.digest', () => crypto.subtle.digest],\n        // IndexedDB\n        ['IDBFactory.prototype.open', () => IDBFactory.prototype.open],\n        // Storage\n        ['Storage.prototype.getItem', () => Storage.prototype.getItem],\n        ['Storage.prototype.setItem', () => Storage.prototype.setItem],\n        ['Storage.prototype.removeItem', () => Storage.prototype.removeItem],\n        // Encoding\n        ['btoa', () => window.btoa],\n        ['atob', () => window.atob],\n        ['TextEncoder.prototype.encode', () => TextEncoder.prototype.encode],\n        ['TextDecoder.prototype.decode', () => TextDecoder.prototype.decode],\n        // Typed Arrays & ArrayBuffer (A1)\n        ['Uint8Array', () => window.Uint8Array],\n        ['Uint8Array.prototype.set', () => Uint8Array.prototype.set],\n        ['Uint8Array.prototype.subarray', () => Uint8Array.prototype.subarray],\n        ['Uint8Array.prototype.slice', () => Uint8Array.prototype.slice],\n        ['ArrayBuffer', () => window.ArrayBuffer],\n        ['ArrayBuffer.prototype.slice', () => ArrayBuffer.prototype.slice],\n        ['DataView', () => window.DataView],\n        // Blob & URL (A2)\n        ['Blob', () => window.Blob],\n        ['URL', () => window.URL],\n        ['URL.createObjectURL', () => URL.createObjectURL],\n        ['URL.revokeObjectURL', () => URL.revokeObjectURL],\n    ];\n    // BUG-F: All .push() replaced with arr[arr.length]=x — index assignment avoids\n    // live Array.prototype.push which could be silently replaced before daemon runs.\n    // CompressionStream / DecompressionStream — optional in older browsers\n    if (window.CompressionStream) {\n        _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['CompressionStream', () => window.CompressionStream];\n    }\n    if (window.DecompressionStream) {\n        _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['DecompressionStream', () => window.DecompressionStream];\n    }\n    // Function meta-methods (E1b)\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Function.prototype.call', () => Function.prototype.call];\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Function.prototype.apply', () => Function.prototype.apply];\n    // CRIT-1: Reflect.apply — must stay native to protect _isNative\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Reflect.apply', () => Reflect.apply];\n    // CRIT-3/4: event subscription and dispatch must stay native\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['EventTarget.prototype.addEventListener', () => EventTarget.prototype.addEventListener];\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['EventTarget.prototype.dispatchEvent', () => EventTarget.prototype.dispatchEvent];\n    // CRIT-6: XHR.send — captured at boot but was missing from live checks\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['XMLHttpRequest.prototype.send', () => XMLHttpRequest.prototype.send];\n    // BUG-A/C/D/F: additional captured methods must stay native on every tick\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['RegExp.prototype.test', () => RegExp.prototype.test];\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Object.freeze', () => Object.freeze];\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Array.prototype.push', () => Array.prototype.push];\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['String.prototype.slice', () => String.prototype.slice];\n    // BUG-E: Symbol.iterator — detect if array iteration is poisoned\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Array.prototype[Symbol.iterator]', () => Array.prototype[Symbol.iterator]];\n    // DOM exfiltration — getAttribute must stay native (used by MO defense layer)\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Element.prototype.getAttribute', () => Element.prototype.getAttribute];\n    // BUG-J/K: toLowerCase and indexOf — used throughout DOM exfiltration hooks;\n    // replacing either post-boot bypasses attribute/tag-name checks or the entire\n    // HTML threat scanner early-exit and attribute extraction logic.\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['String.prototype.toLowerCase', () => String.prototype.toLowerCase];\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['String.prototype.indexOf', () => String.prototype.indexOf];\n    // V11/V12: appendChild and removeChild — used by alert overlay, veil, and\n    // MO scanner to inject/remove DOM elements via captured _nodeAppend/_nodeRemove.\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Node.prototype.appendChild', () => Node.prototype.appendChild];\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Node.prototype.removeChild', () => Node.prototype.removeChild];\n    // V13: querySelectorAll — used by MO observer to scan descendant elements.\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Element.prototype.querySelectorAll', () => Element.prototype.querySelectorAll];\n    // removeAttribute — used by MO observer and scanner to strip malicious attributes.\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Element.prototype.removeAttribute', () => Element.prototype.removeAttribute];\n    // Date.now — used for alert rate-limiting and healer deadline; replacing it\n    // could suppress alerts or keep the healer alive indefinitely.\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Date.now', () => Date.now];\n    // Location.prototype.reload — used in the alert dismiss/reload button.\n    if (_N.locationReload) _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Location.prototype.reload', () => Location.prototype.reload];\n    // CacheStorage / ServiceWorkerContainer — optional; validate when present.\n    if (_N.cacheStorageKeys) _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['CacheStorage.prototype.keys', () => (typeof CacheStorage !== 'undefined' ? CacheStorage.prototype.keys : _N.cacheStorageKeys)];\n    if (_N.cacheStorageDelete) _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['CacheStorage.prototype.delete', () => (typeof CacheStorage !== 'undefined' ? CacheStorage.prototype.delete : _N.cacheStorageDelete)];\n    if (_N.swGetRegistrations) _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['ServiceWorkerContainer.prototype.getRegistrations', () => (typeof ServiceWorkerContainer !== 'undefined' ? ServiceWorkerContainer.prototype.getRegistrations : _N.swGetRegistrations)];\n    // Promise.prototype.then — replacing it silently kills _nukeCachesAndWorkers\n    _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['Promise.prototype.then', () => Promise.prototype.then];\n    // ServiceWorkerRegistration.prototype.unregister — used per-registration in threat response\n    if (_N.swUnregister) _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['ServiceWorkerRegistration.prototype.unregister', () => (typeof ServiceWorkerRegistration !== 'undefined' ? ServiceWorkerRegistration.prototype.unregister : _N.swUnregister)];\n    // MessagePort.prototype.postMessage — used in the MC watchdog self-ping\n    if (_N.portPostMessage) _NATIVE_CHECKS[_NATIVE_CHECKS.length] = ['MessagePort.prototype.postMessage', () => (typeof MessagePort !== 'undefined' ? MessagePort.prototype.postMessage : _N.portPostMessage)];\n\n    /* ──────────────────────────────────────────────────────────\n       3.  Threat response\n       ────────────────────────────────────────────────────────── */\n    let _lastAlertAt = 0;\n    const _ALERT_COOLDOWN_MS = 10_000;\n\n    // Wipe all snv-* keys from storage using a two-pass strategy:\n    //   Pass 1 — overwrite every key value with zeros (destroys key\n    //             material immediately; even if removeItem is later\n    //             intercepted or fails, the actual bytes are gone)\n    //   Pass 2 — delete the entries via the captured native prototype ref\n    //\n    // Uses _ls/_ss (captured object refs) as `this`, NOT the live\n    // window.localStorage / window.sessionStorage getters, so a\n    // getter-level replacement attack cannot redirect calls to a fake\n    // storage object.\n    function _nukeStorage() {\n        if (_DISABLE_PROACTIVE_ANTITAMPER) return;\n        const nuke = (store) => {\n            if (!store) return;\n            try {\n                const len = _reflectApply(_N.storageLength, store, []);\n                const keys = [];\n                let _ki = 0;\n                for (let i = 0; i < len; i++) {\n                    const k = _reflectApply(_N.storageKey, store, [i]);\n                    // BUG-C: k?.startsWith('snv-') uses live String.prototype.startsWith;\n                    // _pureSlice uses only bracket indexing + concatenation — zero prototype calls.\n                    // BUG-F: index assignment (keys[_ki++]) replaces keys.push(k).\n                    if (k && _pureSlice(k, 0, 4) === 'snv-') { keys[_ki++] = k; }\n                }\n                if (!keys.length) return;\n\n                // Pass 1: zero out the value — key material is gone\n                //         even if the delete step is somehow blocked\n                // STR-1: for-loop replaces '\\x00'.repeat(256) — no String.prototype.repeat call\n                let zeros = '';\n                for (let _zi = 0; _zi < 256; _zi++) zeros += '\\x00';\n                // BUG-B: keys.forEach() uses live Array.prototype.forEach;\n                // an indexed for-loop is completely immune to prototype replacement.\n                for (_ki = 0; _ki < keys.length; _ki++) {\n                    try { _reflectApply(_N.storageSetItem, store, [keys[_ki], zeros]); } catch { }\n                }\n\n                // Pass 2: delete the entries\n                for (_ki = 0; _ki < keys.length; _ki++) {\n                    try { _reflectApply(_N.storageRemoveItem, store, [keys[_ki]]); } catch { }\n                }\n            } catch { /* storage access denied — skip */ }\n        };\n        nuke(_ss);\n        nuke(_ls);\n    }\n\n    // E9: Service Worker & CacheStorage nuke.\n    // If an attacker managed to run code (e.g. Self-XSS), they might spawn a\n    // rogue Service Worker for persistence or stash data in the Cache API.\n    // We unregister all SWs and delete all Cache API storage to guarantee a clean slate.\n    // Uses _caches/_sw (captured object refs, see section 0) and captured prototype\n    // methods from _N so live window.caches / navigator.serviceWorker replacements\n    // cannot redirect these calls to fake objects.\n    function _nukeCachesAndWorkers() {\n        try {\n            if (_caches && _N.cacheStorageKeys && _N.promiseThen) {\n                // BUG-I: indexed for-loops replace .forEach() — immune to\n                // Array.prototype.forEach replacement (same rationale as BUG-B/E).\n                // FIX-PT: use captured _N.promiseThen instead of live .then() —\n                // a post-boot Promise.prototype.then replacement would otherwise\n                // turn this entire block into a silent no-op.\n                _reflectApply(_N.promiseThen, _reflectApply(_N.cacheStorageKeys, _caches, []), [keys => {\n                    if (keys && keys.length) {\n                        for (let _ki = 0; _ki < keys.length; _ki++) {\n                            try {\n                                if (_N.cacheStorageDelete) {\n                                    _reflectApply(_N.cacheStorageDelete, _caches, [keys[_ki]]);\n                                }\n                            } catch { }\n                        }\n                    }\n                }, () => { }]);\n            }\n        } catch { }\n        try {\n            if (_sw && _N.swGetRegistrations && _N.promiseThen) {\n                // FIX-PT: same rationale — use captured promiseThen and swUnregister\n                // so post-boot prototype replacements cannot suppress SW cleanup.\n                _reflectApply(_N.promiseThen, _reflectApply(_N.swGetRegistrations, _sw, []), [regs => {\n                    if (regs && regs.length) {\n                        for (let _ri = 0; _ri < regs.length; _ri++) {\n                            try {\n                                if (_N.swUnregister) _reflectApply(_N.swUnregister, regs[_ri], []);\n                                else regs[_ri].unregister();\n                            } catch { }\n                        }\n                    }\n                }, () => { }]);\n            }\n        } catch { }\n    }\n\n    // Random class for the alert host element — generated once per page session.\n    // ABP cosmetic-filter rules store class selectors persistently; a class that\n    // changes on every page load cannot be persistently blocked across sessions.\n    // Must start with a letter so it is valid as a CSS identifier.\n    // HEX-1: use 5 CSPRNG bytes → 10-char hex string. No Math.random(), no .toString(36).\n    let _ALERT_HOST_CLS = 'xsafenova00'; // fallback (never a real CSS class used by ABP rules)\n    try {\n        const _alcBuf = new _N.Uint8Array(5);\n        _reflectApply(_N.getRandomValues, crypto, [_alcBuf]);\n        let _alc = 'x';\n        for (let _ali = 0; _ali < 5; _ali++) {\n            _alc += _HEX_CHARS[_alcBuf[_ali] >> 4] + _HEX_CHARS[_alcBuf[_ali] & 15];\n        }\n        _ALERT_HOST_CLS = _alc;\n    } catch { /* crypto unavailable — fallback is fine for ABP resistance */ }\n\n    // F1: Reference-based alert overlay tracking.\n    // Removal detection uses overlay.isConnected (reference-based),\n    // which is unaffected by class changes or same-id decoys.\n    let _alertOverlay = null;\n    let _alertHealer = null; // Bug 1b: module-level ref so old healer can be disconnected\n\n    function _showAlert(reason) {\n        // CSS for alert internals — injected into ShadowRoot (external CSS cannot\n        // penetrate closed shadows). Uses the same values as app.css .snv-* rules.\n        // Every element receives _ALERT_HOST_CLS as first class for ABP-resistance.\n        const _css = `\n.snv-card{max-width:520px;width:calc(100% - 96px);background:#1e1e1e;border:1px solid #f44747;border-radius:2px;padding:24px 28px;color:#d4d4d4;text-align:left;box-shadow:0 8px 32px rgba(0,0,0,.6)}\n.snv-header{display:flex;align-items:center;gap:10px;margin-bottom:16px}\n.snv-icon{color:#f44747;flex-shrink:0}\n.snv-title{font-size:14px;font-weight:600;color:#f44747;letter-spacing:.01em}\n.snv-reason{font-size:12px;font-family:'Cascadia Code',Consolas,'Courier New',monospace;background:#252526;padding:8px 12px;border:1px solid #3c3c3c;border-radius:2px;margin-bottom:14px;word-break:break-all;color:#f44747}\n.snv-desc{font-size:13px;line-height:1.6;color:#d4d4d4;margin-bottom:6px}\n.snv-desc strong{color:#fff}\n.snv-hint{font-size:13px;line-height:1.6;color:#858585;margin-bottom:18px}\n.snv-btn{background:#5a1a1a;color:#f44747;border:1px solid #7a2222;border-radius:2px;padding:6px 14px;font-family:'Segoe UI',system-ui,sans-serif;font-size:13px;cursor:pointer}\n.snv-btn:hover{background:#7a2222}`;\n\n        const render = () => {\n            // Remove previous alert by stored reference, not by predictable selector\n            try { _alertOverlay?.remove(); _alertOverlay = null; } catch { }\n\n            // Bug 1b: Disconnect the previous healer before creating a new one.\n            // Without this, the old MO keeps trying to re-append the old (already\n            // removed) overlay on every body childList change, stacking overlays.\n            if (_alertHealer) {\n                try { _alertHealer.disconnect(); } catch { }\n                _alertHealer = null;\n            }\n\n            const _rc = _ALERT_HOST_CLS; // shorthand for random class\n\n            // Use the captured native createElement so hooks cannot interfere\n            const overlay = _reflectApply(_N.createElement, document, ['div']);\n            // F1: Random session-specific class — cannot be persistently blocked by\n            //     cosmetic filters (class name is unguessable and changes every load).\n            //     CSS class \"snv-overlay\" provides the visual styles; _rc defeats ABP.\n            overlay.className = _rc + ' snv-overlay';\n            // Critical properties use !important; inline !important beats any\n            // author-stylesheet !important per the CSS cascade specification.\n            // ITER-1: indexed loop — immune to Array.prototype[Symbol.iterator] poisoning\n            const _ovStyles = [\n                ['position', 'fixed'],\n                ['inset', '0'],\n                ['z-index', '2147483647'],\n                ['background', 'rgba(0,0,0,.85)'],\n                ['display', 'flex'],\n                ['align-items', 'center'],\n                ['justify-content', 'center'],\n                ['font-family', '\"Segoe UI\",system-ui,-apple-system,sans-serif'],\n            ];\n            for (let _osi = 0; _osi < _ovStyles.length; _osi++) {\n                try { overlay.style.setProperty(_ovStyles[_osi][0], _ovStyles[_osi][1], 'important'); } catch { }\n            }\n            _alertOverlay = overlay;\n\n            // STR-3: for-loop + bracket index + === operator — no String.prototype.replace calls.\n            // String.prototype.replace could be spoofed to return unsanitised content,\n            // enabling XSS injection into the shadow DOM.  Bracket indexing and === are\n            // pure language operators and cannot be intercepted from userland JS.\n            const _raw = '' + reason;\n            let safeReason = '';\n            for (let _sri = 0; _sri < _raw.length; _sri++) {\n                const _c = _raw[_sri];\n                if (_c === '&') safeReason += '&amp;';\n                else if (_c === '<') safeReason += '&lt;';\n                else if (_c === '>') safeReason += '&gt;';\n                else if (_c === '\"') safeReason += '&quot;';\n                else safeReason += _c;\n            }\n\n            // E7: Closed ShadowRoot — content is invisible to document.querySelector\n            // and to inline MutationObserver-based removal attacks. An attacker can\n            // still remove the host element, but cannot find or suppress the button.\n            let contentRoot = overlay;\n            try {\n                const shadow = overlay.attachShadow({ mode: 'closed' });\n                contentRoot = shadow;\n            } catch { /* ShadowRoot unavailable — fall back to plain overlay */ }\n\n            // Inject styles into shadow (external CSS cannot reach closed shadow)\n            contentRoot.innerHTML = `<style>${_css}</style>\n<div class=\"${_rc} snv-card\">\n  <div class=\"${_rc} snv-header\">\n    <svg class=\"${_rc} snv-icon\" width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M12 2L3 7v5c0 5.25 3.75 10.15 9 11.35C17.25 22.15 21 17.25 21 12V7z\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linejoin=\"round\"/>\n      <path d=\"M12 8v5\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"/>\n      <circle cx=\"12\" cy=\"16\" r=\"1\" fill=\"currentColor\"/>\n    </svg>\n    <span class=\"${_rc} snv-title\">SafeNova Proactive</span>\n  </div>\n  <div class=\"${_rc} snv-reason\">${safeReason}</div>\n  <div class=\"${_rc} snv-desc\">\n    A suspicious operation was <strong>blocked</strong> and all encrypted session keys have been <strong>cleared</strong>.\n  </div>\n  <div class=\"${_rc} snv-hint\">\n    This may indicate a malicious browser extension attempting to intercept or exfiltrate data. Audit your installed extensions and reload.\n  </div>\n  <button class=\"${_rc} snv-btn\" id=\"snv-pa-ok\">\n    I understand — Reload\n  </button>\n</div>`;\n\n            try {\n                // V11: Use captured _nodeAppend — live Node.prototype.appendChild\n                // could be replaced post-boot to silently prevent alert overlay.\n                _reflectApply(_nodeAppend, document.body || document.documentElement, [overlay]);\n            } catch { }\n\n            // querySelector searches contentRoot (shadow), not document scope\n            const btn = contentRoot.querySelector('#snv-pa-ok');\n            if (btn) {\n                btn.addEventListener('click', () => {\n                    overlay.remove();\n                    // Use captured Location.prototype.reload so an attacker who\n                    // replaces window.location.reload after boot cannot suppress reload.\n                    try { _reflectApply(_N.locationReload, window.location, []); }\n                    catch { window.location.reload(); }\n                }, { once: true });\n            }\n\n            // E7: Self-healing — re-append and reinforce visibility if overlay was\n            // removed from DOM OR hidden via a CSS cosmetic filter. Using\n            // overlay.isConnected (reference-based) instead of getElementById so an\n            // attacker removing the element but keeping a same-id decoy doesn't fool us.\n            if (_N.MutationObserver) {\n                const _healDeadline = _reflectApply(_N.dateNow, Date, []) + 180_000;\n                const _healer = new _N.MutationObserver(() => {\n                    // Bug 1c: Stale closure guard — if a newer render() has replaced\n                    // _alertOverlay, this healer is orphaned. Disconnect immediately\n                    // instead of trying to re-add an overlay that was intentionally removed.\n                    if (overlay !== _alertOverlay) { _healer.disconnect(); return; }\n                    if (_reflectApply(_N.dateNow, Date, []) > _healDeadline) { _healer.disconnect(); _alertHealer = null; return; }\n                    if (!overlay.isConnected) {\n                        try { _reflectApply(_nodeAppend, document.body || document.documentElement, [overlay]); } catch { }\n                    }\n                    // Reinforce display:flex!important in case a stylesheet injection hid it\n                    try { overlay.style.setProperty('display', 'flex', 'important'); } catch { }\n                });\n                _alertHealer = _healer;\n                try {\n                    // FIX-BDY: observe documentElement (subtree:true) instead of\n                    // document.body — if an attacker replaces document.body after\n                    // render(), the old observer is on a detached node and never\n                    // fires again.  documentElement cannot be replaced from JS.\n                    _healer.observe(document.documentElement, { childList: true, subtree: true });\n                } catch { }\n            }\n        };\n\n        if (document.body) {\n            render();\n        } else {\n            // CRIT-3: use captured _N.addEventListener so a live replacement cannot\n            // prevent the fallback render from firing.\n            _reflectApply(_N.addEventListener, window, ['DOMContentLoaded', render, { once: true }]);\n        }\n    }\n\n    /* ──────────────────────────────────────────────────────────\n       G2.  Console threat log\n       ────────────────────────────────────────────────────────── */\n    // Prints a styled red error to the DevTools console using the captured\n    // _N.consoleError reference, so an attacker who replaces console.error\n    // after daemon.js loads cannot suppress the message.\n    function _logThreatToConsole(reason) {\n        try {\n            const _ce = _N.consoleError || console.error.bind(console);\n            _ce(\n                '%c\\u26d4\\ufe0f  SafeNova Proactive  \\u2502  THREAT DETECTED',\n                'background:#6a0000;color:#ff5555;font-size:13px;font-weight:700;padding:3px 8px;border-radius:3px'\n            );\n            _ce('%c' + ('' + reason),\n                'color:#ff4444;font-weight:600;font-size:12px;padding-left:4px'\n            );\n            _ce(\n                '%cAll encrypted session keys have been cleared. The container is locked.',\n                'color:#cc6666;padding-left:4px'\n            );\n        } catch { }\n    }\n\n    /* ──────────────────────────────────────────────────────────\n       G3.  Debugger trap (Self-XSS / console attack deterrent)\n       ────────────────────────────────────────────────────────── */\n    // Schedules a 'debugger' statement every 50 ms after a threat fires.\n    // When DevTools are open the JS engine pauses at each breakpoint, blocking\n    // follow-up console commands. When DevTools are closed this is a no-op.\n    // The interval ID is stored in _trapIds so window.clearInterval (our guarded\n    // wrapper) silently drops any attempt to cancel it from untrusted code.\n    // The trap self-cancels after 5 minutes.\n    function _startDebuggerTrap() {\n        if (_debugTrapActive) return;\n        _debugTrapActive = true;\n        const _trapEnd = _reflectApply(_N.dateNow, Date, []) + 300_000;\n        const _tidRef = { id: null };\n        const _fire = function snvDebugTrap() {\n            debugger; // intentional — Self-XSS deterrent // eslint-disable-line no-debugger\n            if (_reflectApply(_N.dateNow, Date, []) > _trapEnd) {\n                _reflectApply(_N._clearInterval, window, [_tidRef.id]);\n                delete _trapIds[_tidRef.id]; // SET-2: delete operator\n                _debugTrapActive = false;\n            }\n        };\n        _tidRef.id = _reflectApply(_N._setInterval, window, [_fire, 50]);\n        _trapIds[_tidRef.id] = 1; // SET-2: direct assignment\n    }\n\n    function _triggerAlert(reason) {\n        if (_DISABLE_PROACTIVE_ANTITAMPER) return;\n        // Always clear storage immediately — even if we rate-limit the UI\n        _nukeStorage();\n        _nukeCachesAndWorkers();\n\n        // Directly zero in-memory app state — bypasses the event system;\n        // works even if App.lockContainer or the snv:lock listener was patched.\n        _wipeAppState();\n\n        // G2: Emit red console error — forensic trace visible in DevTools.\n        _logThreatToConsole(reason);\n\n        // G3: Start debugger trap — freezes DevTools console if open.\n        _startDebuggerTrap();\n\n        // CRIT-4: Use captured _N.dispatchEvent + _N.CustomEvent so a live\n        // window.dispatchEvent = () => {} replacement cannot silently drop\n        // the snv:lock event that triggers lockContainer() in main.js.\n        try {\n            _reflectApply(_N.dispatchEvent, window, [\n                new _N.CustomEvent('snv:lock', { detail: { reason } })]);\n        } catch { }\n\n        const now = _reflectApply(_N.dateNow, Date, []);\n        if (now - _lastAlertAt < _ALERT_COOLDOWN_MS) return;\n        _lastAlertAt = now;\n        _showAlert(reason);\n    }\n\n    /* ──────────────────────────────────────────────────────────\n       4.  URL origin check\n       ────────────────────────────────────────────────────────── */\n    function _isExternal(urlStr) {\n        if (!urlStr) return false;\n        try {\n            const s = '' + urlStr;\n            // data: URLs are inline resources (canvas thumbnails, etc.) — always safe\n            // BUG-G: s[0] is a pure bracket-indexing operator; _pureSlice uses only\n            // bracket indexing + concatenation — no hookable prototype method at all.\n            if (s[0] === 'd' && _pureSlice(s, 0, 5) === 'data:') return false;\n            const parsed = new _N.URL(s, window.location.href);\n            const proto = parsed.protocol;\n            // Browser-extension resources are injected by user-installed extensions\n            if (proto === 'chrome-extension:' || proto === 'moz-extension:'\n                || proto === 'safari-web-extension:') return false;\n            return parsed.origin !== _origin;\n        } catch {\n            return true; // FAIL-CLOSED: unparseable URL is treated as external\n        }\n    }\n\n    /* ──────────────────────────────────────────────────────────\n       5.  Hook installation — double-hook pattern\n           The real logic lives in IIFE-private _*Impl closures.\n           The publicly assigned functions are thin forwarders, so\n           fetch.toString() / XMLHttpRequest.prototype.open.toString()\n           reveals only the forwarder body — not the security logic.\n\n           _fetchImpl, _xhrOpenImpl, _sendBeaconImpl are invisible\n           from the DevTools console (closure scope, not on window).\n       ────────────────────────────────────────────────────────── */\n\n    // ── Inner implementations (closure-private) ────────────────\n    const _fetchImpl = function (input) {\n        // V10: Do not use `instanceof Request` — attackable via Symbol.hasInstance.\n        // `typeof` is an operator (unhookable); .url is read either way.\n        const url = (typeof input === 'object' && input !== null && typeof input.url === 'string')\n            ? input.url : ('' + (input ?? ''));\n        if (_isExternal(url)) {\n            _triggerAlert('Outbound fetch blocked → ' + url);\n            return Promise.reject(new Error('[SafeNova Proactive] External fetch blocked'));\n        }\n        // V9: Use captured _reflectApply instead of live Function.prototype.apply,\n        // which could be replaced post-boot to intercept pass-through arguments.\n        return _reflectApply(_N.fetch, this === window ? window : globalThis, arguments);\n    };\n\n    const _xhrOpenImpl = function (method, url) {\n        if (_isExternal('' + (url ?? ''))) {\n            _triggerAlert('Outbound XHR blocked → ' + url);\n            throw new Error('[SafeNova Proactive] External XHR blocked');\n        }\n        return _reflectApply(_N.xhrOpen, this, arguments);\n    };\n\n    const _sendBeaconImpl = function (url) {\n        if (_isExternal('' + (url ?? ''))) {\n            _triggerAlert('sendBeacon to external URL blocked → ' + url);\n            return false;\n        }\n        return _reflectApply(_N.sendBeacon, navigator, arguments);\n    };\n\n    // ── D2: DOM exfiltration defense constants ─────────────────\n    // Pure-object lookup — `in` operator is a language construct,\n    // unhookable (unlike Set.prototype.has or Array.includes).\n    const _RESOURCE_ATTRS = {\n        src: 1, href: 1, data: 1, ping: 1,\n        srcset: 1, action: 1, formaction: 1, poster: 1\n    };\n    // FIX-SS: srcset values contain space-separated URL+descriptor tokens\n    // (e.g. \"photo.jpg 2x\" or \"img.jpg 320w\"). Passing a raw srcset string\n    // to new URL() throws, making _isExternal return true (fail-closed) for\n    // every srcset — any extension that adds srcset causes a false alert and\n    // key wipe. srcset is kept in _RESOURCE_ATTRS so setAttribute/innerHTML\n    // hooks (which parse individual tokens) can still check it, but the MO\n    // attribute-change and element-scan paths skip it to avoid false positives.\n    const _RESOURCE_ATTRS_NO_SRCSET = {\n        src: 1, href: 1, data: 1, ping: 1,\n        action: 1, formaction: 1, poster: 1\n    };\n    const _ON_ATTR_RE = /\\bon[a-z]+\\s*=/i;\n\n    /* ──────────────────────────────────────────────────────────\n       5b. DOM exfiltration hook implementations (D2)\n           Same double-hook pattern as network hooks above.\n       ────────────────────────────────────────────────────────── */\n\n    // ── setAttribute — blocks on* handlers and external resource URLs ──\n    const _setAttributeImpl = function (name, value) {\n        // BUG-K: _pureToLower uses a frozen A-Z→a-z lookup + bracket indexing —\n        // no prototype method call; immune to any String.prototype.toLowerCase hook.\n        const lName = _pureToLower('' + (name ?? ''));\n        if (lName.length > 2 && lName[0] === 'o' && lName[1] === 'n') {\n            _triggerAlert('Inline event handler via setAttribute blocked \\u2192 ' + lName);\n            return;\n        }\n        // <a> and <area> href= are navigation-only (user-activated click),\n        // not auto-loading. Blocking them causes false positives for normal app links.\n        // ping= on anchors is still caught below (auto-fires on click).\n        if (lName === 'href') {\n            const tag = _pureToLower('' + (this.tagName || ''));\n            if (tag === 'a' || tag === 'area') {\n                return _reflectApply(_N.setAttribute, this, arguments);\n            }\n        }\n        if (lName in _RESOURCE_ATTRS && _isExternal('' + (value ?? ''))) {\n            _triggerAlert('External resource via setAttribute blocked \\u2192 ' + lName + '=' + value);\n            return;\n        }\n        return _reflectApply(_N.setAttribute, this, arguments);\n    };\n\n    // ── HTML content threat scanner ────────────────────────────\n    // indexOf-based extraction avoids hookable String.prototype.match/exec.\n    function _htmlHasThreat(html) {\n        const h = '' + (html ?? '');\n        if (_reflectApply(_reTest, _ON_ATTR_RE, [h])) return 'inline event handler';\n        // BUG-J: _pureIndexOf uses a nested indexed loop + bracket comparison —\n        // no prototype method call; immune to any String.prototype.indexOf hook.\n        if (_pureIndexOf(h, '://') === -1) return false;\n        const _RATTR_KEYS = ['src=', 'href=', 'data=', 'ping=', 'action=', 'formaction=', 'poster=', 'srcset='];\n        // BUG-K: _pureToLower — pure operator-level ASCII lowercasing.\n        // FIX-WS: _pureCollapseAttrSpaces normalises 'attr = \"url\"' → 'attr=\"url\"'\n        // so that attributes with whitespace around '=' are not missed by the\n        // literal 'src=' search (HTML5 spec allows optional whitespace around '=').\n        // Both hLow and hOrig apply the same normalisation so their indices align.\n        const hLow = _pureCollapseAttrSpaces(_pureToLower(h));\n        const hOrig = _pureCollapseAttrSpaces(h);\n        for (let _ri = 0; _ri < _RATTR_KEYS.length; _ri++) {\n            const attr = _RATTR_KEYS[_ri];\n            let apos = 0;\n            while (true) {\n                apos = _pureIndexOf(hLow, attr, apos);\n                if (apos === -1) break;\n                let vs = apos + attr.length;\n                // After normalisation post-'=' spaces are already removed;\n                // the loop is kept as a no-op fallback for defence-in-depth.\n                while (vs < hOrig.length && (hOrig[vs] === ' ' || hOrig[vs] === '\\t')) vs++;\n                let ve;\n                if (hOrig[vs] === '\"' || hOrig[vs] === \"'\") {\n                    const q = hOrig[vs]; vs++;\n                    ve = _pureIndexOf(hOrig, q, vs);\n                    if (ve === -1) ve = hOrig.length;\n                } else {\n                    ve = vs;\n                    while (ve < hOrig.length && hOrig[ve] !== ' ' && hOrig[ve] !== '>' && hOrig[ve] !== '\\t') ve++;\n                }\n                // BUG-L: _pureSlice extracts substring via bracket + concatenation —\n                // immune to any String.prototype.slice / .substring hook.\n                const url = _pureSlice(hOrig, vs, ve);\n                if (_isExternal(url)) return attr + url;\n                apos = ve;\n            }\n        }\n        return false;\n    }\n\n    const _insertAdjacentHTMLImpl = function (position, html) {\n        const threat = _htmlHasThreat(html);\n        if (threat) { _triggerAlert('Threat in insertAdjacentHTML blocked \\u2192 ' + threat); return; }\n        return _reflectApply(_N.insertAdjacentHTML, this, arguments);\n    };\n\n    const _docWriteImpl = function () {\n        let combined = '';\n        for (let _i = 0; _i < arguments.length; _i++) combined += '' + (arguments[_i] ?? '');\n        const threat = _htmlHasThreat(combined);\n        if (threat) { _triggerAlert('Threat in document.write blocked \\u2192 ' + threat); return; }\n        return _reflectApply(_N.docWrite, this, arguments);\n    };\n    const _docWritelnImpl = function () {\n        let combined = '';\n        for (let _i = 0; _i < arguments.length; _i++) combined += '' + (arguments[_i] ?? '');\n        const threat = _htmlHasThreat(combined);\n        if (threat) { _triggerAlert('Threat in document.writeln blocked \\u2192 ' + threat); return; }\n        return _reflectApply(_N.docWriteln, this, arguments);\n    };\n\n    // ── D3: post-init iframe creation block ───────────────────\n    // An attacker who gains JS execution after daemon.js runs could:\n    //   1. document.createElement('iframe') → fresh about:blank realm\n    //   2. grab iwin.fetch / iwin.XMLHttpRequest.prototype.open etc.\n    //      (they ARE native, so _isNative() passes them)\n    //   3. assign them back over the hooked prototypes → all network/DOM\n    //      hooks silently stripped without triggering any _NATIVE_CHECKS alert\n    // Blocking <iframe> creation post-init closes this entire attack vector.\n    // All other tags pass through unmodified — extensions that create <script>\n    // or other elements for their own purposes are unaffected.\n    const _createElementImpl = function (tagName) {\n        if (_iframeRestoreDone) {\n            // BUG-K: _pureToLower — pure operator-level; no prototype dependency.\n            const _tag = _pureToLower('' + (tagName ?? ''));\n            if (_tag === 'iframe') {\n                _triggerAlert('iframe creation blocked post-init \\u2192 native-reset attack vector');\n                // Return a harmless <div> so the call site does not throw,\n                // minimising fingerprinting surface for the attacker.\n                return _reflectApply(_N.createElement, this, ['div']);\n            }\n        }\n        return _reflectApply(_N.createElement, this, arguments);\n    };\n\n    const _locAssignImpl = function (url) {\n        if (_isExternal('' + (url ?? ''))) {\n            _triggerAlert('External navigation (assign) blocked \\u2192 ' + url); return;\n        }\n        return _reflectApply(_N.locAssign, this, arguments);\n    };\n    const _locReplaceImpl = function (url) {\n        if (_isExternal('' + (url ?? ''))) {\n            _triggerAlert('External navigation (replace) blocked \\u2192 ' + url); return;\n        }\n        return _reflectApply(_N.locReplace, this, arguments);\n    };\n\n    const _locHrefSetImpl = _N.locHrefDesc?.set ? (function () {\n        const _origSet = _N.locHrefDesc.set;\n        return function (val) {\n            if (_isExternal('' + (val ?? ''))) {\n                _triggerAlert('External navigation (href) blocked \\u2192 ' + val); return;\n            }\n            _reflectApply(_origSet, this, [val]);\n        };\n    })() : null;\n\n    const _innerHTMLSetImpl = _N.innerHTMLDesc?.set ? (function () {\n        const _origSet = _N.innerHTMLDesc.set;\n        return function (val) {\n            const threat = _htmlHasThreat(val);\n            if (threat) { _triggerAlert('Threat in innerHTML blocked \\u2192 ' + threat); return; }\n            _reflectApply(_origSet, this, [val]);\n        };\n    })() : null;\n    const _outerHTMLSetImpl = _N.outerHTMLDesc?.set ? (function () {\n        const _origSet = _N.outerHTMLDesc.set;\n        return function (val) {\n            const threat = _htmlHasThreat(val);\n            if (threat) { _triggerAlert('Threat in outerHTML blocked \\u2192 ' + threat); return; }\n            _reflectApply(_origSet, this, [val]);\n        };\n    })() : null;\n\n    const _formSubmitImpl = function () {\n        const action = '' + (this.action || '');\n        if (_isExternal(action)) {\n            _triggerAlert('Form submit to external URL blocked \\u2192 ' + action); return;\n        }\n        return _reflectApply(_N.formSubmit, this, arguments);\n    };\n\n    // Shared console output for silently-blocked operations (no modal alert, no key-wipe).\n    // Uses captured _N.consoleError — immune to post-load console.error replacement.\n    function _logBlockedToConsole(msg) {\n        try {\n            const _ce = _N.consoleError || console.error.bind(console);\n            _ce('%c\\u26d4\\ufe0f  SafeNova Proactive  \\u2502  BLOCKED',\n                'background:#6a0000;color:#ff5555;font-size:13px;font-weight:700;padding:3px 8px;border-radius:3px');\n            _ce('%c' + ('' + msg),\n                'color:#ff4444;font-weight:600;font-size:12px;padding-left:4px');\n        } catch { }\n    }\n\n    // Resource property (.src/.href/.data) hook helper.\n    // Caches the setter in _H[hKey] so re-hooks reuse the same closure.\n    // noAlert — if truthy, log to console only (no modal/key-wipe). Used for\n    // <script>.src so that an injected script tag is quietly neutralised rather\n    // than surfaced as a full intrusion alert (reduces alert fatigue while still\n    // blocking the load and leaving a forensic trace in DevTools).\n    function _hookResourceProp(proto, propName, origDesc, hKey, label, noAlert) {\n        if (!origDesc || !origDesc.set) return;\n        if (!_H[hKey]) {\n            const _impl = noAlert\n                ? function (val) {\n                    if (_isExternal('' + (val ?? ''))) {\n                        _logBlockedToConsole(label + ' blocked \\u2192 ' + val);\n                        return;\n                    }\n                    _reflectApply(origDesc.set, this, [val]);\n                }\n                : function (val) {\n                    if (_isExternal('' + (val ?? ''))) {\n                        _triggerAlert('External ' + label + ' blocked \\u2192 ' + val); return;\n                    }\n                    _reflectApply(origDesc.set, this, [val]);\n                };\n            _H[hKey] = _mkProxy(_impl, 'snv_' + hKey);\n        }\n        Object.defineProperty(proto, propName, {\n            configurable: true, enumerable: true,\n            get: origDesc.get, set: _H[hKey]\n        });\n    }\n\n    // MutationObserver element threat scanner — checks a single element node.\n    function _scanElementForThreats(el) {\n        if (!el || el.nodeType !== 1) return;\n        // BUG-K: _pureToLower — pure operator-level ASCII lowercasing; no prototype\n        // dependency. Replacing String.prototype.toLowerCase cannot affect this.\n        const _elTag = _pureToLower('' + (el.tagName || ''));\n        // <script> elements: only intercept external-src injections.\n        // Same-origin and relative scripts (app's own modules) are allowed through.\n        // Inline scripts have no src and are handled upstream by innerHTML/document.write hooks.\n        // Full alert suppressed for scripts — console-only to avoid modal fatigue.\n        if (_elTag === 'script') {\n            let _scriptSrc = '';\n            try { _scriptSrc = '' + (_reflectApply(_N.getAttribute, el, ['src']) || ''); } catch { }\n            if (_scriptSrc && _isExternal(_scriptSrc)) {\n                // V12: Use captured _nodeRemove — live Node.prototype.removeChild\n                // could be replaced to prevent removal of injected script elements.\n                try { if (el.parentNode) _reflectApply(_nodeRemove, el.parentNode, [el]); } catch { }\n                _logBlockedToConsole('Injected external <script> removed from DOM \\u2192 src=' + _scriptSrc);\n            }\n            return; // never fall through to the general attribute scan for script elements\n        }\n        const attrs = el.attributes;\n        if (!attrs) return;\n        // <a> and <area> href= are navigation attributes (user-clicked, not auto-loaded).\n        // Flagging them causes false positives on legitimate app links (e.g. about/credits).\n        // ping= on anchors is NOT skipped — it auto-fires a POST request on click.\n        const _isNavEl = (_elTag === 'a' || _elTag === 'area');\n        for (let _ai = 0; _ai < attrs.length; _ai++) {\n            const _a = attrs[_ai];\n            const aName = _pureToLower('' + _a.name);\n            if (aName.length > 2 && aName[0] === 'o' && aName[1] === 'n') {\n                try { _reflectApply(_N.removeAttribute, el, [aName]); } catch { }\n                _triggerAlert('Inline event handler on DOM element \\u2192 ' + aName);\n                return;\n            }\n            if (_isNavEl && aName === 'href') continue; // navigation-only, not a resource loader\n            // FIX-SS: use _RESOURCE_ATTRS_NO_SRCSET — srcset values contain URL+descriptor tokens\n            // (e.g. \"img.jpg 2x\") that are not valid URLs; passing them to new URL() throws, so\n            // _isExternal returns true (fail-closed) for every srcset, causing false alerts.\n            if (aName in _RESOURCE_ATTRS_NO_SRCSET) {\n                const val = '' + (_a.value || '');\n                if (_isExternal(val)) {\n                    try { _reflectApply(_N.removeAttribute, el, [aName]); } catch { }\n                    _triggerAlert('External resource on DOM element \\u2192 ' + aName + '=' + val);\n                    return;\n                }\n            }\n        }\n    }\n\n    /* ──────────────────────────────────────────────────────────\n       App state emergency wipe\n       Captured after window 'load' so window.App is available.\n       Directly nullifies in-memory key material as a direct\n       bypass when the snv:lock event handler in main.js might\n       itself be patched or replaced by an attacker.\n       ────────────────────────────────────────────────────────── */\n    let _appRef = null;\n    // _appCryptoRefs: frozen snapshot of _mkProxy-wrapped Crypto method references.\n    // Populated at 'load' (after crypto.js has executed). Used in tick 6d.\n    let _appCryptoRefs = null;\n    // _appMethodRefs: frozen snapshot of _mkProxy-wrapped App method references.\n    // Populated at 'load'. Used in tick 6e to detect App.lockContainer replacement.\n    let _appMethodRefs = null;\n    // _vfsInitRef / _wmCloseAllRef: raw (unwrapped) function references for VFS.init\n    // and WinManager.closeAll.  Populated at 'load'.  Used in _wipeAppState so that\n    // a console-level replacement before a threat fires cannot prevent the in-memory\n    // VFS clear or window-panel teardown.  The objects themselves receive _mkProxy\n    // wrappers at load time; these raw refs bypass the proxy for direct invocation.\n    let _vfsInitRef = null;\n    let _wmCloseAllRef = null;\n    // _debugTrapActive: prevents stacking multiple 50 ms debugger intervals.\n    let _debugTrapActive = false;\n    // _trapIds: guarded map of debugger-trap interval IDs.\n    // Our window.clearInterval wrapper silently drops calls targeting these.\n    // SET-2: plain object — `id in _trapIds` and `delete _trapIds[id]` are pure\n    // language operators; Set.prototype.has/.add/.delete could be spoofed via Self-XSS.\n    const _trapIds = {};\n    // _readVFS / _readWinManager: bare-identifier readers for the module-scope consts\n    // declared in vfs.js / desktop.js.  Like App, these are `const` declarations that\n    // land in the JS lexical environment record, NOT on window — so window.VFS and\n    // window.WinManager are always undefined and any live access must use the bare name.\n    const _readVFS = () => (typeof VFS !== 'undefined' ? VFS : null);\n    const _readWinManager = () => (typeof WinManager !== 'undefined' ? WinManager : null);\n\n    // _readApp: safe reader for the `App` global const (state.js).\n    // `const App = {...}` in state.js goes into the JS global LEXICAL environment\n    // record, NOT onto the global object (window), so `window.App` is always\n    // undefined. The bare identifier `App` resolves it via the scope chain at\n    // call time (same technique as bare `Crypto` for crypto.js).  The function\n    // is defined as an arrow so callers can safely call it inside try/catch\n    // without worrying about `this` binding.\n    const _readApp = () => (typeof App !== 'undefined' ? App : null);\n    try {\n        // CRIT-3: Use captured _N.addEventListener (not live window.addEventListener).\n        // A MV2 extension that replaces EventTarget.prototype.addEventListener at\n        // document_start would prevent this 'load' listener from being installed,\n        // leaving _appCryptoRefs/_appMethodRefs permanently null and permanently\n        // disabling G1 checks for Crypto and App.lockContainer.\n        _reflectApply(_N.addEventListener, window, ['load', function () {\n            try { _appRef = _readApp(); } catch { }\n            // G1: Wrap critical Crypto methods through _mkProxy and capture\n            // the proxied references for per-tick tamper detection (6d).\n            // Each method’s toString() now reveals only the thin _mkProxy\n            // forwarder body — the real implementation is closure-private.\n            // 'Crypto' bare identifier resolves to the app's script-scope const\n            // (declared in crypto.js as `const Crypto = ...`), NOT to window.Crypto\n            // (browser WebCrypto API). We verify identity with .encrypt\n            // (app module has it; browser WebCrypto does not — it has .subtle).\n            try {\n                if (typeof Crypto !== 'undefined' && typeof Crypto.encrypt === 'function') {\n                    // Wrap each Crypto method through _mkProxy — toString() on\n                    // the live property reveals only the thin forwarder body,\n                    // hiding the real implementation in a closure-private _p.\n                    const _pEncrypt = _mkProxy(Crypto.encrypt, 'snvCryptoEncrypt');\n                    const _pDecrypt = _mkProxy(Crypto.decrypt, 'snvCryptoDecrypt');\n                    const _pEncryptBin = _mkProxy(Crypto.encryptBin, 'snvCryptoEncryptBin');\n                    const _pDecryptBin = _mkProxy(Crypto.decryptBin, 'snvCryptoDecryptBin');\n                    const _pDeriveKey = _mkProxy(Crypto.deriveKey, 'snvCryptoDeriveKey');\n                    const _pDeriveKeyAndRaw = _mkProxy(Crypto.deriveKeyAndRaw, 'snvCryptoDeriveKeyAndRaw');\n                    // importRawKey: called by home.js + fileops.js to turn raw AES\n                    // bytes into a CryptoKey.  Replacing it could capture key material.\n                    const _pImportRawKey = _mkProxy(Crypto.importRawKey, 'snvCryptoImportRawKey');\n                    // checkVerification: called from home.js, fileops.js, desktop.js\n                    // to verify the password against the stored blob.  Replacing with\n                    // () => true bypasses authentication in all unlock paths.\n                    const _pCheckVerification = _mkProxy(Crypto.checkVerification, 'snvCryptoCheckVerification');\n                    // makeVerification: called when creating / changing a container\n                    // password to generate the stored verification blob.\n                    const _pMakeVerification = _mkProxy(Crypto.makeVerification, 'snvCryptoMakeVerification');\n                    // Install proxied versions on the Crypto module object\n                    Crypto.encrypt = _pEncrypt;\n                    Crypto.decrypt = _pDecrypt;\n                    Crypto.encryptBin = _pEncryptBin;\n                    Crypto.decryptBin = _pDecryptBin;\n                    Crypto.deriveKey = _pDeriveKey;\n                    Crypto.deriveKeyAndRaw = _pDeriveKeyAndRaw;\n                    Crypto.importRawKey = _pImportRawKey;\n                    Crypto.checkVerification = _pCheckVerification;\n                    Crypto.makeVerification = _pMakeVerification;\n                    // Store proxied refs for per-tick tamper detection (6d)\n                    _appCryptoRefs = Object.freeze({\n                        encrypt: _pEncrypt,\n                        decrypt: _pDecrypt,\n                        encryptBin: _pEncryptBin,\n                        decryptBin: _pDecryptBin,\n                        deriveKey: _pDeriveKey,\n                        deriveKeyAndRaw: _pDeriveKeyAndRaw,\n                        importRawKey: _pImportRawKey,\n                        checkVerification: _pCheckVerification,\n                        makeVerification: _pMakeVerification,\n                    });\n                }\n            } catch { }\n            // G1 (App security methods): wrap App.lockContainer through _mkProxy\n            // and install the proxied version on the App object.  A console-level\n            // replacement (App.lockContainer = () => {}) is detected on the next\n            // tick.  _readApp() uses the bare identifier because state.js declares\n            // `const App = {...}` in the lexical env record, NOT on window.\n            try {\n                const _a = _readApp();\n                if (_a && typeof _a.lockContainer === 'function') {\n                    const _pLockContainer = _mkProxy(_a.lockContainer, 'snvLockContainer');\n                    _a.lockContainer = _pLockContainer;\n                    _appMethodRefs = Object.freeze({\n                        lockContainer: _pLockContainer,\n                    });\n                }\n            } catch { }\n            // BUG-4: Capture raw VFS.init and WinManager.closeAll by reference\n            // for direct use in _wipeAppState (immune to post-capture replacement),\n            // then install _mkProxy wrappers on the objects so toString() hides\n            // the real implementations.  Bare identifiers (_readVFS / _readWinManager)\n            // because both are `const` module-scope declarations, never on window.\n            try {\n                const _v = _readVFS();\n                if (_v && typeof _v.init === 'function') {\n                    _vfsInitRef = _v.init; // raw ref for _wipeAppState\n                    _v.init = _mkProxy(_v.init, 'snvVfsInit');\n                }\n            } catch { }\n            try {\n                const _wm = _readWinManager();\n                if (_wm && typeof _wm.closeAll === 'function') {\n                    _wmCloseAllRef = _wm.closeAll; // raw ref for _wipeAppState\n                    _wm.closeAll = _mkProxy(_wm.closeAll, 'snvWmCloseAll');\n                }\n            } catch { }\n        }, { once: true }]);\n    } catch { }\n\n    // Tracks whether _wipeAppState has already installed the DOM lockdown.\n    // The veil and forced-reload run exactly once per page lifetime;\n    // subsequent calls (one per watchdog tick) only re-zero key material.\n    let _wipeExecuted = false;\n\n    function _wipeAppState() {\n        if (_DISABLE_PROACTIVE_ANTITAMPER) return;\n        // ── Part 1 (always): zero in-memory key material ─────────────\n        try {\n            const a = _appRef || _readApp();\n            if (a) {\n                try { if (a.key !== undefined) a.key = null; } catch { }\n                try { if (a.container !== undefined) a.container = null; } catch { }\n                try { if (a.clipboard !== undefined) a.clipboard = null; } catch { }\n                try { if (a.thumbCache !== undefined) a.thumbCache = {}; } catch { }\n                try { if (a.selection && typeof a.selection.clear === 'function') a.selection.clear(); } catch { }\n            }\n        } catch { }\n\n        // ── Part 2 (once): DOM content wipe + veil + reload ────────\n        // Bug 1a: guard ensures this block executes only ONCE per page lifetime\n        // so the watchdog firing 3×/s does not stack 3 veils/s or schedule\n        // dozens of competing reload timers.\n        if (_wipeExecuted) return;\n        _wipeExecuted = true;\n\n        // Bug 2: Directly wipe DOM-resident decrypted content.\n        // These operations use exclusively captured native references so the\n        // attacker cannot intercept them by patching the live window/proto chain.\n        // Helper: call a captured Document prototype method with document as context.\n        const _docCall = (fn, arg) => {\n            if (!fn) return null;\n            try { return _reflectApply(fn, document, [arg]); } catch { return null; }\n        };\n\n        // 2a. Zero editor textarea — wipes decrypted file plaintext from the DOM.\n        //     Use the captured HTMLTextAreaElement.prototype.value setter — if an\n        //     attacker redefined the .value property on the element instance or\n        //     prototype, our captured setter still reaches the native C++ binding.\n        try {\n            const ta = _docCall(_N.docGetElementById, 'editor-textarea');\n            if (ta) {\n                if (_N.taValueSetter) _reflectApply(_N.taValueSetter, ta, ['']);\n                else ta.value = '';\n            }\n        } catch { }\n\n        // 2b. Zero password input so credential is not visible after lockdown.\n        try {\n            const pw = _docCall(_N.docGetElementById, 'unlock-pw');\n            if (pw) {\n                if (_N.inputValueSetter) _reflectApply(_N.inputValueSetter, pw, ['']);\n                else pw.value = '';\n            }\n        } catch { }\n\n        // 2c. Force-close open modals (editor, viewer) using !important to beat\n        //     any injected stylesheet that tries to keep them visible.\n        // BUG-E: indexed loop avoids Array.prototype[Symbol.iterator].\n        const _modalIds = ['modal-editor', 'modal-viewer'];\n        for (let _mi = 0; _mi < _modalIds.length; _mi++) {\n            try {\n                const el = _docCall(_N.docGetElementById, _modalIds[_mi]);\n                if (el) el.style.setProperty('display', 'none', 'important');\n            } catch { }\n        }\n\n        // 2d. Force the view back to home: deactivate desktop, activate home.\n        //     Uses classList directly (no hooked setters needed here — classList\n        //     is a live DOMTokenList backed by the browser engine, not patchable\n        //     from JS in a way that affects our captured getElementById result).\n        try {\n            const desktop = _docCall(_N.docGetElementById, 'view-desktop');\n            if (desktop) {\n                desktop.classList.remove('active');\n                desktop.style.setProperty('display', 'none', 'important');\n            }\n        } catch { }\n        try {\n            const home = _docCall(_N.docGetElementById, 'view-home');\n            if (home) {\n                home.classList.add('active');\n                home.style.removeProperty('display');\n            }\n        } catch { }\n\n        // 2e. Revoke active Blob URLs so decrypted content (thumbnails, file previews)\n        //     is freed from memory and the browser can no longer serve their data.\n        try {\n            if (_N.docQuerySelectorAll) {\n                const _blobs = _reflectApply(_N.docQuerySelectorAll,\n                    document, ['img[src^=\"blob:\"],video[src^=\"blob:\"],a[href^=\"blob:\"]']);\n                for (let i = 0; i < _blobs.length; i++) {\n                    try {\n                        const src = _blobs[i].src || _blobs[i].href || '';\n                        if (src) _N.revokeObjectURL(src);\n                    } catch { }\n                }\n            }\n        } catch { }\n\n        // 2f. Clear in-memory VFS tree — holds decrypted file metadata and structure.\n        // Use the captured _vfsInitRef so a console VFS.init = () => {} swap before\n        // the threat fires cannot leave the file tree in memory.\n        // VFS is a `const` module-scope declaration (not on window); use _readVFS().\n        try {\n            if (_vfsInitRef) { _vfsInitRef(); }\n            else { const _v = _readVFS(); if (_v) _v.init(); }\n        } catch { }\n\n        // 2g. Close all floating window panels — they contain decrypted filenames and icons.\n        try {\n            if (_wmCloseAllRef) { _wmCloseAllRef(); }\n            else { const _wm = _readWinManager(); if (_wm) _wm.closeAll(); }\n        } catch { }\n\n        // F3: Security barrier veil — animated diagonal stripes (Minecraft barrier style).\n        // Covers all application content below the alert overlay.\n        // z-index 2147483646: below alert (2147483647), above everything else.\n        // CSS class \"snv-veil\" provides visual styles; _ALERT_HOST_CLS defeats ABP.\n        //\n        // Technique: linear-gradient (NOT repeating) over a square tile with stops at\n        // 25 % / 50 % / 75 % / 100 %. This is the only approach that produces clean\n        // parallel diagonal stripes without the diamond / hexagon artefacts that\n        // repeating-linear-gradient produces when tiled with background-size.\n        //\n        // Tile size T = 32 px.  Animation: shift background-position by (T, T) per\n        // cycle — the square tile guarantees a perfectly seamless loop at any speed.\n        //\n        // Stripe colours:\n        //   dark band  → rgba(6, 0, 0, 0.92) — near-black with a faint red tint\n        //   light band → rgba(155, 18, 18, 0.60) — muted dark red, semi-transparent\n        //\n        // NOTE: No background-color — stripes are semi-transparent so the app\n        // content bleeds through, making the barrier visible but not fully opaque.\n        try {\n            const _T = 32; // tile side, px — must be even for clean 50 % boundary\n            const _Tpx = _T + 'px';\n            const _dark = 'rgba(6,0,0,.92)';\n            const _red = 'rgba(155,18,18,.60)';\n            // String concatenation — no Array.prototype.join call\n            const _barrier =\n                'linear-gradient(-45deg,' +\n                _dark + ' 0%,' + _dark + ' 25%,' +\n                _red + ' 25%,' + _red + ' 50%,' +\n                _dark + ' 50%,' + _dark + ' 75%,' +\n                _red + ' 75%,' + _red + ' 100%)';\n            const veil = _reflectApply(_N.createElement, document, ['div']);\n            veil.className = _ALERT_HOST_CLS + ' snv-veil';\n            // ITER-2: indexed loop — immune to Array.prototype[Symbol.iterator] poisoning\n            const _veilStyles = [\n                ['position', 'fixed'],\n                ['inset', '0'],\n                ['z-index', '2147483646'],\n                ['background-image', _barrier],\n                ['background-size', _Tpx + ' ' + _Tpx],\n                ['display', 'block'],\n            ];\n            for (let _vsi = 0; _vsi < _veilStyles.length; _vsi++) {\n                try { veil.style.setProperty(_veilStyles[_vsi][0], _veilStyles[_vsi][1], 'important'); } catch { }\n            }\n            // V11: Use captured _nodeAppend — live .appendChild could be hooked.\n            _reflectApply(_nodeAppend, document.body || document.documentElement, [veil]);\n            // Shifting background-position by exactly one tile (T, T) per iteration\n            // is mathematically guaranteed to produce a seamless loop.\n            if (_N.elementAnimate) {\n                try {\n                    _reflectApply(_N.elementAnimate, veil, [\n                        [{ backgroundPosition: '0 0' },\n                        { backgroundPosition: _Tpx + ' ' + _Tpx }],\n                        { duration: 700, iterations: Infinity, easing: 'linear' }\n                    ]);\n                } catch { }\n            }\n        } catch { }\n    }\n\n    // ── _mkProxy: opaque hook factory ──────────────────────────\n    // Creates a thin forwarder whose toString() reveals only:\n    //   \"function () { return _reflectApply(_p, this, arguments); }\"\n    // All security logic lives in the closure-private impl (_p).\n    // Uses _reflectApply (captured Reflect.apply) — immune to\n    // Function.prototype.apply replacement.\n    //   impl:  closure-private implementation function\n    //   name:  cosmetic .name for console display (e.g. 'snvFetch')\n    //   proto: optional .prototype to copy (constructor proxies)\n    const _mkProxy = function (impl, name, proto) {\n        const _p = impl;\n        const _fn = function () { return _reflectApply(_p, this, arguments); };\n        if (name) try { Object.defineProperty(_fn, 'name', { value: name, configurable: true }); } catch { }\n        if (proto !== void 0) try { _fn.prototype = proto; } catch { }\n        return _fn;\n    };\n\n    // ── Constructor hook impl factory (Worker, SharedWorker, EventSource) ──\n    // Eliminates duplication between identical constructor hooks.\n    //   nativeCtor: captured native constructor\n    //   label:      display name for alerts (e.g. 'Worker')\n    //   blockData:  if truthy, also block data: URL scripts\n    function _mkCtorImpl(nativeCtor, label, blockData) {\n        return function () {\n            const urlStr = '' + (arguments[0] ?? '');\n            // BUG-J: _pureIndexOf — nested indexed loop, zero prototype calls.\n            if (blockData && _pureIndexOf(urlStr, 'data:') === 0) {\n                _triggerAlert(label + ' with data: URL blocked');\n                throw new Error('[SafeNova Proactive] ' + label + ' data: URL blocked');\n            }\n            // BUG-H: Block blob: URLs for Workers/SharedWorkers.  A same-origin\n            // blob: URL passes _isExternal (origin matches) but the Worker runs\n            // in a separate global with a clean, unhooked fetch — any code inside\n            // the blob can exfiltrate data without triggering page-level hooks.\n            // SafeNova never creates Workers; any blob: Worker is suspicious.\n            if (blockData && _pureIndexOf(urlStr, 'blob:') === 0) {\n                _triggerAlert(label + ' with blob: URL blocked');\n                throw new Error('[SafeNova Proactive] ' + label + ' blob: URL blocked');\n            }\n            if (_isExternal(urlStr)) {\n                _triggerAlert(label + ' to external URL blocked \\u2192 ' + urlStr);\n                throw new Error('[SafeNova Proactive] External ' + label + ' blocked');\n            }\n            return arguments.length >= 2\n                ? new nativeCtor(arguments[0], arguments[1])\n                : new nativeCtor(arguments[0]);\n        };\n    }\n\n    // ── Timer string-guard impl factory (setTimeout, setInterval) ──\n    function _mkTimerImpl(nativeFn, label) {\n        return function (fn) {\n            if (typeof fn === 'string') {\n                _triggerAlert(label + ' with string callback blocked');\n                return 0;\n            }\n            return _reflectApply(nativeFn, window, arguments);\n        };\n    }\n\n    // ── Publicly visible hooks (thin forwarders via _mkProxy) ──\n    const _H = {}; // live hook references — checked every tick\n\n    function _installHooks() {\n        // All hooks use _mkProxy — toString() on each shows only the\n        // thin forwarder body, not the security logic in the impl closure.\n\n        _H.fetch = _mkProxy(_fetchImpl, 'snvFetch');\n        window.fetch = _H.fetch;\n\n        _H.xhrOpen = _mkProxy(_xhrOpenImpl, 'snvXhrOpen');\n        XMLHttpRequest.prototype.open = _H.xhrOpen;\n\n        if (_N.sendBeacon) {\n            _H.sendBeacon = _mkProxy(_sendBeaconImpl, 'snvSendBeacon');\n            navigator.sendBeacon = _H.sendBeacon;\n        }\n\n        // ── D2: DOM exfiltration hooks ──────────────────────────\n\n        _H.setAttribute = _mkProxy(_setAttributeImpl, 'snvSetAttribute');\n        Element.prototype.setAttribute = _H.setAttribute;\n\n        if (_innerHTMLSetImpl) {\n            _H.innerHTMLSet = _mkProxy(_innerHTMLSetImpl, 'snvInnerHTMLSet');\n            Object.defineProperty(Element.prototype, 'innerHTML', {\n                configurable: true, enumerable: true,\n                get: _N.innerHTMLDesc.get, set: _H.innerHTMLSet\n            });\n        }\n        if (_outerHTMLSetImpl) {\n            _H.outerHTMLSet = _mkProxy(_outerHTMLSetImpl, 'snvOuterHTMLSet');\n            Object.defineProperty(Element.prototype, 'outerHTML', {\n                configurable: true, enumerable: true,\n                get: _N.outerHTMLDesc.get, set: _H.outerHTMLSet\n            });\n        }\n\n        if (_N.insertAdjacentHTML) {\n            _H.insertAdjacentHTML = _mkProxy(_insertAdjacentHTMLImpl, 'snvInsertAdjacentHTML');\n            Element.prototype.insertAdjacentHTML = _H.insertAdjacentHTML;\n        }\n        if (_N.docWrite) {\n            _H.docWrite = _mkProxy(_docWriteImpl, 'snvDocWrite');\n            Document.prototype.write = _H.docWrite;\n        }\n        if (_N.docWriteln) {\n            _H.docWriteln = _mkProxy(_docWritelnImpl, 'snvDocWriteln');\n            Document.prototype.writeln = _H.docWriteln;\n        }\n\n        if (_N.locAssign) {\n            try {\n                _H.locAssign = _mkProxy(_locAssignImpl, 'snvLocAssign');\n                Location.prototype.assign = _H.locAssign;\n            } catch { /* Location.prototype.assign non-configurable */ }\n        }\n        if (_N.locReplace) {\n            try {\n                _H.locReplace = _mkProxy(_locReplaceImpl, 'snvLocReplace');\n                Location.prototype.replace = _H.locReplace;\n            } catch { /* Location.prototype.replace non-configurable */ }\n        }\n        if (_locHrefSetImpl && _N.locHrefDesc) {\n            try {\n                _H.locHrefSet = _mkProxy(_locHrefSetImpl, 'snvLocHrefSet');\n                Object.defineProperty(Location.prototype, 'href', {\n                    configurable: true, enumerable: true,\n                    get: _N.locHrefDesc.get, set: _H.locHrefSet\n                });\n            } catch { /* Location.prototype.href non-configurable */ }\n        }\n\n        _H.formSubmit = _mkProxy(_formSubmitImpl, 'snvFormSubmit');\n        HTMLFormElement.prototype.submit = _H.formSubmit;\n\n        _hookResourceProp(HTMLImageElement.prototype, 'src', _N.imgSrcDesc, 'imgSrcSet', 'img.src');\n        _hookResourceProp(HTMLScriptElement.prototype, 'src', _N.scriptSrcDesc, 'scriptSrcSet', 'script.src', true);\n        _hookResourceProp(HTMLIFrameElement.prototype, 'src', _N.iframeSrcDesc, 'iframeSrcSet', 'iframe.src');\n        _hookResourceProp(HTMLVideoElement.prototype, 'src', _N.videoSrcDesc, 'videoSrcSet', 'video.src');\n        _hookResourceProp(HTMLAudioElement.prototype, 'src', _N.audioSrcDesc, 'audioSrcSet', 'audio.src');\n        _hookResourceProp(HTMLEmbedElement.prototype, 'src', _N.embedSrcDesc, 'embedSrcSet', 'embed.src');\n        _hookResourceProp(HTMLObjectElement.prototype, 'data', _N.objectDataDesc, 'objectDataSet', 'object.data');\n        _hookResourceProp(HTMLLinkElement.prototype, 'href', _N.linkHrefDesc, 'linkHrefSet', 'link.href');\n        // HTMLAnchorElement.href and HTMLAreaElement.href are intentionally NOT hooked:\n        // they are navigation-only attributes (require user click) and blocking them\n        // causes false positives on legitimate external links in the app UI.\n\n        // D3: Restricted createElement hook — only <iframe> is blocked post-init.\n        // Extensions (Adblock, Dark Reader, etc.) that create <script> or other\n        // elements are unaffected; _createElementImpl passes all non-iframe tags\n        // straight through to the native. The 'iframe' tag is the only primitive\n        // needed for the fresh-realm native-reset attack, so it alone is blocked.\n        _H.createElement = _mkProxy(_createElementImpl, 'snvCreateElement');\n        Document.prototype.createElement = _H.createElement;\n    }\n\n    /* ──────────────────────────────────────────────────────────\n       6.  Watchdog  (setInterval: 50 ms; other mechanisms: 800–980 ms)\n       ────────────────────────────────────────────────────────── */\n    let _heartbeatN = 0; // monotonic counter for dead man's switch (E5)\n    function _tick() {\n        if (_DISABLE_PROACTIVE_ANTITAMPER) {\n            // Only dispatch heartbeat — skip all protection checks\n            try {\n                _reflectApply(_N.dispatchEvent, window, [\n                    new _N.CustomEvent('snv:alive', { detail: { n: ++_heartbeatN } })]);\n            } catch { }\n            return;\n        }\n        // 6a. Verify our hooks are still in place.\n        //     Extensions routinely wrap fetch/XHR for their own purposes\n        //     (ad blocking, privacy, etc.), so we silently re-install\n        //     without firing an alert — this is NOT a security threat,\n        //     just normal browser extension behaviour.\n        const hookTampered =\n            window.fetch !== _H.fetch ||\n            XMLHttpRequest.prototype.open !== _H.xhrOpen ||\n            (_N.sendBeacon && navigator.sendBeacon !== _H.sendBeacon) ||\n            Element.prototype.setAttribute !== _H.setAttribute ||\n            (_H.formSubmit && HTMLFormElement.prototype.submit !== _H.formSubmit) ||\n            (_H.insertAdjacentHTML && Element.prototype.insertAdjacentHTML !== _H.insertAdjacentHTML) ||\n            (_H.docWrite && Document.prototype.write !== _H.docWrite) ||\n            (_H.docWriteln && Document.prototype.writeln !== _H.docWriteln) ||\n            (_H.locAssign && Location.prototype.assign !== _H.locAssign) ||\n            (_H.locReplace && Location.prototype.replace !== _H.locReplace) ||\n            // D3: guard the post-init iframe-creation block\n            (_H.createElement && Document.prototype.createElement !== _H.createElement);\n\n        if (hookTampered) {\n            _installHooks(); // silent re-hook, no alert\n        }\n\n        // 6b. Verify security-critical natives are still native.\n        //     We call the LIVE getter on each check, not our cached ref,\n        //     so we catch live substitutions made after our capture.\n        // BUG-E: for-of + destructuring [name, getLive] relies on\n        // Array.prototype[Symbol.iterator]; indexed loop is immune.\n        for (let _ni = 0; _ni < _NATIVE_CHECKS.length; _ni++) {\n            const _nc = _NATIVE_CHECKS[_ni];\n            const name = _nc[0], getLive = _nc[1];\n            let live;\n            try { live = getLive(); } catch {\n                _triggerAlert('Security-critical property was removed: ' + name);\n                return;\n            }\n            if (!_isNative(live)) {\n                _triggerAlert('Native function tampered: ' + name);\n                return; // one alert per tick avoids spam\n            }\n        }\n\n        // 6d. App function integrity — verify critical Crypto module methods\n        //     have not been replaced via the DevTools console (Self-XSS / G1).\n        //     'Crypto' (bare identifier) resolves to the app's script-scope const\n        //     via the JS declarative env record (checked before the object env record\n        //     where window.Crypto / WebCrypto lives).\n        //     _appCryptoRefs is null until 'load' fires — early ticks are safe.\n        if (_appCryptoRefs) {\n            let _liveC = null;\n            try { _liveC = Crypto; } catch { /* Crypto became undefined — treat as tampered */ }\n            if (!_liveC ||\n                _liveC.encrypt !== _appCryptoRefs.encrypt ||\n                _liveC.decrypt !== _appCryptoRefs.decrypt ||\n                _liveC.encryptBin !== _appCryptoRefs.encryptBin ||\n                _liveC.decryptBin !== _appCryptoRefs.decryptBin ||\n                _liveC.deriveKey !== _appCryptoRefs.deriveKey ||\n                _liveC.deriveKeyAndRaw !== _appCryptoRefs.deriveKeyAndRaw ||\n                _liveC.importRawKey !== _appCryptoRefs.importRawKey ||\n                _liveC.checkVerification !== _appCryptoRefs.checkVerification ||\n                _liveC.makeVerification !== _appCryptoRefs.makeVerification\n            ) {\n                _triggerAlert('App function tampered: Crypto');\n                return;\n            }\n        }\n\n        // 6e. App method integrity — verify App.lockContainer has not been\n        //     replaced via the DevTools console (Self-XSS).\n        //     `const App = {...}` in state.js is in the JS global LEXICAL env\n        //     record, not on window. Use _readApp() (bare identifier) to read it.\n        //     Captures both: property replacement (App.lockContainer = () => {})\n        //     and full object substitution.\n        //     _appMethodRefs is null until 'load' fires — early ticks are safe.\n        if (_appMethodRefs) {\n            let _liveApp = null;\n            try { _liveApp = _readApp(); } catch { }\n            if (!_liveApp || _liveApp.lockContainer !== _appMethodRefs.lockContainer) {\n                _triggerAlert('App function tampered: lockContainer');\n                return;\n            }\n        }\n\n        // 6c. Dead man's switch heartbeat — monotonic counter (C1/E5)\n        //     main.js accepts ONLY events where counter is strictly increasing.\n        //     An attacker faking snv:alive events cannot know the current count.\n        //     CRIT-4: Use captured dispatchEvent + CustomEvent so a live\n        //     window.dispatchEvent replacement cannot suppress the heartbeat.\n        try {\n            _reflectApply(_N.dispatchEvent, window, [\n                new _N.CustomEvent('snv:alive', { detail: { n: ++_heartbeatN } })]);\n        } catch { }\n    }\n\n    /* ──────────────────────────────────────────────────────────\n       7.  Guard token, __snvVerify, __snvEmergencyLock\n       ────────────────────────────────────────────────────────── */\n    // Guard token includes the session canary for __snvVerify cross-check.\n    // If _guardPreexisted == true, the attacker pre-defined this property as\n    // non-configurable; our Object.defineProperty will throw but __snvVerify\n    // will return false because the attacker's guard won't carry our _canary.\n    try {\n        Object.defineProperty(window, '__snvGuard', {\n            value: Object.freeze({ active: _captureClean, version: 6, _c: _canary }),\n            writable: false,\n            configurable: false,\n            enumerable: false,\n        });\n    } catch { /* pre-defined by attacker — __snvVerify will catch this */ }\n\n    // __snvVerify — canary cross-check. main.js calls this instead of (or in addition\n    // to) checking __snvGuard.active. Attacker who pre-defined __snvGuard cannot\n    // produce the correct _canary without reading this IIFE's closure at runtime.\n    try {\n        Object.defineProperty(window, '__snvVerify', {\n            value: function snvVerify() {\n                return _captureClean &&\n                    !_guardPreexisted &&\n                    typeof window.__snvGuard === 'object' &&\n                    window.__snvGuard !== null &&\n                    window.__snvGuard._c === _canary;\n            },\n            writable: false,\n            configurable: false,\n            enumerable: false,\n        });\n    } catch { /* attacker pre-defined — main.js falls back to __snvGuard.active */ }\n\n    // __snvEmergencyLock — exposed non-configurable function that wipes storage\n    // and app state directly, bypassing the event system entirely.\n    try {\n        Object.defineProperty(window, '__snvEmergencyLock', {\n            value: function snvEmergencyLock(reason) {\n                _nukeStorage();\n                _wipeAppState();\n                // BUG-FIX: Previously, callers (e.g. the dead man's switch in main.js)\n                // that used __snvEmergencyLock directly would create the veil via\n                // _wipeAppState but NEVER show the alert overlay — the user saw the\n                // animated stripes pattern with no explanation and no reload button.\n                // When a reason string is provided, show the full alert overlay.\n                // The snv:lock handler in main.js calls this WITHOUT a reason (to avoid\n                // a duplicate overlay, since _triggerAlert already calls _showAlert).\n                if (typeof reason === 'string' && reason) _showAlert(reason);\n            },\n            writable: false,\n            configurable: false,\n            enumerable: false,\n        });\n    } catch { }\n\n    // If captures were already tainted, do NOT start watchdog — just bail.\n    // The app will show the \"Proactive failed to initialize\" screen.\n    if (!_captureClean) return;\n\n    /* ──────────────────────────────────────────────────────────\n       8.  Boot — four independent timer mechanisms (B1-B4)\n           Killing the watchdog requires neutralizing ALL FOUR.\n       ────────────────────────────────────────────────────────── */\n    if (!_DISABLE_PROACTIVE_ANTITAMPER) _installHooks();\n\n    // Timer IDs for the watchdog — guarded against clearInterval/clearTimeout\n    // SET-1: plain object replaces Set — `id in obj` and `delete obj[id]` are pure\n    // language operators, completely unhookable.  Set.prototype.has/.add/.delete\n    // could be spoofed via console Self-XSS to let an attacker clear our timer IDs.\n    const _watchdogIds = {};   // id → 1 mapping (numeric keys, no prototype conflict)\n    let _watchdogCount = 0;    // manual size counter; Set.prototype.size is a getter\n    const _wdQueue = [];       // insertion-order queue for trim; closed over, no external ref\n    let _wdQHead = 0;          // soft-delete head — advances past already-removed entries\n\n    // ── Mechanism 1: setInterval (50 ms) ───────────────────────\n    const _ivId = _reflectApply(_N._setInterval, window, [_tick, 50]);\n    _watchdogIds[_ivId] = 1; _watchdogCount++;\n    _wdQueue[_wdQueue.length] = _ivId;\n\n    // ── Mechanism 2: recursive setTimeout (~937 ms, prime offset) ─\n    //    For recursive setTimeout the timer IDs change every iteration.\n    //    We cap the map to the last 16 IDs to avoid unbounded memory growth\n    //    (at 937 ms cadence this is ~15 seconds of IDs — more than enough\n    //    to foil an attacker who reads the current map snapshot in the\n    //    brief window between when a new ID is generated and added).\n    (function _stLoop() {\n        _tick();\n        const id = _reflectApply(_N._setTimeout, window, [_stLoop, 937]);\n        _watchdogIds[id] = 1; _watchdogCount++;\n        _wdQueue[_wdQueue.length] = id;\n        if (_watchdogCount > 16) {\n            // Trim oldest setTimeout ID: walk queue from head, skip entries already\n            // deleted or matching _ivId (the setInterval ID we always keep).\n            while (_wdQHead < _wdQueue.length) {\n                const _old = _wdQueue[_wdQHead++];\n                if (_old in _watchdogIds && _old !== _ivId) {\n                    delete _watchdogIds[_old];\n                    _watchdogCount--;\n                    break;\n                }\n            }\n        }\n    })();\n\n    // ── Mechanism 3: requestAnimationFrame chain ───────────────\n    //    rAF cannot be killed via clearInterval/clearTimeout.\n    //    Throttled to ~1 s cadence to avoid burning CPU.\n    //    _rafId is tracked so the cancelAnimationFrame guard (E4)\n    //    can silently ignore attempts to kill this specific chain.\n    let _lastRafTick = 0, _rafId = null;\n    function _rafLoop(ts) {\n        if (ts - _lastRafTick >= 980) { _lastRafTick = ts; _tick(); }\n        _rafId = _reflectApply(_N._requestAnimationFrame, window, [_rafLoop]);\n    }\n    _rafId = _reflectApply(_N._requestAnimationFrame, window, [_rafLoop]);\n\n    // ── Mechanism 4: MessageChannel self-ping (~800 ms) ────────\n    //    CRIT-5 (anti-removal hardening): completely independent of\n    //    setInterval, setTimeout, and requestAnimationFrame — those\n    //    three share the same underlying timer infrastructure in all\n    //    current JS engines.  MessageChannel.postMessage scheduling\n    //    goes through a separate queue (microtask/message-event loop)\n    //    and cannot be killed by replacing window.setInterval,\n    //    window.setTimeout, or window.cancelAnimationFrame.\n    //    An attacker who wants to silence ALL four mechanisms must\n    //    simultaneously neutralise four unrelated browser subsystems,\n    //    which is not feasible from Self-XSS / extension scope.\n    try {\n        const _mc = new MessageChannel();\n        let _lastMcTick = 0;\n        _mc.port2.onmessage = function _mcLoop() {\n            const _now = _reflectApply(_N.dateNow, Date, []);\n            if (_now - _lastMcTick >= 800) { _lastMcTick = _now; _tick(); }\n            // Re-schedule via a captured setTimeout so the interval can be\n            // tuned independently; if setTimeout was killed (all IDs in\n            // _watchdogIds are expired) the MessageChannel still fires once\n            // more and the dead man's switch in main.js covers the rest.\n            // FIX-MP: use captured _N.portPostMessage so a post-boot replacement\n            // of MessagePort.prototype.postMessage cannot kill this mechanism.\n            const _pmFn = _N.portPostMessage || _mc.port1.postMessage.bind(_mc.port1);\n            _reflectApply(_N._setTimeout, window, [() => { _reflectApply(_pmFn, _mc.port1, [null]); }, 800]);\n        };\n        // FIX-MP: same rationale for the initial prime.\n        const _pmFn = _N.portPostMessage || _mc.port1.postMessage.bind(_mc.port1);\n        _reflectApply(_pmFn, _mc.port1, [null]); // prime the first message\n    } catch { /* MessageChannel unavailable — three mechanisms still active */ }\n\n    // ── B2: Guard clearInterval / clearTimeout ─────────────────\n    //    If external code tries to clear our watchdog timer IDs,\n    //    silently ignore the call. Legitimate code never targets\n    //    foreign timer IDs.\n    const _clearIntervalImpl = function (id) {\n        if (id in _watchdogIds || id in _trapIds) return; // SET-1/2: `in` operator\n        return _reflectApply(_N._clearInterval, window, [id]);\n    };\n    window.clearInterval = _mkProxy(_clearIntervalImpl, 'snvClearInterval');\n    const _clearTimeoutImpl = function (id) {\n        if (id in _watchdogIds || id in _trapIds) return; // SET-1/2: protect both watchdog and debugger-trap IDs\n        return _reflectApply(_N._clearTimeout, window, [id]);\n    };\n    window.clearTimeout = _mkProxy(_clearTimeoutImpl, 'snvClearTimeout');\n\n    // ── E4: Guard cancelAnimationFrame ─────────────────────────\n    //    Silently ignore attempts to cancel our rAF chain ID.\n    if (_N._cancelAnimationFrame) {\n        const _cancelRAFImpl = function (id) {\n            if (_rafId !== null && id === _rafId) return;\n            return _reflectApply(_N._cancelAnimationFrame, window, [id]);\n        };\n        window.cancelAnimationFrame = _mkProxy(_cancelRAFImpl, 'snvCancelAnimationFrame');\n    }\n\n    // ── E6: WebSocket hook ──────────────────────────────\n    //    SafeNova makes no WebSocket connections.\n    //    Any attempt to open a WebSocket to an external host\n    //    is blocked and triggers a threat alert.\n    const _NativeWebSocket = window.WebSocket;\n    if (_NativeWebSocket && _isNative(_NativeWebSocket)) {\n        const _wsImpl = function (url) {\n            const urlStr = '' + (url ?? ''); // STR-2: concatenation op, no String() call\n            const isSameOrigin = (function () {\n                try {\n                    // Parse with the captured _N.URL to resist a live window.URL replacement.\n                    // Compare .host (hostname + port) so wss://localhost:9999 is rejected\n                    // when the page is on localhost:8080 — hostname-only checks would pass.\n                    const parsed = new _N.URL(urlStr);\n                    const proto = parsed.protocol;\n                    if (proto !== 'ws:' && proto !== 'wss:') return false;\n                    // BUG-K: _pureToLower — pure operator-level; no prototype dependency.\n                    return _pureToLower(parsed.host) === _pureToLower(window.location.host);\n                } catch { return false; }\n            }());\n            if (!isSameOrigin) {\n                _triggerAlert('WebSocket to external host blocked \\u2192 ' + urlStr);\n                throw new Error('[SafeNova Proactive] External WebSocket blocked');\n            }\n            return arguments.length >= 2\n                ? new _NativeWebSocket(arguments[0], arguments[1])\n                : new _NativeWebSocket(arguments[0]);\n        };\n        window.WebSocket = _mkProxy(_wsImpl, 'snvWebSocket', _NativeWebSocket.prototype);\n    }\n\n    // ── E11: window.open — popup / navigation exfiltration ─────\n    //    window.open('https://evil.com/steal?data=...') triggers a GET\n    //    request that bypasses fetch/XHR/sendBeacon/WebSocket hooks.\n    //    Same-origin opens (popups, _blank same-site) pass through.\n    const _NativeWindowOpen = window.open;\n    if (_NativeWindowOpen && _isNative(_NativeWindowOpen)) {\n        const _woImpl = function (url) {\n            const urlStr = '' + (url ?? '');\n            if (urlStr && _isExternal(urlStr)) {\n                _triggerAlert('window.open to external URL blocked \\u2192 ' + urlStr);\n                return null;\n            }\n            return _reflectApply(_NativeWindowOpen, window, arguments);\n        };\n        window.open = _mkProxy(_woImpl, 'snvWindowOpen');\n    }\n\n    // ── E12: EventSource — SSE-based exfiltration ───────────────\n    //    new EventSource('https://evil.com/steal?data=...')  opens a\n    //    persistent HTTP GET connection to an external server.\n    const _NativeEventSource = window.EventSource;\n    if (_NativeEventSource && _isNative(_NativeEventSource)) {\n        window.EventSource = _mkProxy(_mkCtorImpl(_NativeEventSource, 'EventSource'), 'snvEventSource', _NativeEventSource.prototype);\n    }\n\n    // ── E13: Worker / SharedWorker — worker-based exfiltration ──\n    //    Workers run in a separate global scope with a clean native\n    //    fetch that bypasses all page-level network hooks.\n    //    data: URLs inline hostile code; external URLs pull it.\n    const _NativeWorker = window.Worker;\n    if (_NativeWorker && _isNative(_NativeWorker)) {\n        window.Worker = _mkProxy(_mkCtorImpl(_NativeWorker, 'Worker', true), 'snvWorker', _NativeWorker.prototype);\n    }\n    const _NativeSharedWorker = window.SharedWorker;\n    if (_NativeSharedWorker && _isNative(_NativeSharedWorker)) {\n        window.SharedWorker = _mkProxy(_mkCtorImpl(_NativeSharedWorker, 'SharedWorker', true), 'snvSharedWorker', _NativeSharedWorker.prototype);\n    }\n\n    // ── E13b: ServiceWorker registration — preventive block ─────\n    //    A rogue SW can intercept all fetches on next page load,\n    //    injecting code BEFORE daemon.js runs.  SafeNova does not\n    //    use ServiceWorkers.  Block register() preventively.\n    //    Existing SWs are nuked reactively in _nukeCachesAndWorkers.\n    try {\n        const _swcProto = navigator && navigator.serviceWorker\n            && Object.getPrototypeOf(navigator.serviceWorker);\n        if (_swcProto && typeof _swcProto.register === 'function') {\n            const _nativeSwRegister = _swcProto.register;\n            _swcProto.register = _mkProxy(function () {\n                _triggerAlert('ServiceWorker registration blocked');\n                return Promise.reject(\n                    new Error('[SafeNova Proactive] ServiceWorker registration blocked'));\n            }, 'snvSwRegister');\n        }\n    } catch { /* serviceWorker unavailable */ }\n\n    // ── E14: setTimeout / setInterval string-callback guard ─────\n    //    setTimeout('malicious code', n) / setInterval('...', n) are\n    //    eval-equivalent.  SafeNova only ever passes function refs.\n    //    String callbacks are blocked unconditionally.\n    //    Daemon's own timers use _N._setTimeout/_N._setInterval\n    //    directly, bypassing this hook.\n    window.setTimeout = _mkProxy(_mkTimerImpl(_N._setTimeout, 'setTimeout'), 'snvSetTimeout');\n    window.setInterval = _mkProxy(_mkTimerImpl(_N._setInterval, 'setInterval'), 'snvSetInterval');\n\n    // ── E15: window.eval — indirect eval block ──────────────────\n    //    Direct eval('...') in strict-mode code cannot be overridden.\n    //    Indirect eval: (0,eval)('...') or window.eval('...') IS\n    //    overridable — this is the attack path from DevTools console.\n    //    SafeNova uses zero eval in its codebase — block all.\n    if (window.eval && _isNative(window.eval)) {\n        const _evalImpl = function () {\n            _triggerAlert('eval() blocked \\u2192 dynamic code injection detected');\n            throw new Error('[SafeNova Proactive] eval() blocked');\n        };\n        const _snvEval = _mkProxy(_evalImpl, 'snvEval');\n        try {\n            // configurable:false prevents delete window.eval restoring native\n            Object.defineProperty(window, 'eval', {\n                configurable: false, enumerable: true, writable: false, value: _snvEval\n            });\n        } catch { window.eval = _snvEval; }\n    }\n\n    // ── E16: new Function() constructor — string-to-code block ──\n    //    new Function('return evil()') is a second eval-equivalent.\n    //    Blocked ONLY when called as a constructor (new.target is set).\n    //    Plain calls without new: Function.prototype.toString, feature\n    //    detection, and various browser extensions call Function() or\n    //    Function.prototype.bind() etc. legitimately — these must pass.\n    //    Per spec §10.2.1 the only dangerous path is `new Function(src)`.\n    const _NativeFunctionCtor = window.Function;\n    if (_NativeFunctionCtor && _isNative(_NativeFunctionCtor)) {\n        const _fnCtorImpl = function () {\n            if (new.target) {\n                _triggerAlert('new Function() blocked \\u2192 dynamic code injection detected');\n                throw new Error('[SafeNova Proactive] new Function() blocked');\n            }\n            return _reflectApply(_NativeFunctionCtor, this, arguments);\n        };\n        const _snvFunction = _mkProxy(_fnCtorImpl, 'snvFunction', _NativeFunctionCtor.prototype);\n        try {\n            Object.defineProperty(window, 'Function', {\n                configurable: false, enumerable: true, writable: false, value: _snvFunction\n            });\n        } catch { window.Function = _snvFunction; }\n    }\n\n    // ── E10: Script-element presence monitor ───────────────────\n    //    BONUS (anti-removal hardening) — captures the daemon's own\n    //    <script> element via document.currentScript at IIFE evaluation\n    //    time, then watches document.head via MutationObserver.\n    //\n    //    Why: Removing the <script> tag from the DOM (DevTools Elements\n    //    panel → Delete node) does NOT stop already-running JavaScript.\n    //    The watchdog keeps firing.  HOWEVER it signals an attacker's\n    //    intent to disable daemon.js on the NEXT page reload — removing\n    //    the tag from the live document does not persist; but an attacker\n    //    who discovers the daemon script path may also try to remove it\n    //    via an injected fetch/XHR or service worker that patches the\n    //    HTML.  Detecting the DOM removal gives an early forensic signal.\n    //\n    //    The observer is read-only forensics only — no false positives\n    //    because legitimate extension / page code never removes our tag.\n    try {\n        const _ownScript = document.currentScript; // null if already async\n        if (_ownScript && _N.MutationObserver) {\n            const _headObserver = new _N.MutationObserver(mutations => {\n                // BUG-E: indexed loops — immune to Array.prototype[Symbol.iterator]\n                // and NodeList's own iterator being replaced.\n                for (let _mi = 0; _mi < mutations.length; _mi++) {\n                    const _removed = mutations[_mi].removedNodes;\n                    for (let _ri = 0; _ri < _removed.length; _ri++) {\n                        if (_removed[_ri] === _ownScript) {\n                            _triggerAlert('SafeNova Proactivity removed from DOM');\n                            _headObserver.disconnect();\n                            return;\n                        }\n                    }\n                }\n            });\n            const _headTarget = _ownScript.parentNode || document.head || document.documentElement;\n            _headObserver.observe(_headTarget, { childList: true, subtree: false });\n        }\n    } catch { /* currentScript unavailable in module context — skip silently */ }\n\n    // ── 8b. DOM exfiltration MutationObserver (defense-in-depth) ──\n    //    Watches the entire document tree for added elements or attribute\n    //    changes containing external src/href/data/on* values.\n    //    Last line of defense: catches attacks that bypass property/method hooks.\n    try {\n        if (_N.MutationObserver) {\n            const _domObserver = new _N.MutationObserver(function (mutations) {\n                for (let _mi = 0; _mi < mutations.length; _mi++) {\n                    const _mut = mutations[_mi];\n                    if (_mut.type === 'childList') {\n                        const _added = _mut.addedNodes;\n                        for (let _ni = 0; _ni < _added.length; _ni++) {\n                            const _node = _added[_ni];\n                            if (_node.nodeType !== 1) continue;\n                            _scanElementForThreats(_node);\n                            // V13: Use captured Element.prototype.querySelectorAll —\n                            // live method could be replaced to return empty NodeList,\n                            // letting child elements of injected nodes bypass scanning.\n                            const _desc = _N.elQuerySelectorAll\n                                ? _reflectApply(_N.elQuerySelectorAll, _node, ['*']) : [];\n                            for (let _di = 0; _di < _desc.length; _di++) {\n                                _scanElementForThreats(_desc[_di]);\n                            }\n                        }\n                    }\n                    if (_mut.type === 'attributes') {\n                        // BUG-K: _pureToLower — pure operator-level; no prototype dependency.\n                        const _aName = _pureToLower('' + (_mut.attributeName || ''));\n                        const _tgt = _mut.target;\n                        if (_tgt.nodeType !== 1) continue;\n                        if (_aName.length > 2 && _aName[0] === 'o' && _aName[1] === 'n') {\n                            try { _reflectApply(_N.removeAttribute, _tgt, [_aName]); } catch { }\n                            _triggerAlert('Inline event handler attribute changed \\u2192 ' + _aName);\n                            continue;\n                        }\n                        // <a> and <area> href changes are navigation-only — not auto-loading resources\n                        if (_aName === 'href') {\n                            const _tgtTag = _pureToLower('' + (_tgt.tagName || ''));\n                            if (_tgtTag === 'a' || _tgtTag === 'area') continue;\n                        }\n                        // FIX-SS: use _RESOURCE_ATTRS_NO_SRCSET — same reason as _scanElementForThreats\n                        if (_aName in _RESOURCE_ATTRS_NO_SRCSET) {\n                            let _val;\n                            try { _val = _reflectApply(_N.getAttribute, _tgt, [_aName]); } catch { continue; }\n                            if (_isExternal('' + (_val || ''))) {\n                                try { _reflectApply(_N.removeAttribute, _tgt, [_aName]); } catch { }\n                                _triggerAlert('External resource attribute changed \\u2192 ' + _aName + '=' + _val);\n                            }\n                        }\n                    }\n                }\n            });\n            _domObserver.observe(document.documentElement, {\n                childList: true, subtree: true, attributes: true\n            });\n        }\n    } catch { /* MutationObserver unavailable — other hooks still active */ }\n\n    // ── D1: Visibility-change fast check ───────────────────────\n    //    When the tab becomes visible again, run an immediate full\n    //    tick so an attacker cannot exploit the ~1 s gap.\n    //    CRIT-3: Use captured _N.addEventListener so a live replacement\n    //    cannot prevent this fast-check from being installed.\n    _reflectApply(_N.addEventListener, document, ['visibilitychange', () => {\n        if (document.visibilityState === 'visible') _tick();\n    }]);\n\n})();\n"
  },
  {
    "path": "src/js/state.js",
    "content": "'use strict';\n\n/* ============================================================\n   SESSION ENCRYPTION  —  AES-256-GCM encrypted session storage\n\n   Two distinct encryption keys are used, one per scope:\n\n   Tab-scope  (snv-s-{cid}  in sessionStorage):\n   • Encrypted with snv-sk — a per-tab AES key stored in sessionStorage.\n   • snv-sk itself is wrap-encrypted with the same 3-source HKDF key\n     as snv-bsk before being written to sessionStorage, so a raw dump\n     of sessionStorage cannot recover the key without also possessing\n     the browser fingerprint, snv-kc cookie, and SafeNovaKS IDB record.\n   • Survives page refresh within the same tab; dies when the tab\n     is closed (sessionStorage is wiped).\n\n   Persistent  (snv-sb-{cid}  in localStorage)  [\"Stay signed in\"]:\n   • Encrypted with snv-bsk — a shared AES-256-GCM key in localStorage.\n   • snv-bsk itself is AES-GCM-encrypted before being stored — the\n     wrapping key is derived on-the-fly via HKDF from THREE independent\n     sources and NEVER written to any storage:\n       1. Browser fingerprint (origin, userAgent, platform, language,\n          hardwareConcurrency, colorDepth, pixelDepth) — ties sessions\n          to the specific browser version and OS environment.\n       2. 32 random bytes in a cookie (snv-kc, SameSite=Strict) —\n          survives across sessions, isolated from localStorage.\n       3. 32 random bytes in a separate IndexedDB (SafeNovaKS) —\n          independent from the main SafeNovaEFS database.\n     An attacker must compromise ALL three storage mechanisms\n     simultaneously to reconstruct the wrap-key.\n     Copying localStorage alone is useless — without the matching\n     cookie AND the SafeNovaKS database AND the same browser\n     fingerprint, snv-bsk is undecryptable.\n     Browser updates that change the UA string will invalidate sessions;\n     the user re-enters their password once and a new session is created.\n   • Survives browser restarts until the 7-day TTL expires or the\n     user explicitly signs out.\n\n   Separation guarantees:\n   • Tab-scope blobs → only the originating tab can decrypt them\n     (key in sessionStorage, not shared).\n   • Persistent blobs → any tab of the SAME browser can decrypt\n     them (shared snv-bsk, same fingerprint + cookie + IDB → same wrap-key).\n   • A copied localStorage is useless without the matching browser,\n     cookie, and SafeNovaKS IndexedDB.\n   ============================================================ */\n\n/* ── Tab-scope session key (sessionStorage, per-tab, wrap-encrypted) ── */\nlet _sessionKey = null;\n\nasync function _getOrCreateSessionKey() {\n    if (_sessionKey) return _sessionKey;\n    // snv-sk is wrap-encrypted with the same 3-source browser wrap key as snv-bsk,\n    // so a raw sessionStorage dump cannot recover the inner key without also\n    // possessing the fingerprint, snv-kc cookie, and SafeNovaKS IDB record.\n    const wrapKey = await _getOrCreateBrowserWrapKey();\n    const stored = sessionStorage.getItem('snv-sk');\n    if (stored) {\n        try {\n            const blobBytes = Uint8Array.from(atob(stored), ch => ch.charCodeAt(0));\n            // Legacy format (pre-wrap): exactly 32 raw bytes stored plain — migrate on-the-fly\n            if (blobBytes.length === 32) {\n                const wrapIV = crypto.getRandomValues(new Uint8Array(12)),\n                    ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: wrapIV }, wrapKey, blobBytes),\n                    blob = new Uint8Array(12 + ct.byteLength);\n                blob.set(wrapIV);\n                blob.set(new Uint8Array(ct), 12);\n                sessionStorage.setItem('snv-sk', btoa(String.fromCharCode(...blob)));\n                _sessionKey = await crypto.subtle.importKey('raw', blobBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);\n                return _sessionKey;\n            }\n            // Current format: IV(12) + AES-GCM(CT) wrapping the 32-byte raw key\n            const iv = blobBytes.slice(0, 12), ct = blobBytes.slice(12);\n            const raw = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, wrapKey, ct);\n            _sessionKey = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);\n            return _sessionKey;\n        } catch { /* corrupted or fingerprint changed — regenerate below */ }\n    }\n    // Generate fresh snv-sk and wrap it with the browser wrap key before storing\n    const raw = crypto.getRandomValues(new Uint8Array(32)),\n        wrapIV = crypto.getRandomValues(new Uint8Array(12)),\n        ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: wrapIV }, wrapKey, raw),\n        blob = new Uint8Array(12 + ct.byteLength);\n    blob.set(wrapIV);\n    blob.set(new Uint8Array(ct), 12);\n    sessionStorage.setItem('snv-sk', btoa(String.fromCharCode(...blob)));\n    _sessionKey = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);\n    return _sessionKey;\n}\n\n/* ── Browser fingerprint → HKDF wrap-key (never stored) ──\n   The wrap-key is derived from THREE independent sources:\n   1. Browser fingerprint (browser+OS environment, see below)\n   2. Random 32-byte secret stored in a cookie (snv-kc)\n   3. Random 32-byte secret stored in a SEPARATE IndexedDB (SafeNovaKS)\n\n   An attacker must compromise ALL three storage mechanisms\n   simultaneously to reconstruct the wrap-key:\n   • localStorage alone is useless (no cookie or IDB secret)\n   • A disk image copy lacks the cookie (browser-bound)\n   • Clearing cookies or the key-store IDB invalidates the key\n\n   Includes navigator.userAgent and navigator.platform to bind sessions\n   to the specific browser version and OS. Browser updates that change\n   the UA string will invalidate existing sessions — the user re-enters\n   their password once and a new session is established automatically.\n   Also used as the wrap key for snv-sk (sessionStorage), so both\n   storage types require the same 3-source credential to recover. */\nlet _browserWrapKey = null;\n\nfunction _getBrowserFingerprint() {\n    const n = navigator, s = screen;\n    return [\n        window.location.origin,              // deployment-bound\n        n.userAgent || '',                   // browser + OS version\n        n.platform || '',                    // OS/CPU platform\n        n.language || '',                    // system language\n        String(n.hardwareConcurrency || 0),  // CPU core count\n        String(s.colorDepth || 0),           // display bit depth\n        String(s.pixelDepth || 0),\n    ].join('\\x00');\n}\n\n/* ── Cookie key-part (snv-kc): 32 random bytes ── */\nfunction _readKeyPartCookie() {\n    const m = document.cookie.match(/(?:^|;\\s*)snv-kc=([A-Za-z0-9+/=]+)/);\n    return m ? m[1] : null;\n}\n\nfunction _writeKeyPartCookie(b64) {\n    const maxAge = 400 * 24 * 60 * 60, // ~400 days (browser max-age ceiling)\n        secure = location.protocol === 'https:' ? '; Secure' : '';\n    document.cookie = `snv-kc=${b64}; path=/; max-age=${maxAge}; SameSite=Strict${secure}`;\n}\n\nasync function _getOrCreateKeyPartCookie() {\n    InitLog.step('wrap-key: cookie part');\n    const existing = _readKeyPartCookie();\n    if (existing) {\n        try {\n            const bytes = Uint8Array.from(atob(existing), c => c.charCodeAt(0));\n            if (bytes.length === 32) {\n                _writeKeyPartCookie(existing); // refresh max-age\n                InitLog.done('wrap-key: cookie part', 'existing');\n                return bytes;\n            }\n        } catch { /* corrupted — regenerate */ }\n    }\n    const bytes = crypto.getRandomValues(new Uint8Array(32));\n    _writeKeyPartCookie(btoa(String.fromCharCode(...bytes)));\n    InitLog.done('wrap-key: cookie part', 'new');\n    return bytes;\n}\n\n/* ── IndexedDB key-part (SafeNovaKS): 32 random bytes in an\n   independent database, separate from the main SafeNovaEFS ── */\nconst _KS_DB_NAME = 'SafeNovaKS';\nconst _KS_TIMEOUT = 4000; // 4 s — abort if IDB hangs (blocked, quota, etc.)\n\nasync function _getOrCreateKeyPartIDB() {\n    InitLog.step('wrap-key: SafeNovaKS IDB');\n    return new Promise((resolve, reject) => {\n        const timer = setTimeout(() => reject(new Error('SafeNovaKS open timeout')), _KS_TIMEOUT);\n        let settled = false;\n        const done = (v) => { if (!settled) { settled = true; clearTimeout(timer); InitLog.done('wrap-key: SafeNovaKS IDB'); resolve(v); } },\n            fail = (e) => { if (!settled) { settled = true; clearTimeout(timer); InitLog.error('wrap-key: SafeNovaKS IDB', e); reject(e); } };\n\n        let db;\n        try {\n            const req = indexedDB.open(_KS_DB_NAME, 1);\n            req.onupgradeneeded = e => {\n                try {\n                    db = e.target.result;\n                    if (!db.objectStoreNames.contains('keys'))\n                        db.createObjectStore('keys', { keyPath: 'id' });\n                } catch (err) { fail(err); }\n            };\n            req.onblocked = () => fail(new Error('SafeNovaKS blocked'));\n            req.onerror = () => fail(req.error);\n            req.onsuccess = e => {\n                try {\n                    db = e.target.result;\n                    const tx = db.transaction('keys', 'readonly'),\n                        get = tx.objectStore('keys').get('snv-ki');\n                    get.onsuccess = () => {\n                        try {\n                            const rec = get.result;\n                            const val = rec?.value;\n                            let bytes = null, needsMigration = false;\n\n                            if (typeof val === 'string' && val.length > 0) {\n                                // Current format: base64 string — immune to cross-realm\n                                // instanceof issues caused by daemon.js iframe restoration\n                                try { bytes = Uint8Array.from(atob(val), c => c.charCodeAt(0)); } catch { }\n                            } else if (val && typeof val === 'object') {\n                                // Legacy format: raw typed array stored before base64 migration.\n                                // daemon.js replaces window.Uint8Array/ArrayBuffer with iframe\n                                // copies, but IDB structured clone deserializes into the original\n                                // realm's constructors — instanceof fails across realms.\n                                // Indexed element access (pure language operator) is realm-safe.\n                                needsMigration = true;\n                                const len = typeof val.length === 'number' ? val.length\n                                    : typeof val.byteLength === 'number' ? val.byteLength : 0;\n                                if (len === 32 && typeof val[0] === 'number') {\n                                    bytes = new Uint8Array(32);\n                                    for (let i = 0; i < 32; i++) bytes[i] = val[i];\n                                }\n                            }\n\n                            if (bytes && bytes.length === 32) {\n                                if (needsMigration) {\n                                    // Migrate legacy binary → base64 for future reads\n                                    const b64 = btoa(String.fromCharCode(...bytes));\n                                    try {\n                                        const tx2 = db.transaction('keys', 'readwrite');\n                                        tx2.objectStore('keys').put({ id: 'snv-ki', value: b64 });\n                                        tx2.oncomplete = () => { db.close(); done(bytes); };\n                                        tx2.onerror = () => { db.close(); done(bytes); };\n                                    } catch { db.close(); done(bytes); }\n                                } else {\n                                    db.close();\n                                    done(bytes);\n                                }\n                            } else {\n                                // Generate new key part and store as base64 string\n                                const newBytes = crypto.getRandomValues(new Uint8Array(32));\n                                const b64 = btoa(String.fromCharCode(...newBytes));\n                                const tx2 = db.transaction('keys', 'readwrite');\n                                tx2.objectStore('keys').put({ id: 'snv-ki', value: b64 });\n                                tx2.oncomplete = () => { db.close(); done(newBytes); };\n                                tx2.onerror = () => { db.close(); fail(tx2.error); };\n                            }\n                        } catch (err) { try { db.close(); } catch { } fail(err); }\n                    };\n                    get.onerror = () => { try { db.close(); } catch { } fail(get.error); };\n                } catch (err) { try { db?.close(); } catch { } fail(err); }\n            };\n        } catch (err) { fail(err); }\n    });\n}\n\nasync function _getOrCreateBrowserWrapKey() {\n    if (_browserWrapKey) return _browserWrapKey;\n    InitLog.step('wrap-key: HKDF derive');\n\n    // 1. Deterministic browser fingerprint\n    const fpBytes = new TextEncoder().encode(_getBrowserFingerprint());\n\n    // 2. Random secret from cookie\n    let cookiePart;\n    try { cookiePart = await _getOrCreateKeyPartCookie(); }\n    catch (e) {\n        InitLog.error('wrap-key: cookie part', e);\n        throw new Error('Cookie key-part unavailable: ' + (e?.message || e));\n    }\n\n    // 3. Random secret from separate IndexedDB\n    let idbPart;\n    try { idbPart = await _getOrCreateKeyPartIDB(); }\n    catch (e) {\n        InitLog.error('wrap-key: SafeNovaKS IDB', e);\n        throw new Error('IDB key-part unavailable: ' + (e?.message || e));\n    }\n\n    // Concatenate all three components: fingerprint \\0 cookie(32) \\0 idb(32)\n    const combined = new Uint8Array(fpBytes.length + 1 + 32 + 1 + 32);\n    combined.set(fpBytes);\n    combined[fpBytes.length] = 0;\n    combined.set(cookiePart, fpBytes.length + 1);\n    combined[fpBytes.length + 1 + 32] = 0;\n    combined.set(idbPart, fpBytes.length + 1 + 32 + 1);\n\n    const hkdf = await crypto.subtle.importKey('raw', combined, 'HKDF', false, ['deriveKey']),\n        salt = new Uint8Array(32), // all-zero deterministic salt\n        info = new TextEncoder().encode('snv-browser-wrap-v3');\n    _browserWrapKey = await crypto.subtle.deriveKey(\n        { name: 'HKDF', hash: 'SHA-256', salt, info },\n        hkdf,\n        { name: 'AES-GCM', length: 256 },\n        false,\n        ['encrypt', 'decrypt']\n    );\n    InitLog.done('wrap-key: HKDF derive');\n    return _browserWrapKey;\n}\n\n/* ── Browser-scope session key (localStorage, shared across all tabs, wrap-encrypted) ── */\nlet _browserScopeKey = null;\n\nasync function _getOrCreateBrowserScopeKey() {\n    if (_browserScopeKey) return _browserScopeKey;\n    InitLog.step('browser-scope-key');\n    let wrapKey;\n    try {\n        wrapKey = await _getOrCreateBrowserWrapKey();\n    } catch (e) {\n        // If wrap-key derivation fails (cookie/IDB unavailable), persistent\n        // sessions cannot work. Clear any stored snv-bsk and propagate.\n        InitLog.error('browser-scope-key', 'wrap-key derivation failed: ' + e?.message);\n        localStorage.removeItem('snv-bsk');\n        throw e;\n    }\n    const stored = localStorage.getItem('snv-bsk');\n    if (stored) {\n        try {\n            const blobBytes = Uint8Array.from(atob(stored), ch => ch.charCodeAt(0));\n            // Legacy format (pre-fingerprint-wrap): exactly 32 raw bytes, no IV prefix.\n            // Migrate on-the-fly: import the raw key, re-wrap it, overwrite localStorage.\n            if (blobBytes.length === 32) {\n                const legacyKey = await crypto.subtle.importKey('raw', blobBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']),\n                    rawExported = await crypto.subtle.exportKey('raw', legacyKey),\n                    wrapIV = crypto.getRandomValues(new Uint8Array(12)),\n                    ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: wrapIV }, wrapKey, rawExported),\n                    newBlob = new Uint8Array(12 + ct.byteLength);\n                newBlob.set(wrapIV);\n                newBlob.set(new Uint8Array(ct), 12);\n                localStorage.setItem('snv-bsk', btoa(String.fromCharCode(...newBlob)));\n                _browserScopeKey = await crypto.subtle.importKey('raw', rawExported, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);\n                InitLog.done('browser-scope-key', 'legacy-migrated');\n                return _browserScopeKey;\n            }\n            // Current format: IV(12) + AES-GCM(CT) wrapping the 32-byte raw key\n            const iv = blobBytes.slice(0, 12), ct = blobBytes.slice(12);\n            const raw = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, wrapKey, ct);\n            _browserScopeKey = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);\n            InitLog.done('browser-scope-key', 'existing');\n            return _browserScopeKey;\n        } catch { /* corrupted, stale wrap-key, or invalid base64 — regenerate below */ }\n    }\n    // Generate fresh snv-bsk and wrap it with the browser-specific key before storing\n    const raw = crypto.getRandomValues(new Uint8Array(32)),\n        wrapIV = crypto.getRandomValues(new Uint8Array(12)),\n        ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: wrapIV }, wrapKey, raw),\n        blob = new Uint8Array(12 + ct.byteLength);\n    blob.set(wrapIV);\n    blob.set(new Uint8Array(ct), 12);\n    localStorage.setItem('snv-bsk', btoa(String.fromCharCode(...blob)));\n    _browserScopeKey = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);\n    InitLog.done('browser-scope-key', 'new');\n    return _browserScopeKey;\n}\n\n// Browser-scope sessions expire after 7 days; tab-scope persist until tab closes\nconst SESSION_TTL_BROWSER = 7 * 24 * 60 * 60 * 1000;\n\nasync function _encryptSessionPayload(key, cid, rawKeyBytes, expiryMs) {\n    const iv = crypto.getRandomValues(new Uint8Array(12)),\n        payload = new Uint8Array(8 + rawKeyBytes.length);\n    new DataView(payload.buffer).setBigUint64(0, BigInt(expiryMs), true);\n    payload.set(rawKeyBytes, 8);\n    const aad = new TextEncoder().encode('snv-session:' + cid),\n        ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: aad }, key, payload),\n        blob = new Uint8Array(12 + ct.byteLength);\n    blob.set(iv);\n    blob.set(new Uint8Array(ct), 12);\n    return btoa(String.fromCharCode(...blob));\n}\n\nasync function _decryptSessionPayload(key, cid, b64) {\n    const blob = Uint8Array.from(atob(b64), ch => ch.charCodeAt(0)),\n        iv = blob.slice(0, 12), ct = blob.slice(12);\n    const aad = new TextEncoder().encode('snv-session:' + cid),\n        dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, additionalData: aad }, key, ct),\n        payload = new Uint8Array(dec),\n        expiry = Number(new DataView(payload.buffer).getBigUint64(0, true));\n    if (Date.now() > expiry) return null; // expired\n    return payload.slice(8); // 32-byte raw key material\n}\n\n// rawKeyBytes — Uint8Array(32) from Crypto.deriveRaw(), never the plaintext password\nasync function saveSession(cid, rawKeyBytes, scope) {\n    if (scope === 'browser') {\n        // Use the shared browser-scope key so ALL tabs can resume this session\n        const key = await _getOrCreateBrowserScopeKey(),\n            b64 = await _encryptSessionPayload(key, cid, rawKeyBytes, Date.now() + SESSION_TTL_BROWSER);\n        localStorage.setItem('snv-sb-' + cid, b64);\n        sessionStorage.removeItem('snv-s-' + cid);\n    } else {\n        // Use the per-tab key; only this tab can decrypt it\n        const key = await _getOrCreateSessionKey(),\n            b64 = await _encryptSessionPayload(key, cid, rawKeyBytes, Number.MAX_SAFE_INTEGER);\n        sessionStorage.setItem('snv-s-' + cid, b64);\n        localStorage.removeItem('snv-sb-' + cid);\n    }\n}\n\n// Returns Uint8Array(32) raw key bytes on success, or null on failure/expiry\nasync function loadSession(cid) {\n    // Tab-scope first — per-tab key, lives in sessionStorage\n    const tabBlob = sessionStorage.getItem('snv-s-' + cid);\n    if (tabBlob) {\n        try {\n            const key = await _getOrCreateSessionKey();\n            const raw = await _decryptSessionPayload(key, cid, tabBlob);\n            if (raw) { InitLog.done('loadSession', 'tab-scope OK'); return raw; }\n            // null means expired\n            InitLog.error('loadSession', 'tab-scope expired');\n            sessionStorage.removeItem('snv-s-' + cid);\n        } catch (e) {\n            // Corrupted tab blob — belongs to this tab, safe to clear\n            InitLog.error('loadSession', 'tab-scope error: ' + (e?.message || e));\n            sessionStorage.removeItem('snv-s-' + cid);\n        }\n    }\n\n    // Browser-scope — shared key, any tab can decrypt\n    const browserBlob = localStorage.getItem('snv-sb-' + cid);\n    if (browserBlob) {\n        try {\n            const key = await _getOrCreateBrowserScopeKey();\n            const raw = await _decryptSessionPayload(key, cid, browserBlob);\n            if (raw) { InitLog.done('loadSession', 'browser-scope OK'); return raw; }\n            // null means expired\n            InitLog.error('loadSession', 'browser-scope expired');\n            localStorage.removeItem('snv-sb-' + cid);\n        } catch (e) {\n            // Corrupted blob or wrap-key unavailable — remove it\n            InitLog.error('loadSession', 'browser-scope error: ' + (e?.message || e));\n            localStorage.removeItem('snv-sb-' + cid);\n        }\n    }\n\n    return null;\n}\n\nfunction clearSession(cid) {\n    sessionStorage.removeItem('snv-s-' + cid);\n    localStorage.removeItem('snv-sb-' + cid);\n}\n\nfunction hasSession(cid) {\n    return !!(sessionStorage.getItem('snv-s-' + cid) || localStorage.getItem('snv-sb-' + cid));\n}\n\n/* ============================================================\n   TAB SESSION GUARD\n   Prevents the same container from being opened in two tabs\n   simultaneously. Uses localStorage for cross-tab visibility\n   and a heartbeat to detect stale (dead tab) claims.\n   ============================================================ */\nconst _OPEN_TTL = 30000; // consider stale after 6 missed heartbeats (heartbeat = 5 s)\nlet _sessionHeartbeat = null;\n\nfunction _openKey(cid) { return 'snv-open-' + cid; }\n\n/** Returns true if another live tab currently has this container open. */\nfunction _checkContainerSession(cid) {\n    try {\n        const raw = localStorage.getItem(_openKey(cid));\n        if (!raw) return false;\n        const d = JSON.parse(raw);\n        return !!(d && d.tab !== _TAB_ID && (Date.now() - d.ts) < _OPEN_TTL);\n    } catch { return false; }\n}\n\n/** Claim the container session for this tab and start heartbeat. */\nfunction _startContainerSession(cid) {\n    const write = () => localStorage.setItem(_openKey(cid), JSON.stringify({ tab: _TAB_ID, ts: Date.now() }));\n    write();\n    if (_sessionHeartbeat) clearInterval(_sessionHeartbeat);\n    _sessionHeartbeat = setInterval(() => {\n        if (App.container?.id === cid) write(); else _stopContainerSession(cid);\n    }, 5000);\n}\n\n/** Release the container session claim for this tab. */\nfunction _stopContainerSession(cid) {\n    if (_sessionHeartbeat) { clearInterval(_sessionHeartbeat); _sessionHeartbeat = null; }\n    if (!cid) return;\n    try {\n        const raw = localStorage.getItem(_openKey(cid));\n        if (raw) {\n            const d = JSON.parse(raw);\n            if (d.tab === _TAB_ID) localStorage.removeItem(_openKey(cid));\n        }\n    } catch { localStorage.removeItem(_openKey(cid)); }\n}\n\n/** Force-claim: writes kick flag → causes the other tab to lock itself via storage event. */\nfunction _forceClaimSession(cid) {\n    localStorage.setItem(_openKey(cid), JSON.stringify({ tab: _TAB_ID, ts: Date.now(), kick: true }));\n}\n\n/* ============================================================\n   APP STATE\n   ============================================================ */\nconst App = {\n    view: 'home',\n    container: null,   // container metadata object\n    key: null,   // CryptoKey (in-memory only, never persisted)\n    folder: 'root',\n    selection: new Set(),\n    clipboard: null,   // { op: 'copy'|'cut', ids: [...] }\n    thumbCache: {},    // nodeId → dataURL\n    _winCtx: null,   // active FolderWindow context (set by FolderWindow ops)\n    _ctxScreenPos: null, // screen {x,y} of last context-menu click (used to position new files/folders)\n\n    async init() {\n        InitLog.step('App.init');\n        if (!window.isSecureContext || !window.crypto?.subtle) {\n            const ol = document.getElementById('loading-overlay');\n            if (ol) {\n                const reason = !window.isSecureContext\n                    ? 'Open this page over <strong style=\"color:var(--text)\">HTTPS</strong> or <code style=\"color:var(--accent);font-family:monospace\">localhost</code>.'\n                    : 'This browser does not support the Web Crypto API.';\n                ol.innerHTML = `\n          <div style=\"text-align:center;max-width:380px;padding:0 24px\">\n            <svg width=\"44\" height=\"44\" viewBox=\"0 0 24 24\" fill=\"none\" style=\"color:#f44747;margin-bottom:16px\" xmlns=\"http://www.w3.org/2000/svg\">\n              <path d=\"M12 2L2 20h20z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/>\n              <path d=\"M12 9v5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n              <circle cx=\"12\" cy=\"16.5\" r=\"0.8\" fill=\"currentColor\"/>\n            </svg>\n            <div style=\"color:var(--text);font-size:16px;font-weight:600;margin-bottom:8px\">Web Crypto API unavailable</div>\n            <div style=\"color:var(--text-dim);font-size:13px;line-height:1.7\">${reason}<br>Use Chrome, Firefox, or Edge.</div>\n          </div>`;\n                ol.style.cssText += 'display:flex;opacity:1;pointer-events:all;';\n            }\n            return;\n        }\n        await DB.init();\n        this.showView('home');\n        await Home.render();\n        await updateStorageInfo();\n        InitLog.done('App.init');\n    },\n\n    showView(name) {\n        document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));\n        document.getElementById('view-' + name).classList.add('active');\n        this.view = name;\n    },\n\n    // Return to home WITHOUT killing the session (password stays remembered)\n    async backToMenu() {\n        document.title = 'SafeNova';\n        // Close any open file preview or editor so decrypted data is not left visible\n        if (typeof closeViewer === 'function') closeViewer();\n        if (typeof discardEditor === 'function') discardEditor();\n        Overlay.hide();\n        if (this.container?.id) _stopContainerSession(this.container.id);\n        this.key = null;\n        this.container = null;\n        this.folder = 'root';\n        this.selection = new Set();\n        this.clipboard = null;\n        this.thumbCache = {};\n        if (typeof _cancelThumbQueue === 'function') _cancelThumbQueue();\n        this._winCtx = null;\n        if (typeof WinManager !== 'undefined') WinManager.closeAll();\n        if (typeof _resetContainerSettings === 'function') _resetContainerSettings();\n        if (typeof Desktop !== 'undefined') {\n            Desktop._desktopFolder = 'root';\n            Desktop._sel = this.selection;\n        }\n        VFS.init();\n        this.showView('home');\n        await Home.render();\n        await updateStorageInfo();\n    },\n\n    async lockContainer() {\n        document.title = 'SafeNova';\n        const cname = this.container?.name ?? null;\n        // Close any open file preview or editor — critical: clears decrypted content from DOM\n        if (typeof closeViewer === 'function') closeViewer();\n        if (typeof discardEditor === 'function') discardEditor();\n        Overlay.hide();\n        // Drain the loading counter: snv:lock / dead man's switch / cross-tab kick can fire\n        // mid-operation (while _appBusy > 0). Without an explicit reset, beforeunload would\n        // keep showing the \"unsaved changes\" dialog long after the container is locked.\n        while (_appBusy > 0) hideLoading();\n        const cid = this.container?.id;\n        if (cid) { _stopContainerSession(cid); clearSession(cid); }\n        // Null out heavy blobs in the container record before releasing the key.\n        // _alogZ and lazyWorkspace accumulate as persistent orphans in IDB if not\n        // explicitly cleared — Chrome's lazy blob GC won't reclaim them promptly.\n        // NOTE: _exportCache is intentionally kept — it allows passwordless export\n        // even after lock. It is cleaned by _cleanOrphanedExportCache() on unlock\n        // (if the setting was disabled) and by nukeContainer() on full delete.\n        try {\n            const c = this.container;\n            if (c && (c._alogZ || c.lazyWorkspace)) {\n                c._alogZ = null;\n                c.lazyWorkspace = null;\n                await DB.saveContainer(c);\n            }\n        } catch { /* non-critical — proceed to lock even if IDB write fails */ }\n        this.key = null;\n        this.container = null;\n        this.folder = 'root';\n        this.selection = new Set();\n        this.clipboard = null;\n        this.thumbCache = {};\n        if (typeof _cancelThumbQueue === 'function') _cancelThumbQueue();\n        this._winCtx = null;\n        // Close all open folder windows\n        if (typeof WinManager !== 'undefined') WinManager.closeAll();\n        if (typeof _resetContainerSettings === 'function') _resetContainerSettings();\n        // Reset desktop folder tracking\n        if (typeof Desktop !== 'undefined') {\n            Desktop._desktopFolder = 'root';\n            Desktop._sel = this.selection;\n        }\n        VFS.init();\n        this.showView('home');\n        await Home.render();\n        await updateStorageInfo();\n        toast(cname ? `“${cname}” locked` : 'Container locked', 'info');\n    }\n};\n\n/* ============================================================\n   LOADING OVERLAY\n   ============================================================ */\nlet _appBusy = 0;\nfunction showLoading(msg = 'Processing...') {\n    _appBusy++;\n    document.getElementById('loading-msg').textContent = msg;\n    document.getElementById('loading-overlay').classList.add('show');\n}\nfunction hideLoading() {\n    _appBusy = Math.max(0, _appBusy - 1);\n    document.getElementById('loading-overlay').classList.remove('show');\n}\n\n/* ============================================================\n   TOAST NOTIFICATIONS\n   ============================================================ */\nfunction toast(msg, type = 'info') {\n    const t = document.createElement('div');\n    t.className = 'toast ' + type;\n    const iconMap = {\n        success: Icons.info,\n        error: Icons.warning,\n        warn: Icons.warning,\n        info: Icons.info,\n    };\n    t.innerHTML = `<span style=\"color:var(--text-dim)\">${iconMap[type] || ''}</span><span>${escHtml(msg)}</span>`;\n    document.getElementById('toast-container').appendChild(t);\n    setTimeout(() => t.remove(), 3200);\n}\n\n/* ============================================================\n   MODAL OVERLAY HELPER\n   ============================================================ */\nconst Overlay = {\n    current: null,\n    _hideTimer: null,\n\n    show(modalId) {\n        // Cancel any pending hide so the modal doesn't get wiped by a deferred setTimeout\n        if (this._hideTimer) { clearTimeout(this._hideTimer); this._hideTimer = null; }\n        const ov = document.getElementById('modal-overlay');\n        ov.querySelectorAll('.modal').forEach(m => m.style.display = 'none');\n        const m = document.getElementById(modalId);\n        if (m) m.style.display = 'flex';\n        ov.classList.add('show');\n        this.current = modalId;\n    },\n\n    hide() {\n        document.getElementById('modal-overlay').classList.remove('show');\n        this._hideTimer = setTimeout(() => {\n            this._hideTimer = null;\n            document.getElementById('modal-overlay')\n                .querySelectorAll('.modal').forEach(m => m.style.display = 'none');\n        }, 200);\n        this.current = null;\n        // If cancelled from a FolderWindow context — restore main desktop state\n        if (App._winCtx !== null) {\n            App._winCtx = null;\n            if (typeof Desktop !== 'undefined') {\n                App.folder = Desktop._desktopFolder;\n                App.selection = Desktop._sel;\n            }\n        }\n    }\n};\n\n/* ============================================================\n   STORAGE INFO  —  20 GB device limit + low-space warnings\n   ============================================================ */\nlet _storageWarnShown = false;\n\nasync function updateStorageInfo() {\n    try {\n        if (!navigator.storage?.estimate) return;\n        const est = await navigator.storage.estimate();\n        const used = est.usage || 0,\n            quota = est.quota || 0;\n\n        // Cap the visual scale at DEVICE_LIMIT (20 GB).\n        // available = free space remaining within SafeNova's own limit,\n        // not the full browser quota — other origin data is irrelevant here.\n        const displayMax = Math.min(quota > 0 ? quota : DEVICE_LIMIT, DEVICE_LIMIT),\n            available = displayMax - used,\n            pct = displayMax > 0 ? Math.min((used / displayMax) * 100, 100) : 0;\n\n        const fill = document.getElementById('storage-bar-fill'),\n            txt = document.getElementById('storage-text');\n        if (fill) {\n            fill.style.width = pct + '%';\n            fill.className = 'storage-bar-fill' + (pct > 90 ? ' danger' : pct > 70 ? ' warn' : '');\n        }\n        if (txt) txt.textContent = `${fmtSize(used)} / ${fmtSize(displayMax)}  ·  ${fmtSize(available)} free`;\n\n        // Storage warning banner\n        const banner = document.getElementById('storage-warning-banner');\n        if (banner) {\n            if (available < 200 * 1024 * 1024) {        // < 200 MB\n                banner.querySelector('span').textContent =\n                    `Critical: only ${fmtSize(available)} of storage remaining on this device. Data may not be saved.`;\n                banner.classList.add('show');\n            } else if (available < 1 * 1024 * 1024 * 1024) { // < 1 GB\n                banner.querySelector('span').textContent =\n                    `Low storage: ${fmtSize(available)} remaining on this device.`;\n                banner.classList.add('show');\n            } else {\n                banner.classList.remove('show');\n            }\n        }\n\n        // One-time toast for low storage\n        if (!_storageWarnShown && available < 500 * 1024 * 1024) {\n            _storageWarnShown = true;\n            if (available < 100 * 1024 * 1024) {\n                toast(`Critical: only ${fmtSize(available)} free on this device!`, 'error');\n            } else {\n                toast(`Low storage: ${fmtSize(available)} remaining.`, 'warn');\n            }\n        }\n\n        // TrueWebCrypt containers usage\n        const containers = await DB.getContainers(),\n            twcUsed = containers.reduce((s, c) => s + (c.totalSize || 0), 0),\n            twcPct = displayMax > 0 ? Math.min((twcUsed / displayMax) * 100, 100) : 0,\n            twcFill = document.getElementById('twc-bar-fill'),\n            twcTxt = document.getElementById('twc-text');\n        if (twcFill) twcFill.style.width = twcPct + '%';\n        if (twcTxt) twcTxt.textContent = `${fmtSize(twcUsed)} in ${containers.length} container${containers.length !== 1 ? 's' : ''}`;\n    } catch (e) { /* silently ignore — storage API may be restricted */ }\n}\n\n/* ============================================================\n   CHECK DEVICE STORAGE BEFORE WRITE\n   Returns { ok: bool, available: number }\n   ============================================================ */\nasync function checkStorageSpace(needed) {\n    try {\n        if (!navigator.storage?.estimate) return { ok: true, available: Infinity };\n        const est = await navigator.storage.estimate(),\n            quota = est.quota || 0,\n            used = est.usage || 0,\n            // Check against SafeNova's 20 GB limit (capped at device quota if lower)\n            displayMax = Math.min(quota > 0 ? quota : DEVICE_LIMIT, DEVICE_LIMIT),\n            available = displayMax - used;\n        // Keep 50 MB safety margin\n        if (available - needed < 50 * 1024 * 1024) {\n            return { ok: false, available };\n        }\n        return { ok: true, available };\n    } catch { return { ok: true, available: Infinity }; }\n}\n"
  },
  {
    "path": "src/js/vfs.js",
    "content": "'use strict';\n\n/* ============================================================\n   VFS  —  Virtual File System (in-memory, serialized encrypted)\n   ============================================================ */\nconst VFS = (() => {\n    let _nodes = {};  // id → Node\n    let _pos = {};  // parentId → { nodeId: {x, y} }\n    let _childIndex = {};  // parentId → Set<childId> — O(1) children lookup\n\n    function _rebuildChildIndex() {\n        _childIndex = {};\n        for (const id of Object.keys(_nodes)) {\n            if (id === 'root') continue;\n            const pid = _nodes[id].parentId;\n            if (pid != null) {\n                if (!_childIndex[pid]) _childIndex[pid] = new Set();\n                _childIndex[pid].add(id);\n            }\n        }\n    }\n\n    function init() {\n        _nodes = { root: { id: 'root', type: 'folder', name: '/', parentId: null, ctime: Date.now(), mtime: Date.now() } };\n        _pos = { root: {} };\n        _childIndex = {};\n    }\n\n    function fromObj(obj) {\n        _nodes = obj.nodes || {};\n        _pos = obj.pos || {};\n        if (!_nodes.root) _nodes.root = { id: 'root', type: 'folder', name: '/', parentId: null, ctime: Date.now(), mtime: Date.now() };\n        if (!_pos.root) _pos.root = {};\n        // Integrity repair pass 1: reattach orphaned nodes whose parentId points to non-existent node\n        Object.values(_nodes).forEach(n => {\n            if (n.id !== 'root' && n.parentId && !_nodes[n.parentId]) {\n                console.warn('VFS: orphaned node', n.id, '— reattaching to root');\n                n.parentId = 'root';\n            }\n        });\n        // Integrity repair pass 2: detect and break real parent→child cycles\n        // For each node walk the ancestor chain; if we revisit any node in the same chain → cycle\n        Object.keys(_nodes).forEach(id => {\n            if (id === 'root') return;\n            const chain = new Set();\n            let cur = id;\n            while (cur && cur !== 'root') {\n                if (chain.has(cur)) {\n                    console.warn('VFS: cycle detected at node', cur, '— reattaching to root');\n                    _nodes[cur].parentId = 'root';\n                    break;\n                }\n                if (!_nodes[cur]) break;\n                chain.add(cur);\n                cur = _nodes[cur].parentId;\n            }\n        });\n        // Integrity repair pass 3: prune _pos entries whose parent or child node no longer exists\n        for (const pid of Object.keys(_pos)) {\n            if (pid !== 'root' && !_nodes[pid]) { delete _pos[pid]; continue; }\n            for (const nid of Object.keys(_pos[pid] || {})) {\n                if (!_nodes[nid]) delete _pos[pid][nid];\n            }\n        }\n        // Rebuild O(1) children index after loading + all repairs\n        _rebuildChildIndex();\n    }\n\n    function toObj() { return { nodes: _nodes, pos: _pos }; }\n\n    function children(pid) {\n        const ids = _childIndex[pid];\n        if (!ids || ids.size === 0) return [];\n        const result = [];\n        for (const id of ids) { if (_nodes[id]) result.push(_nodes[id]); }\n        return result;\n    }\n\n    function getPos(pid, nid) { return (_pos[pid] || {})[nid] || null; }\n    function setPos(pid, nid, x, y) {\n        if (!_pos[pid]) _pos[pid] = {};\n        // Always snap to the current grid so sub-pixel drift and legacy off-grid positions\n        // are corrected at write time. Math.round handles both truncation and rounding.\n        const sx = 8 + Math.max(0, Math.round((Math.round(x) - 8) / GRID_X)) * GRID_X,\n            sy = 8 + Math.max(0, Math.round((Math.round(y) - 8) / GRID_Y)) * GRID_Y;\n        _pos[pid][nid] = { x: sx, y: sy };\n    }\n    function delPos(pid, nid) { if (_pos[pid]) delete _pos[pid][nid]; }\n\n    function add(nd) {\n        if (!nd?.id || nd.id === 'root' || !['file', 'folder'].includes(nd.type)) return;\n        _nodes[nd.id] = nd;\n        if (!_pos[nd.id] && nd.type === 'folder') _pos[nd.id] = {};\n        // Maintain child index\n        if (nd.parentId != null) {\n            if (!_childIndex[nd.parentId]) _childIndex[nd.parentId] = new Set();\n            _childIndex[nd.parentId].add(nd.id);\n        }\n    }\n\n    function remove(id, _visited = new Set()) {\n        if (_visited.has(id)) return;\n        _visited.add(id);\n        const n = _nodes[id]; if (!n) return;\n        if (n.type === 'folder') {\n            children(id).forEach(c => remove(c.id, _visited));\n            delete _pos[id];\n            delete _childIndex[id];\n        }\n        delPos(n.parentId, id);\n        if (_childIndex[n.parentId]) _childIndex[n.parentId].delete(id);\n        delete _nodes[id];\n    }\n\n    function rename(id, newName) {\n        const n = _nodes[id];\n        if (id !== 'root' && n) {\n            n.name = newName;\n            n.mtime = Date.now();\n            // Refresh mime when the file extension changes so icon and viewer\n            // reflect the new type rather than the original upload type.\n            if (n.type === 'file' && typeof getMime === 'function') {\n                n.mime = getMime(newName);\n            }\n        }\n    }\n\n    function move(id, newParentId) {\n        const n = _nodes[id]; if (!n || !_nodes[newParentId]) return 'not_found';\n        if (id === 'root' || _nodes[newParentId].type !== 'folder') return 'not_found';\n        // prevent move into self or descendant — visited Set guards against corrupt-data infinite loops\n        let c = newParentId;\n        const _mv = new Set();\n        while (c) {\n            if (c === id) return 'cycle';\n            if (_mv.has(c)) break;\n            _mv.add(c);\n            c = (_nodes[c] || {}).parentId;\n        }\n        // prevent duplicate name in destination\n        if (children(newParentId).some(s => s.id !== id && s.name.toLowerCase() === n.name.toLowerCase())) return 'duplicate';\n        const oldParentId = n.parentId;\n        delPos(n.parentId, id);\n        n.parentId = newParentId;\n        n.mtime = Date.now();\n        // Update child indexes\n        if (_childIndex[oldParentId]) _childIndex[oldParentId].delete(id);\n        if (!_childIndex[newParentId]) _childIndex[newParentId] = new Set();\n        _childIndex[newParentId].add(id);\n        return 'ok';\n    }\n\n    function totalSize() {\n        let sum = 0;\n        for (const id in _nodes) {\n            const n = _nodes[id];\n            if (n.type === 'file' && Number.isFinite(n.size)) sum += n.size;\n        }\n        return sum;\n    }\n\n    function breadcrumb(folderId) {\n        const path = [], visited = new Set(); let cur = folderId;\n        while (cur && !visited.has(cur)) {\n            visited.add(cur);\n            path.unshift(_nodes[cur]);\n            cur = (_nodes[cur] || {}).parentId;\n        }\n        return path;\n    }\n\n    function fullPath(nodeId) {\n        const parts = [], visited = new Set();\n        let cur = nodeId;\n        while (cur) {\n            if (visited.has(cur)) break; // cycle guard\n            visited.add(cur);\n            const n = _nodes[cur];\n            if (!n) break;\n            if (n.id === 'root') break;\n            parts.unshift(n.name);\n            cur = n.parentId;\n        }\n        return '/~/' + (App.container ? App.container.name : '') + (parts.length ? '/' + parts.join('/') : '');\n    }\n\n    function autoPos(pid, idx, area) {\n        const W = (area && area.clientWidth) || 800,\n            cols = Math.max(1, Math.floor((W - 16) / GRID_X));\n        // Build set of occupied grid cells\n        const occupied = new Set();\n        Object.values(_pos[pid] || {}).forEach(p => {\n            const cx = Math.round((p.x - 8) / GRID_X),\n                cy = Math.round((p.y - 8) / GRID_Y);\n            occupied.add(`${cx}_${cy}`);\n        });\n        // Row-by-row scan for first free cell\n        for (let row = 0; row < 10000; row++) {\n            for (let col = 0; col < cols; col++) {\n                if (!occupied.has(`${col}_${row}`)) return { x: 8 + col * GRID_X, y: 8 + row * GRID_Y };\n            }\n        }\n        const col = idx % cols, row = Math.floor(idx / cols);\n        return { x: 8 + col * GRID_X, y: 8 + row * GRID_Y };\n    }\n\n    function node(id) { return _nodes[id]; }\n\n    function hasChildNamed(pid, name) {\n        const lower = name.toLowerCase();\n        return children(pid).some(n => n.name.toLowerCase() === lower);\n    }\n\n    function wouldCycle(id, newParentId) {\n        let c = newParentId;\n        const _wc = new Set();\n        while (c) {\n            if (c === id) return true;\n            if (_wc.has(c)) break;\n            _wc.add(c);\n            c = (_nodes[c] || {}).parentId;\n        }\n        return false;\n    }\n\n    function remapPositions(oldGX, oldGY, newGX, newGY) {\n        if (oldGX === newGX && oldGY === newGY) return;\n        for (const pid of Object.keys(_pos)) {\n            const map = _pos[pid];\n            for (const nid of Object.keys(map)) {\n                const p = map[nid];\n                const cx = Math.round((p.x - 8) / oldGX),\n                    cy = Math.round((p.y - 8) / oldGY);\n                map[nid] = { x: 8 + cx * newGX, y: 8 + cy * newGY };\n            }\n        }\n    }\n\n    // ── Integrity checker ──────────────────────────────────────\n    // Returns array of step results: [{ name, status:'pass'|'warn'|'fail', detail, issues[], fixed[] }]\n    // When repair=true, fixes issues in-place. Synchronous (VFS structure only).\n    function check(repair) {\n        const steps = [];\n\n        function step(name, fn, informational = false) {\n            const issues = [], fixed = [],\n                log = (sev, msg) => issues.push({ sev, msg }),\n                fix = (msg) => fixed.push(msg);\n            fn(log, fix);\n            const hasCrit = issues.some(i => i.sev === 'critical'),\n                status = issues.length === 0 ? 'pass' : hasCrit ? 'fail' : 'warn',\n                detail = issues.length === 0 ? 'OK' : `${issues.length} issue${issues.length !== 1 ? 's' : ''}${repair && fixed.length ? `, ${fixed.length} fixed` : ''}`;\n            steps.push({ name, status, detail, issues, fixed, informational });\n        }\n\n        // 1. Root node & position map\n        step('Root node integrity', (log, fix) => {\n            if (!_nodes.root) {\n                log('critical', 'Root node missing');\n                if (repair) { _nodes.root = { id: 'root', type: 'folder', name: '/', parentId: null, ctime: Date.now(), mtime: Date.now() }; fix('Recreated root node'); }\n            } else {\n                if (_nodes.root.parentId !== null) {\n                    log('warn', 'Root has non-null parentId');\n                    if (repair) { _nodes.root.parentId = null; fix('Set root parentId to null'); }\n                }\n                if (_nodes.root.type !== 'folder') {\n                    log('critical', 'Root node type is not folder');\n                    if (repair) { _nodes.root.type = 'folder'; fix('Fixed root type to folder'); }\n                }\n            }\n            if (!_pos.root) {\n                log('warn', 'Root position map missing');\n                if (repair) { _pos.root = {}; fix('Recreated root position map'); }\n            }\n        });\n\n        const allIds = Object.keys(_nodes);\n\n        // 2. Node required fields\n        step('Node field validation', (log, fix) => {\n            const _now = Date.now();\n            for (const id of allIds) {\n                const n = _nodes[id];\n                if (!n.id || n.id !== id) {\n                    log('critical', `Node \"${id}\": id mismatch or missing`);\n                    if (repair) { n.id = id; fix(`Fixed id for \"${id}\"`); }\n                }\n                if (id !== 'root' && !n.name) {\n                    log('warn', `Node \"${id}\": name missing`);\n                    if (repair) { n.name = 'Recovered_' + id.slice(0, 6); fix(`Set fallback name for \"${id}\"`); }\n                }\n                if (!n.type || !['file', 'folder'].includes(n.type)) {\n                    log('warn', `Node \"${id}\": invalid type \"${n.type}\"`);\n                    if (repair) { n.type = 'file'; fix(`Set type to file for \"${id}\"`); }\n                }\n                if (id !== 'root') {\n                    const badCtime = !n.ctime || typeof n.ctime !== 'number' || n.ctime <= 0 || !isFinite(n.ctime),\n                        badMtime = !n.mtime || typeof n.mtime !== 'number' || n.mtime <= 0 || !isFinite(n.mtime);\n                    if (badCtime) {\n                        log('warn', `\"${n.name || id}\": missing or invalid ctime`);\n                        if (repair) { n.ctime = _now; fix(`Restored ctime for \"${n.name || id}\"`); }\n                    }\n                    if (badMtime) {\n                        log('warn', `\"${n.name || id}\": missing or invalid mtime`);\n                        if (repair) { n.mtime = n.ctime || _now; fix(`Restored mtime for \"${n.name || id}\"`); }\n                    }\n                }\n            }\n        });\n\n        // 3. Node ID format validation\n        step('Node ID format validation', (log, fix) => {\n            const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,\n                fallbackRe = /^[0-9a-z]{6,20}$/i;\n            let badCount = 0;\n            for (const id of allIds) {\n                if (id === 'root') continue;\n                if (!uuidRe.test(id) && !fallbackRe.test(id)) {\n                    badCount++;\n                    log('warn', `\"${id.slice(0, 24)}${id.length > 24 ? '…' : ''}\": malformed node ID`);\n                    if (repair) {\n                        const n = _nodes[id], newId = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2);\n                        n.id = newId;\n                        _nodes[newId] = n;\n                        delete _nodes[id];\n                        // Update children referencing old ID as parent\n                        for (const cid of Object.keys(_nodes)) {\n                            if (_nodes[cid].parentId === id) _nodes[cid].parentId = newId;\n                        }\n                        // Migrate position data\n                        if (_pos[id]) { _pos[newId] = _pos[id]; delete _pos[id]; }\n                        for (const pid of Object.keys(_pos)) {\n                            if (_pos[pid][id]) { _pos[pid][newId] = _pos[pid][id]; delete _pos[pid][id]; }\n                        }\n                        fix(`Reassigned ID for \"${n.name || newId}\"`);\n                    }\n                }\n            }\n        });\n\n        // 4. Timestamp anomaly detection — when >50% of nodes share an identical ctime,\n        //    it indicates a bulk-import or corruption event. On repair, spread those ctimes\n        //    across a 1-second window so each node gets a unique, meaningful timestamp.\n        step('Timestamp anomaly detection', (log, fix) => {\n            if (allIds.length < 20) return;\n            const ctimeMap = new Map();\n            for (const id of allIds) {\n                if (id === 'root') continue;\n                const ct = _nodes[id].ctime;\n                if (ct) ctimeMap.set(ct, (ctimeMap.get(ct) || 0) + 1);\n            }\n            let maxCluster = 0, maxTime = 0;\n            for (const [t, count] of ctimeMap) {\n                if (count > maxCluster) { maxCluster = count; maxTime = t; }\n            }\n            const total = allIds.length - 1;\n            if (maxCluster > total * 0.5 && maxCluster > 50) {\n                log('warn', `${maxCluster} of ${total} nodes share identical ctime (${new Date(maxTime).toISOString()}) — possible bulk-import or VFS corruption`);\n                if (repair) {\n                    // Spread affected nodes across 1-second window (1 ms apart) so each gets unique ctime\n                    const base = Date.now();\n                    let offset = 0;\n                    for (const id of allIds) {\n                        if (id === 'root') continue;\n                        if (_nodes[id].ctime === maxTime) {\n                            _nodes[id].ctime = base + offset;\n                            if (!_nodes[id].mtime || _nodes[id].mtime === maxTime) _nodes[id].mtime = base + offset;\n                            offset++;\n                        }\n                    }\n                    fix(`Spread ${maxCluster} identical ctimes across a 1-second window`);\n                }\n            }\n        });\n\n        // 5. File name validation\n        step('File name validation', (log, fix) => {\n            for (const id of allIds) {\n                if (id === 'root') continue;\n                const n = _nodes[id];\n                if (typeof n.name === 'string') {\n                    if (n.name.trim() === '') {\n                        log('warn', `Node \"${id}\": name is empty/whitespace`);\n                        if (repair) { n.name = 'Unnamed_' + id.slice(0, 6); fix(`Set fallback name for \"${id}\"`); }\n                    } else if (n.name.length > 255) {\n                        log('warn', `\"${n.name.slice(0, 30)}...\": name exceeds 255 chars`);\n                        if (repair) { n.name = n.name.slice(0, 200) + '…'; fix(`Truncated name of \"${id}\"`); }\n                    } else if (/[<>:\"/\\\\|?*\\x00-\\x1f]/.test(n.name)) {\n                        log('warn', `\"${n.name}\": contains invalid characters`);\n                        if (repair) { n.name = n.name.replace(/[<>:\"/\\\\|?*\\x00-\\x1f]/g, '_'); fix(`Sanitized name of \"${id}\"`); }\n                    }\n                }\n            }\n        });\n\n        // 6. Orphaned nodes (parentId → non-existent node)\n        step('Orphaned node detection', (log, fix) => {\n            for (const id of allIds) {\n                if (id === 'root') continue;\n                const n = _nodes[id];\n                if (n.parentId && !_nodes[n.parentId]) {\n                    log('critical', `\"${n.name || id}\": parent \"${n.parentId}\" does not exist`);\n                    if (repair) { n.parentId = 'root'; fix(`Reattached \"${n.name || id}\" to root`); }\n                }\n                if (!n.parentId) {\n                    log('warn', `\"${n.name || id}\": missing parentId`);\n                    if (repair) { n.parentId = 'root'; fix(`Assigned parentId=root for \"${n.name || id}\"`); }\n                }\n            }\n        });\n\n        // 7. Parent type validation — parent must be a folder\n        step('Parent type validation', (log, fix) => {\n            for (const id of allIds) {\n                if (id === 'root') continue;\n                const n = _nodes[id];\n                const parent = _nodes[n.parentId];\n                if (parent && parent.type !== 'folder') {\n                    log('critical', `\"${n.name || id}\": parent \"${parent.name || n.parentId}\" is not a folder`);\n                    if (repair) { n.parentId = 'root'; fix(`Reattached \"${n.name || id}\" to root (parent was a file)`); }\n                }\n            }\n        });\n\n        // 8. Cycle detection + 9. Reachability (combined O(n) with memoization)\n        //    Each node is visited at most twice (once for cycle check, once for reachability cache)\n        step('Parent-child cycle detection', (log, fix) => {\n            // Phase A: detect and break cycles\n            const visiting = new Set(), safe = new Set(['root']);\n            for (const id of allIds) {\n                if (id === 'root' || safe.has(id)) continue;\n                const path = [];\n                let cur = id;\n                while (cur && cur !== 'root' && !safe.has(cur)) {\n                    if (visiting.has(cur)) {\n                        // Cycle found — break at this node\n                        log('critical', `Cycle at \"${_nodes[cur]?.name || cur}\"`);\n                        if (repair) { _nodes[cur].parentId = 'root'; fix(`Broke cycle: reattached \"${_nodes[cur]?.name || cur}\" to root`); }\n                        break;\n                    }\n                    visiting.add(cur);\n                    path.push(cur);\n                    if (!_nodes[cur]) break;\n                    cur = _nodes[cur].parentId;\n                }\n                // Mark entire path as safe (regardless of how the chain ended)\n                for (const p of path) { safe.add(p); visiting.delete(p); }\n            }\n        });\n\n        step('Node reachability analysis', (log, fix) => {\n            // Phase B: check reachability with cache (O(n) amortized)\n            const reachCache = new Map(); // id → boolean\n            reachCache.set('root', true);\n            function isReachable(nid) {\n                if (reachCache.has(nid)) return reachCache.get(nid);\n                const chain = [];\n                let cur = nid;\n                while (cur && !reachCache.has(cur)) {\n                    chain.push(cur);\n                    cur = _nodes[cur]?.parentId;\n                }\n                const result = cur ? (reachCache.get(cur) || false) : false;\n                for (const c of chain) reachCache.set(c, result);\n                return result;\n            }\n            for (const id of allIds) {\n                if (id === 'root') continue;\n                if (!isReachable(id)) {\n                    log('warn', `\"${_nodes[id]?.name || id}\" is unreachable from root`);\n                    if (repair) {\n                        _nodes[id].parentId = 'root';\n                        reachCache.set(id, true);\n                        fix(`Reattached unreachable \"${_nodes[id]?.name || id}\" to root`);\n                    }\n                }\n            }\n        });\n\n        // 10. Timestamp validation\n        step('Timestamp integrity', (log, fix) => {\n            const now = Date.now(), futureThreshold = now + 86400000;\n            for (const id of allIds) {\n                const n = _nodes[id];\n                if (!n.ctime || typeof n.ctime !== 'number' || n.ctime <= 0) {\n                    log('warn', `\"${n.name || id}\": invalid ctime`);\n                    if (repair) { n.ctime = now; fix(`Fixed ctime for \"${n.name || id}\"`); }\n                } else if (n.ctime > futureThreshold) {\n                    log('warn', `\"${n.name || id}\": ctime is in the future`);\n                    if (repair) { n.ctime = now; fix(`Reset future ctime for \"${n.name || id}\"`); }\n                }\n                if (!n.mtime || typeof n.mtime !== 'number' || n.mtime <= 0) {\n                    log('warn', `\"${n.name || id}\": invalid mtime`);\n                    if (repair) { n.mtime = now; fix(`Fixed mtime for \"${n.name || id}\"`); }\n                } else if (n.mtime > futureThreshold) {\n                    log('warn', `\"${n.name || id}\": mtime is in the future`);\n                    if (repair) { n.mtime = now; fix(`Reset future mtime for \"${n.name || id}\"`); }\n                }\n                if (n.mtime && n.ctime && n.mtime < n.ctime) {\n                    log('warn', `\"${n.name || id}\": mtime is earlier than ctime`);\n                    if (repair) { n.mtime = n.ctime; fix(`Corrected mtime for \"${n.name || id}\"`); }\n                }\n            }\n        });\n\n        // 11. File size & folder size validation\n        step('File size validation', (log, fix) => {\n            for (const id of allIds) {\n                const n = _nodes[id];\n                if (n.type === 'file') {\n                    if (n.size !== undefined && (!Number.isFinite(n.size) || n.size < 0)) {\n                        log('warn', `\"${n.name || id}\": invalid size ${n.size}`);\n                        if (repair) { n.size = 0; fix(`Reset size for \"${n.name || id}\"`); }\n                    }\n                    if (n.size === undefined) {\n                        log('warn', `\"${n.name || id}\": missing size property`);\n                        if (repair) { n.size = 0; fix(`Set size=0 for \"${n.name || id}\"`); }\n                    }\n                }\n                if (n.type === 'folder' && n.size !== undefined) {\n                    log('warn', `Folder \"${n.name || id}\": has size property`);\n                    if (repair) { delete n.size; fix(`Removed size from folder \"${n.name || id}\"`); }\n                }\n            }\n        });\n\n        // 12. File MIME type check\n        step('File metadata validation', (log, fix) => {\n            const allowed = new Set(['id', 'name', 'type', 'parentId', 'ctime', 'mtime', 'size', 'mime', 'color']);\n            for (const id of allIds) {\n                const n = _nodes[id];\n                if (n.type === 'file') {\n                    if (n.mime !== undefined && typeof n.mime !== 'string') {\n                        log('warn', `\"${n.name || id}\": invalid mime type`);\n                        if (repair) { delete n.mime; fix(`Removed invalid mime for \"${n.name || id}\"`); }\n                    }\n                }\n                for (const key of Object.keys(n)) {\n                    if (!allowed.has(key)) {\n                        log('warn', `\"${n.name || id}\": unexpected property \"${key}\"`);\n                        if (repair) { delete n[key]; fix(`Removed unknown property \"${key}\" from \"${n.name || id}\"`); }\n                    }\n                }\n            }\n        });\n\n        // 13. Duplicate names in same parent\n        step('Duplicate name detection', (log, fix) => {\n            const parentChildren = {};\n            for (const id of allIds) {\n                if (id === 'root') continue;\n                const pid = _nodes[id].parentId;\n                if (!parentChildren[pid]) parentChildren[pid] = [];\n                parentChildren[pid].push(_nodes[id]);\n            }\n            for (const pid of Object.keys(parentChildren)) {\n                const seen = new Map();\n                for (const n of parentChildren[pid]) {\n                    const lower = n.name?.toLowerCase();\n                    if (seen.has(lower)) {\n                        log('warn', `Duplicate \"${n.name}\" in \"${_nodes[pid]?.name || pid}\"`);\n                        if (repair) {\n                            let counter = 2, base = n.name, ext = '';\n                            const dotIdx = base.lastIndexOf('.');\n                            if (n.type === 'file' && dotIdx > 0) { ext = base.slice(dotIdx); base = base.slice(0, dotIdx); }\n                            while (parentChildren[pid].some(s => s.name.toLowerCase() === (base + ' (' + counter + ')' + ext).toLowerCase())) counter++;\n                            n.name = base + ' (' + counter + ')' + ext;\n                            fix(`Renamed duplicate to \"${n.name}\"`);\n                        }\n                    } else {\n                        seen.set(lower, n);\n                    }\n                }\n            }\n        });\n\n        // 14. Empty folder chains (folder containing only empty folders, depth > 5, read-only)\n        step('Empty folder chain detection', (log) => {\n            const childCount = {}, folderKids = new Map();\n            for (const id of allIds) {\n                if (id === 'root') continue;\n                const pid = _nodes[id].parentId;\n                if (!childCount[pid]) childCount[pid] = { files: 0, folders: 0 };\n                if (_nodes[id].type === 'file') childCount[pid].files++;\n                else {\n                    childCount[pid].folders++;\n                    if (!folderKids.has(pid)) folderKids.set(pid, []);\n                    folderKids.get(pid).push(id);\n                }\n            }\n            // Iterative post-order DFS — no recursion to avoid stack overflow on deep chains\n            const emptyCache = new Map();\n            for (const startId of allIds) {\n                if (_nodes[startId]?.type !== 'folder' || startId === 'root') continue;\n                if (emptyCache.has(startId)) continue;\n                const stack = [{ id: startId, childIdx: 0 }];\n                while (stack.length) {\n                    const top = stack[stack.length - 1];\n                    const kids = folderKids.get(top.id) || [];\n                    let pushed = false;\n                    while (top.childIdx < kids.length) {\n                        const kid = kids[top.childIdx++];\n                        if (!emptyCache.has(kid) && _nodes[kid]?.type === 'folder') {\n                            stack.push({ id: kid, childIdx: 0 });\n                            pushed = true;\n                            break;\n                        }\n                    }\n                    if (!pushed) {\n                        stack.pop();\n                        const cc = childCount[top.id];\n                        if (!cc || (cc.files === 0 && cc.folders === 0)) {\n                            emptyCache.set(top.id, 1);\n                        } else if (cc.files > 0) {\n                            emptyCache.set(top.id, 0);\n                        } else {\n                            let maxD = 0;\n                            for (const sub of (folderKids.get(top.id) || [])) maxD = Math.max(maxD, emptyCache.get(sub) || 0);\n                            emptyCache.set(top.id, maxD > 0 ? maxD + 1 : 0);\n                        }\n                    }\n                }\n            }\n            for (const id of allIds) {\n                if (_nodes[id]?.type !== 'folder' || id === 'root') continue;\n                const d = emptyCache.get(id) || 0;\n                if (d > 5) log('warn', `\"${_nodes[id].name}\": empty folder chain ${d} levels deep`);\n            }\n        }, true);\n\n        // 15. Stale position entries\n        step('Position table cleanup', (log, fix) => {\n            for (const pid of Object.keys(_pos)) {\n                if (pid !== 'root' && !_nodes[pid]) {\n                    log('warn', `Position map for deleted folder \"${pid}\"`);\n                    if (repair) { delete _pos[pid]; fix(`Removed stale position map \"${pid}\"`); }\n                    continue;\n                }\n                for (const nid of Object.keys(_pos[pid] || {})) {\n                    if (!_nodes[nid]) {\n                        log('warn', `Position for deleted node \"${nid}\"`);\n                        if (repair) { delete _pos[pid][nid]; fix(`Removed stale position \"${nid}\"`); }\n                    } else if (_nodes[nid].parentId !== pid) {\n                        log('warn', `\"${_nodes[nid]?.name || nid}\" positioned in wrong folder`);\n                        if (repair) { delete _pos[pid][nid]; fix(`Removed misplaced position for \"${_nodes[nid]?.name || nid}\"`); }\n                    }\n                }\n            }\n        });\n\n        // 16. Missing position maps for folders\n        step('Folder position maps', (log, fix) => {\n            for (const id of allIds) {\n                if (_nodes[id].type === 'folder' && !_pos[id]) {\n                    log('warn', `Folder \"${_nodes[id].name || id}\" has no position map`);\n                    if (repair) { _pos[id] = {}; fix(`Created position map for \"${_nodes[id].name || id}\"`); }\n                }\n            }\n        });\n\n        // 17. Position completeness — only flag if the parent folder has been visited\n        // Positions are lazily assigned when a folder is first opened.\n        // Files in never-opened folders naturally have no position yet — not an error.\n        step('Position entry completeness', (log, fix) => {\n            for (const id of allIds) {\n                if (id === 'root') continue;\n                const pid = _nodes[id].parentId;\n                if (!pid || !_pos[pid]) continue;\n                const posMap = _pos[pid];\n                // If no siblings have positions, the folder was never opened — skip\n                if (Object.keys(posMap).length === 0) continue;\n                if (!posMap[id]) {\n                    log('warn', `\"${_nodes[id]?.name || id}\" has no position in parent folder`);\n                    if (repair) {\n                        const ap = autoPos(pid, 0, null);\n                        _pos[pid][id] = { x: ap.x, y: ap.y };\n                        fix(`Auto-positioned \"${_nodes[id]?.name || id}\"`);\n                    }\n                }\n            }\n        });\n\n        // 18. Position collisions\n        step('Position collision detection', (log, fix) => {\n            for (const pid of Object.keys(_pos)) {\n                const cellMap = new Map();\n                for (const nid of Object.keys(_pos[pid] || {})) {\n                    const p = _pos[pid][nid];\n                    const key = `${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`;\n                    if (cellMap.has(key)) {\n                        log('warn', `Collision: \"${_nodes[nid]?.name || nid}\" and \"${_nodes[cellMap.get(key)]?.name || cellMap.get(key)}\"`);\n                        if (repair) {\n                            const newP = autoPos(pid, 0, null);\n                            _pos[pid][nid] = { x: newP.x, y: newP.y };\n                            fix(`Relocated \"${_nodes[nid]?.name || nid}\"`);\n                        }\n                    } else {\n                        cellMap.set(key, nid);\n                    }\n                }\n            }\n        });\n\n        // 19. Grid alignment\n        step('Grid alignment verification', (log, fix) => {\n            for (const pid of Object.keys(_pos)) {\n                for (const nid of Object.keys(_pos[pid] || {})) {\n                    const p = _pos[pid][nid];\n                    const sx = 8 + Math.max(0, Math.round((p.x - 8) / GRID_X)) * GRID_X,\n                        sy = 8 + Math.max(0, Math.round((p.y - 8) / GRID_Y)) * GRID_Y;\n                    if (p.x !== sx || p.y !== sy) {\n                        log('warn', `\"${_nodes[nid]?.name || nid}\" is off-grid (${p.x},${p.y})`);\n                        if (repair) { _pos[pid][nid] = { x: sx, y: sy }; fix(`Snapped \"${_nodes[nid]?.name || nid}\" to grid`); }\n                    }\n                }\n            }\n        });\n\n        // 20. Folder depth check (O(n) memoized)\n        step('Folder depth analysis', (log) => {\n            const dc = new Map([[undefined, 0], [null, 0], ['root', 0]]);\n            function depth(nid) {\n                if (dc.has(nid)) return dc.get(nid);\n                const chain = [];\n                let cur = nid;\n                while (cur && !dc.has(cur)) { chain.push(cur); cur = _nodes[cur]?.parentId; }\n                let d = dc.get(cur) || 0;\n                for (let i = chain.length - 1; i >= 0; i--) dc.set(chain[i], ++d);\n                return dc.get(nid) || 0;\n            }\n            for (const id of allIds) {\n                if (_nodes[id]?.type !== 'folder' || id === 'root') continue;\n                const d = depth(id);\n                if (d > 50) log('warn', `\"${_nodes[id]?.name || id}\" is nested ${d} levels deep`);\n            }\n        });\n\n        // 21. Node count & summary stats\n        step('Node count summary', (log) => {\n            const files = allIds.filter(id => _nodes[id]?.type === 'file').length,\n                folders = allIds.filter(id => _nodes[id]?.type === 'folder').length - 1,\n                posEntries = Object.values(_pos).reduce((s, m) => s + Object.keys(m).length, 0);\n            log('info', `${files} file${files !== 1 ? 's' : ''}, ${folders} folder${folders !== 1 ? 's' : ''}, ${posEntries} position entries`);\n        });\n\n        // Override: last step is always info-only (pass)\n        const last = steps[steps.length - 1];\n        last.status = 'pass';\n        last.detail = last.issues[0]?.msg || 'OK';\n\n        // After repair passes that mutate _nodes directly, rebuild child index\n        if (repair) _rebuildChildIndex();\n\n        return steps;\n    }\n\n    // Returns list of file IDs that exist in VFS as type 'file'\n    function fileIds() {\n        return Object.keys(_nodes).filter(id => _nodes[id]?.type === 'file');\n    }\n\n    // Bulk-purge every node that is NOT an ancestor of any live file.\n    // O(n) — single pass: mark ancestors, then delete everything else.\n    // Returns count of removed nodes.\n    function purgeDeadBranches(liveFileIds) {\n        const keep = new Set(['root']);\n        for (const id of liveFileIds) {\n            let cur = id;\n            while (cur && !keep.has(cur)) {\n                keep.add(cur);\n                cur = _nodes[cur]?.parentId;\n            }\n        }\n        // Delete all nodes not in keep — single pass, no child scans\n        let removed = 0;\n        for (const id of Object.keys(_nodes)) {\n            if (!keep.has(id)) {\n                delete _nodes[id];\n                removed++;\n            }\n        }\n        // Rebuild _pos: drop maps for gone folders, drop entries for gone nodes\n        for (const pid of Object.keys(_pos)) {\n            if (!keep.has(pid)) { delete _pos[pid]; continue; }\n            for (const nid of Object.keys(_pos[pid] || {})) {\n                if (!keep.has(nid)) delete _pos[pid][nid];\n            }\n        }\n        _rebuildChildIndex();\n        return removed;\n    }\n\n    // Flatten tree: move all files deeper than MAX_DEPTH up to their nearest ≤MAX_DEPTH ancestor,\n    // then delete any (now-empty) folders still at depth > MAX_DEPTH.\n    // Preserves ALL file data. Returns count of removed folders.\n    function flattenDeepContent(MAX_DEPTH = 50) {\n        const allIds = Object.keys(_nodes);\n\n        // 1. O(n) memoized depth computation\n        const dc = new Map([[undefined, 0], [null, 0], ['root', 0]]);\n        function depth(nid) {\n            if (dc.has(nid)) return dc.get(nid);\n            const chain = [];\n            let cur = nid;\n            while (cur && !dc.has(cur)) { chain.push(cur); cur = _nodes[cur]?.parentId; }\n            let d = dc.get(cur) || 0;\n            for (let i = chain.length - 1; i >= 0; i--) dc.set(chain[i], ++d);\n            return dc.get(nid) || 0;\n        }\n        for (const id of allIds) depth(id);\n\n        // 2. Pre-build name sets per folder for collision detection\n        const namesByParent = new Map();\n        function getNames(pid) {\n            if (!namesByParent.has(pid)) {\n                const s = new Set(\n                    Object.keys(_nodes)\n                        .filter(id => _nodes[id]?.parentId === pid)\n                        .map(id => _nodes[id].name.toLowerCase())\n                );\n                namesByParent.set(pid, s);\n            }\n            return namesByParent.get(pid);\n        }\n        function uniqueName(pid, name) {\n            const names = getNames(pid);\n            if (!names.has(name.toLowerCase())) { names.add(name.toLowerCase()); return name; }\n            const dot = name.lastIndexOf('.'),\n                base = dot >= 0 ? name.slice(0, dot) : name,\n                ext = dot >= 0 ? name.slice(dot) : '';\n            let i = 1;\n            while (names.has(`${base} (${i})${ext}`.toLowerCase())) i++;\n            const result = `${base} (${i})${ext}`;\n            names.add(result.toLowerCase());\n            return result;\n        }\n\n        // 3. Reparent all files whose parent folder is deeper than MAX_DEPTH\n        for (const id of allIds) {\n            const n = _nodes[id];\n            if (!n || n.type !== 'file') continue;\n            if (depth(n.parentId) <= MAX_DEPTH) continue;\n\n            // Walk up to the closest ancestor at depth ≤ MAX_DEPTH\n            let targetPid = n.parentId;\n            while (targetPid && depth(targetPid) > MAX_DEPTH) targetPid = _nodes[targetPid]?.parentId;\n            if (!targetPid) targetPid = 'root';\n\n            const oldPid = n.parentId;\n            n.name = uniqueName(targetPid, n.name);\n            n.parentId = targetPid;\n            n.mtime = Date.now();\n\n            // Update position maps\n            if (_pos[oldPid]) delete _pos[oldPid][id];\n            if (!_pos[targetPid]) _pos[targetPid] = {};\n            const posIdx = Object.keys(_pos[targetPid]).length,\n                cols = Math.max(1, Math.floor((800 - 16) / GRID_X));\n            _pos[targetPid][id] = { x: 8 + (posIdx % cols) * GRID_X, y: 8 + Math.floor(posIdx / cols) * GRID_Y };\n        }\n\n        // 4. Delete all folders now at depth > MAX_DEPTH (no files remain in them)\n        let removed = 0;\n        for (const id of Object.keys(_nodes)) {\n            const n = _nodes[id];\n            if (!n || n.type !== 'folder' || id === 'root') continue;\n            if (depth(id) > MAX_DEPTH) {\n                delete _nodes[id];\n                delete _pos[id];\n                removed++;\n            }\n        }\n\n        // 5. Clean stale _pos entries\n        for (const pid of Object.keys(_pos)) {\n            if (!_nodes[pid] && pid !== 'root') { delete _pos[pid]; continue; }\n            for (const nid of Object.keys(_pos[pid] || {})) {\n                if (!_nodes[nid]) delete _pos[pid][nid];\n            }\n        }\n\n        _rebuildChildIndex();\n        return removed;\n    }\n\n    // Fix all corrupted/missing timestamps on every node. O(n), no side-effects beyond timestamps.\n    // Returns count of nodes whose metadata was patched.\n    function repairMetadata() {\n        const now = Date.now();\n        let fixed = 0;\n        for (const id of Object.keys(_nodes)) {\n            if (id === 'root') continue;\n            const n = _nodes[id];\n            let changed = false;\n            if (!n.ctime || typeof n.ctime !== 'number' || n.ctime <= 0 || !isFinite(n.ctime)) {\n                n.ctime = now; changed = true;\n            }\n            if (!n.mtime || typeof n.mtime !== 'number' || n.mtime <= 0 || !isFinite(n.mtime)) {\n                n.mtime = n.ctime; changed = true;\n            }\n            if (changed) fixed++;\n        }\n        return fixed;\n    }\n\n    // Batch auto-positioning: builds the occupied set ONCE and assigns positions to all items\n    // that need one. Stores positions in _pos[pid] and returns Map<nodeId, {x, y}>.\n    function autoPosBatch(pid, items, area) {\n        if (!items.length) return new Map();\n        if (!_pos[pid]) _pos[pid] = {};\n        const W = (area && area.clientWidth) || 800,\n            cols = Math.max(1, Math.floor((W - 16) / GRID_X));\n        // Build occupied set once from existing positions\n        const occupied = new Set();\n        for (const p of Object.values(_pos[pid])) {\n            occupied.add(`${Math.round((p.x - 8) / GRID_X)}_${Math.round((p.y - 8) / GRID_Y)}`);\n        }\n        const results = new Map();\n        let scanRow = 0, scanCol = 0;\n        outer: for (const n of items) {\n            for (let row = scanRow; row < 10000; row++) {\n                for (let col = (row === scanRow ? scanCol : 0); col < cols; col++) {\n                    if (!occupied.has(`${col}_${row}`)) {\n                        const pos = { x: 8 + col * GRID_X, y: 8 + row * GRID_Y };\n                        _pos[pid][n.id] = pos;\n                        occupied.add(`${col}_${row}`);\n                        results.set(n.id, pos);\n                        scanRow = row; scanCol = col + 1;\n                        if (scanCol >= cols) { scanCol = 0; scanRow = row + 1; }\n                        continue outer;\n                    }\n                }\n            }\n        }\n        return results;\n    }\n\n    return {\n        init, fromObj, toObj, children, node, add, remove, rename, move, wouldCycle,\n        getPos, setPos, delPos, totalSize, breadcrumb, fullPath, autoPos, autoPosBatch,\n        hasChildNamed, remapPositions, check, fileIds, purgeDeadBranches,\n        flattenDeepContent, repairMetadata\n    };\n})();\n"
  }
]