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..8735c62
--- /dev/null
+++ b/docs/ui-lab.md
@@ -0,0 +1,180 @@
+# 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 imported from the existing onboarding assets in:
+
+```text
+src/assets/onboarding/
+```
+
+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,
+already-resolved Vite asset URLs are returned as-is, so fixtures can reuse
+existing imported assets. Paths beginning with `mock://` are 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/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 }) {
{group.images.map((image) => {
const isSelected = selectedIds.has(image.id);
- const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
+ const src = mediaSrc(image.thumbnail_path);
return (
state.toggleGallerySelected);
const canFindSimilar = image.embedding_status === "ready";
- const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
+ const src = mediaSrc(image.thumbnail_path);
return (
diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx
index 5eb2f3e..7508055 100644
--- a/src/components/Lightbox.tsx
+++ b/src/components/Lightbox.tsx
@@ -1,9 +1,10 @@
import { useEffect, useCallback, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
-import { convertFileSrc, invoke } from "@tauri-apps/api/core";
+import { invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store";
import { VideoPlayer } from "./VideoPlayer";
+import { mediaSrc } from "../lib/mediaSrc";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -475,12 +476,12 @@ export function Lightbox() {
transition={{ duration: 0.12 }}
>
{selectedImage.media_kind === "video" ? (
-
+
) : (
<>
void; animated: boolean }) {
- const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null;
+ const src = mediaSrc(node.entry.thumbnail_path);
const { w, h, accent } = node;
const driftTransition = {
duration: node.driftDuration,
diff --git a/src/dev/applyMockScenario.ts b/src/dev/applyMockScenario.ts
new file mode 100644
index 0000000..a221de9
--- /dev/null
+++ b/src/dev/applyMockScenario.ts
@@ -0,0 +1,17 @@
+import { useGalleryStore } from "../store";
+import { getMockScenario } from "./mockScenarios";
+
+export function applyMockScenario() {
+ const scenario = getMockScenario();
+ const store = useGalleryStore.getState();
+
+ if (scenario === "album") {
+ const albumId = store.albums[0]?.id;
+ if (albumId !== undefined) store.viewAlbum(albumId);
+ return;
+ }
+
+ if (scenario === "duplicates") {
+ store.setView("duplicates");
+ }
+}
diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts
new file mode 100644
index 0000000..2749b7f
--- /dev/null
+++ b/src/dev/mockBackend.ts
@@ -0,0 +1,403 @@
+import { emit } from "@tauri-apps/api/event";
+import type { Album, Folder, ImageRecord, ImageTag, SortOrder } from "../store";
+import { compareImages, createMockDb, mockExif } from "./mockFixtures";
+import { getMockScenario } from "./mockScenarios";
+
+const db = createMockDb(getMockScenario());
+let nextFolderId = 100;
+let nextAlbumId = 100;
+let nextTagId = 10_000;
+
+type AnyPayload = Record | undefined;
+
+function params(payload: unknown): Record {
+ if (!payload || typeof payload !== "object") return {};
+ const record = payload as Record;
+ return record.params && typeof record.params === "object" ? record.params : record;
+}
+
+function page(items: T[], offset = 0, limit = 200) {
+ return {
+ images: items.slice(offset, offset + limit),
+ total: items.length,
+ offset,
+ limit,
+ };
+}
+
+function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] {
+ const p = params(payload);
+ const query = String(p.search ?? p.query ?? "").trim().toLowerCase();
+ return input.filter((image) => {
+ if (p.folder_id !== undefined && p.folder_id !== null && image.folder_id !== p.folder_id) return false;
+ if (p.media_kind && image.media_kind !== p.media_kind) return false;
+ if (p.favorites_only && !image.favorite) return false;
+ if (p.rating_min !== undefined && p.rating_min !== null && image.rating < p.rating_min) return false;
+ if (p.embedding_failed_only && image.embedding_status !== "failed") return false;
+ if (p.tagging_failed_only && !image.ai_tagger_error) return false;
+ if (query && !image.filename.toLowerCase().includes(query)) return false;
+ return true;
+ });
+}
+
+function getImages(payload: unknown) {
+ const p = params(payload);
+ const sort = (p.sort ?? "date_desc") as SortOrder;
+ return page(
+ filterImages(db.images, payload).sort(compareImages(sort)),
+ Number(p.offset ?? 0),
+ Number(p.limit ?? 200),
+ );
+}
+
+function searchTags(payload: unknown) {
+ const p = params(payload);
+ const query = String(p.query ?? "").trim().toLowerCase();
+ const matchingIds = new Set(
+ Object.entries(db.tagsByImageId)
+ .filter(([, tags]) => tags.some((tag) => tag.tag.toLowerCase().includes(query)))
+ .map(([imageId]) => Number(imageId)),
+ );
+ const images = filterImages(db.images, payload)
+ .filter((image) => matchingIds.has(image.id))
+ .sort(compareImages((p.sort ?? "date_desc") as SortOrder));
+ return page(images, Number(p.offset ?? 0), Number(p.limit ?? 200));
+}
+
+function semanticSearch(payload: unknown): ImageRecord[] {
+ const p = params(payload);
+ const query = String(p.query ?? "").toLowerCase();
+ const scored = filterImages(db.images, payload)
+ .map((image, index) => ({
+ image,
+ score:
+ (image.generated_caption?.toLowerCase().includes(query) ? 0 : 20) +
+ (image.filename.toLowerCase().includes(query) ? 0 : 10) +
+ (index % 9),
+ }))
+ .sort((left, right) => left.score - right.score)
+ .slice(0, Number(p.limit ?? 200))
+ .map((entry) => entry.image);
+ return scored.length ? scored : filterImages(db.images, payload).slice(0, Number(p.limit ?? 200));
+}
+
+function albumImages(payload: unknown) {
+ const p = params(payload);
+ const albumId = Number(p.album_id);
+ const ids = new Set(db.albumImageIds[albumId] ?? []);
+ const sort = (p.sort ?? "date_desc") as SortOrder;
+ return page(
+ db.images.filter((image) => ids.has(image.id)).sort(compareImages(sort)),
+ Number(p.offset ?? 0),
+ Number(p.limit ?? 200),
+ );
+}
+
+function similarImages(payload: unknown) {
+ const p = params(payload);
+ const source = db.images.find((image) => image.id === p.image_id);
+ let images = db.images.filter((image) => image.id !== p.image_id && image.embedding_status === "ready");
+ if (p.album_id !== undefined && p.album_id !== null) {
+ const ids = new Set(db.albumImageIds[Number(p.album_id)] ?? []);
+ images = images.filter((image) => ids.has(image.id));
+ } else if (p.folder_id !== undefined && p.folder_id !== null) {
+ images = images.filter((image) => image.folder_id === p.folder_id);
+ }
+ const ordered = images.sort((left, right) => {
+ const leftScore = Math.abs(left.rating - (source?.rating ?? 0)) + (left.folder_id === source?.folder_id ? 0 : 2);
+ const rightScore = Math.abs(right.rating - (source?.rating ?? 0)) + (right.folder_id === source?.folder_id ? 0 : 2);
+ return leftScore - rightScore;
+ });
+ const offset = Number(p.offset ?? 0);
+ const limit = Number(p.limit ?? 200);
+ return {
+ images: ordered.slice(offset, offset + limit),
+ offset,
+ limit,
+ has_more: offset + limit < ordered.length,
+ };
+}
+
+function updateImages(ids: number[], updates: { favorite?: boolean | null; rating?: number | null }) {
+ const changed: ImageRecord[] = [];
+ for (const image of db.images) {
+ if (!ids.includes(image.id)) continue;
+ if (updates.favorite !== null && updates.favorite !== undefined) image.favorite = updates.favorite;
+ if (updates.rating !== null && updates.rating !== undefined) image.rating = updates.rating;
+ changed.push({ ...image });
+ }
+ return changed;
+}
+
+function refreshAlbumCounts() {
+ for (const album of db.albums) {
+ const ids = db.albumImageIds[album.id] ?? [];
+ album.image_count = ids.length;
+ album.cover_image_id = ids[0] ?? null;
+ album.cover_thumbnail_path = db.images.find((image) => image.id === ids[0])?.thumbnail_path ?? null;
+ album.updated_at = new Date().toISOString();
+ }
+}
+
+function createAlbum(name: string): Album {
+ const album: Album = {
+ id: nextAlbumId++,
+ name,
+ cover_image_id: null,
+ cover_thumbnail_path: null,
+ image_count: 0,
+ sort_order: db.albums.length + 1,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ };
+ db.albums.push(album);
+ db.albumImageIds[album.id] = [];
+ return album;
+}
+
+function addFolder(path: string): Folder {
+ const parts = path.split(/[\\/]/).filter(Boolean);
+ const name = parts[parts.length - 1] ?? "New Folder";
+ const folder: Folder = {
+ id: nextFolderId++,
+ path,
+ name,
+ image_count: 0,
+ indexed_at: new Date().toISOString(),
+ scan_error: null,
+ sort_order: db.folders.length + 1,
+ };
+ db.folders.push(folder);
+ return folder;
+}
+
+function safeFallback(cmd: string, payload: AnyPayload): unknown {
+ if (cmd.includes("app|version")) return "0.1.1-ui";
+ if (cmd.includes("path|resolve_directory")) return "C:\\Users\\phokus\\AppData\\Roaming\\Phokus";
+ if (cmd.includes("path|join")) {
+ const paths = payload?.paths ?? payload?.args ?? [];
+ return Array.isArray(paths) ? paths.join("\\") : "C:\\Users\\phokus\\AppData\\Roaming\\Phokus";
+ }
+ if (cmd.includes("notification|is_permission_granted")) return false;
+ if (cmd.includes("notification|request_permission")) return "denied";
+ if (cmd.includes("dialog|open")) return null;
+ if (cmd.includes("window|is_maximized")) return false;
+ if (cmd.includes("window|minimize")) return null;
+ if (cmd.includes("window|toggle_maximize")) return null;
+ if (cmd.includes("window|close")) return null;
+ if (cmd.includes("opener|reveal_item_in_dir")) return null;
+ if (cmd.includes("opener|open")) return null;
+ if (cmd.includes("process|relaunch")) return null;
+ console.warn("[Phokus UI Lab] Unmocked command:", cmd, payload);
+ return null;
+}
+
+export async function handleMockCommand(cmd: string, payload?: unknown): Promise {
+ const p = params(payload);
+
+ switch (cmd) {
+ case "get_folders":
+ return db.folders;
+ case "add_folder":
+ addFolder(String(p.path ?? "C:\\Users\\phokus\\Pictures\\NewFolder"));
+ return null;
+ case "add_folders":
+ return (p.paths ?? []).map((path: string) => ({ status: "added", data: addFolder(path) }));
+ case "remove_folder":
+ db.folders = db.folders.filter((folder) => folder.id !== p.folderId);
+ return null;
+ case "rename_folder": {
+ const folder = db.folders.find((entry) => entry.id === p.folderId);
+ if (folder) folder.name = String(p.newName ?? folder.name);
+ return null;
+ }
+ case "update_folder_path": {
+ const folder = db.folders.find((entry) => entry.id === p.folderId);
+ if (folder) folder.path = String(p.newPath ?? folder.path);
+ return null;
+ }
+ case "reindex_folder":
+ case "reorder_folders":
+ return null;
+ case "list_directories":
+ return {
+ current: p.path ?? null,
+ parent: p.path ? "C:\\Users\\phokus" : null,
+ entries: db.folders.map((folder) => ({ name: folder.name, path: folder.path, has_children: false })),
+ };
+ case "get_background_job_progress":
+ return db.backgroundJobs;
+ case "get_images":
+ return getImages(payload);
+ case "semantic_search_images":
+ return semanticSearch(payload);
+ case "search_images_by_tag":
+ return searchTags(payload);
+ case "get_images_by_ids":
+ return (p.image_ids ?? []).map((id: number) => db.images.find((image) => image.id === id)).filter(Boolean);
+ case "find_similar_images":
+ case "find_similar_by_region":
+ return similarImages(payload);
+ case "get_tag_cloud":
+ return db.scenario === "empty" ? [] : db.tagCloud;
+ case "get_explore_tags":
+ return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 48));
+ case "suggest_image_tags":
+ return ["select", "warm light"];
+ case "rename_tag":
+ case "delete_tag":
+ return cmd === "delete_tag" ? 6 : null;
+ case "get_image_tags":
+ return db.tagsByImageId[p.image_id] ?? [];
+ case "add_user_tag": {
+ const tag: ImageTag = { id: nextTagId++, image_id: p.image_id, tag: p.tag, source: "user", ai_model: null, confidence: null, created_at: new Date().toISOString() };
+ db.tagsByImageId[p.image_id] = [...(db.tagsByImageId[p.image_id] ?? []), tag];
+ return tag;
+ }
+ case "remove_tag":
+ for (const [imageId, imageTags] of Object.entries(db.tagsByImageId)) {
+ db.tagsByImageId[Number(imageId)] = imageTags.filter((tag) => tag.id !== p.tag_id);
+ }
+ return null;
+ case "get_image_exif":
+ return mockExif(Number(p.image_id));
+ case "update_image_details":
+ return updateImages([Number(p.image_id)], { favorite: p.favorite, rating: p.rating })[0];
+ case "bulk_update_details":
+ return updateImages(p.image_ids ?? [], { favorite: p.favorite, rating: p.rating });
+ case "bulk_add_tags":
+ case "bulk_remove_tag":
+ return null;
+ case "delete_images_from_disk":
+ return p.image_ids ?? [];
+ case "list_albums":
+ refreshAlbumCounts();
+ return db.albums;
+ case "create_album":
+ return createAlbum(String(p.name ?? "Untitled Album"));
+ case "rename_album": {
+ const album = db.albums.find((entry) => entry.id === p.album_id);
+ if (album) album.name = String(p.new_name ?? album.name);
+ return null;
+ }
+ case "delete_album":
+ db.albums = db.albums.filter((album) => album.id !== p.album_id);
+ delete db.albumImageIds[p.album_id];
+ return null;
+ case "delete_albums":
+ db.albums = db.albums.filter((album) => !(p.album_ids ?? []).includes(album.id));
+ for (const id of p.album_ids ?? []) delete db.albumImageIds[id];
+ return null;
+ case "reorder_albums":
+ return null;
+ case "add_images_to_album": {
+ const existing = new Set(db.albumImageIds[p.album_id] ?? []);
+ for (const id of p.image_ids ?? []) existing.add(id);
+ db.albumImageIds[p.album_id] = [...existing];
+ refreshAlbumCounts();
+ return p.image_ids?.length ?? 0;
+ }
+ case "remove_images_from_album":
+ db.albumImageIds[p.album_id] = (db.albumImageIds[p.album_id] ?? []).filter((id) => !(p.image_ids ?? []).includes(id));
+ refreshAlbumCounts();
+ return null;
+ case "get_album_images":
+ return albumImages(payload);
+ case "load_duplicate_scan_cache":
+ return db.duplicateScannedAt ? { groups: db.duplicateGroups, scanned_at: db.duplicateScannedAt } : null;
+ case "find_duplicates":
+ await emit("duplicate_scan_progress", { phase: "hashing", processed: Math.floor(db.images.length * 0.6), total: db.images.length, skipped: db.scenario === "errors" ? 3 : 0 });
+ db.duplicateScannedAt = Math.floor(Date.now() / 1000);
+ return {
+ groups: db.duplicateGroups,
+ scanned_files: db.images.length,
+ candidate_files: db.duplicateGroups.flatMap((group) => group.images).length,
+ skipped_files: db.scenario === "errors" ? 3 : 0,
+ };
+ case "invalidate_duplicate_scan_cache":
+ return null;
+ case "get_caption_model_status":
+ return { model_id: "Salesforce/blip-image-captioning-base", model_name: "BLIP Base", local_dir: "mock://models/caption", ready: true, missing_files: [] };
+ case "get_caption_acceleration":
+ case "set_caption_acceleration":
+ return p.acceleration ?? "auto";
+ case "get_caption_detail":
+ case "set_caption_detail":
+ return p.detail ?? "paragraph";
+ case "prepare_caption_model":
+ case "delete_caption_model":
+ return { model_id: "Salesforce/blip-image-captioning-base", model_name: "BLIP Base", local_dir: "mock://models/caption", ready: true, missing_files: [] };
+ case "probe_caption_runtime":
+ return { ready: true, acceleration: "auto", detail: "paragraph", tokenizer_vocab_size: 30522, sessions: [] };
+ case "probe_caption_image":
+ return { input_shape: [1, 3, 384, 384], output_shape: [1, 768], output_values: 768, acceleration: "auto" };
+ case "generate_caption_for_image": {
+ const image = db.images.find((entry) => entry.id === p.image_id);
+ if (image) image.generated_caption = `Generated in UI Lab for ${image.filename}.`;
+ return image;
+ }
+ case "queue_caption_jobs":
+ case "clear_caption_jobs":
+ case "reset_generated_captions":
+ case "queue_tagging_jobs":
+ case "clear_tagging_jobs":
+ return 12;
+ case "get_tagger_model_status":
+ return { model_id: "SmilingWolf/wd-vit-tagger-v3", model_name: "WD ViT Tagger", local_dir: "mock://models/tagger", ready: true, missing_files: [] };
+ case "get_tagger_acceleration":
+ case "set_tagger_acceleration":
+ return p.acceleration ?? "auto";
+ case "get_tagger_threshold":
+ case "set_tagger_threshold":
+ return p.threshold ?? 0.35;
+ case "get_tagger_batch_size":
+ case "set_tagger_batch_size":
+ return p.batch_size ?? 8;
+ case "probe_tagger_runtime":
+ return { ready: true, acceleration: "auto", session: { file: "model.onnx", inputs: ["input"], outputs: ["tags"] } };
+ case "get_tagging_queue_scope":
+ return "all";
+ case "get_tagging_queue_folder_ids":
+ case "get_muted_folder_ids":
+ return [];
+ case "set_tagging_queue_scope":
+ case "set_tagging_queue_folder_ids":
+ case "set_muted_folder_ids":
+ case "set_notifications_paused":
+ case "set_worker_paused":
+ return null;
+ case "get_notifications_paused":
+ case "get_onboarding_completed":
+ return db.scenario !== "empty";
+ case "set_onboarding_completed":
+ case "set_last_seen_version":
+ return null;
+ case "get_worker_states":
+ return (p.folderIds ?? []).map((folder_id: number) => ({ folder_id, thumbnail_paused: false, metadata_paused: false, embedding_paused: false, tagging_paused: false }));
+ case "get_build_variant":
+ return "cpu";
+ case "get_last_seen_version":
+ return "0.1.1-ui";
+ case "get_ffmpeg_status":
+ return { installed: true, downloading: false, failed: false };
+ case "retry_ffmpeg_download":
+ case "open_app_data_folder":
+ case "open_map_location":
+ case "open_changelog_url":
+ return null;
+ case "get_database_info":
+ return { size_mb: 14.2, reclaimable_mb: 1.8 };
+ case "vacuum_database":
+ return { before_mb: 14.2, after_mb: 12.4, freed_mb: 1.8 };
+ case "rebuild_semantic_index":
+ return db.images.length;
+ case "get_orphaned_thumbnails_info":
+ return { count: 9, size_mb: 3.4 };
+ case "cleanup_orphaned_thumbnails":
+ return { deleted_count: 9, freed_mb: 3.4 };
+ case "retry_failed_embeddings":
+ return null;
+ default:
+ return safeFallback(cmd, payload as AnyPayload);
+ }
+}
diff --git a/src/dev/mockFixtures.ts b/src/dev/mockFixtures.ts
new file mode 100644
index 0000000..9a30c1b
--- /dev/null
+++ b/src/dev/mockFixtures.ts
@@ -0,0 +1,320 @@
+import type {
+ Album,
+ AiRating,
+ DuplicateGroup,
+ ExploreTagEntry,
+ Folder,
+ FolderJobProgress,
+ ImageExif,
+ ImageRecord,
+ ImageTag,
+ SortOrder,
+ TagCloudEntry,
+} from "../store";
+import type { MockScenario } from "./mockScenarios";
+import alpineLake from "../assets/onboarding/alpinelake.webp";
+import architecture from "../assets/onboarding/architecture.webp";
+import balloon from "../assets/onboarding/balloon.webp";
+import beach from "../assets/onboarding/beach.webp";
+import cat from "../assets/onboarding/cat.webp";
+import cityNight from "../assets/onboarding/citynight.webp";
+import cozy from "../assets/onboarding/cozy.webp";
+import dunes from "../assets/onboarding/dunes.webp";
+import flower from "../assets/onboarding/flower.webp";
+import forest from "../assets/onboarding/forest.webp";
+import landscape1 from "../assets/onboarding/landscape1.webp";
+import landscape2 from "../assets/onboarding/landscape2.webp";
+import sunset from "../assets/onboarding/sunset.webp";
+import sunsetCoast from "../assets/onboarding/sunsetcoast.webp";
+import sunsetLake from "../assets/onboarding/sunsetlake.webp";
+
+export interface MockDb {
+ scenario: MockScenario;
+ folders: Folder[];
+ images: ImageRecord[];
+ albums: Album[];
+ albumImageIds: Record;
+ tagsByImageId: Record;
+ tagCloud: TagCloudEntry[];
+ exploreTags: ExploreTagEntry[];
+ backgroundJobs: FolderJobProgress[];
+ duplicateGroups: DuplicateGroup[];
+ duplicateScannedAt: number | null;
+}
+
+const folderNames = ["Camera Roll", "Client Selects", "Video Archive"];
+const mediaNames = [
+ "alpine-lake",
+ "city-after-rain",
+ "coastal-walk",
+ "studio-portrait",
+ "market-neon",
+ "forest-floor",
+ "product-macro",
+ "golden-hour",
+ "night-drive",
+ "desk-flatlay",
+ "museum-hall",
+ "wildflower",
+];
+const tags = [
+ "landscape",
+ "portrait",
+ "street",
+ "travel",
+ "night",
+ "product",
+ "editorial",
+ "architecture",
+ "nature",
+ "warm light",
+ "blue hour",
+ "select",
+];
+const aiRatings: AiRating[] = ["general", "sensitive", "questionable"];
+const fixtureAssets = [
+ alpineLake,
+ architecture,
+ balloon,
+ beach,
+ cat,
+ cityNight,
+ cozy,
+ dunes,
+ flower,
+ forest,
+ landscape1,
+ landscape2,
+ sunset,
+ sunsetCoast,
+ sunsetLake,
+];
+
+function daysAgo(days: number): string {
+ return new Date(Date.now() - days * 86_400_000).toISOString();
+}
+
+function mockPath(folder: number, name: string, index: number, ext: string): string {
+ return `mock://full/${folder}-${name}-${index}.${ext}`;
+}
+
+function mockThumb(index: number): string {
+ return fixtureAssets[index % fixtureAssets.length];
+}
+
+function makeImage(index: number, folderId: number, scenario: MockScenario): ImageRecord {
+ const name = mediaNames[index % mediaNames.length];
+ const isVideo = index % 11 === 6;
+ const failedEmbedding = scenario === "errors" && index % 9 === 0;
+ const failedTagging = scenario === "errors" && index % 7 === 0;
+ const ext = isVideo ? "mp4" : "jpg";
+ return {
+ id: index + 1,
+ folder_id: folderId,
+ path: isVideo ? mockPath(folderId, name, index + 1, ext) : mockThumb(index),
+ filename: `${name}-${String(index + 1).padStart(3, "0")}.${ext}`,
+ thumbnail_path: scenario === "errors" && index % 10 === 3 ? "mock://missing-thumb.svg" : mockThumb(index),
+ width: isVideo ? 3840 : 3000 + (index % 5) * 180,
+ height: isVideo ? 2160 : 2000 + (index % 4) * 160,
+ file_size: 480_000 + index * 73_000,
+ created_at: daysAgo(index + 3),
+ modified_at: daysAgo(index + 1),
+ taken_at: daysAgo(index + 10),
+ mime_type: isVideo ? "video/mp4" : "image/jpeg",
+ media_kind: isVideo ? "video" : "image",
+ duration_ms: isVideo ? 42_000 + index * 1300 : null,
+ video_codec: isVideo ? "h264" : null,
+ audio_codec: isVideo ? "aac" : null,
+ metadata_updated_at: daysAgo(index),
+ metadata_error: scenario === "errors" && index % 8 === 2 ? "EXIF parse failed in UI fixture" : null,
+ favorite: index % 5 === 0,
+ rating: index % 6,
+ embedding_status: failedEmbedding ? "failed" : index % 13 === 0 ? "processing" : "ready",
+ embedding_model: failedEmbedding ? null : "clip-vit-b-32",
+ embedding_updated_at: failedEmbedding ? null : daysAgo(index),
+ embedding_error: failedEmbedding ? "Fixture embedding failure" : null,
+ generated_caption: `Fixture caption for ${name.replace(/-/g, " ")}.`,
+ caption_model: "blip-base",
+ caption_updated_at: daysAgo(index),
+ caption_error: null,
+ ai_rating: aiRatings[index % aiRatings.length],
+ ai_tagger_model: failedTagging ? null : "wd-vit-tagger-v3",
+ ai_tagged_at: failedTagging ? null : daysAgo(index),
+ ai_tagger_error: failedTagging ? "Fixture tagger failure" : null,
+ };
+}
+
+function makeFolders(scenario: MockScenario): Folder[] {
+ if (scenario === "empty") return [];
+ return folderNames.map((name, index) => ({
+ id: index + 1,
+ path: `C:\\Users\\phokus\\Pictures\\${name.replace(/ /g, "")}`,
+ name,
+ image_count: 0,
+ indexed_at: daysAgo(index + 1),
+ scan_error: scenario === "errors" && index === 1 ? "Folder moved or permission denied" : null,
+ sort_order: index + 1,
+ }));
+}
+
+function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
+ if (scenario === "empty") return [];
+ const count = scenario === "huge" ? 720 : scenario === "errors" ? 54 : 72;
+ const images = Array.from({ length: count }, (_, index) => makeImage(index, folders[index % folders.length].id, scenario));
+ for (const folder of folders) {
+ folder.image_count = images.filter((image) => image.folder_id === folder.id).length;
+ }
+ return images;
+}
+
+function makeTags(images: ImageRecord[]): Record {
+ return Object.fromEntries(
+ images.map((image, index) => [
+ image.id,
+ [0, 1, 2].map((offset) => ({
+ id: image.id * 10 + offset,
+ image_id: image.id,
+ tag: tags[(index + offset) % tags.length],
+ source: offset === 0 ? "user" : "ai",
+ ai_model: offset === 0 ? null : "wd-vit-tagger-v3",
+ confidence: offset === 0 ? null : 0.72 + offset * 0.07,
+ created_at: daysAgo(index + offset),
+ })),
+ ]),
+ );
+}
+
+function makeAlbums(images: ImageRecord[], scenario: MockScenario): { albums: Album[]; albumImageIds: Record } {
+ if (scenario === "empty") return { albums: [], albumImageIds: {} };
+ const selects = images.filter((image) => image.rating >= 4).slice(0, 36).map((image) => image.id);
+ const motion = images.filter((image) => image.media_kind === "video").map((image) => image.id);
+ const favorites = images.filter((image) => image.favorite).slice(0, 40).map((image) => image.id);
+ const albumImageIds = { 1: selects, 2: motion, 3: favorites };
+ const albums: Album[] = [
+ { id: 1, name: "Portfolio Shortlist", cover_image_id: selects[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === selects[0])?.thumbnail_path ?? null, image_count: selects.length, sort_order: 1, created_at: daysAgo(40), updated_at: daysAgo(2) },
+ { id: 2, name: "Motion Tests", cover_image_id: motion[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === motion[0])?.thumbnail_path ?? null, image_count: motion.length, sort_order: 2, created_at: daysAgo(38), updated_at: daysAgo(3) },
+ { id: 3, name: "Favorites", cover_image_id: favorites[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === favorites[0])?.thumbnail_path ?? null, image_count: favorites.length, sort_order: 3, created_at: daysAgo(20), updated_at: daysAgo(1) },
+ ];
+ return { albums, albumImageIds };
+}
+
+function makeProgress(folders: Folder[], scenario: MockScenario): FolderJobProgress[] {
+ return folders.map((folder, index) => ({
+ folder_id: folder.id,
+ thumbnail_pending: scenario === "busy" ? 18 - index * 3 : 0,
+ metadata_pending: scenario === "busy" ? 12 - index * 2 : 0,
+ embedding_pending: scenario === "busy" ? 36 - index * 4 : scenario === "errors" ? 4 : 0,
+ embedding_ready: Math.max(0, folder.image_count - (scenario === "busy" ? 20 : 2)),
+ embedding_failed: scenario === "errors" ? 3 + index : 0,
+ caption_pending: scenario === "busy" ? 24 - index * 2 : 0,
+ caption_ready: Math.max(0, Math.floor(folder.image_count * 0.5)),
+ caption_failed: scenario === "errors" ? 2 : 0,
+ tagging_pending: scenario === "busy" ? 30 - index * 5 : 0,
+ tagging_ready: Math.max(0, Math.floor(folder.image_count * 0.65)),
+ tagging_failed: scenario === "errors" ? 4 : 0,
+ }));
+}
+
+function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] {
+ return tags.slice(0, 10).map((_, index) => {
+ const group = images.filter((image) => image.id % (index + 2) === 0).slice(0, 28);
+ const representative = group[0] ?? images[index];
+ return {
+ count: Math.max(group.length, 1),
+ representative_image_id: representative?.id ?? index + 1,
+ thumbnail_path: representative?.thumbnail_path ?? null,
+ image_ids: group.length ? group.map((image) => image.id) : [representative?.id ?? 1],
+ };
+ });
+}
+
+function makeExploreTags(images: ImageRecord[]): ExploreTagEntry[] {
+ return tags.map((tag, index) => {
+ const representative = images[index % Math.max(images.length, 1)];
+ return {
+ tag,
+ count: Math.max(3, Math.round(images.length / (index + 3))),
+ representative_image_id: representative?.id ?? index + 1,
+ thumbnail_path: representative?.thumbnail_path ?? null,
+ };
+ });
+}
+
+function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] {
+ if (scenario === "empty") return [];
+ const candidates = images.filter((image) => image.media_kind === "image");
+ const groups = [0, 1, 2, 3].map((groupIndex) => ({
+ file_hash: `fixture-hash-${groupIndex}`,
+ file_size: candidates[groupIndex]?.file_size ?? 1_200_000,
+ images: candidates.slice(groupIndex * 4, groupIndex * 4 + (groupIndex === 1 ? 3 : 2)),
+ })).filter((group) => group.images.length > 1);
+ return scenario === "duplicates" ? groups.concat(groups.map((group, index) => ({
+ ...group,
+ file_hash: `${group.file_hash}-extra`,
+ images: candidates.slice(20 + index * 3, 23 + index * 3),
+ })).filter((group) => group.images.length > 1)) : groups;
+}
+
+export function createMockDb(scenario: MockScenario): MockDb {
+ const folders = makeFolders(scenario);
+ const images = makeImages(scenario, folders);
+ const { albums, albumImageIds } = makeAlbums(images, scenario);
+ return {
+ scenario,
+ folders,
+ images,
+ albums,
+ albumImageIds,
+ tagsByImageId: makeTags(images),
+ tagCloud: makeTagCloud(images),
+ exploreTags: makeExploreTags(images),
+ backgroundJobs: makeProgress(folders, scenario),
+ duplicateGroups: makeDuplicateGroups(images, scenario),
+ duplicateScannedAt: scenario === "duplicates" ? Math.floor(Date.now() / 1000) - 420 : null,
+ };
+}
+
+export function compareImages(sort: SortOrder): (left: ImageRecord, right: ImageRecord) => number {
+ return (left, right) => {
+ switch (sort) {
+ case "name_asc":
+ return left.filename.localeCompare(right.filename);
+ case "name_desc":
+ return right.filename.localeCompare(left.filename);
+ case "size_asc":
+ return left.file_size - right.file_size;
+ case "size_desc":
+ return right.file_size - left.file_size;
+ case "rating_asc":
+ return left.rating - right.rating;
+ case "rating_desc":
+ return right.rating - left.rating;
+ case "duration_asc":
+ return (left.duration_ms ?? 0) - (right.duration_ms ?? 0);
+ case "duration_desc":
+ return (right.duration_ms ?? 0) - (left.duration_ms ?? 0);
+ case "date_asc":
+ case "taken_asc":
+ return Date.parse(left.taken_at ?? left.created_at ?? "") - Date.parse(right.taken_at ?? right.created_at ?? "");
+ case "date_desc":
+ case "taken_desc":
+ default:
+ return Date.parse(right.taken_at ?? right.created_at ?? "") - Date.parse(left.taken_at ?? left.created_at ?? "");
+ }
+ };
+}
+
+export function mockExif(imageId: number): ImageExif {
+ return {
+ make: "Phokus Fixture Co.",
+ model: `MockCam ${100 + imageId}`,
+ lens: "35mm f/1.8",
+ iso: String(100 + (imageId % 8) * 100),
+ f_number: "f/4",
+ exposure_time: "1/250",
+ focal_length: "35mm",
+ datetime_original: daysAgo(imageId),
+ gps_lat: imageId % 2 === 0 ? 51.5072 : null,
+ gps_lon: imageId % 2 === 0 ? -0.1276 : null,
+ };
+}
diff --git a/src/dev/mockScenarios.ts b/src/dev/mockScenarios.ts
new file mode 100644
index 0000000..b78a016
--- /dev/null
+++ b/src/dev/mockScenarios.ts
@@ -0,0 +1,17 @@
+export type MockScenario = "rich" | "empty" | "busy" | "duplicates" | "album" | "errors" | "huge";
+
+const SCENARIOS = new Set([
+ "rich",
+ "empty",
+ "busy",
+ "duplicates",
+ "album",
+ "errors",
+ "huge",
+]);
+
+export function getMockScenario(): MockScenario {
+ if (typeof window === "undefined") return "rich";
+ const value = new URLSearchParams(window.location.search).get("scenario");
+ return value && SCENARIOS.has(value as MockScenario) ? (value as MockScenario) : "rich";
+}
diff --git a/src/dev/setupMockTauri.ts b/src/dev/setupMockTauri.ts
new file mode 100644
index 0000000..c566de0
--- /dev/null
+++ b/src/dev/setupMockTauri.ts
@@ -0,0 +1,11 @@
+import { mockConvertFileSrc, mockIPC, mockWindows } from "@tauri-apps/api/mocks";
+import { handleMockCommand } from "./mockBackend";
+
+mockWindows("main");
+mockConvertFileSrc("windows");
+
+mockIPC((cmd, payload) => handleMockCommand(cmd, payload), {
+ shouldMockEvents: true,
+});
+
+console.info("[Phokus UI Lab] Mock Tauri backend installed.");
diff --git a/src/lib/mediaSrc.ts b/src/lib/mediaSrc.ts
new file mode 100644
index 0000000..7fc8b22
--- /dev/null
+++ b/src/lib/mediaSrc.ts
@@ -0,0 +1,17 @@
+import { convertFileSrc } from "@tauri-apps/api/core";
+
+const MOCK_MEDIA_PREFIX = "mock://";
+
+export function mediaSrc(path: string | null): string | null {
+ if (!path) return null;
+
+ if (import.meta.env.MODE === "ui" && (path.startsWith("/") || path.startsWith("http"))) {
+ return path;
+ }
+
+ if (import.meta.env.MODE === "ui" && path.startsWith(MOCK_MEDIA_PREFIX)) {
+ return `/dev-media/${path.slice(MOCK_MEDIA_PREFIX.length)}`;
+ }
+
+ return convertFileSrc(path);
+}
diff --git a/src/main.tsx b/src/main.tsx
index 8b1ddb9..83ce6dd 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,10 +1,19 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import App from "./App";
import "./index.css";
-ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
-
- ,
-);
+async function bootstrap() {
+ if (import.meta.env.MODE === "ui") {
+ await import("./dev/setupMockTauri");
+ }
+
+ const { default: App } = await import("./App");
+
+ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+ ,
+ );
+}
+
+void bootstrap();