## Summary Bring all direction-flavoured docs into sync with the May 2026 state of the project. **Primary distribution targets are now:** 1. **Desktop offline standalone HTML** (`dist/web-standalone/index.html`) — produced by `yarn build:web:standalone` 2. **Android APK** (Capacitor wrapper) — produced by `.github/workflows/build-android.yml` or `scripts/build-apk-local.sh` **Demoted to secondary:** - The deployed web PWA (`dist/web/`) is still buildable and self-hostable via the included Docker + Cloudflare Pages paths, but is no longer the recommended user-facing distribution. **Out of scope:** - iOS in any form (App Store, PWA via Safari, Add to Home Screen). ## What changed | File | Change | |---|---| | `.claude/rules/project-direction.md` | Main rewrite: "One code path, two distribution targets"; "Mobile = Android APK, not iOS"; "What's NOT in scope" updated; Phase E.1 issue list recast | | `.claude/rules/modernization-roadmap.md` | Phase E.1 table: #50 (iOS Photos picker) and #52 (PWA install prompt UX) marked out-of-scope; #23 recast from Web Share Target PWA to Android Intent filter; "PWA is sole channel" claim brought current; key constraints updated | | `.claude/rules/privacy-invariants.md` | §2 expanded to cover all three distribution paths (standalone inlines everything, APK uses Capacitor's localhost interceptor, self-host PWA caches via service worker) | | `CLAUDE.md` | Top of file + Tech Stack section reflect dual primary distribution | | `README.md` | Features list + Project Direction section reflect dual primary, iOS dropped, standalone-on-Android note updated to point at the APK | | `docs/android-apk.md` | Line 21 note flipped (APK is now primary, not "personal-distribution / not official"); comparison table relabelled; AAR conclusion updated | | `docs/deploying.md` | Reframed as the self-host PWA doc; iOS Safari install instructions removed; intro note clarifies this is secondary distribution | | `docs/architecture.md` | History note brought current — mentions APK #156 + standalone HTML as primaries | | `docs/PRIVACY_GAPS.md` | Android filesystem-isolation note updated to recommend the APK path | | `docs/standalone-html.md` | "No PWA install" trade-off bullet now points at the Android APK | 10 files changed, 81 insertions, 58 deletions. ## Phase E issues to close as out-of-scope This direction change makes two open issues out-of-scope. Suggested follow-up — close (not in this PR, since closing is a separate decision): - **#50** iOS Photos picker UX note — iOS dropped entirely - **#52** PWA install prompt UX — deployed PWA demoted to self-host only #23 (Web Share Target PWA) is recast rather than closed — the underlying "let users share files into MetaScrub from the Android gallery" feature is still wanted, but the implementation switches from the Web Share Target API in the manifest to a Capacitor `@capacitor/share` / native Intent filter. ## What I deliberately did NOT touch - **`CHANGELOG.md`** — historical release notes. The Phase G entry says "PWA is the sole distribution channel" which was accurate at the time; changelogs are snapshots, not living documents. - **`docs/superpowers/plans/2026-05-14-phase-g-rollout.md` + `docs/superpowers/specs/2026-05-14-phase-g-electron-retirement-design.md`** — historical phase plans/specs describing the work as it was at the time of execution. - **Playwright e2e mobile-iOS configs** — these test responsive layouts under an iOS Safari user agent, useful coverage independent of iOS being a shipping target. Removing them is a separate test-strategy decision, not a direction-doc concern. - **Source code** — only two iOS references in `src/`, both in comments describing the mobile-browser landscape generally (not iOS-gated code paths). No code change needed for the direction shift. ## Test plan - [x] All edits are documentation-only; no source code touched - [x] No `*.md` linting in CI; prettier-check only targets `src/**/*.{ts,tsx}` - [ ] Reviewer reads `.claude/rules/project-direction.md` first; satellites mirror its language - [ ] Decide whether to close #50 + #52 as out-of-scope as a follow-up 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Randa <obuvuyoviz26@gmail.com> Reviewed-on: http://forgejo.localhost:3000/forgejo_admin/exifcleaner-web/pulls/172
7.8 KiB
Standalone single-file HTML
yarn build:web:standalone produces dist/web-standalone/index.html — one self-contained HTML file with the entire app inlined (JS, CSS, dependencies, ~830 KB). Open it directly in any modern browser, no server, no install, no internet. Hand the file to someone, they double-click it, it runs.
Note
This is a desktop distribution artifact. Android doesn't have a working "open local HTML in browser" flow even with a single file — for mobile, use the APK wrapper documented in android-apk.md.
Why this exists
The instinctive thought is "the deployed web bundle is just static HTML/JS/CSS, so dist/web/index.html should open from disk too." It doesn't, on Chromium-based browsers. The actual failure mode and root cause are documented in android-apk.md § Why an APK; the short version is that Chromium refuses to load ES modules from a file:// page over CORS, and there's no way to opt out from the JS side.
The fix is structural: bundle the entire app as one classic <script> (not <script type="module">) inside the HTML itself. With no external file references, there's no CORS check to fail. The trade-off is losing lazy-loading and PWA features, both of which the deployed build keeps.
How it differs from yarn build:web
| Concern | yarn build:web (deployed PWA) |
yarn build:web:standalone |
|---|---|---|
| Output | dist/web/ — index.html + assets/ + manifest + service worker + icons |
dist/web-standalone/index.html — one file |
| Loading mechanism | <script type="module" src="./assets/index-XXX.js"> |
<script>...inlined IIFE...</script> |
| File-URL compatible | ❌ (Chromium CORS blocks ES module load) | ✅ (no external loads) |
| HTTPS compatible | ✅ | ✅ (but no point — use the deployed build) |
| Size | ~440 KB JS gzipped to ~180 KB, code-split into chunks | ~830 KB single file (~310 KB gzipped) |
| Lazy-loaded PDF chunk | ✅ (separate index-CpwmG4Zv.js, ~428 KB) |
❌ (folded into main bundle — loaded eagerly) |
| Service worker / PWA install | ✅ | ❌ (no SW under file:// anyway) |
| CSP meta tag | ✅ (default-src 'none'; script-src 'self'; …) |
❌ (CSP doesn't apply usefully to a single-file bundle under file://; nothing external to gate) |
| Favicon + manifest | ✅ | ❌ (stripped — <link> tags would fail CORS under file://) |
| Best for | Public/private web deployments, PWA install | One-file hand-off to a single recipient, USB/email distribution |
How it's built
vite.config.web.standalone.ts builds on the same source tree as vite.config.web.ts, with these differences:
vite-plugin-singlefile— inlines every chunk and CSS asset into the HTML output.publicDir: false— skips copyingpublic/(manifest, icons,_headers) into the output. The deployed build needs them; the standalone build doesn't.build.modulePreload: false— disables Vite's modulepreload polyfill (a small<script type="module">IIFE). With everything already inlined, there are no preload targets, so the polyfill would be dead weight and an extra module-script tag we'd otherwise have to strip.assetsInlineLimit: 256 KB— large enough to inline any small asset Vite references from JS (the deployed build's icons are 142 KB each — generous but harmless ceiling).rollupOptions.output.inlineDynamicImports: true— required by singlefile, makesimport("./...")calls resolve to the same bundle at build time.standaloneHtmlFixupPlugin(customcloseBundlehook) — runs after singlefile, strips<link rel="manifest">,<link rel="icon">, the service-worker registration script, and rewrites the inlined<script type="module" crossorigin>to a plain<script>. ThecloseBundlehook reads the output file from disk after the build is fully written, which avoids Vite's transformIndexHtml ordering surprises (singlefile and any transformIndexHtml plugin can run in non-obvious orders relative to each other).- No PWA plugin —
VitePWAwould register a service worker, which never registers underfile://and would just log an error on every load. - No CSP plugin — under
file://the meta CSP would either be unmatchable ('self'against anullorigin), or would need'unsafe-inline'to allow the inlined script. The deployed build'spublic/_headersandnginx.confprovide real CSP enforcement; the meta tag is redundant for the deployed path and broken for the standalone path. Just drop it here.
The output is a single HTML file with one inline <script> and one inline <style> block.
How to verify a build
yarn build:web:standalone
file --mime dist/web-standalone/index.html # → text/html
wc -c dist/web-standalone/index.html # → ~830000
ls dist/web-standalone/ # → just index.html
Then open the file in a browser:
- Linux:
xdg-open dist/web-standalone/index.html - macOS:
open dist/web-standalone/index.html - Windows:
start dist\web-standalone\index.html
Or double-click index.html from a file manager. Drag-drop a sample image to confirm processing works (the strategies run entirely in-browser; no network call).
Distribution
Email, USB stick, Signal/AirDrop/Magic Wormhole — anything that moves a single file. Recipient saves it anywhere, opens it. Works offline forever — there's nothing to phone home about. If you want to verify nothing leaks: open DevTools → Network tab while using the app; the only entry should be the initial file:// load of index.html itself.
Trade-offs / caveats
- No lazy-loading. The PDF strategy (~430 KB) loads eagerly even if the user never processes a PDF. For the hand-off use case this is fine; for a hosted deploy it would be wasteful.
- No service worker. The deployed PWA caches itself for offline use after first visit; the standalone HTML is always offline because it never had a network connection to lose.
- No PWA install. "Add to home screen" requires a service worker. Android users who want a real app icon should install the Android APK; users with a self-hosted PWA deploy can install from there.
- No favicon. Stripped because
<link rel="icon">would trigger a CORS error in some browsers' console underfile://. Cosmetic, no functional impact. - No manifest. Same reason.
- Bigger initial load. 830 KB single file vs ~620 KB across two split chunks. ~30% size penalty for the convenience of a single deliverable.
- No code-signing / no trust UI. The file is just HTML; the browser will not give it any "trusted app" treatment.
Why not just tell people to install Firefox
Firefox does load ES modules from file:// correctly (it treats same-folder file:// as same-origin). The deployed-style dist/web/ works there. But:
- Most people don't have Firefox installed.
- Asking them to install Firefox to use a metadata stripper has a worse UX than handing them one HTML file that works in whatever browser they already have.
- The trust signal "open this attachment in your browser" beats "go install a different browser first."
The standalone HTML works everywhere, including Firefox, including private/incognito tabs.
Privacy invariants — re-verified for this path
- No server-side processing (§1): the inlined code is the same WASM/pure-TS strategy registry as every other build path. Nothing fetches.
- Fully offline (§2): the file never makes a network request. Verifiable in DevTools.
- Forensic verification (§3): unchanged. The strategies are byte-identical to the deployed build.
- RAW unsupported (§4): unchanged. Same registry.
- No telemetry, no auto-update (§5): nothing was added. The file has no way to phone home.
- Timestamps absent or epoch (§6): unchanged. The strategies handle this in the same code paths.