diff --git a/README.md b/README.md
index b559491..3e0fb69 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,9 @@ pnpm dev:app
# Frontend only
pnpm dev:vite
+# Browser-only UI Lab with mocked Tauri APIs
+pnpm dev:ui
+
# Production build (CPU)
pnpm build:app:cpu
@@ -119,6 +122,9 @@ pnpm build:app:cuda
pnpm build:vite
```
+For visual frontend work without launching Tauri or the Rust backend, see
+[Phokus UI Lab](docs/ui-lab.md).
+
## How it works
1. Add a folder from the sidebar — the Rust indexer walks it recursively.
diff --git a/docs/ui-lab.md b/docs/ui-lab.md
new file mode 100644
index 0000000..38a05ab
--- /dev/null
+++ b/docs/ui-lab.md
@@ -0,0 +1,182 @@
+# Phokus UI Lab
+
+Phokus UI Lab is a browser-only development mode for visual work on the real
+Phokus frontend. It runs the same `App.tsx`, Zustand store, components, and CSS
+as the Tauri app, but installs Tauri JavaScript mocks before the app imports.
+
+This gives UI agents and contributors a stable browser target without launching
+the Rust backend or a Tauri window.
+
+## Run It
+
+```bash
+pnpm dev:ui
+```
+
+Then open:
+
+```text
+http://127.0.0.1:1422
+```
+
+The script runs Vite in the custom `ui` mode:
+
+```bash
+vite --mode ui --host 127.0.0.1 --port 1422 --strictPort --open
+```
+
+The normal app development commands are unchanged:
+
+```bash
+pnpm dev:app # Tauri app + Rust backend
+pnpm dev:vite # frontend dev server for Tauri dev
+```
+
+## Scenarios
+
+UI Lab reads `?scenario=` from the URL. If no scenario is provided, it uses
+`rich`.
+
+| URL | Purpose |
+| --- | --- |
+| `/?scenario=rich` | Default realistic library with folders, albums, ratings, favorites, tags, images, and videos |
+| `/?scenario=empty` | First-run state with no folders or media |
+| `/?scenario=busy` | Background workers with pending thumbnail, metadata, embedding, caption, and tagging jobs |
+| `/?scenario=duplicates` | Duplicate Finder opened with duplicate groups already available |
+| `/?scenario=album` | Gallery opened directly into an album |
+| `/?scenario=errors` | Broken thumbnails, folder scan errors, failed embeddings, failed tagging, and metadata issues |
+| `/?scenario=huge` | Large mock library for virtualization and layout checks |
+
+Examples:
+
+```text
+http://127.0.0.1:1422/?scenario=duplicates
+http://127.0.0.1:1422/?scenario=errors
+http://127.0.0.1:1422/?scenario=huge
+```
+
+## How It Works
+
+`src/main.tsx` bootstraps the app asynchronously. In `ui` mode it imports
+`src/dev/setupMockTauri.ts` first, then imports the real `App`.
+
+That order matters because static imports are hoisted. The Tauri mocks must be
+installed before `App`, the store, the title bar, or visual components import
+Tauri APIs.
+
+The mock setup installs:
+
+- `mockWindows("main")` for window APIs used by the title bar
+- `mockConvertFileSrc("windows")` as a baseline Tauri file URL mock
+- `mockIPC(..., { shouldMockEvents: true })` for `invoke`, `listen`, and `emit`
+
+The in-memory backend lives in:
+
+```text
+src/dev/mockBackend.ts
+src/dev/mockFixtures.ts
+src/dev/mockScenarios.ts
+src/dev/applyMockScenario.ts
+```
+
+Happy-path fixture media is generated from existing onboarding images and
+website screenshots into:
+
+```text
+public/dev-media/fixture-*.webp
+```
+
+The pack is deliberately small and deterministic, but varied enough for browser
+screenshots: square, portrait, landscape, monochrome, tinted, framed, and
+interface-like crops. Synthetic or deliberately broken fixture paths can still
+use the `mock://` scheme described below.
+
+## Mock Media
+
+Visual components should use `mediaSrc(...)` instead of calling
+`convertFileSrc(...)` directly:
+
+```ts
+import { mediaSrc } from "../lib/mediaSrc";
+
+const src = mediaSrc(image.thumbnail_path);
+```
+
+In normal Tauri modes, `mediaSrc` delegates to `convertFileSrc`. In UI Lab,
+root-relative asset URLs such as `/dev-media/fixture-01.webp` are returned
+as-is. Paths beginning with `mock://` are also served from `public/dev-media`:
+
+```text
+mock://thumb-1.svg -> /dev-media/thumb-1.svg
+```
+
+Use `mediaSrc` when adding components that render thumbnails, album covers,
+preview images, or video posters.
+
+## Adding A Mock Command
+
+If the browser console logs an unmocked command, add it to
+`src/dev/mockBackend.ts`.
+
+Prefer returning realistic data for commands that affect visible UI. For actions
+that would touch the machine, return a safe no-op:
+
+```ts
+case "open_app_data_folder":
+ return null;
+```
+
+When adding fixture data, keep it shaped like the real store/Rust contracts.
+Most frontend-facing types are exported from `src/store.ts`, so the mocks can
+stay type-checked against the real UI.
+
+## Adding A Scenario
+
+1. Add the scenario name to `MockScenario` and `SCENARIOS` in
+ `src/dev/mockScenarios.ts`.
+2. Adjust fixture creation in `src/dev/mockFixtures.ts`.
+3. If the scenario should open a specific view, add that behavior in
+ `src/dev/applyMockScenario.ts`.
+4. Visit `http://127.0.0.1:1422/?scenario=your-scenario` and check for console
+ errors.
+
+## What UI Lab Is For
+
+UI Lab is intended for:
+
+- visual iteration on the real app shell
+- layout and responsive checks
+- state-heavy views like Explore, Timeline, Duplicates, albums, and Settings
+- AI-agent screenshot/browser inspection
+- safe UI work without filesystem or Rust-side effects
+
+It is not a replacement for Tauri app testing. Validate native behavior with
+`pnpm dev:app` when touching:
+
+- file or folder pickers
+- filesystem permissions
+- real thumbnail/video loading
+- window drag, minimize, maximize, or close behavior
+- Rust backend performance
+- updater installation
+- WebView2-specific behavior
+
+## Verification
+
+Useful checks after changing UI Lab:
+
+```bash
+pnpm exec tsc --noEmit
+pnpm build:vite
+pnpm dev:ui
+```
+
+Then smoke-test at least:
+
+```text
+http://127.0.0.1:1422/?scenario=rich
+http://127.0.0.1:1422/?scenario=empty
+http://127.0.0.1:1422/?scenario=duplicates
+http://127.0.0.1:1422/?scenario=errors
+http://127.0.0.1:1422/?scenario=huge
+```
diff --git a/package.json b/package.json
index fc358f8..d3d03e3 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"clean:app": "cd src-tauri && cargo clean",
"dev:app": "tauri dev",
"dev:app:cpu": "tauri dev -- --no-default-features",
+ "dev:ui": "vite --mode ui --host 127.0.0.1 --port 1422 --strictPort --open",
"dev:vite": "vite",
"dev:web": "cd website && pnpm dev",
"format:app": "cd src-tauri && cargo fmt",
diff --git a/public/dev-media/fixture-01.webp b/public/dev-media/fixture-01.webp
new file mode 100644
index 0000000..cfb5d53
Binary files /dev/null and b/public/dev-media/fixture-01.webp differ
diff --git a/public/dev-media/fixture-02.webp b/public/dev-media/fixture-02.webp
new file mode 100644
index 0000000..3a3f308
Binary files /dev/null and b/public/dev-media/fixture-02.webp differ
diff --git a/public/dev-media/fixture-03.webp b/public/dev-media/fixture-03.webp
new file mode 100644
index 0000000..518ab7f
Binary files /dev/null and b/public/dev-media/fixture-03.webp differ
diff --git a/public/dev-media/fixture-04.webp b/public/dev-media/fixture-04.webp
new file mode 100644
index 0000000..456e7a7
Binary files /dev/null and b/public/dev-media/fixture-04.webp differ
diff --git a/public/dev-media/fixture-05.webp b/public/dev-media/fixture-05.webp
new file mode 100644
index 0000000..b5d78a8
Binary files /dev/null and b/public/dev-media/fixture-05.webp differ
diff --git a/public/dev-media/fixture-06.webp b/public/dev-media/fixture-06.webp
new file mode 100644
index 0000000..be187f6
Binary files /dev/null and b/public/dev-media/fixture-06.webp differ
diff --git a/public/dev-media/fixture-07.webp b/public/dev-media/fixture-07.webp
new file mode 100644
index 0000000..d3ef32c
Binary files /dev/null and b/public/dev-media/fixture-07.webp differ
diff --git a/public/dev-media/fixture-08.webp b/public/dev-media/fixture-08.webp
new file mode 100644
index 0000000..86429ba
Binary files /dev/null and b/public/dev-media/fixture-08.webp differ
diff --git a/public/dev-media/fixture-09.webp b/public/dev-media/fixture-09.webp
new file mode 100644
index 0000000..4a53a55
Binary files /dev/null and b/public/dev-media/fixture-09.webp differ
diff --git a/public/dev-media/fixture-10.webp b/public/dev-media/fixture-10.webp
new file mode 100644
index 0000000..71e062b
Binary files /dev/null and b/public/dev-media/fixture-10.webp differ
diff --git a/public/dev-media/fixture-11.webp b/public/dev-media/fixture-11.webp
new file mode 100644
index 0000000..2dafc30
Binary files /dev/null and b/public/dev-media/fixture-11.webp differ
diff --git a/public/dev-media/fixture-12.webp b/public/dev-media/fixture-12.webp
new file mode 100644
index 0000000..85d9cbe
Binary files /dev/null and b/public/dev-media/fixture-12.webp differ
diff --git a/public/dev-media/fixture-13.webp b/public/dev-media/fixture-13.webp
new file mode 100644
index 0000000..458acd4
Binary files /dev/null and b/public/dev-media/fixture-13.webp differ
diff --git a/public/dev-media/fixture-14.webp b/public/dev-media/fixture-14.webp
new file mode 100644
index 0000000..a470726
Binary files /dev/null and b/public/dev-media/fixture-14.webp differ
diff --git a/public/dev-media/fixture-15.webp b/public/dev-media/fixture-15.webp
new file mode 100644
index 0000000..9b4a39a
Binary files /dev/null and b/public/dev-media/fixture-15.webp differ
diff --git a/public/dev-media/fixture-16.webp b/public/dev-media/fixture-16.webp
new file mode 100644
index 0000000..33818a7
Binary files /dev/null and b/public/dev-media/fixture-16.webp differ
diff --git a/public/dev-media/fixture-17.webp b/public/dev-media/fixture-17.webp
new file mode 100644
index 0000000..dc7e679
Binary files /dev/null and b/public/dev-media/fixture-17.webp differ
diff --git a/public/dev-media/fixture-18.webp b/public/dev-media/fixture-18.webp
new file mode 100644
index 0000000..bd0708d
Binary files /dev/null and b/public/dev-media/fixture-18.webp differ
diff --git a/public/dev-media/fixture-19.webp b/public/dev-media/fixture-19.webp
new file mode 100644
index 0000000..176d938
Binary files /dev/null and b/public/dev-media/fixture-19.webp differ
diff --git a/public/dev-media/fixture-20.webp b/public/dev-media/fixture-20.webp
new file mode 100644
index 0000000..d01d146
Binary files /dev/null and b/public/dev-media/fixture-20.webp differ
diff --git a/public/dev-media/fixture-21.webp b/public/dev-media/fixture-21.webp
new file mode 100644
index 0000000..c20a2e5
Binary files /dev/null and b/public/dev-media/fixture-21.webp differ
diff --git a/public/dev-media/fixture-22.webp b/public/dev-media/fixture-22.webp
new file mode 100644
index 0000000..477bdec
Binary files /dev/null and b/public/dev-media/fixture-22.webp differ
diff --git a/public/dev-media/fixture-23.webp b/public/dev-media/fixture-23.webp
new file mode 100644
index 0000000..79f7fac
Binary files /dev/null and b/public/dev-media/fixture-23.webp differ
diff --git a/public/dev-media/fixture-24.webp b/public/dev-media/fixture-24.webp
new file mode 100644
index 0000000..2bb3cdb
Binary files /dev/null and b/public/dev-media/fixture-24.webp differ
diff --git a/public/dev-media/fixture-25.webp b/public/dev-media/fixture-25.webp
new file mode 100644
index 0000000..645fb8b
Binary files /dev/null and b/public/dev-media/fixture-25.webp differ
diff --git a/public/dev-media/fixture-26.webp b/public/dev-media/fixture-26.webp
new file mode 100644
index 0000000..3b4631c
Binary files /dev/null and b/public/dev-media/fixture-26.webp differ
diff --git a/public/dev-media/fixture-27.webp b/public/dev-media/fixture-27.webp
new file mode 100644
index 0000000..8083f34
Binary files /dev/null and b/public/dev-media/fixture-27.webp differ
diff --git a/public/dev-media/fixture-28.webp b/public/dev-media/fixture-28.webp
new file mode 100644
index 0000000..ac2be2b
Binary files /dev/null and b/public/dev-media/fixture-28.webp differ
diff --git a/public/dev-media/fixture-29.webp b/public/dev-media/fixture-29.webp
new file mode 100644
index 0000000..a4af699
Binary files /dev/null and b/public/dev-media/fixture-29.webp differ
diff --git a/public/dev-media/fixture-30.webp b/public/dev-media/fixture-30.webp
new file mode 100644
index 0000000..347adb1
Binary files /dev/null and b/public/dev-media/fixture-30.webp differ
diff --git a/public/dev-media/fixture-31.webp b/public/dev-media/fixture-31.webp
new file mode 100644
index 0000000..e814fbd
Binary files /dev/null and b/public/dev-media/fixture-31.webp differ
diff --git a/public/dev-media/fixture-32.webp b/public/dev-media/fixture-32.webp
new file mode 100644
index 0000000..3f27199
Binary files /dev/null and b/public/dev-media/fixture-32.webp differ
diff --git a/public/dev-media/fixture-33.webp b/public/dev-media/fixture-33.webp
new file mode 100644
index 0000000..71f00ad
Binary files /dev/null and b/public/dev-media/fixture-33.webp differ
diff --git a/public/dev-media/fixture-34.webp b/public/dev-media/fixture-34.webp
new file mode 100644
index 0000000..42d7a58
Binary files /dev/null and b/public/dev-media/fixture-34.webp differ
diff --git a/public/dev-media/fixture-35.webp b/public/dev-media/fixture-35.webp
new file mode 100644
index 0000000..aa91feb
Binary files /dev/null and b/public/dev-media/fixture-35.webp differ
diff --git a/public/dev-media/fixture-36.webp b/public/dev-media/fixture-36.webp
new file mode 100644
index 0000000..42bbd28
Binary files /dev/null and b/public/dev-media/fixture-36.webp differ
diff --git a/public/dev-media/fixture-37.webp b/public/dev-media/fixture-37.webp
new file mode 100644
index 0000000..e3ea474
Binary files /dev/null and b/public/dev-media/fixture-37.webp differ
diff --git a/public/dev-media/fixture-38.webp b/public/dev-media/fixture-38.webp
new file mode 100644
index 0000000..f098693
Binary files /dev/null and b/public/dev-media/fixture-38.webp differ
diff --git a/public/dev-media/fixture-39.webp b/public/dev-media/fixture-39.webp
new file mode 100644
index 0000000..8fe6fff
Binary files /dev/null and b/public/dev-media/fixture-39.webp differ
diff --git a/public/dev-media/fixture-40.webp b/public/dev-media/fixture-40.webp
new file mode 100644
index 0000000..645dc2c
Binary files /dev/null and b/public/dev-media/fixture-40.webp differ
diff --git a/public/dev-media/fixture-41.webp b/public/dev-media/fixture-41.webp
new file mode 100644
index 0000000..e32819f
Binary files /dev/null and b/public/dev-media/fixture-41.webp differ
diff --git a/public/dev-media/fixture-42.webp b/public/dev-media/fixture-42.webp
new file mode 100644
index 0000000..614a0c3
Binary files /dev/null and b/public/dev-media/fixture-42.webp differ
diff --git a/public/dev-media/fixture-43.webp b/public/dev-media/fixture-43.webp
new file mode 100644
index 0000000..1cc52b3
Binary files /dev/null and b/public/dev-media/fixture-43.webp differ
diff --git a/public/dev-media/fixture-44.webp b/public/dev-media/fixture-44.webp
new file mode 100644
index 0000000..08cc36f
Binary files /dev/null and b/public/dev-media/fixture-44.webp differ
diff --git a/public/dev-media/fixture-45.webp b/public/dev-media/fixture-45.webp
new file mode 100644
index 0000000..5618a23
Binary files /dev/null and b/public/dev-media/fixture-45.webp differ
diff --git a/public/dev-media/fixture-46.webp b/public/dev-media/fixture-46.webp
new file mode 100644
index 0000000..3a9a298
Binary files /dev/null and b/public/dev-media/fixture-46.webp differ
diff --git a/public/dev-media/fixture-47.webp b/public/dev-media/fixture-47.webp
new file mode 100644
index 0000000..60733e8
Binary files /dev/null and b/public/dev-media/fixture-47.webp differ
diff --git a/public/dev-media/fixture-48.webp b/public/dev-media/fixture-48.webp
new file mode 100644
index 0000000..4d255cc
Binary files /dev/null and b/public/dev-media/fixture-48.webp differ
diff --git a/src/App.tsx b/src/App.tsx
index 3dfe52f..75de078 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -48,12 +48,16 @@ export default function App() {
if (import.meta.env.PROD) {
void checkForUpdates({ quiet: true });
}
- loadFolders().then(() => {
+ loadFolders().then(async () => {
void loadBackgroundJobProgress();
void loadCaptionModelStatus();
void loadDuplicateScanCache();
- void loadAlbums();
- return loadImages(true);
+ await loadAlbums();
+ await loadImages(true);
+ if (import.meta.env.MODE === "ui") {
+ const { applyMockScenario } = await import("./dev/applyMockScenario");
+ applyMockScenario();
+ }
});
let unlisten: (() => void) | undefined;
subscribeToProgress().then((fn) => {
diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx
index f7fe36e..c0e006d 100644
--- a/src/components/DuplicateFinder.tsx
+++ b/src/components/DuplicateFinder.tsx
@@ -1,8 +1,8 @@
import { useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
-import { convertFileSrc } from "@tauri-apps/api/core";
import { DuplicateGroup, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
+import { mediaSrc } from "../lib/mediaSrc";
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
@@ -68,7 +68,7 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {