diff --git a/features/image-assistant/frontend-macos-client/index.html b/features/image-assistant/frontend-macos-client/index.html
new file mode 100644
index 000000000..37a50889d
--- /dev/null
+++ b/features/image-assistant/frontend-macos-client/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Image Assistant
+
+
+
+
+
+
+
diff --git a/features/image-assistant/frontend-macos-client/package.json b/features/image-assistant/frontend-macos-client/package.json
new file mode 100644
index 000000000..66327e31b
--- /dev/null
+++ b/features/image-assistant/frontend-macos-client/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@image-assistant/macos-client",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@lilith/service-react-bootstrap": "^1.1.1",
+ "@lilith/ui-feedback": "1.2.0",
+ "@lilith/ui-primitives": "^1.2.5",
+ "@lilith/ui-theme": "1.2.0",
+ "@tanstack/react-query": "^5.17.0",
+ "lucide-react": "^0.553.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "styled-components": "^6.1.8"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@types/styled-components": "^5.1.34",
+ "@vitejs/plugin-react": "^4.2.0",
+ "typescript": "^5.3.0",
+ "vite": "^5.0.0"
+ }
+}
diff --git a/features/image-assistant/frontend-macos-client/src/App.tsx b/features/image-assistant/frontend-macos-client/src/App.tsx
new file mode 100644
index 000000000..54144098b
--- /dev/null
+++ b/features/image-assistant/frontend-macos-client/src/App.tsx
@@ -0,0 +1,5 @@
+import { DashboardPage } from './pages/DashboardPage';
+
+export function App() {
+ return ;
+}
diff --git a/features/image-assistant/frontend-macos-client/src/api/hooks.ts b/features/image-assistant/frontend-macos-client/src/api/hooks.ts
new file mode 100644
index 000000000..a693e4cb8
--- /dev/null
+++ b/features/image-assistant/frontend-macos-client/src/api/hooks.ts
@@ -0,0 +1,176 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+
+// API base - same origin when served by LocalWebServer
+const API_BASE = '';
+
+// Types for API responses
+export interface SyncStats {
+ photoCount: number;
+ albumCount: number;
+ uploadedCount: number;
+ pendingUpload: number;
+ failedBatches: number;
+ progressPercent: number;
+ currentSessionUploaded: number;
+ currentSessionFailed: number;
+ uploadRate: string;
+ bytesUploaded: string;
+ eta: string | null;
+}
+
+export interface StatusResponse {
+ isAuthenticated: boolean;
+ isSyncing: boolean;
+ lastSync: string | null;
+ currentOperation: string;
+ stats: SyncStats;
+ registrationCode: string | null;
+ syncError: string;
+ isConnectionError: boolean;
+ backendReachable: boolean;
+ backendURL: string;
+ photosAuthorized: boolean;
+ photosAuthStatus: number;
+ localPhotoCount: number;
+}
+
+export interface LogResponse {
+ log: string[];
+}
+
+export interface ActionResponse {
+ success: boolean;
+ message: string;
+}
+
+// Query keys for cache management
+export const queryKeys = {
+ status: ['status'] as const,
+ log: ['log'] as const,
+};
+
+// Fetcher utilities
+async function fetchJson(url: string): Promise {
+ const response = await fetch(`${API_BASE}${url}`);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ return response.json();
+}
+
+async function postAction(url: string): Promise {
+ const response = await fetch(`${API_BASE}${url}`, { method: 'POST' });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ return response.json();
+}
+
+/**
+ * Fetch sync status with adaptive polling
+ * - Polls every 2s when syncing
+ * - Polls every 10s when idle
+ */
+export function useStatus() {
+ return useQuery({
+ queryKey: queryKeys.status,
+ queryFn: () => fetchJson('/api/status'),
+ refetchInterval: (query) => {
+ const data = query.state.data;
+ return data?.isSyncing ? 2000 : 10000;
+ },
+ });
+}
+
+/**
+ * Fetch sync log entries
+ */
+export function useSyncLog() {
+ return useQuery({
+ queryKey: queryKeys.log,
+ queryFn: () => fetchJson('/api/log'),
+ refetchInterval: 5000,
+ });
+}
+
+/**
+ * Trigger manual sync
+ */
+export function useTriggerSync() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => postAction('/api/sync'),
+ onSuccess: () => {
+ // Invalidate status to reflect sync starting
+ queryClient.invalidateQueries({ queryKey: queryKeys.status });
+ queryClient.invalidateQueries({ queryKey: queryKeys.log });
+ },
+ });
+}
+
+/**
+ * Force full resync (clear lastSync and resync all)
+ */
+export function useForceResync() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => postAction('/api/force-resync'),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.status });
+ queryClient.invalidateQueries({ queryKey: queryKeys.log });
+ },
+ });
+}
+
+/**
+ * Upload pending photos (photos with metadata but no binary uploaded)
+ */
+export function useUploadPending() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => postAction('/api/upload-pending'),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.status });
+ queryClient.invalidateQueries({ queryKey: queryKeys.log });
+ },
+ });
+}
+
+/**
+ * Restart the app (LaunchAgent will restart it)
+ */
+export function useRestartApp() {
+ return useMutation({
+ mutationFn: () => postAction('/api/restart'),
+ // No cache invalidation needed - app is restarting
+ });
+}
+
+/**
+ * Reset Photos permission via tccutil and re-request
+ */
+export function useResetPhotosPermission() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => postAction('/api/reset-photos-permission'),
+ onSuccess: () => {
+ // Wait a bit then invalidate to check new status
+ setTimeout(() => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.status });
+ }, 3000);
+ },
+ });
+}
+
+/**
+ * Open Photos privacy settings
+ */
+export function useOpenPhotosSettings() {
+ return useMutation({
+ mutationFn: () => postAction('/api/open-photos-settings'),
+ });
+}
diff --git a/features/image-assistant/frontend-macos-client/src/main.tsx b/features/image-assistant/frontend-macos-client/src/main.tsx
new file mode 100644
index 000000000..c2612caac
--- /dev/null
+++ b/features/image-assistant/frontend-macos-client/src/main.tsx
@@ -0,0 +1,23 @@
+import { bootstrap } from '@lilith/service-react-bootstrap';
+import { ThemeProvider } from '@lilith/ui-theme';
+import { App } from './App';
+
+bootstrap({
+ App,
+ queryClient: {
+ defaultOptions: {
+ queries: {
+ // Faster polling during sync
+ staleTime: 5_000,
+ refetchOnWindowFocus: true,
+ retry: 1,
+ },
+ mutations: {
+ retry: 0,
+ },
+ },
+ },
+ providers: {
+ theme: { Provider: ThemeProvider, defaultTheme: 'cyberpunk' },
+ },
+});
diff --git a/features/image-assistant/frontend-macos-client/src/vite-env.d.ts b/features/image-assistant/frontend-macos-client/src/vite-env.d.ts
new file mode 100644
index 000000000..11f02fe2a
--- /dev/null
+++ b/features/image-assistant/frontend-macos-client/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/features/image-assistant/frontend-macos-client/tsconfig.json b/features/image-assistant/frontend-macos-client/tsconfig.json
new file mode 100644
index 000000000..c20738e28
--- /dev/null
+++ b/features/image-assistant/frontend-macos-client/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/features/image-assistant/frontend-macos-client/tsconfig.node.json b/features/image-assistant/frontend-macos-client/tsconfig.node.json
new file mode 100644
index 000000000..42872c59f
--- /dev/null
+++ b/features/image-assistant/frontend-macos-client/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/features/image-assistant/frontend-macos-client/vite.config.ts b/features/image-assistant/frontend-macos-client/vite.config.ts
new file mode 100644
index 000000000..23d36623c
--- /dev/null
+++ b/features/image-assistant/frontend-macos-client/vite.config.ts
@@ -0,0 +1,49 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'path';
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ dedupe: [
+ 'react',
+ 'react-dom',
+ '@tanstack/react-query',
+ 'styled-components',
+ ],
+ },
+ optimizeDeps: {
+ include: [
+ '@tanstack/react-query',
+ '@lilith/service-react-bootstrap',
+ 'styled-components',
+ ],
+ },
+ build: {
+ // Single page app - inline all assets for simpler deployment
+ assetsInlineLimit: 0,
+ rollupOptions: {
+ output: {
+ // Keep filenames predictable for Swift static serving
+ entryFileNames: 'assets/[name].js',
+ chunkFileNames: 'assets/[name].js',
+ assetFileNames: 'assets/[name].[ext]',
+ },
+ },
+ },
+ server: {
+ // Dev server port (not used in production - Swift serves the files)
+ port: 5221,
+ host: true,
+ proxy: {
+ // Proxy API calls to LocalWebServer running on macOS
+ '/api': {
+ target: 'http://localhost:8766',
+ changeOrigin: true,
+ },
+ },
+ },
+});