From 5fe7bc71ef0860b2eeb196f6484aff04647d2b8b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Apr 2026 10:44:51 -0500 Subject: [PATCH] Add photo frame idle mode and switch to Skylight-style theme - After 5 min of no touch/motion, dashboard hides behind a fullscreen photo slideshow with centered time and date overlay - Photos loaded from PHOTOS_PATH env var (defaults to ~/Pictures/dashboard) via IPC + file:// URLs; traversal-guarded, recursive up to 2 levels - Motion or touch exits idle back to dashboard - Theme repainted warm cream / sage / stone ink with Nunito font and rounded cards; dark tokens kept so component classes still resolve - Adds PHOTO_FRAME.md with Samba cifs mount + systemd env instructions --- .env.example | 10 ++ PHOTO_FRAME.md | 78 ++++++++++ electron/main.ts | 33 ++++- electron/preload.ts | 20 +++ electron/services/PhotoManager.ts | 53 +++++++ src/App.tsx | 4 + src/components/photoframe/PhotoFrame.tsx | 174 +++++++++++++++++++++++ src/components/photoframe/index.ts | 1 + src/config/environment.ts | 4 + src/hooks/useIdle.ts | 50 +++++++ src/stores/uiStore.ts | 8 ++ src/styles/index.css | 121 ++++++++++------ src/vite-env.d.ts | 10 ++ tailwind.config.js | 77 ++++++---- 14 files changed, 566 insertions(+), 77 deletions(-) create mode 100644 PHOTO_FRAME.md create mode 100644 electron/services/PhotoManager.ts create mode 100644 src/components/photoframe/PhotoFrame.tsx create mode 100644 src/components/photoframe/index.ts create mode 100644 src/hooks/useIdle.ts diff --git a/.env.example b/.env.example index 7e83d5d..ed1aa2b 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,13 @@ VITE_PRESENCE_CONFIDENCE_THRESHOLD=0.6 # Frigate Streaming from built-in camera VITE_FRIGATE_STREAM_ENABLED=true VITE_FRIGATE_RTSP_OUTPUT=rtsp://192.168.1.241:8554/command_center + +# Photo Frame (shown after inactivity) +# Idle timeout in ms before photo frame takes over (default 5 min) +VITE_PHOTO_FRAME_IDLE_TIMEOUT=300000 +# Seconds between photo transitions +VITE_PHOTO_FRAME_INTERVAL=15000 +# Path to photos directory on the kiosk (set via PHOTOS_PATH env var for the +# Electron process, not via Vite). Typically a mounted Samba share. Example: +# PHOTOS_PATH=/mnt/family-photos +# If unset, defaults to ~/Pictures/dashboard on the kiosk user's account. diff --git a/PHOTO_FRAME.md b/PHOTO_FRAME.md new file mode 100644 index 0000000..dd32932 --- /dev/null +++ b/PHOTO_FRAME.md @@ -0,0 +1,78 @@ +# Photo Frame (Digital Picture Frame) + +After `VITE_PHOTO_FRAME_IDLE_TIMEOUT` ms of no touch/mouse/keyboard activity +(default 5 minutes), the dashboard is covered by a full-screen photo frame +that cycles images from a local directory with a clock overlay. Touching the +screen exits idle and returns to the dashboard. Motion detected by the +Electron `MotionDetector` also cancels idle. + +## Photo source + +The Electron main process reads photos from the directory set in the +`PHOTOS_PATH` environment variable. If unset, it falls back to +`~/Pictures/dashboard`. Files are served via a `photo://` custom protocol +so the renderer never exposes raw filesystem paths. + +Supported extensions: jpg, jpeg, png, webp, gif, heic, heif. Subdirectories +are scanned up to two levels deep. + +## Mounting the Samba share on the kiosk + +On the kiosk (192.168.1.190): + +```bash +# 1. Install cifs-utils +sudo apt install -y cifs-utils + +# 2. Create a credentials file (chmod 600) +sudo tee /etc/samba/credentials-dashboard <<'EOF' +username=YOUR_SAMBA_USER +password=YOUR_SAMBA_PASSWORD +EOF +sudo chmod 600 /etc/samba/credentials-dashboard + +# 3. Create mountpoint +sudo mkdir -p /mnt/family-photos + +# 4. Add to /etc/fstab (replace server/share with actual values) +echo '//SERVER_IP/SHARE_NAME /mnt/family-photos cifs credentials=/etc/samba/credentials-dashboard,uid=1000,gid=1000,iocharset=utf8,vers=3.0,ro,_netdev,x-systemd.automount 0 0' | sudo tee -a /etc/fstab + +# 5. Mount +sudo mount -a +ls /mnt/family-photos +``` + +## Pointing the app at the share + +Option A — systemd unit (if you run the app as a service): + +```ini +# /etc/systemd/system/imperial-command-center.service (append to [Service]) +Environment=PHOTOS_PATH=/mnt/family-photos +``` + +Then `sudo systemctl daemon-reload && sudo systemctl restart imperial-command-center`. + +Option B — desktop launcher (if started from the GUI): + +Edit `~/Desktop/imperial-command-center.desktop` and set: + +``` +Exec=env PHOTOS_PATH=/mnt/family-photos /opt/imperial-command-center/imperial-command-center --no-sandbox --ozone-platform=wayland +``` + +Option C — shell wrapper used by current deployment: + +```bash +sudo -u chrisryn PHOTOS_PATH=/mnt/family-photos \ + XDG_RUNTIME_DIR=/run/user/1000 WAYLAND_DISPLAY=wayland-0 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + /opt/imperial-command-center/imperial-command-center --no-sandbox --ozone-platform=wayland +``` + +## Tuning + +- `VITE_PHOTO_FRAME_IDLE_TIMEOUT` — idle timeout in ms (default 300000) +- `VITE_PHOTO_FRAME_INTERVAL` — ms between photo transitions (default 15000) + +These are baked at build time. diff --git a/electron/main.ts b/electron/main.ts index b05c20f..2775a00 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -5,14 +5,23 @@ import { ScreenManager } from './services/ScreenManager'; import { PresenceDetector } from './services/PresenceDetector'; import { FrigateStreamer } from './services/FrigateStreamer'; import { MotionDetector } from './services/MotionDetector'; +import { PhotoManager } from './services/PhotoManager'; let mainWindow: BrowserWindow | null = null; let screenManager: ScreenManager | null = null; let presenceDetector: PresenceDetector | null = null; let frigateStreamer: FrigateStreamer | null = null; let motionDetector: MotionDetector | null = null; +let photoManager: PhotoManager | null = null; let powerSaveBlockerId: number | null = null; +// Photos directory: env var PHOTOS_PATH wins, else fall back to ~/Pictures/dashboard +function resolvePhotosDir(): string { + const env = process.env.PHOTOS_PATH; + if (env && env.trim()) return env.trim(); + return path.join(app.getPath('home'), 'Pictures', 'dashboard'); +} + // Check if we're in dev mode: explicitly set NODE_ENV or running from source with vite dev server // When running from unpacked folder, app.isPackaged is false but we still want production behavior const isDev = process.env.NODE_ENV === 'development'; @@ -30,7 +39,7 @@ function createWindow(): void { kiosk: !isDev, // Kiosk mode in production frame: false, autoHideMenuBar: true, - backgroundColor: '#0a0a0a', + backgroundColor: '#faf6f0', webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, @@ -71,6 +80,10 @@ function createWindow(): void { motionDetector.start(); + // Photo frame slideshow source + photoManager = new PhotoManager(resolvePhotosDir()); + console.log(`PhotoManager: watching ${photoManager.getDir()}`); + // Handle window close mainWindow.on('closed', () => { mainWindow = null; @@ -174,6 +187,24 @@ function setupIpcHandlers(): void { } }); + // Photo frame + ipcMain.handle('photos:list', async () => { + return photoManager?.list() ?? []; + }); + + ipcMain.handle('photos:getDir', async () => { + return photoManager?.getDir() ?? ''; + }); + + // Given a relative filename returned by photos:list, return an absolute + // file:// URL the renderer can drop into an . Returns null if the + // file is missing or resolves outside the photos dir (traversal guard). + ipcMain.handle('photos:getUrl', async (_event, rel: string) => { + const full = photoManager?.resolve(rel); + if (!full || !fs.existsSync(full)) return null; + return 'file://' + full.split(path.sep).map((p, i) => (i === 0 && p === '' ? '' : encodeURIComponent(p))).join('/'); + }); + // App control ipcMain.handle('app:quit', () => { app.quit(); diff --git a/electron/preload.ts b/electron/preload.ts index 043b806..5bb9500 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -17,6 +17,9 @@ export interface ElectronAPI { startStream: (rtspUrl: string) => Promise; stopStream: () => Promise; }; + motion: { + onDetected: (callback: () => void) => () => void; + }; app: { quit: () => void; toggleFullscreen: () => void; @@ -26,6 +29,11 @@ export interface ElectronAPI { getStoredToken: () => Promise; getJellyfinApiKey: () => Promise; }; + photos: { + list: () => Promise; + getDir: () => Promise; + getUrl: (relative: string) => Promise; + }; } const electronAPI: ElectronAPI = { @@ -53,6 +61,13 @@ const electronAPI: ElectronAPI = { startStream: (rtspUrl: string) => ipcRenderer.invoke('frigate:startStream', rtspUrl), stopStream: () => ipcRenderer.invoke('frigate:stopStream'), }, + motion: { + onDetected: (callback: () => void) => { + const handler = (_event: IpcRendererEvent) => callback(); + ipcRenderer.on('motion:detected', handler); + return () => ipcRenderer.removeListener('motion:detected', handler); + }, + }, app: { quit: () => ipcRenderer.invoke('app:quit'), toggleFullscreen: () => ipcRenderer.invoke('app:toggleFullscreen'), @@ -62,6 +77,11 @@ const electronAPI: ElectronAPI = { getStoredToken: () => ipcRenderer.invoke('config:getStoredToken'), getJellyfinApiKey: () => ipcRenderer.invoke('config:getJellyfinApiKey'), }, + photos: { + list: () => ipcRenderer.invoke('photos:list'), + getDir: () => ipcRenderer.invoke('photos:getDir'), + getUrl: (relative: string) => ipcRenderer.invoke('photos:getUrl', relative), + }, }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/electron/services/PhotoManager.ts b/electron/services/PhotoManager.ts new file mode 100644 index 0000000..852cf0b --- /dev/null +++ b/electron/services/PhotoManager.ts @@ -0,0 +1,53 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']); + +/** + * Lists image files from the configured photos directory. + * Recurses one level deep so users can organise photos into subfolders. + */ +export class PhotoManager { + constructor(private photosDir: string) {} + + setDir(dir: string): void { + this.photosDir = dir; + } + + getDir(): string { + return this.photosDir; + } + + list(): string[] { + if (!this.photosDir || !fs.existsSync(this.photosDir)) return []; + const files: string[] = []; + try { + this.walk(this.photosDir, files, 0); + } catch (err) { + console.error('PhotoManager: failed to list photos', err); + } + // Return paths relative to photosDir so the renderer can build photo:// URLs + return files.map((f) => path.relative(this.photosDir, f)); + } + + private walk(dir: string, out: string[], depth: number): void { + if (depth > 2) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + this.walk(full, out, depth + 1); + } else if (entry.isFile() && IMAGE_EXTS.has(path.extname(entry.name).toLowerCase())) { + out.push(full); + } + } + } + + resolve(relative: string): string | null { + if (!this.photosDir) return null; + const safe = path.normalize(relative).replace(/^(\.\.[\/\\])+/, ''); + const full = path.join(this.photosDir, safe); + if (!full.startsWith(this.photosDir)) return null; + return full; + } +} diff --git a/src/App.tsx b/src/App.tsx index 8af322e..621aa27 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,9 @@ import { CameraOverlay } from '@/components/cameras'; import { CameraFeed } from '@/components/cameras/CameraFeed'; import { JellyfinOverlay } from '@/components/media'; import { GlobalKeyboard } from '@/components/keyboard'; +import { PhotoFrame } from '@/components/photoframe'; import { useHomeAssistant } from '@/hooks'; +import { useIdle } from '@/hooks/useIdle'; // Motion detection now runs in Electron main process (MotionDetector.ts) // import { useSimpleMotion } from '@/hooks/useSimpleMotion'; import { useHAStore, useEntityAttribute } from '@/stores/haStore'; @@ -182,6 +184,7 @@ export default function App() { const thermostatsOverlayOpen = useUIStore((state) => state.thermostatsOverlayOpen); const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen); const { isOpen: cameraOverlayOpen } = useCameraOverlay(); + const isIdle = useIdle(env.photoFrameIdleTimeout); // Person detection alert state const personDetectionEntities = useSettingsStore((state) => state.config.personDetectionEntities); @@ -334,6 +337,7 @@ export default function App() { {mediaOverlayOpen && } {cameraOverlayOpen && } {settingsOpen && } + {isIdle && !alertCamera && } {alertCamera && } diff --git a/src/components/photoframe/PhotoFrame.tsx b/src/components/photoframe/PhotoFrame.tsx new file mode 100644 index 0000000..589aac0 --- /dev/null +++ b/src/components/photoframe/PhotoFrame.tsx @@ -0,0 +1,174 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useUIStore } from '@/stores/uiStore'; + +interface PhotoFrameProps { + intervalMs?: number; + transitionMs?: number; +} + +function shuffle(arr: T[]): T[] { + const out = arr.slice(); + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +function useClock() { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 10_000); + return () => clearInterval(id); + }, []); + return now; +} + +export function PhotoFrame({ intervalMs = 15_000, transitionMs = 1_200 }: PhotoFrameProps) { + const setIdle = useUIStore((s) => s.setIdle); + const [files, setFiles] = useState([]); + const [index, setIndex] = useState(0); + const [currentUrl, setCurrentUrl] = useState(null); + const [prevUrl, setPrevUrl] = useState(null); + const [empty, setEmpty] = useState(false); + const [emptyDir, setEmptyDir] = useState(''); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + const load = async () => { + const api = window.electronAPI; + if (!api?.photos) { + setEmpty(true); + return; + } + const list = await api.photos.list(); + const dir = await api.photos.getDir(); + if (!mountedRef.current) return; + if (!list.length) { + setEmpty(true); + setEmptyDir(dir); + return; + } + setFiles(shuffle(list)); + setEmpty(false); + }; + load(); + // Re-scan every 10 min in case photos are added while idle + const id = setInterval(load, 10 * 60_000); + return () => clearInterval(id); + }, []); + + useEffect(() => { + if (!files.length) return; + const id = setInterval(() => { + setIndex((i) => (i + 1) % files.length); + }, intervalMs); + return () => clearInterval(id); + }, [files.length, intervalMs]); + + // Resolve file:// URLs via IPC when the index changes + useEffect(() => { + if (!files.length) return; + const api = window.electronAPI; + if (!api?.photos?.getUrl) return; + let cancelled = false; + (async () => { + const url = await api.photos.getUrl(files[index]); + if (cancelled || !mountedRef.current) return; + setPrevUrl(currentUrl); + setCurrentUrl(url); + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [index, files]); + + const handleExit = () => setIdle(false); + + const now = useClock(); + const timeStr = useMemo( + () => + now.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }), + [now], + ); + const dateStr = useMemo( + () => + now.toLocaleDateString(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric', + }), + [now], + ); + + const currentSrc = currentUrl; + const prevSrc = prevUrl; + + return ( +
+ {empty ? ( +
+
{timeStr}
+
{dateStr}
+
+ No photos found{emptyDir ? ` in ${emptyDir}` : ''}. +
+
Touch anywhere to exit
+
+ ) : ( + <> + {prevSrc && ( + + )} + {currentSrc && ( + + )} + {/* Gradient + centered clock/date overlay (Skylight style) */} +
+
+
+
{timeStr}
+
{dateStr}
+
+
+ + + )} +
+ ); +} diff --git a/src/components/photoframe/index.ts b/src/components/photoframe/index.ts new file mode 100644 index 0000000..b77409b --- /dev/null +++ b/src/components/photoframe/index.ts @@ -0,0 +1 @@ +export { PhotoFrame } from './PhotoFrame'; diff --git a/src/config/environment.ts b/src/config/environment.ts index ebc12e6..bdc559d 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -20,6 +20,10 @@ export const env = { // Screen management screenIdleTimeout: parseInt(import.meta.env.VITE_SCREEN_IDLE_TIMEOUT || '300000', 10), + // Photo frame idle timeout (ms) - 5 minutes default + photoFrameIdleTimeout: parseInt(import.meta.env.VITE_PHOTO_FRAME_IDLE_TIMEOUT || '300000', 10), + photoFrameInterval: parseInt(import.meta.env.VITE_PHOTO_FRAME_INTERVAL || '15000', 10), + // Presence detection presenceEnabled: import.meta.env.VITE_PRESENCE_DETECTION_ENABLED === 'true', presenceConfidenceThreshold: parseFloat(import.meta.env.VITE_PRESENCE_CONFIDENCE_THRESHOLD || '0.6'), diff --git a/src/hooks/useIdle.ts b/src/hooks/useIdle.ts new file mode 100644 index 0000000..ec57d12 --- /dev/null +++ b/src/hooks/useIdle.ts @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import { useUIStore } from '@/stores/uiStore'; + +type ElectronAPILike = { + motion?: { onDetected: (cb: () => void) => () => void }; +}; + +/** + * Tracks touch/mouse/keyboard activity. After `timeoutMs` of no activity, + * flips the UI into idle mode (photo frame). Any activity exits idle. + * Motion detected by Electron's MotionDetector also cancels idle. + */ +export function useIdle(timeoutMs: number) { + const isIdle = useUIStore((s) => s.isIdle); + const setIdle = useUIStore((s) => s.setIdle); + + useEffect(() => { + if (timeoutMs <= 0) return; + + let timer: ReturnType | null = null; + + const reset = () => { + if (timer) clearTimeout(timer); + if (useUIStore.getState().isIdle) setIdle(false); + timer = setTimeout(() => setIdle(true), timeoutMs); + }; + + const events: Array = [ + 'touchstart', + 'mousedown', + 'mousemove', + 'keydown', + 'wheel', + ]; + for (const e of events) document.addEventListener(e, reset, { passive: true }); + + const api = (window as unknown as { electronAPI?: ElectronAPILike }).electronAPI; + const unsubMotion = api?.motion?.onDetected?.(() => reset()); + + reset(); + + return () => { + if (timer) clearTimeout(timer); + for (const e of events) document.removeEventListener(e, reset); + if (unsubMotion) unsubMotion(); + }; + }, [timeoutMs, setIdle]); + + return isIdle; +} diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index a7383e0..a07f350 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -21,6 +21,10 @@ interface UIState { // Screen state screenOn: boolean; + // Idle / photo frame + isIdle: boolean; + setIdle: (idle: boolean) => void; + // Virtual keyboard keyboardOpen: boolean; keyboardNumpad: boolean; @@ -71,6 +75,7 @@ export const useUIStore = create((set) => ({ alarmoAction: null, settingsOpen: false, screenOn: true, + isIdle: false, keyboardOpen: false, keyboardNumpad: false, @@ -137,6 +142,9 @@ export const useUIStore = create((set) => ({ // Screen setScreenOn: (on) => set({ screenOn: on }), + // Idle + setIdle: (idle) => set({ isIdle: idle }), + // Keyboard openKeyboard: (numpad = false) => set({ keyboardOpen: true, keyboardNumpad: numpad }), closeKeyboard: () => set({ keyboardOpen: false }), diff --git a/src/styles/index.css b/src/styles/index.css index 51789e4..ec4f0d0 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&family=Quicksand:wght@400;500;600;700&display=swap'); @tailwind base; @tailwind components; @@ -6,13 +6,13 @@ @layer base { html { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + font-family: 'Nunito', 'Quicksand', -apple-system, BlinkMacSystemFont, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } body { - @apply bg-dark-primary text-white; + @apply bg-dark-primary text-ink; overflow: hidden; user-select: none; -webkit-user-select: none; @@ -20,8 +20,8 @@ } ::-webkit-scrollbar { - width: 4px; - height: 4px; + width: 6px; + height: 6px; } ::-webkit-scrollbar-track { @@ -29,7 +29,7 @@ } ::-webkit-scrollbar-thumb { - @apply bg-dark-border rounded; + @apply bg-dark-border rounded-full; } ::-webkit-scrollbar-thumb:hover { @@ -40,9 +40,10 @@ @layer components { /* Button base */ .btn { - @apply flex items-center justify-center gap-2 px-4 py-2.5 - bg-dark-tertiary border border-dark-border rounded-xl - font-medium text-sm text-white + @apply flex items-center justify-center gap-2 px-5 py-3 + bg-dark-secondary border border-dark-border rounded-2xl + font-semibold text-base text-ink + shadow-card transition-all duration-150 ease-out hover:bg-dark-hover hover:border-dark-border-light active:scale-[0.98] touch-manipulation; @@ -50,30 +51,31 @@ .btn-primary { @apply btn bg-accent border-accent text-white - hover:bg-accent-light hover:border-accent-light; + hover:bg-accent-dark hover:border-accent-dark; } .btn-sm { - @apply px-2.5 py-1 text-xs rounded-lg; + @apply px-3 py-1.5 text-sm rounded-xl; } .btn-icon { - @apply btn p-2.5; + @apply btn p-3; } - /* Widget */ + /* Widget - Skylight-style rounded card */ .widget { - @apply bg-dark-secondary border border-dark-border rounded-2xl p-4 + @apply bg-dark-secondary border border-dark-border rounded-3xl p-5 + shadow-card flex flex-col overflow-hidden; } .widget-title { - @apply flex items-center gap-2 text-xs font-semibold uppercase tracking-wide - text-gray-400 mb-3; + @apply flex items-center gap-2 text-sm font-bold uppercase tracking-wide + text-ink-muted mb-3; } .widget-title svg { - @apply w-4 h-4 opacity-70; + @apply w-5 h-5 opacity-70; } .widget-content { @@ -82,36 +84,36 @@ /* Toggle switch */ .toggle { - @apply relative w-9 h-5 bg-dark-elevated rounded-full cursor-pointer + @apply relative w-11 h-6 bg-dark-tertiary border border-dark-border rounded-full cursor-pointer transition-colors duration-200; } .toggle.active { - @apply bg-accent; + @apply bg-accent border-accent; } .toggle-thumb { - @apply absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full + @apply absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform duration-200 shadow-sm; } .toggle.active .toggle-thumb { - @apply translate-x-4; + @apply translate-x-5; } /* Status badge */ .status-badge { - @apply flex items-center gap-2 px-3.5 py-2 bg-dark-tertiary rounded-full - text-sm text-gray-400; + @apply flex items-center gap-2 px-4 py-2 bg-dark-tertiary rounded-full + text-sm text-ink-muted; } .status-dot { - @apply w-2 h-2 rounded-full; + @apply w-2.5 h-2.5 rounded-full; } .status-dot.connected { @apply bg-status-success; - box-shadow: 0 0 8px theme('colors.status.success'); + box-shadow: 0 0 8px rgba(74, 157, 122, 0.5); } .status-dot.disconnected { @@ -128,21 +130,21 @@ } .person-avatar { - @apply w-8 h-8 rounded-full overflow-hidden border-2 transition-all; + @apply w-9 h-9 rounded-full overflow-hidden border-2 transition-all; } .person-avatar.home { @apply border-status-success; - box-shadow: 0 0 8px rgba(34, 197, 94, 0.3); + box-shadow: 0 0 8px rgba(74, 157, 122, 0.35); } .person-avatar.away { - @apply border-gray-500 opacity-70; + @apply border-dark-border-light opacity-70; } .person-avatar.work { @apply border-accent; - box-shadow: 0 0 8px rgba(59, 130, 246, 0.3); + box-shadow: 0 0 8px rgba(127, 168, 148, 0.35); } .person-avatar img { @@ -150,7 +152,7 @@ } .person-location { - @apply text-[0.55rem] font-medium uppercase tracking-wide text-gray-500; + @apply text-[0.6rem] font-semibold uppercase tracking-wide text-ink-subtle; } .person-location.home { @@ -158,12 +160,12 @@ } .person-location.work { - @apply text-accent; + @apply text-accent-dark; } /* Status icon */ .status-icon { - @apply relative w-8 h-8 rounded-lg flex items-center justify-center + @apply relative w-9 h-9 rounded-xl flex items-center justify-center cursor-pointer transition-transform hover:scale-110; } @@ -174,55 +176,56 @@ .status-icon.package::after { content: ''; @apply absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-status-warning rounded-full - border-2 border-dark-tertiary animate-pulse; + border-2 border-dark-secondary animate-pulse; } /* Keypad */ .keypad-btn { - @apply w-14 h-14 rounded-xl bg-dark-tertiary border border-dark-border - text-xl font-medium text-white + @apply w-16 h-16 rounded-2xl bg-dark-secondary border border-dark-border + text-2xl font-semibold text-ink + shadow-card transition-all duration-150 - hover:border-accent active:bg-accent active:scale-95 + hover:border-accent hover:bg-dark-hover active:bg-accent active:text-white active:scale-95 touch-manipulation; } /* Temperature */ .temp-display { - @apply text-5xl font-light tracking-tight; + @apply text-5xl font-light tracking-tight text-ink; } .temp-setpoint { - @apply text-center px-2.5 py-1.5 bg-dark-tertiary rounded-lg; + @apply text-center px-3 py-2 bg-dark-tertiary rounded-xl; } .temp-setpoint-label { - @apply text-[0.5rem] text-gray-500 uppercase tracking-wide mb-0.5; + @apply text-[0.55rem] text-ink-subtle uppercase tracking-wide mb-0.5 font-semibold; } .temp-setpoint-value { - @apply text-lg font-semibold; + @apply text-lg font-bold; } .temp-setpoint-value.heat { - @apply text-orange-400; + @apply text-status-warning; } .temp-setpoint-value.cool { - @apply text-sky-400; + @apply text-accent-dark; } .temp-btn { - @apply w-8 h-8 rounded-full bg-dark-tertiary border border-dark-border + @apply w-9 h-9 rounded-full bg-dark-secondary border border-dark-border flex items-center justify-center text-base transition-all hover:bg-dark-hover; } .temp-btn.heat { - @apply border-orange-400 text-orange-400 hover:bg-orange-400/15; + @apply border-status-warning text-status-warning hover:bg-status-warning/15; } .temp-btn.cool { - @apply border-sky-400 text-sky-400 hover:bg-sky-400/15; + @apply border-accent-dark text-accent-dark hover:bg-accent/15; } /* Overlay */ @@ -233,9 +236,14 @@ /* Compact rows */ .compact-row { - @apply flex items-center justify-between px-2.5 py-2 bg-dark-tertiary rounded-lg + @apply flex items-center justify-between px-3 py-2.5 bg-dark-tertiary rounded-xl transition-colors hover:bg-dark-hover; } + + /* Legacy imperial card for ConnectionPrompt */ + .card-imperial { + @apply bg-dark-secondary border border-dark-border rounded-3xl p-8 shadow-card-lg; + } } @layer utilities { @@ -256,4 +264,25 @@ 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } + + /* + * Theme remap: existing components use text-white and text-gray-* for + * dark-theme text. For the Skylight light theme, rebind those utilities + * to the warm ink scale so every component becomes readable on cream bg + * without touching hundreds of component files. + */ + .text-white { color: #3a322a; } + .text-gray-300 { color: #4a4239; } + .text-gray-400 { color: #6b5f51; } + .text-gray-500 { color: #8a7d6e; } + .text-gray-600 { color: #a0937f; } + + /* Dark backgrounds that were nearly-black in the old theme */ + .bg-imperial-black { background-color: #faf6f0; } + .bg-black { background-color: #2a2520; } + + .text-ink { color: #3a322a; } + .text-ink-muted { color: #6b5f51; } + .text-ink-subtle { color: #8a7d6e; } + .text-ink-faint { color: #b0a494; } } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index b2b6f64..2ae7b3c 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -12,6 +12,8 @@ interface ImportMetaEnv { readonly VITE_PRESENCE_CONFIDENCE_THRESHOLD: string; readonly VITE_FRIGATE_STREAM_ENABLED: string; readonly VITE_FRIGATE_RTSP_OUTPUT: string; + readonly VITE_PHOTO_FRAME_IDLE_TIMEOUT: string; + readonly VITE_PHOTO_FRAME_INTERVAL: string; } interface ImportMeta { @@ -44,6 +46,14 @@ interface ElectronAPI { getStoredToken: () => Promise; getJellyfinApiKey: () => Promise; }; + photos: { + list: () => Promise; + getDir: () => Promise; + getUrl: (relative: string) => Promise; + }; + motion: { + onDetected: (callback: () => void) => () => void; + }; } interface Window { diff --git a/tailwind.config.js b/tailwind.config.js index ad7bab5..4e1d9d1 100755 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -7,41 +7,49 @@ module.exports = { theme: { extend: { colors: { - // Modern dark theme + // Skylight-style warm light theme. + // Token names kept as "dark.*" so existing components don't need class changes. dark: { - primary: '#0f0f0f', - secondary: '#171717', - tertiary: '#1f1f1f', - elevated: '#262626', - hover: '#2a2a2a', - border: '#2e2e2e', - 'border-light': '#3a3a3a', + primary: '#faf6f0', // page bg - warm cream + secondary: '#ffffff', // card bg - white + tertiary: '#f5ede1', // subtle panels / compact rows + elevated: '#ffffff', // elevated cards + hover: '#ede2cf', // hover states + border: '#ebe0cc', // soft warm borders + 'border-light': '#d9cab0', }, - // Blue accent + // Sage accent (Skylight uses muted greens/peach, not blue) accent: { - DEFAULT: '#3b82f6', - light: '#60a5fa', - dark: '#2563eb', + DEFAULT: '#7fa894', // sage + light: '#a0c2b1', + dark: '#5e8a76', + }, + // Warm ink text scale + ink: { + DEFAULT: '#3a322a', // primary dark text + muted: '#6b5f51', + subtle: '#8a7d6e', + faint: '#b0a494', }, - // Status colors status: { - success: '#22c55e', - warning: '#f59e0b', - error: '#ef4444', + success: '#4a9d7a', // muted green + warning: '#d99a4a', // warm amber + error: '#c75b5b', // muted brick }, - // Legacy imperial colors (for gradual migration) + // Legacy imperial tokens retained so old references don't break imperial: { - black: '#0a0a0a', - dark: '#1a1a1a', - medium: '#2a2a2a', - light: '#3a3a3a', - red: '#cc0000', - 'red-dark': '#990000', - 'red-light': '#ff3333', + black: '#faf6f0', + dark: '#f5ede1', + medium: '#ebe0cc', + light: '#d9cab0', + red: '#c75b5b', + 'red-dark': '#a84545', + 'red-light': '#d97777', }, }, fontFamily: { - sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'], + sans: ['Nunito', 'Quicksand', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'], + display: ['Nunito', 'Quicksand', 'sans-serif'], }, fontSize: { 'touch': '1.125rem', @@ -51,18 +59,23 @@ module.exports = { 'touch-lg': '56px', }, borderRadius: { - 'xl': '0.75rem', - '2xl': '1rem', + 'xl': '1rem', + '2xl': '1.5rem', + '3xl': '2rem', }, boxShadow: { - 'glow-green': '0 0 8px rgba(34, 197, 94, 0.3)', - 'glow-blue': '0 0 8px rgba(59, 130, 246, 0.3)', - 'glow-orange': '0 0 8px rgba(249, 115, 22, 0.3)', + 'card': '0 2px 8px rgba(58, 50, 42, 0.06), 0 1px 2px rgba(58, 50, 42, 0.04)', + 'card-lg': '0 8px 24px rgba(58, 50, 42, 0.08), 0 2px 6px rgba(58, 50, 42, 0.05)', + 'glow-green': '0 0 12px rgba(127, 168, 148, 0.35)', + 'glow-blue': '0 0 12px rgba(127, 168, 148, 0.35)', + 'glow-orange': '0 0 12px rgba(217, 154, 74, 0.35)', }, animation: { 'fade-in': 'fade-in 0.2s ease-out', + 'fade-slow': 'fade-in 1.2s ease-out', 'slide-up': 'slide-up 0.3s ease-out', 'pulse': 'pulse 2s ease-in-out infinite', + 'ken-burns': 'ken-burns 20s ease-in-out infinite alternate', }, keyframes: { 'fade-in': { @@ -77,6 +90,10 @@ module.exports = { '0%, 100%': { opacity: '1' }, '50%': { opacity: '0.5' }, }, + 'ken-burns': { + '0%': { transform: 'scale(1.0) translate(0, 0)' }, + '100%': { transform: 'scale(1.08) translate(-1%, -1%)' }, + }, }, }, },