diff --git a/.gitignore b/.gitignore
index a547bf3..251ce6d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,6 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
-dist
dist-ssr
*.local
diff --git a/README.md b/README.md
index 1b6b114..44b72cc 100644
--- a/README.md
+++ b/README.md
@@ -24,13 +24,16 @@ npm run dev # http://localhost:5173
## Production Build
```bash
-npm run build # → dist/ (static files)
+npm run build # → dist/index.html (single self-contained file)
npm run preview # test the production build locally
```
-The `dist/` folder contains fully static HTML/CSS/JS that works from:
+The build produces a **single `dist/index.html`** file with all JavaScript, CSS, and assets (including favicon) inlined as data URIs. No external files, no network requests — it works from:
- `file://` protocol (open `dist/index.html` directly)
- Any static web server (nginx, Apache, GitHub Pages, etc.)
+- USB stick, email attachment, or any offline medium
+
+The single-file output is handled by [`vite-plugin-singlefile`](https://www.npmjs.com/package/vite-plugin-singlefile) for JS/CSS inlining, plus a post-build script that inlines the favicon SVG and removes leftover asset files.
## Architecture
@@ -57,6 +60,8 @@ src/
│ └── security.svelte.js # Auto-lock timer, visibility detection
└── styles/
└── main.css # Dark theme, CSS variables, responsive
+scripts/
+ └── inline-assets.js # Post-build: inlines favicon, removes leftover files
```
### Encryption Flow
@@ -107,8 +112,9 @@ npm run preview # Preview production build
### Stack
-- **Svelte 5** — Runes-based reactivity (`$state`, `$derived`, `$effect`)
+- **Svelte 5** — Runes-based reactivity (`$state`, `$derived`, `$effect`), props-based event passing
- **Vite 8** — Build tool and dev server
+- **vite-plugin-singlefile** — Inlines all JS/CSS into a single HTML file
- **idb** — Promise-based IndexedDB wrapper
- **Web Crypto API** — Native browser cryptography (no external crypto libraries)
- **Vanilla CSS** — Dark theme with CSS custom properties, no preprocessors
diff --git a/dist/index.html b/dist/index.html
new file mode 100644
index 0000000..641a962
--- /dev/null
+++ b/dist/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Password Vault
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 3cf1730..0311ca0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"jsdom": "^29.1.1",
"svelte": "^5.55.5",
"vite": "^8.0.12",
+ "vite-plugin-singlefile": "^2.3.3",
"vitest": "^4.1.6"
}
},
@@ -1030,6 +1031,19 @@
"require-from-string": "^2.0.2"
}
},
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1236,6 +1250,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
@@ -1277,6 +1304,16 @@
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -1647,6 +1684,33 @@
"dev": true,
"license": "CC0-1.0"
},
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -1990,6 +2054,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -2122,6 +2199,28 @@
}
}
},
+ "node_modules/vite-plugin-singlefile": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz",
+ "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">18.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^4.59.0",
+ "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vitefu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
diff --git a/package.json b/package.json
index 76393c9..2063e1c 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "vite build",
+ "build": "vite build && node scripts/inline-assets.js",
"preview": "vite preview --host 0.0.0.0",
"test": "vitest",
"test:run": "vitest run",
@@ -19,6 +19,7 @@
"jsdom": "^29.1.1",
"svelte": "^5.55.5",
"vite": "^8.0.12",
+ "vite-plugin-singlefile": "^2.3.3",
"vitest": "^4.1.6"
},
"dependencies": {
diff --git a/scripts/inline-assets.js b/scripts/inline-assets.js
new file mode 100644
index 0000000..5c7dfd5
--- /dev/null
+++ b/scripts/inline-assets.js
@@ -0,0 +1,47 @@
+/**
+ * Post-build script: inline remaining external assets (favicon) into index.html
+ * and remove leftover files so only a single HTML file remains.
+ */
+import { readFileSync, writeFileSync, rmSync, existsSync } from 'fs'
+import { join, dirname } from 'path'
+import { fileURLToPath } from 'url'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const distDir = join(__dirname, '..', 'dist')
+
+// Read favicon SVG and encode as data URI
+const faviconPath = join(distDir, 'favicon.svg')
+if (existsSync(faviconPath)) {
+ const svgContent = readFileSync(faviconPath, 'utf8')
+ const encoded = Buffer.from(svgContent).toString('base64')
+ const dataUri = `data:image/svg+xml;base64,${encoded}`
+
+ // Replace the favicon link in index.html
+ const indexPath = join(distDir, 'index.html')
+ let html = readFileSync(indexPath, 'utf8')
+ html = html.replace(
+ /]*href="[^"]*favicon\.svg"[^>]*\/?>/i,
+ ``
+ )
+ writeFileSync(indexPath, html)
+
+ // Remove the standalone SVG
+ rmSync(faviconPath)
+ console.log('[inline-assets] Inlined favicon.svg into index.html')
+}
+
+// Remove any other leftover asset files (e.g. icons.svg from Svelte compiler)
+const iconsPath = join(distDir, 'icons.svg')
+if (existsSync(iconsPath)) {
+ rmSync(iconsPath)
+ console.log('[inline-assets] Removed icons.svg')
+}
+
+// Remove assets directory if it exists
+const assetsDir = join(distDir, 'assets')
+if (existsSync(assetsDir)) {
+ rmSync(assetsDir, { recursive: true })
+ console.log('[inline-assets] Removed assets/ directory')
+}
+
+console.log('[inline-assets] Done — dist/ contains only index.html')
diff --git a/vite.config.js b/vite.config.js
index 14de4c8..379fcf4 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,9 +1,20 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
+import { viteSingleFile } from 'vite-plugin-singlefile'
// https://vite.dev/config/
export default defineConfig({
- plugins: [svelte()],
+ plugins: [
+ svelte(),
+ viteSingleFile({
+ inlineStyles: true,
+ inlineScripts: true,
+ removeUnusedCss: true,
+ }),
+ ],
+ build: {
+ target: 'esnext',
+ },
preview: {
allowedHosts: ["dev.thecookiejar.me"]
},