66 Ревизии 9af82531e4 ... 10be8dbd62

Автор SHA1 Съобщение Дата
  tuanchris 10be8dbd62 Fix UI showing stale countdown after skip during pause преди 1 седмица
  tuanchris 8707c4ed58 Remove dangerous lock=None in connection close methods преди 1 седмица
  tuanchris 50df3a3ec7 Fix CPU spin loop in motion thread readline wait преди 1 седмица
  tuanchris 3b4f60ca66 Update package-lock.json dependencies преди 1 седмица
  tuanchris 5b7804685b Fix serial stability issues caused by asyncio event overhead преди 1 седмица
  tuanchris 2f29ac10ed Restore null check in get_status_response() преди 1 седмица
  tuanchris 3e6f60a3be Revert retry timeout - wait indefinitely for GRBL 'ok' преди 1 седмица
  tuanchris 9d406586b5 Replace native select with shadcn Select for serial port преди 1 седмица
  tuanchris 8944764883 Increase serial port selector width to prevent text cutoff преди 1 седмица
  tuanchris 4c23061112 Add Reset button with confirmation dialog преди 1 седмица
  tuanchris 276a9a8361 Center warning text vertically in alert box преди 1 седмица
  tuanchris 1c3f59805d Fix build error and add retry logic for motion commands преди 1 седмица
  tuanchris 5baed8fd37 Prevent debug terminal from interfering with pattern execution преди 1 седмица
  tuanchris a2b2cc20ed Center real-time preview canvas in expanded Now Playing view преди 2 седмици
  tuanchris c3218a4925 Center real-time preview canvas in expanded view преди 2 седмици
  tuanchris a8577fd408 Remove debug logging from multi-table fix преди 2 седмици
  tuanchris cf8e31cf89 Fix race condition: pre-initialize apiClient base URL from localStorage преди 2 седмици
  tuanchris b69f0c67cc Add debug logging for multi-table WebSocket issue преди 2 седмици
  tuanchris f0d6371d85 Fix multi-table WebSocket connections failing in production преди 2 седмици
  tuanchris 4292f0bf1f Persist added tables in backend for multi-device access преди 2 седмици
  tuanchris c59101c55f Improve Now Playing bar mobile UX and PWA safe areas преди 2 седмици
  tuanchris d4c9c3ee71 Show table icons in table selector dropdown преди 2 седмици
  tuanchris 51fd92ab10 Improve UI with floating Now Playing button and various fixes преди 2 седмици
  tuanchris 33c585c313 Add PWA support and improve mobile experience преди 2 седмици
  tuanchris c6d20bc9b2 minor ui change преди 2 седмици
  tuanchris f5ecbebb8a Improve UI consistency and mobile experience преди 2 седмици
  tuanchris 114f756d02 Fix clipboard copy for non-HTTPS contexts (http://dwg.local) преди 2 седмици
  tuanchris 2f31e8f869 Revert header button to Docker restart, remove TableControl Reset button преди 2 седмици
  tuanchris af350051d4 Add missing endpoints to nginx proxy config преди 2 седмици
  tuanchris 6c863793c6 Change header Reset button to do table soft reset instead of Docker restart преди 2 седмици
  tuanchris 71a043c763 Add sort by favorites option to Browse and Playlist pages преди 2 седмици
  tuanchris 9aaba5eb93 Fix dw update to re-exec with new CLI after git pull преди 2 седмици
  tuanchris 717d25c561 Revert auto-update feature, instruct users to SSH and run dw update преди 2 седмици
  tuanchris 77eb462e77 Fix Python 3.9 compatibility for type hints преди 2 седмици
  tuanchris 3f3723a4e1 Fix docker-compose.yml invalid empty environment mapping преди 2 седмици
  tuanchris 46be689ae3 Improve Now Playing Bar positioning and add favorite to pattern panel преди 2 седмици
  tuanchris 1bf3cd50a4 Add Update Now button to Settings page преди 2 седмици
  tuanchris 105e44dabe Add host-based update watcher for Docker deployments преди 2 седмици
  tuanchris b2c0cb9ed0 Improve UI consistency and responsiveness преди 2 седмици
  tuanchris 766f8c44f3 Fix stop timeout during pause and stale "waiting" UI state преди 2 седмици
  tuanchris 72ac7669e2 Add event-driven stop/skip for instant interrupt response преди 2 седмици
  tuanchris 8f118617cd Fix SelectItem highlight not matching pill-shaped dropdown преди 2 седмици
  tuanchris 0cf2ed321c Fix move_to_center/perimeter not working after pattern stop преди 2 седмици
  tuanchris d970b0f611 Fix race condition with stale serial port from wrong backend преди 2 седмици
  tuanchris 61b62dda0c Fix nested button HTML error in PatternCard преди 2 седмици
  tuanchris d2d34d0070 Fix patterns not loading due to stale localStorage URL initialization преди 2 седмици
  tuanchris 204240c925 Move auto-homing to after pause time in playlist execution преди 2 седмици
  tuanchris 44762ed931 Fix WebSocket race condition on page load for multi-table support преди 2 седмици
  tuanchris 1c7a6798bb Add force stop endpoint and improve pattern execution control преди 2 седмици
  tuanchris c751266ba7 Prevent crash when sensor homing partially fails преди 2 седмици
  tuanchris 6816e54457 Revert backend changes to debug serial terminal issue преди 2 седмици
  tuanchris 6290fc3b25 Fix stuck pattern state and improve stop/reset reliability преди 2 седмици
  tuanchris 414d984d0c Fix TypeScript error in serial connect button onClick handler преди 2 седмици
  tuanchris 275211ee73 update преди 2 седмици
  tuanchris bce8e5f393 Fix playlist page layout and silence auto-connect toast преди 2 седмици
  tuanchris c6f9c0460b Add play time badges, editable numeric inputs, and UI refinements преди 2 седмици
  tuanchris 311cdc57f7 Improve mobile responsiveness and unify page styling преди 2 седмици
  tuanchris c40544c91e Add pattern history display, queue management, and multi-table UX improvements преди 2 седмици
  tuanchris 19dd6444c3 Redesign playlist controls and improve button styling преди 2 седмици
  tuanchris 1ecf22cd6c Fix auto-connect settings and SelectLabel error преди 2 седмици
  tuanchris 47bdf8b9c8 Auto-save auto-connect setting on dropdown change преди 2 седмици
  tuanchris 7dd6834309 Improve UI responsiveness, auto-connect options, and LED stability преди 2 седмици
  tuanchris da7b895e92 Fix status indicator not syncing when switching tables преди 2 седмици
  tuanchris 492507f73f Auto-create playlist if not found on get_playlist преди 2 седмици
  tuanchris b4a82c7864 Bump version to 4.0.0 преди 2 седмици
  tuanchris d2d68be821 Fix stop not interrupting motion thread retry loop преди 2 седмици
променени са 47 файла, в които са добавени 13658 реда и са изтрити 6103 реда
  1. 0 86
      .cursorrules
  2. 1 1
      VERSION
  3. 4 0
      docker-compose.yml
  4. 14 7
      dw
  5. 11 0
      frontend/.mcp.json
  6. 42 12
      frontend/index.html
  7. 10310 2729
      frontend/package-lock.json
  8. 4 1
      frontend/package.json
  9. 279 72
      frontend/src/components/NowPlayingBar.tsx
  10. 131 0
      frontend/src/components/ShinyText.tsx
  11. 50 32
      frontend/src/components/TableSelector.tsx
  12. 206 106
      frontend/src/components/layout/Layout.tsx
  13. 6 5
      frontend/src/components/ui/button.tsx
  14. 1 1
      frontend/src/components/ui/color-picker.tsx
  15. 1 1
      frontend/src/components/ui/input.tsx
  16. 1 1
      frontend/src/components/ui/popover.tsx
  17. 1 1
      frontend/src/components/ui/searchable-select.tsx
  18. 4 4
      frontend/src/components/ui/select.tsx
  19. 138 0
      frontend/src/components/ui/sheet.tsx
  20. 10 0
      frontend/src/components/ui/sonner.tsx
  21. 1 1
      frontend/src/components/ui/switch.tsx
  22. 140 22
      frontend/src/contexts/TableContext.tsx
  23. 13 5
      frontend/src/index.css
  24. 47 1
      frontend/src/lib/apiClient.ts
  25. 1 1
      frontend/src/lib/types.ts
  26. 381 221
      frontend/src/pages/BrowsePage.tsx
  27. 90 21
      frontend/src/pages/LEDPage.tsx
  28. 300 195
      frontend/src/pages/PlaylistsPage.tsx
  29. 190 132
      frontend/src/pages/SettingsPage.tsx
  30. 125 54
      frontend/src/pages/TableControlPage.tsx
  31. 60 1
      frontend/vite.config.ts
  32. 545 65
      main.py
  33. 70 32
      modules/connection/connection_manager.py
  34. 215 33
      modules/core/pattern_manager.py
  35. 5 7
      modules/core/process_pool.py
  36. 192 9
      modules/core/state.py
  37. 12 0
      modules/core/version_manager.py
  38. 1 1
      nginx.conf
  39. 30 2242
      package-lock.json
  40. BIN
      static/android-chrome-192x192.png
  41. BIN
      static/android-chrome-512x512.png
  42. BIN
      static/apple-touch-icon.png
  43. BIN
      static/favicon-16x16.png
  44. BIN
      static/favicon-32x32.png
  45. BIN
      static/favicon.ico
  46. BIN
      static/icon.jpeg
  47. 26 1
      static/site.webmanifest

+ 0 - 86
.cursorrules

@@ -1,86 +0,0 @@
-You are an expert in Python, FastAPI, and scalable API development.
-
-Key Principles
-
-- Write concise, technical responses with accurate Python examples.
-- Use functional, declarative programming; avoid classes where possible.
-- Prefer iteration and modularization over code duplication.
-- Use descriptive variable names with auxiliary verbs (e.g., is_active, has_permission).
-- Use lowercase with underscores for directories and files (e.g., routers/user_routes.py).
-- Favor named exports for routes and utility functions.
-- Use the Receive an Object, Return an Object (RORO) pattern.
-
-Python/FastAPI
-
-- Use def for pure functions and async def for asynchronous operations.
-- Use type hints for all function signatures. Prefer Pydantic models over raw dictionaries for input validation.
-- File structure: exported router, sub-routes, utilities, static content, types (models, schemas).
-- Avoid unnecessary curly braces in conditional statements.
-- For single-line statements in conditionals, omit curly braces.
-- Use concise, one-line syntax for simple conditional statements (e.g., if condition: do_something()).
-
-Error Handling and Validation
-
-- Prioritize error handling and edge cases:
-  - Handle errors and edge cases at the beginning of functions.
-  - Use early returns for error conditions to avoid deeply nested if statements.
-  - Place the happy path last in the function for improved readability.
-  - Avoid unnecessary else statements; use the if-return pattern instead.
-  - Use guard clauses to handle preconditions and invalid states early.
-  - Implement proper error logging and user-friendly error messages.
-  - Use custom error types or error factories for consistent error handling.
-
-Dependencies
-
-- FastAPI
-- Pydantic v2
-- Async database libraries like asyncpg or aiomysql
-
-FastAPI-Specific Guidelines
-
-- Use functional components (plain functions) and Pydantic models for input validation and response schemas.
-- Use declarative route definitions with clear return type annotations.
-- Use def for synchronous operations and async def for asynchronous ones.
-- Minimize @app.on_event("startup") and @app.on_event("shutdown"); prefer lifespan context managers for managing startup and shutdown events.
-- Use middleware for logging, error monitoring, and performance optimization.
-- Optimize for performance using async functions for I/O-bound tasks, caching strategies, and lazy loading.
-- Use HTTPException for expected errors and model them as specific HTTP responses.
-- Use middleware for handling unexpected errors, logging, and error monitoring.
-- Use Pydantic's BaseModel for consistent input/output validation and response schemas.
-
-Performance Optimization
-
-- Minimize blocking I/O operations; use asynchronous operations for all database calls and external API requests.
-- Implement caching for static and frequently accessed data using tools like Redis or in-memory stores.
-- Optimize data serialization and deserialization with Pydantic.
-- Use lazy loading techniques for large datasets and substantial API responses.
-
-Key Conventions
-
-1. Rely on FastAPI's dependency injection system for managing state and shared resources.
-2. Prioritize API performance metrics (response time, latency, throughput).
-3. Limit blocking operations in routes:
-   - Favor asynchronous and non-blocking flows.
-   - Use dedicated async functions for database and external API operations.
-   - Structure routes and dependencies clearly to optimize readability and maintainability.
-
-Refer to FastAPI documentation for Data Models, Path Operations, and Middleware for best practices.
-
-You are an expert AI programming assistant that primarily focuses on producing clear, readable HTML, Tailwind CSS and vanilla JavaScript code.
-
-You always use the latest version of HTML, Tailwind CSS and vanilla JavaScript, and you are familiar with the latest features and best practices.
-
-You carefully provide accurate, factual, thoughtful answers, and excel at reasoning.
-
-- Follow the user's requirements carefully & to the letter.
-- Confirm, then write code!
-- Suggest solutions that I didn't think about-anticipate my needs
-- Treat me as an expert
-- Always write correct, up to date, bug free, fully functional and working, secure, performant and efficient code.
-- Focus on readability over being performant.
-- Fully implement all requested functionality.
-- Leave NO todo's, placeholders or missing pieces.
-- Be concise. Minimize any other prose.
-- Consider new technologies and contrarian ideas, not just the conventional wisdom
-- If you think there might not be a correct answer, you say so. If you do not know the answer, say so instead of guessing.
-- If I ask for adjustments to code, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make. 

+ 1 - 1
VERSION

@@ -1 +1 @@
-3.6.0
+4.0.0

+ 4 - 0
docker-compose.yml

@@ -23,6 +23,10 @@ services:
     restart: always
     ports:
       - "8080:8080"
+    # Environment variables for testing (uncomment to enable):
+    # environment:
+    #   FORCE_UPDATE_AVAILABLE: "1"        # Always show update available
+    #   FAKE_LATEST_VERSION: "99.0.0"      # Fake a newer version
     volumes:
       # Mount entire app directory for persistence
       - .:/app

+ 14 - 7
dw

@@ -128,13 +128,20 @@ cmd_update() {
     echo -e "${BLUE}Updating Dune Weaver...${NC}"
     cd "$INSTALL_DIR"
 
-    echo "Pulling latest code..."
-    git pull
+    # Check if we should skip the pull phase (called after re-exec)
+    if [[ "$1" != "--continue" ]]; then
+        echo "Pulling latest code..."
+        git pull
+
+        # Update dw CLI
+        echo "Updating dw command..."
+        sudo cp "$INSTALL_DIR/dw" /usr/local/bin/dw
+        sudo chmod +x /usr/local/bin/dw
 
-    # Update dw CLI
-    echo "Updating dw command..."
-    sudo cp "$INSTALL_DIR/dw" /usr/local/bin/dw
-    sudo chmod +x /usr/local/bin/dw
+        # Re-exec with the new script to ensure new code runs
+        echo "Restarting with updated CLI..."
+        exec /usr/local/bin/dw update --continue
+    fi
 
     if is_docker_mode; then
         echo "Stopping current container..."
@@ -318,7 +325,7 @@ case "${1:-help}" in
         cmd_restart
         ;;
     update)
-        cmd_update
+        cmd_update "$2"
         ;;
     logs)
         cmd_logs "$2"

+ 11 - 0
frontend/.mcp.json

@@ -0,0 +1,11 @@
+{
+  "mcpServers": {
+    "shadcn": {
+      "command": "npx",
+      "args": [
+        "shadcn@latest",
+        "mcp"
+      ]
+    }
+  }
+}

+ 42 - 12
frontend/index.html

@@ -3,30 +3,60 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
+    <meta name="description" content="Control your kinetic sand table" />
+
+    <!-- PWA Meta Tags -->
+    <meta name="theme-color" content="#0a0a0a" />
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+    <meta name="apple-mobile-web-app-title" content="Dune Weaver" />
+    <meta name="mobile-web-app-capable" content="yes" />
 
     <!-- Favicons - will be updated dynamically if custom logo exists -->
     <link rel="icon" type="image/x-icon" href="/static/favicon.ico" id="favicon-ico" />
+    <link rel="icon" type="image/png" sizes="128x128" href="/static/favicon-128x128.png" id="favicon-128" />
+    <link rel="icon" type="image/png" sizes="96x96" href="/static/favicon-96x96.png" id="favicon-96" />
     <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" id="favicon-32" />
     <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" id="favicon-16" />
     <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" id="apple-touch-icon" />
-    <link rel="manifest" href="/static/site.webmanifest" />
+    <link rel="manifest" href="/api/manifest.webmanifest" id="manifest" />
 
     <title>Dune Weaver</title>
 
     <!-- Check for custom favicon -->
     <script>
-      fetch('/api/settings')
-        .then(r => r.json())
-        .then(settings => {
-          if (settings.app && settings.app.custom_logo) {
-            document.getElementById('favicon-ico').href = '/static/custom/favicon.ico';
-            document.getElementById('apple-touch-icon').href = '/static/custom/' + settings.app.custom_logo;
-          }
-          if (settings.app && settings.app.name) {
-            document.title = settings.app.name;
+      // Get base URL for active table (supports multi-table connections)
+      (function() {
+        var baseUrl = '';
+        try {
+          var stored = localStorage.getItem('duneweaver_tables');
+          var activeId = localStorage.getItem('duneweaver_active_table');
+          if (stored && activeId) {
+            var data = JSON.parse(stored);
+            var active = (data.tables || []).find(function(t) { return t.id === activeId; });
+            if (active && !active.isCurrent && active.url && active.url !== window.location.origin) {
+              baseUrl = active.url.replace(/\/$/, '');
+            }
           }
-        })
-        .catch(() => {});
+        } catch (e) {}
+
+        fetch(baseUrl + '/api/settings')
+          .then(function(r) { return r.json(); })
+          .then(function(settings) {
+            if (settings.app && settings.app.custom_logo) {
+              // Use generated icons with proper padding (not the raw uploaded logo)
+              document.getElementById('favicon-ico').href = baseUrl + '/static/custom/favicon.ico';
+              document.getElementById('apple-touch-icon').href = baseUrl + '/static/custom/apple-touch-icon.png';
+            }
+            if (settings.app && settings.app.name) {
+              document.title = settings.app.name;
+              // Also update PWA title
+              var appTitleMeta = document.querySelector('meta[name="apple-mobile-web-app-title"]');
+              if (appTitleMeta) appTitleMeta.content = settings.app.name;
+            }
+          })
+          .catch(function() {});
+      })();
     </script>
   </head>
   <body>

Файловите разлики са ограничени, защото са твърде много
+ 10310 - 2729
frontend/package-lock.json


+ 4 - 1
frontend/package.json

@@ -28,6 +28,7 @@
     "@radix-ui/react-tooltip": "^1.2.8",
     "@tailwindcss/postcss": "^4.1.18",
     "@tanstack/react-query": "^5.90.16",
+    "motion": "^12.27.1",
     "next-themes": "^0.4.6",
     "react": "^19.2.0",
     "react-color": "^2.19.3",
@@ -54,11 +55,13 @@
     "globals": "^16.5.0",
     "lucide-react": "^0.562.0",
     "postcss": "^8.5.6",
+    "shadcn": "^3.7.0",
     "tailwind-merge": "^3.4.0",
     "tailwindcss": "^4.1.18",
     "tailwindcss-animate": "^1.0.7",
     "typescript": "~5.9.3",
     "typescript-eslint": "^8.46.4",
-    "vite": "^7.2.4"
+    "vite": "^7.2.4",
+    "vite-plugin-pwa": "^1.2.0"
   }
 }

+ 279 - 72
frontend/src/components/NowPlayingBar.tsx

@@ -84,6 +84,11 @@ interface SortableQueueItemProps {
   file: string
   index: number
   previewUrl: string | null
+  isFirst: boolean
+  isLast: boolean
+  onMoveToTop: () => void
+  onMoveToBottom: () => void
+  requestPreview: (file: string) => void
 }
 
 function SortableQueueItem({
@@ -91,6 +96,11 @@ function SortableQueueItem({
   file,
   index,
   previewUrl,
+  isFirst,
+  isLast,
+  onMoveToTop,
+  onMoveToBottom,
+  requestPreview,
 }: SortableQueueItemProps) {
   const {
     attributes,
@@ -101,6 +111,31 @@ function SortableQueueItem({
     isDragging,
   } = useSortable({ id })
 
+  const previewContainerRef = useRef<HTMLDivElement>(null)
+  const hasRequestedRef = useRef(false)
+
+  // Lazy load preview when item becomes visible
+  useEffect(() => {
+    if (!previewContainerRef.current || previewUrl || hasRequestedRef.current) return
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        entries.forEach((entry) => {
+          if (entry.isIntersecting && !hasRequestedRef.current) {
+            hasRequestedRef.current = true
+            requestPreview(file)
+            observer.disconnect()
+          }
+        })
+      },
+      { rootMargin: '50px' }
+    )
+
+    observer.observe(previewContainerRef.current)
+
+    return () => observer.disconnect()
+  }, [file, previewUrl, requestPreview])
+
   const style = {
     transform: CSS.Transform.toString(transform),
     transition,
@@ -112,7 +147,7 @@ function SortableQueueItem({
     <div
       ref={setNodeRef}
       style={style}
-      className={`flex items-center gap-2 p-2 rounded-lg transition-colors hover:bg-muted/50 ${isDragging ? 'shadow-lg bg-background' : ''}`}
+      className={`group flex items-center gap-2 p-2 rounded-lg transition-colors hover:bg-muted/50 ${isDragging ? 'shadow-lg bg-background' : ''}`}
     >
       {/* Drag handle */}
       <div
@@ -124,7 +159,7 @@ function SortableQueueItem({
       </div>
 
       {/* Preview thumbnail */}
-      <div className="w-14 h-14 rounded-full overflow-hidden bg-muted border shrink-0">
+      <div ref={previewContainerRef} className="w-28 h-28 rounded-full overflow-hidden bg-muted border shrink-0">
         {previewUrl ? (
           <img
             src={previewUrl}
@@ -134,7 +169,7 @@ function SortableQueueItem({
           />
         ) : (
           <div className="w-full h-full flex items-center justify-center">
-            <span className="material-icons-outlined text-muted-foreground text-2xl">image</span>
+            <span className="material-icons-outlined text-muted-foreground text-4xl">image</span>
           </div>
         )}
       </div>
@@ -144,18 +179,39 @@ function SortableQueueItem({
         <p className="text-sm truncate">{formatPatternName(file)}</p>
         <p className="text-xs text-muted-foreground">#{index + 1}</p>
       </div>
+
+      {/* Move to top/bottom buttons - always visible on mobile, hover on desktop */}
+      <div className="flex flex-col gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity shrink-0">
+        <button
+          onClick={onMoveToTop}
+          disabled={isFirst}
+          className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
+          title="Move to top"
+        >
+          <span className="material-icons-outlined text-sm">vertical_align_top</span>
+        </button>
+        <button
+          onClick={onMoveToBottom}
+          disabled={isLast}
+          className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
+          title="Move to bottom"
+        >
+          <span className="material-icons-outlined text-sm">vertical_align_bottom</span>
+        </button>
+      </div>
     </div>
   )
 }
 
 interface NowPlayingBarProps {
   isLogsOpen?: boolean
+  logsDrawerHeight?: number
   isVisible: boolean
   openExpanded?: boolean
   onClose: () => void
 }
 
-export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = false, onClose }: NowPlayingBarProps) {
+export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVisible, openExpanded = false, onClose }: NowPlayingBarProps) {
   const [status, setStatus] = useState<PlaybackStatus | null>(null)
   const [previewUrl, setPreviewUrl] = useState<string | null>(null)
   const wsRef = useRef<WebSocket | null>(null)
@@ -189,13 +245,29 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
     touchStartY.current = null
   }
 
-  // Use native event listener for touchmove to prevent background scroll
+  // Prevent background scroll when Now Playing bar is visible
+  useEffect(() => {
+    if (isVisible) {
+      // Lock body scroll when bar is visible on mobile
+      document.body.style.overflow = 'hidden'
+      return () => {
+        document.body.style.overflow = ''
+      }
+    }
+  }, [isVisible])
+
+  // Use native event listener for touchmove to prevent background scroll on the bar itself
   useEffect(() => {
     const bar = barRef.current
     if (!bar) return
 
     const handleTouchMove = (e: TouchEvent) => {
-      e.preventDefault()
+      // Only prevent default if not scrolling inside a scrollable element
+      const target = e.target as HTMLElement
+      const scrollableParent = target.closest('[data-scrollable]')
+      if (!scrollableParent) {
+        e.preventDefault()
+      }
     }
 
     bar.addEventListener('touchmove', handleTouchMove, { passive: false })
@@ -636,7 +708,13 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       await apiClient.post('/stop_execution')
       toast.success('Stopped')
     } catch {
-      toast.error('Failed to stop')
+      // Normal stop failed, try force stop
+      try {
+        await apiClient.post('/force_stop')
+        toast.success('Force stopped')
+      } catch {
+        toast.error('Failed to stop')
+      }
     }
   }
 
@@ -653,6 +731,66 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
   const [showQueue, setShowQueue] = useState(false)
   const [queuePreviews, setQueuePreviews] = useState<Record<string, string>>({})
 
+  // Queue dialog swipe-to-dismiss
+  const queueTouchStartY = useRef<number | null>(null)
+  const queueDialogRef = useRef<HTMLDivElement>(null)
+
+  const handleQueueTouchStart = (e: React.TouchEvent) => {
+    queueTouchStartY.current = e.touches[0].clientY
+  }
+
+  const handleQueueTouchEnd = (e: React.TouchEvent) => {
+    if (queueTouchStartY.current === null) return
+    const touchEndY = e.changedTouches[0].clientY
+    const deltaY = touchEndY - queueTouchStartY.current
+
+    // Swipe down to dismiss (only if at top of scroll or large swipe)
+    if (deltaY > 80) {
+      const scrollContainer = queueDialogRef.current?.querySelector('[data-scrollable]') as HTMLElement
+      const isAtTop = !scrollContainer || scrollContainer.scrollTop <= 0
+      if (isAtTop) {
+        setShowQueue(false)
+      }
+    }
+    queueTouchStartY.current = null
+  }
+
+  // Optimistic queue state for smooth drag-and-drop
+  const [optimisticQueue, setOptimisticQueue] = useState<string[] | null>(null)
+  const optimisticTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  // Sync optimistic queue with server state after a delay
+  // This allows the optimistic update to "stick" while the server catches up
+  useEffect(() => {
+    if (optimisticQueue && status?.playlist?.files) {
+      // Clear any pending timeout
+      if (optimisticTimeoutRef.current) {
+        clearTimeout(optimisticTimeoutRef.current)
+      }
+      // After server confirms (via WebSocket), clear optimistic state
+      // We check if server state matches our optimistic state
+      const serverOrder = status.playlist.files.join(',')
+      const optimisticOrder = optimisticQueue.join(',')
+      if (serverOrder === optimisticOrder) {
+        // Server caught up, clear optimistic state
+        setOptimisticQueue(null)
+      } else {
+        // Give server time to catch up, then accept server state
+        optimisticTimeoutRef.current = setTimeout(() => {
+          setOptimisticQueue(null)
+        }, 2000)
+      }
+    }
+    return () => {
+      if (optimisticTimeoutRef.current) {
+        clearTimeout(optimisticTimeoutRef.current)
+      }
+    }
+  }, [status?.playlist?.files, optimisticQueue])
+
+  // Use optimistic queue if available, otherwise use server state
+  const displayQueue = optimisticQueue || status?.playlist?.files || []
+
   // Drag and drop sensors
   const sensors = useSensors(
     useSensor(PointerSensor, {
@@ -682,25 +820,28 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
   // Track which files we've already requested previews for
   const requestedPreviewsRef = useRef<Set<string>>(new Set())
+  const pendingQueuePreviewsRef = useRef<Set<string>>(new Set())
+  const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 
-  // Fetch queue previews when dialog opens
-  useEffect(() => {
-    if (!showQueue || !status?.playlist?.files) {
-      return
-    }
+  // Batched queue preview fetching - collects requests and fetches in batches
+  const requestQueuePreview = useCallback((file: string) => {
+    // Skip if already loaded or pending
+    if (queuePreviews[file] || requestedPreviewsRef.current.has(file) || pendingQueuePreviewsRef.current.has(file)) return
+
+    pendingQueuePreviewsRef.current.add(file)
 
-    // Filter out files we've already requested
-    const filesToFetch = status.playlist.files.filter(f => !requestedPreviewsRef.current.has(f))
-    if (filesToFetch.length === 0) return
+    // Debounce batch fetch
+    if (batchTimeoutRef.current) clearTimeout(batchTimeoutRef.current)
+    batchTimeoutRef.current = setTimeout(async () => {
+      const filesToFetch = Array.from(pendingQueuePreviewsRef.current)
+      pendingQueuePreviewsRef.current.clear()
+      if (filesToFetch.length === 0) return
 
-    // Mark these as requested immediately to prevent duplicate requests
-    filesToFetch.forEach(f => requestedPreviewsRef.current.add(f))
+      // Mark as requested
+      filesToFetch.forEach(f => requestedPreviewsRef.current.add(f))
 
-    // Fetch in batches of 20 to avoid overwhelming the server
-    const batchSize = 20
-    const fetchBatch = async (batch: string[]) => {
       try {
-        const data = await apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: batch })
+        const data = await apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: filesToFetch })
         const newPreviews: Record<string, string> = {}
         for (const [file, result] of Object.entries(data)) {
           if (result.image_data) {
@@ -713,14 +854,16 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       } catch (err) {
         console.error('Failed to fetch queue previews:', err)
       }
-    }
-
-    // Fetch first batch immediately, then stagger the rest
-    for (let i = 0; i < filesToFetch.length; i += batchSize) {
-      const batch = filesToFetch.slice(i, i + batchSize)
-      setTimeout(() => fetchBatch(batch), (i / batchSize) * 200)
-    }
-  }, [showQueue, status?.playlist?.files])
+    }, 100)
+  }, [queuePreviews])
+
+  // Helper to reorder array (move item from one index to another)
+  const reorderArray = (arr: string[], fromIndex: number, toIndex: number): string[] => {
+    const result = [...arr]
+    const [removed] = result.splice(fromIndex, 1)
+    result.splice(toIndex, 0, removed)
+    return result
+  }
 
   // Handle drag end for reordering queue
   // Since playlist now contains only main patterns, indices map directly
@@ -747,12 +890,40 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       return
     }
 
+    // Optimistically update the queue immediately
+    const currentQueue = optimisticQueue || status.playlist.files
+    const newQueue = reorderArray(currentQueue, fromIndex, toIndex)
+    setOptimisticQueue(newQueue)
+
     try {
       await apiClient.post('/reorder_playlist', {
         from_index: fromIndex,
         to_index: toIndex
       })
     } catch {
+      // Revert optimistic update on failure
+      setOptimisticQueue(null)
+      toast.error('Failed to reorder')
+    }
+  }
+
+  // Helper to move queue item to a specific position
+  const moveToPosition = async (fromIndex: number, toIndex: number) => {
+    if (fromIndex === toIndex || !status?.playlist?.files) return
+
+    // Optimistically update the queue immediately
+    const currentQueue = optimisticQueue || status.playlist.files
+    const newQueue = reorderArray(currentQueue, fromIndex, toIndex)
+    setOptimisticQueue(newQueue)
+
+    try {
+      await apiClient.post('/reorder_playlist', {
+        from_index: fromIndex,
+        to_index: toIndex
+      })
+    } catch {
+      // Revert optimistic update on failure
+      setOptimisticQueue(null)
       toast.error('Failed to reorder')
     }
   }
@@ -796,20 +967,22 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
         className="fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300"
         style={{
           bottom: isLogsOpen
-            ? 'calc(20rem + env(safe-area-inset-bottom, 0px))'
+            ? `calc(${logsDrawerHeight}px + 4rem + env(safe-area-inset-bottom, 0px))`
             : 'calc(4rem + env(safe-area-inset-bottom, 0px))'
         }}
         data-now-playing-bar={isExpanded ? 'expanded' : 'collapsed'}
         onTouchStart={handleTouchStart}
         onTouchEnd={handleTouchEnd}
       >
-        {/* Swipe indicator - only on mobile */}
-        <div className="md:hidden flex justify-center pt-2 pb-1">
-          <div className="w-10 h-1 bg-muted-foreground/30 rounded-full" />
-        </div>
+        {/* Max-width container to match page layout */}
+        <div className="h-full max-w-5xl mx-auto relative">
+          {/* Swipe indicator - only on mobile */}
+          <div className="md:hidden flex justify-center pt-2 pb-1">
+            <div className="w-10 h-1 bg-muted-foreground/30 rounded-full" />
+          </div>
 
-        {/* Header with action buttons */}
-        <div className="absolute top-3 right-3 flex items-center gap-1 z-10">
+          {/* Header with action buttons - add safe area when expanded for Dynamic Island */}
+          <div className={`absolute right-3 sm:right-4 flex items-center gap-1 z-10 ${isExpanded ? 'top-3 mt-safe' : 'top-3'}`}>
           {/* Queue button - mobile only, when playlist exists */}
           {isPlaying && status?.playlist && (
             <Button
@@ -852,7 +1025,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
           {!isExpanded && (
             <div className="flex-1 flex flex-col">
               {/* Main row with preview and controls */}
-              <div className="flex-1 flex items-center gap-6 px-6">
+              <div className="flex-1 flex items-center gap-6 px-6 py-4">
                 {/* Current Pattern Preview - Rounded (click to expand) */}
                 <div
                   className="w-48 h-48 rounded-full overflow-hidden bg-muted shrink-0 border-2 cursor-pointer hover:border-primary transition-colors"
@@ -931,7 +1104,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                       {/* Playback Controls - Centered */}
                       <div className="flex items-center justify-center gap-3">
                         <Button
-                          variant="outline"
+                          variant="secondary"
                           size="icon"
                           className="h-10 w-10 rounded-full"
                           onClick={handleStop}
@@ -951,7 +1124,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                         </Button>
                         {status.playlist && (
                           <Button
-                            variant="outline"
+                            variant="secondary"
                             size="icon"
                             className="h-10 w-10 rounded-full"
                             onClick={handleSkip}
@@ -1038,11 +1211,11 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
           {/* Expanded view - Real-time canvas preview */}
           {isExpanded && isPlaying && (
-            <div className="flex-1 flex flex-col md:items-center md:justify-center px-4 py-2 md:py-4 overflow-hidden">
-              <div className="w-full max-w-5xl mx-auto flex flex-col md:flex-row md:items-center md:justify-center gap-3 md:gap-6">
+            <div className="flex-1 flex flex-col md:items-center md:justify-center px-4 py-4 md:py-8 pt-safe overflow-hidden">
+              <div className="w-full max-w-5xl mx-auto flex flex-col md:flex-row md:items-center gap-3 md:gap-6">
                 {/* Canvas - full width on mobile (click to collapse) */}
                 <div
-                  className="flex items-center justify-center cursor-pointer"
+                  className="flex-1 flex items-center justify-center cursor-pointer"
                   onClick={() => setIsExpanded(false)}
                   title="Click to collapse"
                 >
@@ -1050,35 +1223,51 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                     ref={canvasRef}
                     width={600}
                     height={600}
-                    className="rounded-full border-2 hover:border-primary transition-colors max-h-[40vh] max-w-[40vh] w-[40vh] h-[40vh] md:w-[300px] md:h-[300px] md:max-w-none md:max-h-none"
+                    className="rounded-full border-2 hover:border-primary transition-colors w-[40vh] h-[40vh] max-w-[300px] max-h-[300px] md:w-[42vh] md:h-[42vh] md:max-w-[500px] md:max-h-[500px]"
                   />
                 </div>
 
                 {/* Controls */}
                 <div className="md:w-80 shrink-0 flex flex-col justify-start md:justify-center gap-2 md:gap-4">
                 {/* Pattern Info */}
-                <div className="text-center">
-                  {isWaiting ? (
-                    <>
-                      <h2 className="text-lg md:text-xl font-semibold text-muted-foreground">
-                        Waiting for next pattern...
-                      </h2>
-                      {status?.playlist?.next_file && (
-                        <p className="text-sm text-muted-foreground">
-                          Up next: {formatPatternName(status.playlist.next_file)}
-                        </p>
-                      )}
-                    </>
-                  ) : (
-                    <>
-                      <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
-                      {status?.playlist && (
-                        <p className="text-sm text-muted-foreground">
-                          Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
-                        </p>
-                      )}
-                    </>
-                  )}
+                <div className="flex items-center justify-center gap-3">
+                  {/* Current pattern preview */}
+                  <div className="w-10 h-10 md:w-12 md:h-12 rounded-full overflow-hidden bg-muted border shrink-0">
+                    {previewUrl ? (
+                      <img
+                        src={previewUrl}
+                        alt={patternName}
+                        className="w-full h-full object-cover pattern-preview"
+                      />
+                    ) : (
+                      <div className="w-full h-full flex items-center justify-center">
+                        <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
+                      </div>
+                    )}
+                  </div>
+                  <div className="text-left min-w-0">
+                    {isWaiting ? (
+                      <>
+                        <h2 className="text-lg md:text-xl font-semibold text-muted-foreground">
+                          Waiting for next pattern...
+                        </h2>
+                        {status?.playlist?.next_file && (
+                          <p className="text-sm text-muted-foreground">
+                            Up next: {formatPatternName(status.playlist.next_file)}
+                          </p>
+                        )}
+                      </>
+                    ) : (
+                      <>
+                        <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
+                        {status?.playlist && (
+                          <p className="text-sm text-muted-foreground">
+                            Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
+                          </p>
+                        )}
+                      </>
+                    )}
+                  </div>
                 </div>
 
                 {/* Progress */}
@@ -1107,7 +1296,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                 {/* Playback Controls */}
                 <div className="flex items-center justify-center gap-2 md:gap-3">
                   <Button
-                    variant="outline"
+                    variant="secondary"
                     size="icon"
                     className="h-10 w-10 md:h-12 md:w-12 rounded-full"
                     onClick={handleStop}
@@ -1127,7 +1316,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                   </Button>
                   {status?.playlist && (
                     <Button
-                      variant="outline"
+                      variant="secondary"
                       size="icon"
                       className="h-10 w-10 md:h-12 md:w-12 rounded-full"
                       onClick={handleSkip}
@@ -1186,11 +1375,21 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
             </div>
           )}
         </div>
+        </div>{/* Close max-width container */}
       </div>
 
       {/* Queue Dialog */}
       <Dialog open={showQueue} onOpenChange={setShowQueue}>
-        <DialogContent className="max-w-md max-h-[80vh] flex flex-col">
+        <DialogContent
+          ref={queueDialogRef}
+          className="max-w-md max-h-[80vh] flex flex-col"
+          onTouchStart={handleQueueTouchStart}
+          onTouchEnd={handleQueueTouchEnd}
+        >
+          {/* Swipe indicator for mobile */}
+          <div className="md:hidden flex justify-center -mt-2 mb-2">
+            <div className="w-10 h-1 bg-muted-foreground/30 rounded-full" />
+          </div>
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
               <span className="material-icons-outlined">queue_music</span>
@@ -1202,16 +1401,16 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
               )}
             </DialogTitle>
             <DialogDescription className="sr-only">
-              List of patterns in the current playlist queue
+              List of patterns in the current playlist queue. Swipe down to dismiss.
             </DialogDescription>
           </DialogHeader>
 
-          <div className="flex-1 overflow-y-auto -mx-6 px-6 py-2">
-            {status?.playlist?.files && status.playlist.files.length > 0 ? (
+          <div className="flex-1 overflow-y-auto -mx-6 px-6 py-2" data-scrollable>
+            {status?.playlist && displayQueue.length > 0 ? (
               (() => {
                 // Only show upcoming patterns (after current)
                 const currentIndex = status.playlist!.current_index
-                const upcomingFiles = status.playlist!.files
+                const upcomingFiles = displayQueue
                   .map((file, index) => ({ file, index }))
                   .filter(({ index }) => index > currentIndex)
 
@@ -1219,6 +1418,9 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                   return <p className="text-center text-muted-foreground py-8">No upcoming patterns</p>
                 }
 
+                const firstUpcomingIndex = upcomingFiles[0].index
+                const lastUpcomingIndex = upcomingFiles[upcomingFiles.length - 1].index
+
                 return (
                   <DndContext
                     sensors={sensors}
@@ -1237,6 +1439,11 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                             file={file}
                             index={index}
                             previewUrl={queuePreviews[file] || null}
+                            isFirst={index === firstUpcomingIndex}
+                            isLast={index === lastUpcomingIndex}
+                            onMoveToTop={() => moveToPosition(index, firstUpcomingIndex)}
+                            requestPreview={requestQueuePreview}
+                            onMoveToBottom={() => moveToPosition(index, lastUpcomingIndex)}
                           />
                         ))}
                       </div>

+ 131 - 0
frontend/src/components/ShinyText.tsx

@@ -0,0 +1,131 @@
+import React, { useState, useCallback, useEffect, useRef } from 'react';
+import { motion, useMotionValue, useAnimationFrame, useTransform } from 'motion/react';
+
+interface ShinyTextProps {
+  text: string;
+  disabled?: boolean;
+  speed?: number;
+  className?: string;
+  color?: string;
+  shineColor?: string;
+  spread?: number;
+  yoyo?: boolean;
+  pauseOnHover?: boolean;
+  direction?: 'left' | 'right';
+  delay?: number;
+}
+
+const ShinyText: React.FC<ShinyTextProps> = ({
+  text,
+  disabled = false,
+  speed = 2,
+  className = '',
+  color = '#b5b5b5',
+  shineColor = '#ffffff',
+  spread = 120,
+  yoyo = false,
+  pauseOnHover = false,
+  direction = 'left',
+  delay = 0
+}) => {
+  const [isPaused, setIsPaused] = useState(false);
+  const progress = useMotionValue(0);
+  const elapsedRef = useRef(0);
+  const lastTimeRef = useRef<number | null>(null);
+  const directionRef = useRef(direction === 'left' ? 1 : -1);
+
+  const animationDuration = speed * 1000;
+  const delayDuration = delay * 1000;
+
+  useAnimationFrame(time => {
+    if (disabled || isPaused) {
+      lastTimeRef.current = null;
+      return;
+    }
+
+    if (lastTimeRef.current === null) {
+      lastTimeRef.current = time;
+      return;
+    }
+
+    const deltaTime = time - lastTimeRef.current;
+    lastTimeRef.current = time;
+
+    elapsedRef.current += deltaTime;
+
+    // Animation goes from 0 to 100
+    if (yoyo) {
+      const cycleDuration = animationDuration + delayDuration;
+      const fullCycle = cycleDuration * 2;
+      const cycleTime = elapsedRef.current % fullCycle;
+
+      if (cycleTime < animationDuration) {
+        // Forward animation: 0 -> 100
+        const p = (cycleTime / animationDuration) * 100;
+        progress.set(directionRef.current === 1 ? p : 100 - p);
+      } else if (cycleTime < cycleDuration) {
+        // Delay at end
+        progress.set(directionRef.current === 1 ? 100 : 0);
+      } else if (cycleTime < cycleDuration + animationDuration) {
+        // Reverse animation: 100 -> 0
+        const reverseTime = cycleTime - cycleDuration;
+        const p = 100 - (reverseTime / animationDuration) * 100;
+        progress.set(directionRef.current === 1 ? p : 100 - p);
+      } else {
+        // Delay at start
+        progress.set(directionRef.current === 1 ? 0 : 100);
+      }
+    } else {
+      const cycleDuration = animationDuration + delayDuration;
+      const cycleTime = elapsedRef.current % cycleDuration;
+
+      if (cycleTime < animationDuration) {
+        // Animation phase: 0 -> 100
+        const p = (cycleTime / animationDuration) * 100;
+        progress.set(directionRef.current === 1 ? p : 100 - p);
+      } else {
+        // Delay phase - hold at end (shine off-screen)
+        progress.set(directionRef.current === 1 ? 100 : 0);
+      }
+    }
+  });
+
+  useEffect(() => {
+    directionRef.current = direction === 'left' ? 1 : -1;
+    elapsedRef.current = 0;
+    progress.set(0);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [direction]);
+
+  // Transform: p=0 -> 150% (shine off right), p=100 -> -50% (shine off left)
+  const backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`);
+
+  const handleMouseEnter = useCallback(() => {
+    if (pauseOnHover) setIsPaused(true);
+  }, [pauseOnHover]);
+
+  const handleMouseLeave = useCallback(() => {
+    if (pauseOnHover) setIsPaused(false);
+  }, [pauseOnHover]);
+
+  const gradientStyle: React.CSSProperties = {
+    backgroundImage: `linear-gradient(${spread}deg, ${color} 0%, ${color} 35%, ${shineColor} 50%, ${color} 65%, ${color} 100%)`,
+    backgroundSize: '200% auto',
+    WebkitBackgroundClip: 'text',
+    backgroundClip: 'text',
+    WebkitTextFillColor: 'transparent'
+  };
+
+  return (
+    <motion.span
+      className={`inline-block ${className}`}
+      style={{ ...gradientStyle, backgroundPosition }}
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+    >
+      {text}
+    </motion.span>
+  );
+};
+
+export default ShinyText;

+ 50 - 32
frontend/src/components/TableSelector.tsx

@@ -27,14 +27,15 @@ import {
   Layers,
   Plus,
   Check,
-  Wifi,
-  WifiOff,
   Pencil,
   Trash2,
-  ChevronDown,
 } from 'lucide-react'
 
-export function TableSelector() {
+interface TableSelectorProps {
+  children?: React.ReactNode
+}
+
+export function TableSelector({ children }: TableSelectorProps) {
   const {
     tables,
     activeTable,
@@ -121,19 +122,20 @@ export function TableSelector() {
     <>
       <Popover open={isOpen} onOpenChange={setIsOpen}>
         <PopoverTrigger asChild>
-          <Button
-            variant="ghost"
-            size="sm"
-            className="gap-2 h-9 px-3"
-          >
-            <Layers className="h-4 w-4" />
-            <span className="hidden sm:inline max-w-[120px] truncate">
-              {activeTable?.name || 'Select Table'}
-            </span>
-            <ChevronDown className="h-3 w-3 opacity-50" />
-          </Button>
+          {children || (
+            <Button
+              variant="ghost"
+              size="sm"
+              className="gap-2 h-9 px-2"
+            >
+              <Layers className="h-4 w-4" />
+              <span className="hidden sm:inline max-w-[120px] truncate">
+                {activeTable?.appName || activeTable?.name || 'Select Table'}
+              </span>
+            </Button>
+          )}
         </PopoverTrigger>
-        <PopoverContent className="w-72 p-2" align="end">
+        <PopoverContent className="w-72 p-2" align="start" sideOffset={12} alignOffset={-56}>
           <div className="space-y-2">
             {/* Header */}
             <div className="px-2 py-1">
@@ -150,12 +152,28 @@ export function TableSelector() {
                   }`}
                   onClick={() => handleSelectTable(table)}
                 >
-                  {/* Status indicator */}
-                  {table.isOnline ? (
-                    <Wifi className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
-                  ) : (
-                    <WifiOff className="h-3.5 w-3.5 text-red-500 flex-shrink-0" />
-                  )}
+                  {/* Table icon with status indicator */}
+                  <div className="relative flex-shrink-0">
+                    <img
+                      src={
+                        table.customLogo
+                          ? `${table.isCurrent ? '' : table.url}/static/custom/${table.customLogo}`
+                          : `${table.isCurrent ? '' : table.url}/static/android-chrome-192x192.png`
+                      }
+                      alt={table.name}
+                      className="w-8 h-8 rounded-full object-cover"
+                      onError={(e) => {
+                        // Fallback to default icon if image fails to load
+                        (e.target as HTMLImageElement).src = '/static/android-chrome-192x192.png'
+                      }}
+                    />
+                    {/* Online status dot */}
+                    <span
+                      className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-popover ${
+                        table.isOnline ? 'bg-green-500' : 'bg-red-500'
+                      }`}
+                    />
+                  </div>
 
                   {/* Name and info */}
                   <div className="flex-1 min-w-0">
@@ -172,11 +190,6 @@ export function TableSelector() {
                     </span>
                   </div>
 
-                  {/* Selected indicator */}
-                  {activeTable?.id === table.id && (
-                    <Check className="h-4 w-4 text-primary flex-shrink-0" />
-                  )}
-
                   {/* Actions - always visible on mobile, hover on desktop */}
                   <div className="flex md:opacity-0 md:group-hover:opacity-100 items-center gap-1 transition-opacity">
                     <Button
@@ -206,13 +219,18 @@ export function TableSelector() {
                       </Button>
                     )}
                   </div>
+
+                  {/* Selected indicator - far right */}
+                  {activeTable?.id === table.id && (
+                    <Check className="h-4 w-4 text-primary flex-shrink-0" />
+                  )}
                 </div>
               ))}
             </div>
 
             {/* Add table button */}
             <Button
-              variant="outline"
+              variant="secondary"
               size="sm"
               className="w-full gap-2"
               onClick={() => setShowAddDialog(true)}
@@ -253,8 +271,8 @@ export function TableSelector() {
               />
             </div>
           </div>
-          <DialogFooter>
-            <Button variant="outline" onClick={() => setShowAddDialog(false)}>
+          <DialogFooter className="gap-2 sm:gap-0">
+            <Button variant="secondary" onClick={() => setShowAddDialog(false)}>
               Cancel
             </Button>
             <Button onClick={handleAddTable} disabled={isAdding}>
@@ -279,8 +297,8 @@ export function TableSelector() {
               autoFocus
             />
           </div>
-          <DialogFooter>
-            <Button variant="outline" onClick={() => setShowRenameDialog(false)}>
+          <DialogFooter className="gap-2 sm:gap-0">
+            <Button variant="secondary" onClick={() => setShowRenameDialog(false)}>
               Cancel
             </Button>
             <Button onClick={handleRename}>Save</Button>

+ 206 - 106
frontend/src/components/layout/Layout.tsx

@@ -9,6 +9,7 @@ import { cacheAllPreviews } from '@/lib/previewCache'
 import { TableSelector } from '@/components/TableSelector'
 import { useTable } from '@/contexts/TableContext'
 import { apiClient } from '@/lib/apiClient'
+import ShinyText from '@/components/ShinyText'
 
 const navItems = [
   { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
@@ -23,8 +24,16 @@ const DEFAULT_APP_NAME = 'Dune Weaver'
 export function Layout() {
   const location = useLocation()
 
+  // Scroll to top on route change
+  useEffect(() => {
+    window.scrollTo(0, 0)
+  }, [location.pathname])
+
   // Multi-table context - must be called before any hooks that depend on activeTable
-  const { activeTable } = useTable()
+  const { activeTable, tables } = useTable()
+
+  // Use table name as app name when multiple tables exist
+  const hasMultipleTables = tables.length > 1
 
   const [isDark, setIsDark] = useState(() => {
     if (typeof window !== 'undefined') {
@@ -39,10 +48,17 @@ export function Layout() {
   const [appName, setAppName] = useState(DEFAULT_APP_NAME)
   const [customLogo, setCustomLogo] = useState<string | null>(null)
 
+  // Display name: when multiple tables exist, use the active table's name; otherwise use app settings
+  // Get the table from the tables array (most up-to-date source) to ensure we have current data
+  const activeTableData = tables.find(t => t.id === activeTable?.id)
+  const tableName = activeTableData?.name || activeTable?.name
+  const displayName = hasMultipleTables && tableName ? tableName : appName
+
   // Connection status
   const [isConnected, setIsConnected] = useState(false)
   const [isBackendConnected, setIsBackendConnected] = useState(false)
   const [isHoming, setIsHoming] = useState(false)
+  const [homingDismissed, setHomingDismissed] = useState(false)
   const [homingJustCompleted, setHomingJustCompleted] = useState(false)
   const [homingCountdown, setHomingCountdown] = useState(0)
   const [keepHomingLogsOpen, setKeepHomingLogsOpen] = useState(false)
@@ -153,8 +169,12 @@ export function Layout() {
   // Now Playing bar state
   const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
   const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
+  const [currentPlayingFile, setCurrentPlayingFile] = useState<string | null>(null) // Track current file for header button
   const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
 
+  // Derive isCurrentlyPlaying from currentPlayingFile
+  const isCurrentlyPlaying = Boolean(currentPlayingFile)
+
   // Listen for playback-started event (dispatched when user starts a pattern)
   useEffect(() => {
     const handlePlaybackStarted = () => {
@@ -222,17 +242,26 @@ export function Layout() {
             // Update homing status and detect completion
             if (data.data.is_homing !== undefined) {
               const newIsHoming = data.data.is_homing
+              // Detect transition from not homing to homing - reset dismissal
+              if (!wasHomingRef.current && newIsHoming) {
+                setHomingDismissed(false)
+              }
               // Detect transition from homing to not homing
               if (wasHomingRef.current && !newIsHoming) {
                 // Homing just completed - show completion state with countdown
                 setHomingJustCompleted(true)
                 setHomingCountdown(5)
+                setHomingDismissed(false)
               }
               wasHomingRef.current = newIsHoming
               setIsHoming(newIsHoming)
             }
             // Auto-open/close Now Playing bar based on playback state
-            const isPlaying = data.data.is_running || data.data.is_paused
+            // Track current file - this is the most reliable indicator of playback
+            const currentFile = data.data.current_file || null
+            setCurrentPlayingFile(currentFile)
+
+            const isPlaying = Boolean(currentFile) || Boolean(data.data.is_running) || Boolean(data.data.is_paused)
             // Skip auto-open on first message (page refresh) - only react to state changes
             if (wasPlayingRef.current !== null) {
               if (isPlaying && !wasPlayingRef.current) {
@@ -283,6 +312,9 @@ export function Layout() {
     const unsubscribe = apiClient.onBaseUrlChange(() => {
       if (isMounted) {
         wasPlayingRef.current = null // Reset playing state for new table
+        setCurrentPlayingFile(null) // Reset playback state for new table
+        setIsConnected(false) // Reset connection status until new table reports
+        setIsBackendConnected(false) // Show connecting state
         connectWebSocket()
       }
     })
@@ -437,8 +469,8 @@ export function Layout() {
     // Also reconnect when active table changes
   }, [isLogsOpen, activeTable?.id])
 
-  const handleOpenLogs = () => {
-    setIsLogsOpen(true)
+  const handleToggleLogs = () => {
+    setIsLogsOpen((prev) => !prev)
   }
 
   // Filter logs by level
@@ -458,13 +490,38 @@ export function Layout() {
     }
   }
 
-  // Copy logs to clipboard
+  // Copy logs to clipboard (with fallback for non-HTTPS)
   const handleCopyLogs = () => {
     const text = filteredLogs
       .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`)
       .join('\n')
-    navigator.clipboard.writeText(text)
-    toast.success('Logs copied to clipboard')
+    copyToClipboard(text)
+  }
+
+  // Helper to copy text with fallback for non-secure contexts
+  const copyToClipboard = (text: string) => {
+    if (navigator.clipboard && window.isSecureContext) {
+      navigator.clipboard.writeText(text).then(() => {
+        toast.success('Logs copied to clipboard')
+      }).catch(() => {
+        toast.error('Failed to copy logs')
+      })
+    } else {
+      // Fallback for non-secure contexts (http://)
+      const textArea = document.createElement('textarea')
+      textArea.value = text
+      textArea.style.position = 'fixed'
+      textArea.style.left = '-9999px'
+      document.body.appendChild(textArea)
+      textArea.select()
+      try {
+        document.execCommand('copy')
+        toast.success('Logs copied to clipboard')
+      } catch {
+        toast.error('Failed to copy logs')
+      }
+      document.body.removeChild(textArea)
+    }
   }
 
   // Download logs as file
@@ -507,11 +564,11 @@ export function Layout() {
   useEffect(() => {
     const currentNav = navItems.find((item) => item.path === location.pathname)
     if (currentNav) {
-      document.title = `${currentNav.title} | ${appName}`
+      document.title = `${currentNav.title} | ${displayName}`
     } else {
-      document.title = appName
+      document.title = displayName
     }
-  }, [location.pathname, appName])
+  }, [location.pathname, displayName])
 
   useEffect(() => {
     if (isDark) {
@@ -830,7 +887,7 @@ export function Layout() {
     : 0
 
   return (
-    <div className="min-h-dvh bg-background flex flex-col overflow-x-hidden">
+    <div className="min-h-dvh bg-background flex flex-col">
       {/* Cache Progress Blocking Overlay */}
       {cacheProgress?.is_running && (
         <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
@@ -909,7 +966,7 @@ export function Layout() {
                     <Button variant="ghost" onClick={handleSkipCacheAll}>
                       Skip for now
                     </Button>
-                    <Button variant="outline" onClick={handleCacheAllPreviews} className="gap-2">
+                    <Button variant="secondary" onClick={handleCacheAllPreviews} className="gap-2">
                       <span className="material-icons-outlined text-lg">cached</span>
                       Cache All
                     </Button>
@@ -953,7 +1010,7 @@ export function Layout() {
       )}
 
       {/* Backend Connection / Homing Blocking Overlay */}
-      {(!isBackendConnected || isHoming || homingJustCompleted) && (
+      {(!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
         <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
           <div className="w-full max-w-2xl space-y-6">
             {/* Status Header */}
@@ -1029,11 +1086,7 @@ export function Layout() {
                       const logText = connectionLogs
                         .map((log) => `[${new Date(log.timestamp).toLocaleTimeString()}] [${log.level}] ${log.message}`)
                         .join('\n')
-                      navigator.clipboard.writeText(logText).then(() => {
-                        toast.success('Logs copied to clipboard')
-                      }).catch(() => {
-                        toast.error('Failed to copy logs')
-                      })
+                      copyToClipboard(logText)
                     }}
                     className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
                     title="Copy logs to clipboard"
@@ -1075,7 +1128,7 @@ export function Layout() {
                 {!keepHomingLogsOpen ? (
                   <>
                     <Button
-                      variant="outline"
+                      variant="secondary"
                       onClick={() => setKeepHomingLogsOpen(true)}
                       className="gap-2"
                     >
@@ -1108,11 +1161,25 @@ export function Layout() {
               </div>
             )}
 
+            {/* Dismiss button during homing */}
+            {isHoming && !homingJustCompleted && (
+              <div className="flex justify-center">
+                <Button
+                  variant="ghost"
+                  onClick={() => setHomingDismissed(true)}
+                  className="gap-2 text-muted-foreground"
+                >
+                  <span className="material-icons text-base">visibility_off</span>
+                  Dismiss
+                </Button>
+              </div>
+            )}
+
             {/* Hint */}
             {!homingJustCompleted && (
               <p className="text-center text-xs text-muted-foreground">
                 {isHoming
-                  ? 'The table is calibrating its position'
+                  ? 'Homing will continue in the background'
                   : 'Make sure the backend server is running on port 8080'
                 }
               </p>
@@ -1121,84 +1188,108 @@ export function Layout() {
         </div>
       )}
 
-      {/* Header */}
-      <header className="sticky top-0 z-40 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
-        <div className="flex h-14 items-center justify-between px-4">
-          <Link to="/" className="flex items-center gap-2">
-            <img
-              src={customLogo ? `/static/custom/${customLogo}` : '/static/android-chrome-192x192.png'}
-              alt={appName}
-              className="w-8 h-8 rounded-full object-cover"
-            />
-            <span className="font-semibold text-lg">{appName}</span>
-            <span
-              className={`w-2 h-2 rounded-full ${
-                !isBackendConnected
-                  ? 'bg-gray-400'
-                  : isConnected
-                    ? 'bg-green-500 animate-pulse'
-                    : 'bg-red-500'
-              }`}
-              title={
-                !isBackendConnected
-                  ? 'Backend not connected'
-                  : isConnected
-                    ? 'Table connected'
-                    : 'Table disconnected'
-              }
-            />
-          </Link>
+      {/* Header - Floating Pill */}
+      <header className="fixed top-0 left-0 right-0 z-40 pt-safe">
+        {/* Blurry backdrop behind header - only on Browse page where content scrolls under */}
+        {location.pathname === '/' && (
+          <div className="absolute inset-0 bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/50" style={{ height: 'calc(5rem + env(safe-area-inset-top, 0px))' }} />
+        )}
+        <div className="relative w-full max-w-5xl mx-auto px-3 sm:px-4 pt-3 pointer-events-none">
+          <div className="flex h-12 items-center justify-between px-4 rounded-full bg-card shadow-lg border border-border pointer-events-auto">
+          <div className="flex items-center gap-2">
+            <Link to="/">
+              <img
+                src={customLogo ? apiClient.getAssetUrl(`/static/custom/${customLogo}`) : apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
+                alt={displayName}
+                className="w-8 h-8 rounded-full object-cover"
+              />
+            </Link>
+            <TableSelector>
+              <button className="flex items-center gap-1.5 hover:opacity-80 transition-opacity group">
+                <ShinyText
+                  text={displayName}
+                  className="font-semibold text-lg"
+                  speed={4}
+                  color={isDark ? '#a8a8a8' : '#555555'}
+                  shineColor={isDark ? '#ffffff' : '#999999'}
+                  spread={75}
+                />
+                <span className="material-icons-outlined text-muted-foreground text-sm group-hover:text-foreground transition-colors">
+                  expand_more
+                </span>
+                <span
+                  className={`w-2 h-2 rounded-full ${
+                    !isBackendConnected
+                      ? 'bg-gray-400'
+                      : isConnected
+                        ? 'bg-green-500 animate-pulse'
+                        : 'bg-red-500'
+                  }`}
+                  title={
+                    !isBackendConnected
+                      ? 'Backend not connected'
+                      : isConnected
+                        ? 'Table connected'
+                        : 'Table disconnected'
+                  }
+                />
+              </button>
+            </TableSelector>
+          </div>
 
           {/* Desktop actions */}
-          <div className="hidden md:flex items-center gap-1">
-            <TableSelector />
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={() => setIsDark(!isDark)}
-              className="rounded-full"
-              aria-label="Toggle dark mode"
-              title="Toggle Theme"
-            >
-              <span className="material-icons-outlined">
-                {isDark ? 'light_mode' : 'dark_mode'}
-              </span>
-            </Button>
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={handleOpenLogs}
-              className="rounded-full"
-              aria-label="View logs"
-              title="View Application Logs"
-            >
-              <span className="material-icons-outlined">article</span>
-            </Button>
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={handleRestart}
-              className="rounded-full text-amber-500 hover:text-amber-600"
-              aria-label="Restart Docker"
-              title="Restart Docker"
-            >
-              <span className="material-icons-outlined">restart_alt</span>
-            </Button>
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={handleShutdown}
-              className="rounded-full text-red-500 hover:text-red-600"
-              aria-label="Shutdown system"
-              title="Shutdown System"
-            >
-              <span className="material-icons-outlined">power_settings_new</span>
-            </Button>
+          <div className="hidden md:flex items-center gap-0 ml-2">
+            <Popover>
+              <PopoverTrigger asChild>
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  className="rounded-full"
+                  aria-label="Open menu"
+                >
+                  <span className="material-icons-outlined">menu</span>
+                </Button>
+              </PopoverTrigger>
+              <PopoverContent align="end" className="w-56 p-2">
+                <div className="flex flex-col gap-1">
+                  <button
+                    onClick={() => setIsDark(!isDark)}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
+                  >
+                    <span className="material-icons-outlined text-xl">
+                      {isDark ? 'light_mode' : 'dark_mode'}
+                    </span>
+                    {isDark ? 'Light Mode' : 'Dark Mode'}
+                  </button>
+                  <button
+                    onClick={handleToggleLogs}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
+                  >
+                    <span className="material-icons-outlined text-xl">article</span>
+                    View Logs
+                  </button>
+                  <Separator className="my-1" />
+                  <button
+                    onClick={handleRestart}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
+                  >
+                    <span className="material-icons-outlined text-xl">restart_alt</span>
+                    Restart Docker
+                  </button>
+                  <button
+                    onClick={handleShutdown}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-red-500"
+                  >
+                    <span className="material-icons-outlined text-xl">power_settings_new</span>
+                    Shutdown
+                  </button>
+                </div>
+              </PopoverContent>
+            </Popover>
           </div>
 
           {/* Mobile actions */}
-          <div className="flex md:hidden items-center gap-1">
-            <TableSelector />
+          <div className="flex md:hidden items-center gap-0 ml-2">
             <Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
               <PopoverTrigger asChild>
                 <Button
@@ -1228,7 +1319,7 @@ export function Layout() {
                   </button>
                   <button
                     onClick={() => {
-                      handleOpenLogs()
+                      handleToggleLogs()
                       setIsMobileMenuOpen(false)
                     }}
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
@@ -1260,6 +1351,7 @@ export function Layout() {
                 </div>
               </PopoverContent>
             </Popover>
+            </div>
           </div>
         </div>
       </header>
@@ -1271,6 +1363,7 @@ export function Layout() {
           !isLogsOpen && isNowPlayingOpen ? 'pb-80' : ''
         }`}
         style={{
+          paddingTop: 'calc(4.5rem + env(safe-area-inset-top, 0px))',
           paddingBottom: isLogsOpen
             ? isNowPlayingOpen
               ? logsDrawerHeight + 256 + 64 // drawer + now playing + nav
@@ -1284,22 +1377,12 @@ export function Layout() {
       {/* Now Playing Bar */}
       <NowPlayingBar
         isLogsOpen={isLogsOpen}
+        logsDrawerHeight={logsDrawerHeight}
         isVisible={isNowPlayingOpen}
         openExpanded={openNowPlayingExpanded}
         onClose={() => setIsNowPlayingOpen(false)}
       />
 
-      {/* Floating Now Playing Button */}
-      {!isNowPlayingOpen && (
-        <button
-          onClick={() => setIsNowPlayingOpen(true)}
-          className="fixed right-4 z-30 w-12 h-12 rounded-full bg-primary text-primary-foreground shadow-lg flex items-center justify-center transition-all duration-200 hover:bg-primary/90 hover:shadow-xl hover:scale-110 active:scale-95"
-          style={{ bottom: 'calc(5rem + env(safe-area-inset-bottom, 0px))' }}
-          title="Now Playing"
-        >
-          <span className="material-icons">play_circle</span>
-        </button>
-      )}
 
       {/* Logs Drawer */}
       <div
@@ -1403,8 +1486,25 @@ export function Layout() {
         )}
       </div>
 
+      {/* Floating Now Playing Button - hidden when Now Playing bar is open */}
+      {!isNowPlayingOpen && (
+        <button
+          onClick={() => setIsNowPlayingOpen(true)}
+          className="fixed z-40 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 rounded-full bg-card border border-border shadow-lg transition-all hover:shadow-xl hover:scale-105 active:scale-95"
+          style={{ bottom: 'calc(4.5rem + env(safe-area-inset-bottom, 0px))' }}
+          aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
+        >
+          <span className={`material-icons-outlined text-xl ${isCurrentlyPlaying ? 'text-primary' : 'text-muted-foreground'}`}>
+            {isCurrentlyPlaying ? 'play_circle' : 'stop_circle'}
+          </span>
+          <span className="text-sm font-medium">
+            {isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
+          </span>
+        </button>
+      )}
+
       {/* Bottom Navigation */}
-      <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-background pb-safe">
+      <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-card pb-safe">
         <div className="max-w-5xl mx-auto grid grid-cols-5 h-16">
           {navItems.map((item) => {
             const isActive = location.pathname === item.path

+ 6 - 5
frontend/src/components/ui/button.tsx

@@ -5,24 +5,25 @@ import { cva, type VariantProps } from "class-variance-authority"
 import { cn } from "@/lib/utils"
 
 const buttonVariants = cva(
-  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
   {
     variants: {
       variant: {
-        default: "bg-primary text-primary-foreground hover:bg-primary/90",
+        default: "bg-card text-foreground border border-border shadow-sm hover:bg-accent",
+        primary: "bg-primary text-primary-foreground hover:bg-primary/90",
         destructive:
           "bg-destructive text-destructive-foreground hover:bg-destructive/90",
         outline:
           "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
         secondary:
-          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+          "bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground hover:scale-105 hover:shadow-md transition-all duration-150",
         ghost: "hover:bg-accent hover:text-accent-foreground",
         link: "text-primary underline-offset-4 hover:underline",
       },
       size: {
         default: "h-10 px-4 py-2",
-        sm: "h-9 rounded-md px-3",
-        lg: "h-11 rounded-md px-8",
+        sm: "h-9 px-3",
+        lg: "h-11 px-8",
         icon: "h-10 w-10",
         "icon-sm": "h-8 w-8",
       },

+ 1 - 1
frontend/src/components/ui/color-picker.tsx

@@ -47,7 +47,7 @@ export function ColorPicker({
     <Popover open={open} onOpenChange={setOpen}>
       <PopoverTrigger asChild>
         <Button
-          variant="outline"
+          variant="secondary"
           className={cn(
             'w-12 h-12 rounded-full p-1 border-2',
             className

+ 1 - 1
frontend/src/components/ui/input.tsx

@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
       <input
         type={type}
         className={cn(
-          "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+          "flex h-10 w-full rounded-full border border-input bg-background px-4 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
           className
         )}
         ref={ref}

+ 1 - 1
frontend/src/components/ui/popover.tsx

@@ -17,7 +17,7 @@ const PopoverContent = React.forwardRef<
       align={align}
       sideOffset={sideOffset}
       className={cn(
-        "z-[100] w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
+        "z-[100] w-72 rounded-2xl border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
         className
       )}
       {...props}

+ 1 - 1
frontend/src/components/ui/searchable-select.tsx

@@ -58,7 +58,7 @@ export function SearchableSelect({
     <Popover open={open} onOpenChange={setOpen}>
       <PopoverTrigger asChild>
         <Button
-          variant="outline"
+          variant="secondary"
           role="combobox"
           aria-expanded={open}
           disabled={disabled}

+ 4 - 4
frontend/src/components/ui/select.tsx

@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
   <SelectPrimitive.Trigger
     ref={ref}
     className={cn(
-      "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
+      "flex h-10 w-full items-center justify-between rounded-full border border-input bg-background px-4 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
       className
     )}
     {...props}
@@ -73,7 +73,7 @@ const SelectContent = React.forwardRef<
     <SelectPrimitive.Content
       ref={ref}
       className={cn(
-        "relative z-[9999] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
+        "relative z-[9999] max-h-[min(var(--radix-select-content-available-height,256px),256px)] min-w-[8rem] overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
         position === "popper" &&
           "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
         className
@@ -84,7 +84,7 @@ const SelectContent = React.forwardRef<
       <SelectScrollUpButton />
       <SelectPrimitive.Viewport
         className={cn(
-          "p-1",
+          "p-1 max-h-[inherit] overflow-y-auto",
           position === "popper" &&
             "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
         )}
@@ -116,7 +116,7 @@ const SelectItem = React.forwardRef<
   <SelectPrimitive.Item
     ref={ref}
     className={cn(
-      "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+      "relative flex w-full cursor-default select-none items-center rounded-xl py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
       className
     )}
     {...props}

+ 138 - 0
frontend/src/components/ui/sheet.tsx

@@ -0,0 +1,138 @@
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Sheet = SheetPrimitive.Root
+
+const SheetTrigger = SheetPrimitive.Trigger
+
+const SheetClose = SheetPrimitive.Close
+
+const SheetPortal = SheetPrimitive.Portal
+
+const SheetOverlay = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Overlay>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Overlay
+    className={cn(
+      "fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      className
+    )}
+    {...props}
+    ref={ref}
+  />
+))
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+
+const sheetVariants = cva(
+  "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
+  {
+    variants: {
+      side: {
+        top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+        bottom:
+          "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+        left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+        right:
+          "inset-y-0 right-0 h-full w-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-md",
+      },
+    },
+    defaultVariants: {
+      side: "right",
+    },
+  }
+)
+
+interface SheetContentProps
+  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
+    VariantProps<typeof sheetVariants> {}
+
+const SheetContent = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Content>,
+  SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+  <SheetPortal>
+    <SheetOverlay />
+    <SheetPrimitive.Content
+      ref={ref}
+      className={cn(sheetVariants({ side }), className)}
+      {...props}
+    >
+      {children}
+      <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
+        <X className="h-4 w-4" />
+        <span className="sr-only">Close</span>
+      </SheetPrimitive.Close>
+    </SheetPrimitive.Content>
+  </SheetPortal>
+))
+SheetContent.displayName = SheetPrimitive.Content.displayName
+
+const SheetHeader = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col space-y-2 text-center sm:text-left",
+      className
+    )}
+    {...props}
+  />
+)
+SheetHeader.displayName = "SheetHeader"
+
+const SheetFooter = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+      className
+    )}
+    {...props}
+  />
+)
+SheetFooter.displayName = "SheetFooter"
+
+const SheetTitle = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Title>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Title
+    ref={ref}
+    className={cn("text-lg font-semibold text-foreground", className)}
+    {...props}
+  />
+))
+SheetTitle.displayName = SheetPrimitive.Title.displayName
+
+const SheetDescription = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Description>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Description
+    ref={ref}
+    className={cn("text-sm text-muted-foreground", className)}
+    {...props}
+  />
+))
+SheetDescription.displayName = SheetPrimitive.Description.displayName
+
+export {
+  Sheet,
+  SheetPortal,
+  SheetOverlay,
+  SheetTrigger,
+  SheetClose,
+  SheetContent,
+  SheetHeader,
+  SheetFooter,
+  SheetTitle,
+  SheetDescription,
+}

+ 10 - 0
frontend/src/components/ui/sonner.tsx

@@ -5,12 +5,18 @@ type ToasterProps = React.ComponentProps<typeof Sonner>
 
 const Toaster = ({ ...props }: ToasterProps) => {
   const [theme, setTheme] = useState<"light" | "dark">("light")
+  const [isStandalone, setIsStandalone] = useState(false)
 
   useEffect(() => {
     // Check initial theme
     const isDark = document.documentElement.classList.contains("dark")
     setTheme(isDark ? "dark" : "light")
 
+    // Check if running as PWA (standalone mode)
+    const standalone = window.matchMedia('(display-mode: standalone)').matches ||
+      (window.navigator as unknown as { standalone?: boolean }).standalone === true
+    setIsStandalone(standalone)
+
     // Watch for theme changes
     const observer = new MutationObserver((mutations) => {
       mutations.forEach((mutation) => {
@@ -25,10 +31,14 @@ const Toaster = ({ ...props }: ToasterProps) => {
     return () => observer.disconnect()
   }, [])
 
+  // Use larger offset for PWA to account for Dynamic Island/notch (59px typical + 16px padding)
+  const offset = isStandalone ? 75 : 16
+
   return (
     <Sonner
       theme={theme}
       className="toaster group"
+      offset={offset}
       toastOptions={{
         classNames: {
           toast:

+ 1 - 1
frontend/src/components/ui/switch.tsx

@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
 >(({ className, ...props }, ref) => (
   <SwitchPrimitives.Root
     className={cn(
-      "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
+      "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-secondary",
       className
     )}
     {...props}

+ 140 - 22
frontend/src/contexts/TableContext.tsx

@@ -12,12 +12,14 @@ import { apiClient } from '@/lib/apiClient'
 export interface Table {
   id: string
   name: string
+  appName?: string // Application name from settings (e.g., "Dune Weaver")
   url: string
   host?: string
   port?: number
   version?: string
   isOnline?: boolean
   isCurrent?: boolean // True if this is the backend serving the frontend
+  customLogo?: string // Custom logo filename if set (e.g., "logo_abc123.png")
 }
 
 interface TableContextType {
@@ -41,6 +43,19 @@ const TableContext = createContext<TableContextType | null>(null)
 const STORAGE_KEY = 'duneweaver_tables'
 const ACTIVE_TABLE_KEY = 'duneweaver_active_table'
 
+/**
+ * Normalize a URL to its origin for comparison purposes.
+ * This handles port normalization (e.g., :80 for HTTP is stripped).
+ * Returns the origin or the original string if parsing fails.
+ */
+function normalizeUrlOrigin(url: string): string {
+  try {
+    return new URL(url).origin
+  } catch {
+    return url
+  }
+}
+
 interface StoredTableData {
   tables: Table[]
   activeTableId: string | null
@@ -73,8 +88,10 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
           if (active) {
             restoredActiveIdRef.current = activeId // Mark that we restored a selection
             setActiveTableState(active)
-            // Only set non-empty base URL for remote tables
-            if (!active.isCurrent && active.url !== window.location.origin) {
+            // Set base URL for remote tables (tables not on the current origin)
+            // Use normalized URL comparison to handle port differences (e.g., :80 vs no port)
+            // Note: apiClient pre-initializes from localStorage, but this ensures consistency
+            if (normalizeUrlOrigin(active.url) !== window.location.origin) {
               apiClient.setBaseUrl(active.url)
             }
           }
@@ -125,7 +142,8 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
     }
 
     // Update API client base URL
-    if (table.isCurrent || table.url === window.location.origin) {
+    // Use normalized URL comparison to handle port differences (e.g., :80 vs no port)
+    if (normalizeUrlOrigin(table.url) === window.location.origin) {
       apiClient.setBaseUrl('')
     } else {
       apiClient.setBaseUrl(table.url)
@@ -140,13 +158,22 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
     setIsDiscovering(true)
 
     try {
-      // Always fetch the current table's info
-      const infoResponse = await fetch('/api/table-info')
+      // Fetch table info, settings, and known tables in parallel
+      const [infoResponse, settingsResponse, knownTablesResponse] = await Promise.all([
+        fetch('/api/table-info'),
+        fetch('/api/settings').catch(() => null),
+        fetch('/api/known-tables').catch(() => null),
+      ])
+
       if (!infoResponse.ok) {
         throw new Error('Failed to fetch table info')
       }
 
       const info = await infoResponse.json()
+      const settings = settingsResponse?.ok ? await settingsResponse.json() : null
+      const knownTablesData = knownTablesResponse?.ok ? await knownTablesResponse.json() : null
+      const knownTables: Array<{ id: string; name: string; url: string; host?: string; port?: number; version?: string }> = knownTablesData?.tables || []
+
       const currentTable: Table = {
         id: info.id,
         name: info.name,
@@ -154,17 +181,27 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
         version: info.version,
         isOnline: true,
         isCurrent: true,
+        customLogo: settings?.app?.custom_logo || undefined,
       }
 
-      // Merge with existing tables
-      setTables(prev => {
+      // Merge current table with known tables from backend
+      setTables(() => {
         // Start with current table
         const merged: Table[] = [currentTable]
 
-        // Add any other tables (manual additions), mark them for status check
-        prev.forEach(existing => {
-          if (existing.id !== currentTable.id && !existing.isCurrent) {
-            merged.push({ ...existing, isOnline: existing.isOnline ?? false })
+        // Add known tables from backend (these are persisted remote tables)
+        knownTables.forEach(known => {
+          if (known.id !== currentTable.id) {
+            merged.push({
+              id: known.id,
+              name: known.name,
+              url: known.url,
+              host: known.host,
+              port: known.port,
+              version: known.version,
+              isOnline: false, // Will be updated by background refresh
+              isCurrent: false,
+            })
           }
         })
 
@@ -196,6 +233,34 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
       restoredActiveIdRef.current = null
 
       setLastDiscovery(new Date())
+
+      // Refresh remote tables in the background to get their customLogo
+      // Use setTimeout to not block the main discovery flow
+      setTimeout(() => {
+        setTables(currentTables => {
+          const remoteTables = currentTables.filter(t => !t.isCurrent)
+          remoteTables.forEach(async (table) => {
+            try {
+              const [infoResponse, settingsResponse] = await Promise.all([
+                fetch(`${table.url}/api/table-info`, { signal: AbortSignal.timeout(3000) }),
+                fetch(`${table.url}/api/settings`, { signal: AbortSignal.timeout(3000) }).catch(() => null),
+              ])
+              const isOnline = infoResponse.ok
+              const settings = settingsResponse?.ok ? await settingsResponse.json() : null
+              const customLogo = settings?.app?.custom_logo || undefined
+
+              setTables(prev =>
+                prev.map(t => (t.id === table.id ? { ...t, isOnline, customLogo } : t))
+              )
+            } catch {
+              setTables(prev =>
+                prev.map(t => (t.id === table.id ? { ...t, isOnline: false } : t))
+              )
+            }
+          })
+          return currentTables // Return unchanged for now, updates happen in the async callbacks
+        })
+      }, 100)
     } catch (e) {
       console.error('Table refresh failed:', e)
     } finally {
@@ -214,13 +279,19 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
         return null
       }
 
-      // Fetch table info from the URL
-      const response = await fetch(`${normalizedUrl}/api/table-info`)
-      if (!response.ok) {
+      // Fetch table info and settings in parallel
+      const [infoResponse, settingsResponse] = await Promise.all([
+        fetch(`${normalizedUrl}/api/table-info`),
+        fetch(`${normalizedUrl}/api/settings`).catch(() => null),
+      ])
+
+      if (!infoResponse.ok) {
         throw new Error('Failed to fetch table info')
       }
 
-      const info = await response.json()
+      const info = await infoResponse.json()
+      const settings = settingsResponse?.ok ? await settingsResponse.json() : null
+
       const newTable: Table = {
         id: info.id,
         name: name || info.name,
@@ -228,6 +299,26 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
         version: info.version,
         isOnline: true,
         isCurrent: false,
+        customLogo: settings?.app?.custom_logo || undefined,
+      }
+
+      // Persist to backend
+      try {
+        const hostname = new URL(normalizedUrl).hostname
+        await fetch('/api/known-tables', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({
+            id: newTable.id,
+            name: newTable.name,
+            url: newTable.url,
+            host: hostname,
+            version: newTable.version,
+          }),
+        })
+      } catch (e) {
+        console.error('Failed to persist table to backend:', e)
+        // Continue anyway - table will still work for this session
       }
 
       setTables(prev => [...prev, newTable])
@@ -239,7 +330,15 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
   }, [tables])
 
   // Remove a table
-  const removeTable = useCallback((id: string) => {
+  const removeTable = useCallback(async (id: string) => {
+    // Remove from backend
+    try {
+      await fetch(`/api/known-tables/${id}`, { method: 'DELETE' })
+    } catch (e) {
+      console.error('Failed to remove table from backend:', e)
+      // Continue anyway - remove from local state
+    }
+
     setTables(prev => prev.filter(t => t.id !== id))
 
     // If removing active table, switch to another
@@ -268,6 +367,19 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
       })
 
       if (response.ok) {
+        // Also update the known table name in the current backend (for remote tables)
+        if (!table.isCurrent) {
+          try {
+            await fetch(`/api/known-tables/${id}`, {
+              method: 'PATCH',
+              headers: { 'Content-Type': 'application/json' },
+              body: JSON.stringify({ name }),
+            })
+          } catch (e) {
+            console.error('Failed to update known table name:', e)
+          }
+        }
+
         setTables(prev =>
           prev.map(t => (t.id === id ? { ...t, name } : t))
         )
@@ -282,17 +394,23 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
     }
   }, [tables, activeTable])
 
-  // Check if a table is online
+  // Check if a table is online and update its info (including custom logo)
   const refreshTableStatus = useCallback(async (table: Table): Promise<boolean> => {
     try {
       const baseUrl = table.isCurrent ? '' : table.url
-      const response = await fetch(`${baseUrl}/api/table-info`, {
-        signal: AbortSignal.timeout(3000),
-      })
-      const isOnline = response.ok
+
+      // Fetch table info and settings in parallel
+      const [infoResponse, settingsResponse] = await Promise.all([
+        fetch(`${baseUrl}/api/table-info`, { signal: AbortSignal.timeout(3000) }),
+        fetch(`${baseUrl}/api/settings`, { signal: AbortSignal.timeout(3000) }).catch(() => null),
+      ])
+
+      const isOnline = infoResponse.ok
+      const settings = settingsResponse?.ok ? await settingsResponse.json() : null
+      const customLogo = settings?.app?.custom_logo || undefined
 
       setTables(prev =>
-        prev.map(t => (t.id === table.id ? { ...t, isOnline } : t))
+        prev.map(t => (t.id === table.id ? { ...t, isOnline, customLogo } : t))
       )
 
       return isOnline

+ 13 - 5
frontend/src/index.css

@@ -11,7 +11,7 @@
   --color-border: hsl(214.3 31.8% 91.4%);
   --color-input: hsl(214.3 31.8% 91.4%);
   --color-ring: hsl(207 90% 50%);
-  --color-background: hsl(0 0% 100%);
+  --color-background: hsl(220 14% 98%);
   --color-foreground: hsl(222.2 84% 4.9%);
 
   --color-primary: hsl(207 90% 50%);
@@ -20,7 +20,7 @@
   --color-secondary: hsl(210 40% 96.1%);
   --color-secondary-foreground: hsl(222.2 47.4% 11.2%);
 
-  --color-muted: hsl(210 40% 96.1%);
+  --color-muted: hsl(220 14% 92%);
   --color-muted-foreground: hsl(215.4 16.3% 46.9%);
 
   --color-accent: hsl(210 40% 96.1%);
@@ -103,11 +103,19 @@ body {
   min-height: 100dvh; /* Use dynamic viewport height for mobile */
 }
 
-/* Safe area utilities for iOS notch/home indicator */
+/* Safe area utilities for iOS notch/Dynamic Island/home indicator */
+.pt-safe {
+  padding-top: env(safe-area-inset-top, 0px);
+}
+
 .pb-safe {
   padding-bottom: env(safe-area-inset-bottom, 0px);
 }
 
+.mt-safe {
+  margin-top: env(safe-area-inset-top, 0px);
+}
+
 .mb-safe {
   margin-bottom: env(safe-area-inset-bottom, 0px);
 }
@@ -175,7 +183,7 @@ body {
 
 /* Now Playing Bar heights - responsive for mobile vs desktop */
 [data-now-playing-bar="collapsed"] {
-  height: 200px;
+  height: 232px;
 }
 
 [data-now-playing-bar="expanded"] {
@@ -184,7 +192,7 @@ body {
 
 @media (max-width: 767px) {
   [data-now-playing-bar="collapsed"] {
-    height: 256px;
+    height: 288px;
   }
 
   [data-now-playing-bar="expanded"] {

+ 47 - 1
frontend/src/lib/apiClient.ts

@@ -32,7 +32,10 @@ class ApiClient {
    */
   setBaseUrl(url: string): void {
     // Remove trailing slash
-    this._baseUrl = url.replace(/\/$/, '')
+    const newUrl = url.replace(/\/$/, '')
+    // Only notify if the URL actually changed
+    if (newUrl === this._baseUrl) return
+    this._baseUrl = newUrl
     // Notify listeners
     this._listeners.forEach(listener => listener(this._baseUrl))
   }
@@ -196,5 +199,48 @@ class ApiClient {
 // Export singleton instance
 export const apiClient = new ApiClient()
 
+// Pre-initialize base URL from localStorage to avoid race conditions.
+// This runs synchronously at module load time, before React renders,
+// ensuring WebSocket connections use the correct URL from the start.
+function initializeBaseUrlFromStorage(): void {
+  try {
+    const STORAGE_KEY = 'duneweaver_tables'
+    const ACTIVE_TABLE_KEY = 'duneweaver_active_table'
+
+    const stored = localStorage.getItem(STORAGE_KEY)
+    const activeId = localStorage.getItem(ACTIVE_TABLE_KEY)
+
+    if (!stored || !activeId) return
+
+    const data = JSON.parse(stored)
+    const tables = data.tables || []
+    const active = tables.find((t: { id: string }) => t.id === activeId)
+
+    if (!active?.url) return
+
+    // Normalize URL for comparison (handles port differences like :80)
+    const normalizeOrigin = (url: string): string => {
+      try {
+        return new URL(url).origin
+      } catch {
+        return url
+      }
+    }
+
+    const normalizedActiveUrl = normalizeOrigin(active.url)
+    const currentOrigin = window.location.origin
+
+    // Only set base URL for remote tables (different origin)
+    if (normalizedActiveUrl !== currentOrigin) {
+      apiClient.setBaseUrl(active.url)
+    }
+  } catch {
+    // Silently fail - TableContext will handle initialization as fallback
+  }
+}
+
+// Run initialization immediately at module load
+initializeBaseUrlFromStorage()
+
 // Export class for testing
 export { ApiClient }

+ 1 - 1
frontend/src/lib/types.ts

@@ -20,7 +20,7 @@ export interface Playlist {
   files: string[]
 }
 
-export type SortOption = 'name' | 'date' | 'category'
+export type SortOption = 'name' | 'date' | 'size' | 'favorites'
 export type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
 export type RunMode = 'single' | 'indefinite'
 

+ 381 - 221
frontend/src/pages/BrowsePage.tsx

@@ -12,7 +12,6 @@ import { useOnBackendConnected } from '@/hooks/useBackendConnection'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
-import { Separator } from '@/components/ui/separator'
 import { Slider } from '@/components/ui/slider'
 import {
   Select,
@@ -21,6 +20,12 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
+import {
+  Sheet,
+  SheetContent,
+  SheetHeader,
+  SheetTitle,
+} from '@/components/ui/sheet'
 
 // Types
 interface PatternMetadata {
@@ -41,7 +46,7 @@ interface PreviewData {
 // Coordinates come as [theta, rho] tuples from the backend
 type Coordinate = [number, number]
 
-type SortOption = 'name' | 'date' | 'category'
+type SortOption = 'name' | 'date' | 'size' | 'favorites'
 type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
 
 const preExecutionOptions: { value: PreExecution; label: string }[] = [
@@ -89,6 +94,18 @@ export function BrowsePage() {
   const [speed, setSpeed] = useState(1)
   const [progress, setProgress] = useState(0)
 
+  // Pattern execution history state
+  const [patternHistory, setPatternHistory] = useState<{
+    actual_time_formatted: string | null
+    speed: number | null
+  } | null>(null)
+
+  // All pattern histories for badges
+  const [allPatternHistories, setAllPatternHistories] = useState<Record<string, {
+    actual_time_formatted: string | null
+    timestamp: string | null
+  }>>({})
+
   // Canvas and animation refs
   const canvasRef = useRef<HTMLCanvasElement>(null)
   const animationRef = useRef<number | null>(null)
@@ -111,6 +128,29 @@ export function BrowsePage() {
   const fileInputRef = useRef<HTMLInputElement>(null)
   const [isUploading, setIsUploading] = useState(false)
 
+  // Swipe to dismiss sheet on mobile
+  const sheetTouchStartRef = useRef<{ x: number; y: number } | null>(null)
+  const handleSheetTouchStart = (e: React.TouchEvent) => {
+    sheetTouchStartRef.current = {
+      x: e.touches[0].clientX,
+      y: e.touches[0].clientY,
+    }
+  }
+  const handleSheetTouchEnd = (e: React.TouchEvent) => {
+    if (!sheetTouchStartRef.current) return
+    const deltaX = e.changedTouches[0].clientX - sheetTouchStartRef.current.x
+    const deltaY = e.changedTouches[0].clientY - sheetTouchStartRef.current.y
+
+    // Swipe right (positive X) or swipe down (positive Y) to dismiss
+    // Require at least 80px movement and more horizontal/vertical than the other direction
+    if (deltaX > 80 && deltaX > Math.abs(deltaY)) {
+      setIsPanelOpen(false)
+    } else if (deltaY > 80 && deltaY > Math.abs(deltaX)) {
+      setIsPanelOpen(false)
+    }
+    sheetTouchStartRef.current = null
+  }
+
   // Close panel when playback starts
   useEffect(() => {
     const handlePlaybackStarted = () => {
@@ -199,8 +239,13 @@ export function BrowsePage() {
   const fetchPatterns = async () => {
     setIsLoading(true)
     try {
-      const data = await apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata')
+      // Fetch patterns and history in parallel
+      const [data, historyData] = await Promise.all([
+        apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata'),
+        apiClient.get<Record<string, { actual_time_formatted: string | null; timestamp: string | null }>>('/api/pattern_history_all')
+      ])
       setPatterns(data)
+      setAllPatternHistories(historyData)
 
       if (data.length > 0) {
         // Sort patterns by name (default sort) before preloading
@@ -344,9 +389,18 @@ export function BrowsePage() {
         case 'date':
           comparison = a.date_modified - b.date_modified
           break
-        case 'category':
-          comparison = a.category.localeCompare(b.category) || a.name.localeCompare(b.name)
+        case 'size':
+          comparison = a.coordinates_count - b.coordinates_count
+          break
+        case 'favorites': {
+          const aFav = favorites.has(a.path) ? 1 : 0
+          const bFav = favorites.has(b.path) ? 1 : 0
+          comparison = bFav - aFav // Favorites first
+          if (comparison === 0) {
+            comparison = a.name.localeCompare(b.name) // Then by name
+          }
           break
+        }
         default:
           return 0
       }
@@ -354,7 +408,7 @@ export function BrowsePage() {
     })
 
     return result
-  }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc])
+  }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc, favorites])
 
   // Batched preview loading - collects requests and fetches in batches
   const requestPreview = useCallback((path: string) => {
@@ -565,36 +619,27 @@ export function BrowsePage() {
     }
   }, [coordinates, drawPattern])
 
-  const handlePatternClick = (pattern: PatternMetadata) => {
+  const handlePatternClick = async (pattern: PatternMetadata) => {
     setSelectedPattern(pattern)
     setIsPanelOpen(true)
     setPreExecution('adaptive')
-  }
+    setPatternHistory(null) // Reset while loading
 
-  const handleClosePanel = () => {
-    setIsPanelOpen(false)
-  }
-
-  // Swipe to close panel handling
-  const panelRef = useRef<HTMLDivElement>(null)
-  const panelTouchStartX = useRef<number | null>(null)
-
-  const handlePanelTouchStart = (e: React.TouchEvent) => {
-    panelTouchStartX.current = e.touches[0].clientX
-  }
-  const handlePanelTouchEnd = (e: React.TouchEvent) => {
-    if (panelTouchStartX.current === null) return
-    const touchEndX = e.changedTouches[0].clientX
-    const deltaX = touchEndX - panelTouchStartX.current
-    // Swipe right more than 50px to close
-    if (deltaX > 50) {
-      handleClosePanel()
+    // Fetch pattern execution history
+    try {
+      const history = await apiClient.get<{
+        actual_time_formatted: string | null
+        speed: number | null
+      }>(`/api/pattern_history/${encodeURIComponent(pattern.path)}`)
+      setPatternHistory(history)
+    } catch {
+      // Silently ignore - history is optional
     }
-    panelTouchStartX.current = null
   }
 
   const handleOpenAnimatedPreview = async () => {
     if (!selectedPattern) return
+    setIsPanelOpen(false) // Close sheet before opening preview
     setIsAnimatedPreviewOpen(true)
     setIsPlaying(false)
     setProgress(0)
@@ -698,6 +743,25 @@ export function BrowsePage() {
     }
   }
 
+  const handleAddToQueue = async (position: 'next' | 'end') => {
+    if (!selectedPattern) return
+
+    try {
+      await apiClient.post('/add_to_queue', {
+        pattern: selectedPattern.path,
+        position,
+      })
+      toast.success(position === 'next' ? 'Playing next' : 'Added to queue')
+    } catch (error) {
+      const message = error instanceof Error ? error.message : 'Failed to add to queue'
+      if (message.includes('400') || message.includes('No playlist')) {
+        toast.error('No playlist is currently running')
+      } else {
+        toast.error(message)
+      }
+    }
+  }
+
   const getPreviewUrl = (path: string) => {
     const preview = previews[path]
     return preview?.image_data || null
@@ -705,7 +769,7 @@ export function BrowsePage() {
 
   const formatCoordinate = (coord: { x: number; y: number } | null) => {
     if (!coord) return '(-, -)'
-    return `(${coord.x.toFixed(2)}, ${coord.y.toFixed(2)})`
+    return `(${coord.x.toFixed(1)}, ${coord.y.toFixed(1)})`
   }
 
   const canDelete = selectedPattern?.path.startsWith('custom_patterns/')
@@ -755,9 +819,8 @@ export function BrowsePage() {
       await apiClient.uploadFile('/upload_theta_rho', file)
       toast.success(`Pattern "${file.name}" uploaded successfully`)
 
-      // Refresh patterns list
-      const data = await apiClient.get<{ files?: PatternMetadata[] }>('/list_theta_rho_files')
-      setPatterns(data.files || [])
+      // Refresh patterns list using the same function as initial load
+      await fetchPatterns()
     } catch (error) {
       console.error('Upload error:', error)
       toast.error(error instanceof Error ? error.message : 'Failed to upload pattern')
@@ -781,7 +844,7 @@ export function BrowsePage() {
   }
 
   return (
-    <div className={`flex flex-col w-full max-w-5xl mx-auto gap-3 sm:gap-6 py-3 sm:py-6 px-3 sm:px-4 transition-all duration-300 ${isPanelOpen ? 'lg:mr-[28rem]' : ''}`}>
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-3 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4">
       {/* Hidden file input for pattern upload */}
       <input
         ref={fileInputRef}
@@ -791,114 +854,121 @@ export function BrowsePage() {
         className="hidden"
       />
 
-      {/* Page Header - Compact on mobile */}
-      <div className="flex items-center justify-between gap-2 sm:gap-4">
-        <div className="space-y-0 sm:space-y-1 min-w-0">
-          <h1 className="text-xl sm:text-3xl font-bold tracking-tight">Browse Patterns</h1>
-          <p className="text-xs sm:text-base text-muted-foreground truncate">
+      {/* Page Header */}
+      <div className="flex items-start justify-between gap-4 pl-1">
+        <div className="space-y-0.5">
+          <h1 className="text-xl font-semibold tracking-tight">Browse Patterns</h1>
+          <p className="text-xs text-muted-foreground">
             {patterns.length} patterns available
           </p>
         </div>
         <Button
-          variant="outline"
-          size="sm"
+          variant="ghost"
           onClick={() => fileInputRef.current?.click()}
           disabled={isUploading}
-          className="gap-1.5 sm:gap-2 shrink-0 h-8 sm:h-9"
+          className="gap-2 shrink-0 h-9 w-9 sm:h-11 sm:w-auto rounded-full px-0 sm:px-4 justify-center bg-card border border-border shadow-sm hover:bg-accent"
         >
           {isUploading ? (
-            <span className="material-icons-outlined animate-spin text-base sm:text-lg">sync</span>
+            <span className="material-icons-outlined animate-spin text-lg">sync</span>
           ) : (
-            <span className="material-icons-outlined text-base sm:text-lg">add</span>
+            <span className="material-icons-outlined text-lg">add</span>
           )}
           <span className="hidden sm:inline">Add Pattern</span>
         </Button>
       </div>
 
-      <Separator className="my-0" />
-
-      {/* Sticky Filters - Compact on mobile */}
-      <div className="sticky top-14 z-30 py-2 sm:py-4 -mx-4 px-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
-        <div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
-          {/* Search + Category on same row on mobile */}
-          <div className="flex gap-2 flex-1">
-            <div className="relative flex-1">
-              <span className="material-icons-outlined absolute left-2.5 sm:left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-lg sm:text-xl">
-                search
-              </span>
-              <Input
-                value={searchQuery}
-                onChange={(e) => setSearchQuery(e.target.value)}
-                placeholder="Search..."
-                className="pl-8 sm:pl-10 pr-8 sm:pr-10 h-9 sm:h-10 text-sm"
-              />
-              {searchQuery && (
-                <Button
-                  variant="ghost"
-                  size="icon-sm"
-                  onClick={() => setSearchQuery('')}
-                  className="absolute right-1.5 sm:right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground h-6 w-6"
-                >
-                  <span className="material-icons-outlined text-lg">close</span>
-                </Button>
-              )}
-            </div>
-
-            <Select value={selectedCategory} onValueChange={setSelectedCategory}>
-              <SelectTrigger className="w-28 sm:w-44 h-9 sm:h-10 text-sm">
-                <SelectValue placeholder="Category" />
-              </SelectTrigger>
-              <SelectContent>
-                {categories.map((cat) => (
-                  <SelectItem key={cat} value={cat}>
-                    {cat === 'all' ? 'All Categories' : cat === 'root' ? 'Uncategorized' : cat}
-                  </SelectItem>
-                ))}
-              </SelectContent>
-            </Select>
+      {/* Filter Bar */}
+      <div
+        className="sticky z-30 py-3 -mx-0 sm:-mx-4 px-0 sm:px-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
+        style={{ top: 'calc(4.5rem + env(safe-area-inset-top, 0px))' }}
+      >
+        <div className="flex items-center gap-2 sm:gap-3">
+          {/* Search - Pill shaped, white background */}
+          <div className="relative flex-1 min-w-0">
+            <span className="material-icons-outlined absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-muted-foreground text-lg sm:text-xl">
+              search
+            </span>
+            <Input
+              value={searchQuery}
+              onChange={(e) => setSearchQuery(e.target.value)}
+              placeholder="Search..."
+              className="pl-9 sm:pl-11 pr-10 h-9 sm:h-11 rounded-full bg-card border-border shadow-sm text-xs sm:text-sm focus:ring-2 focus:ring-ring"
+            />
+            {searchQuery && (
+              <Button
+                variant="ghost"
+                size="icon"
+                onClick={() => setSearchQuery('')}
+                className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground h-7 w-7 rounded-full"
+              >
+                <span className="material-icons-outlined text-lg">close</span>
+              </Button>
+            )}
           </div>
 
-          {/* Sort controls */}
-          <div className="flex gap-2">
-            <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
-              <SelectTrigger className="w-24 sm:w-36 h-9 sm:h-10 text-sm">
-                <SelectValue placeholder="Sort by" />
-              </SelectTrigger>
-              <SelectContent>
-                <SelectItem value="name">Name</SelectItem>
-                <SelectItem value="date">Date Modified</SelectItem>
-                <SelectItem value="category">Category</SelectItem>
-              </SelectContent>
-            </Select>
-
-            <Button
-              variant="outline"
-              size="icon"
-              onClick={() => setSortAsc(!sortAsc)}
-              className="shrink-0 h-9 w-9 sm:h-10 sm:w-10"
-              title={sortAsc ? 'Ascending' : 'Descending'}
-            >
-              <span className="material-icons-outlined text-lg">
-                {sortAsc ? 'arrow_upward' : 'arrow_downward'}
-              </span>
-            </Button>
-          </div>
+          {/* Category - Icon on mobile, text on desktop */}
+          <Select value={selectedCategory} onValueChange={setSelectedCategory}>
+            <SelectTrigger className="h-9 w-9 sm:h-11 sm:w-auto rounded-full bg-card border-border shadow-sm text-xs sm:text-sm shrink-0 [&>svg]:hidden sm:[&>svg]:block px-0 sm:px-3 justify-center sm:justify-between [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
+              <span className="material-icons-outlined text-lg shrink-0 sm:hidden">folder</span>
+              <SelectValue placeholder="All" />
+            </SelectTrigger>
+            <SelectContent>
+              {categories.map((cat) => (
+                <SelectItem key={cat} value={cat}>
+                  {cat === 'all' ? 'All' : cat === 'root' ? 'Default' : cat}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+
+          {/* Sort - Icon on mobile, text on desktop */}
+          <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
+            <SelectTrigger className="h-9 w-9 sm:h-11 sm:w-auto rounded-full bg-card border-border shadow-sm text-xs sm:text-sm shrink-0 [&>svg]:hidden sm:[&>svg]:block px-0 sm:px-3 justify-center sm:justify-between [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
+              <span className="material-icons-outlined text-lg shrink-0 sm:hidden">sort</span>
+              <SelectValue placeholder="Sort" />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="favorites">Favorites</SelectItem>
+              <SelectItem value="name">Name</SelectItem>
+              <SelectItem value="date">Modified</SelectItem>
+              <SelectItem value="size">Size</SelectItem>
+            </SelectContent>
+          </Select>
+
+          {/* Sort direction - Pill shaped, white background */}
+          <Button
+            variant="outline"
+            size="icon"
+            onClick={() => setSortAsc(!sortAsc)}
+            className="shrink-0 h-9 w-9 sm:h-11 sm:w-11 rounded-full bg-card shadow-sm"
+            title={sortAsc ? 'Ascending' : 'Descending'}
+          >
+            <span className="material-icons-outlined text-lg sm:text-xl">
+              {sortAsc ? 'arrow_upward' : 'arrow_downward'}
+            </span>
+          </Button>
 
+          {/* Cache button - Pill shaped, white background */}
           {!allCached && (
             <Button
               variant="outline"
               onClick={handleCacheAllPreviews}
-              className="gap-2 whitespace-nowrap"
+              className={`shrink-0 rounded-full bg-card shadow-sm gap-2 ${
+                isCaching
+                  ? 'h-9 sm:h-11 w-auto px-3 sm:px-4'
+                  : 'h-9 w-9 sm:h-11 sm:w-auto px-0 sm:px-4 justify-center sm:justify-start'
+              }`}
+              title="Cache All Previews"
             >
               {isCaching ? (
                 <>
                   <span className="material-icons-outlined animate-spin text-lg">sync</span>
-                  <span>{cacheProgress}%</span>
+                  <span className="text-sm">{cacheProgress}%</span>
                 </>
               ) : (
                 <>
                   <span className="material-icons-outlined text-lg">cached</span>
-                  <span className="hidden sm:inline">Cache All</span>
+                  <span className="hidden sm:inline text-sm">Cache</span>
                 </>
               )}
             </Button>
@@ -926,7 +996,7 @@ export function BrowsePage() {
           </div>
           {(searchQuery || selectedCategory !== 'all') && (
             <Button
-              variant="outline"
+              variant="secondary"
               onClick={() => {
                 setSearchQuery('')
                 setSelectedCategory('all')
@@ -945,6 +1015,7 @@ export function BrowsePage() {
                 pattern={pattern}
                 isSelected={selectedPattern?.path === pattern.path}
                 isFavorite={favorites.has(pattern.path)}
+                playTime={allPatternHistories[pattern.path.split('/').pop() || '']?.actual_time_formatted || null}
                 onToggleFavorite={toggleFavorite}
                 onClick={() => handlePatternClick(pattern)}
               />
@@ -955,67 +1026,82 @@ export function BrowsePage() {
 
       <div className="h-48" />
 
-      {/* Slide-in Preview Panel */}
-      <div
-        className={`fixed top-0 bottom-0 right-0 w-full max-w-md transform transition-transform duration-300 ease-in-out z-40 ${
-          isPanelOpen ? 'translate-x-0' : 'translate-x-full'
-        }`}
-        ref={panelRef}
-        onTouchStart={handlePanelTouchStart}
-        onTouchEnd={handlePanelTouchEnd}
-      >
-        <div className="h-full bg-background border-l shadow-xl flex flex-col">
-          <header className="flex h-14 items-center justify-between border-b px-4 shrink-0">
-            <h2 className="text-lg font-semibold truncate pr-4">
-              {selectedPattern?.name || 'Pattern Details'}
-            </h2>
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={handleClosePanel}
-              className="rounded-full text-muted-foreground"
-            >
-              <span className="material-icons-outlined">close</span>
-            </Button>
-          </header>
+      {/* Pattern Details Sheet */}
+      <Sheet open={isPanelOpen} onOpenChange={setIsPanelOpen}>
+        <SheetContent
+          className="flex flex-col p-0 overflow-hidden pt-safe"
+          onTouchStart={handleSheetTouchStart}
+          onTouchEnd={handleSheetTouchEnd}
+        >
+          <SheetHeader className="px-6 py-4 shrink-0">
+            <SheetTitle className="flex items-center gap-2 pr-8">
+              {selectedPattern && (
+                <span
+                  role="button"
+                  tabIndex={0}
+                  className={`shrink-0 transition-colors cursor-pointer flex items-center ${
+                    favorites.has(selectedPattern.path) ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-red-500'
+                  }`}
+                  onClick={(e) => toggleFavorite(selectedPattern.path, e)}
+                  onKeyDown={(e) => {
+                    if (e.key === 'Enter' || e.key === ' ') {
+                      e.preventDefault()
+                      toggleFavorite(selectedPattern.path, e as unknown as React.MouseEvent)
+                    }
+                  }}
+                  title={favorites.has(selectedPattern.path) ? 'Remove from favorites' : 'Add to favorites'}
+                >
+                  <span className="material-icons" style={{ fontSize: '20px' }}>
+                    {favorites.has(selectedPattern.path) ? 'favorite' : 'favorite_border'}
+                  </span>
+                </span>
+              )}
+              <span className="truncate">{selectedPattern?.name || 'Pattern Details'}</span>
+            </SheetTitle>
+          </SheetHeader>
 
           {selectedPattern && (
             <div className="p-6 overflow-y-auto flex-1">
               {/* Clickable Round Preview Image */}
-              <div
-                className="mb-6 aspect-square w-full max-w-[280px] mx-auto overflow-hidden rounded-full border bg-muted relative group cursor-pointer"
-                onClick={handleOpenAnimatedPreview}
-              >
-                {getPreviewUrl(selectedPattern.path) ? (
-                  <img
-                    src={getPreviewUrl(selectedPattern.path)!}
-                    alt={selectedPattern.name}
-                    className="w-full h-full object-cover pattern-preview"
-                  />
-                ) : (
-                  <div className="w-full h-full flex items-center justify-center">
-                    <span className="material-icons-outlined text-4xl text-muted-foreground">
-                      image
-                    </span>
-                  </div>
-                )}
-                {/* Play overlay on hover */}
-                <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-black/20">
-                  <div className="bg-background rounded-full w-12 h-12 flex items-center justify-center shadow-lg">
-                    <span className="material-icons text-2xl">play_arrow</span>
+              <div className="mb-6">
+                <div
+                  className="aspect-square w-full max-w-[280px] mx-auto overflow-hidden rounded-full border bg-muted relative group cursor-pointer"
+                  onClick={handleOpenAnimatedPreview}
+                >
+                  {getPreviewUrl(selectedPattern.path) ? (
+                    <img
+                      src={getPreviewUrl(selectedPattern.path)!}
+                      alt={selectedPattern.name}
+                      className="w-full h-full object-cover pattern-preview"
+                    />
+                  ) : (
+                    <div className="w-full h-full flex items-center justify-center">
+                      <span className="material-icons-outlined text-4xl text-muted-foreground">
+                        image
+                      </span>
+                    </div>
+                  )}
+                  {/* Play badge - always visible */}
+                  <div className="absolute bottom-2 right-2 bg-background/90 backdrop-blur-sm rounded-full w-10 h-10 flex items-center justify-center shadow-md border group-hover:scale-110 transition-transform">
+                    <span className="material-icons text-xl">play_arrow</span>
                   </div>
+                  {/* Hover overlay */}
+                  <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-black/20 rounded-full" />
                 </div>
+                <p className="text-xs text-muted-foreground text-center mt-2">Tap to preview animation</p>
               </div>
 
               {/* Coordinates */}
-              <div className="mb-6 flex justify-between text-sm">
+              <div className="mb-4 flex justify-between text-sm">
                 <div className="flex items-center gap-2">
+                  <span className="material-icons-outlined text-muted-foreground text-base">flag</span>
                   <span className="text-muted-foreground">First:</span>
                   <span className="font-semibold">
                     {formatCoordinate(previews[selectedPattern.path]?.first_coordinate)}
                   </span>
                 </div>
                 <div className="flex items-center gap-2">
+                  <span className="material-icons-outlined text-muted-foreground text-base">check</span>
                   <span className="text-muted-foreground">Last:</span>
                   <span className="font-semibold">
                     {formatCoordinate(previews[selectedPattern.path]?.last_coordinate)}
@@ -1023,6 +1109,24 @@ export function BrowsePage() {
                 </div>
               </div>
 
+              {/* Last Played Info */}
+              {patternHistory?.actual_time_formatted && (
+                <div className="mb-4 flex justify-between text-sm">
+                  <div className="flex items-center gap-2">
+                    <span className="material-icons-outlined text-muted-foreground text-base">schedule</span>
+                    <span className="text-muted-foreground">Last run:</span>
+                    <span className="font-semibold">{patternHistory.actual_time_formatted}</span>
+                  </div>
+                  {patternHistory.speed !== null && (
+                    <div className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-muted-foreground text-base">speed</span>
+                      <span className="text-muted-foreground">Speed:</span>
+                      <span className="font-semibold">{patternHistory.speed}</span>
+                    </div>
+                  )}
+                </div>
+              )}
+
               {/* Pre-Execution Options */}
               <div className="mb-6">
                 <Label className="text-sm font-semibold mb-3 block">Pre-Execution Action</Label>
@@ -1052,58 +1156,65 @@ export function BrowsePage() {
 
               {/* Action Buttons */}
               <div className="space-y-3">
-                <Button
-                  onClick={handleRunPattern}
-                  disabled={isRunning}
-                  className="w-full gap-2"
-                  size="lg"
-                >
-                  {isRunning ? (
-                    <span className="material-icons-outlined animate-spin text-lg">sync</span>
-                  ) : (
-                    <span className="material-icons text-lg">play_arrow</span>
+                {/* Play + Delete row */}
+                <div className="flex gap-2">
+                  <Button
+                    onClick={handleRunPattern}
+                    disabled={isRunning}
+                    className="flex-1 gap-2"
+                    size="lg"
+                  >
+                    {isRunning ? (
+                      <span className="material-icons-outlined animate-spin text-lg">sync</span>
+                    ) : (
+                      <span className="material-icons text-lg">play_arrow</span>
+                    )}
+                    Play
+                  </Button>
+
+                  {canDelete && (
+                    <Button
+                      variant="outline"
+                      onClick={handleDeletePattern}
+                      className="text-destructive hover:bg-destructive/10 hover:border-destructive px-3"
+                      size="lg"
+                    >
+                      <span className="material-icons text-lg">delete</span>
+                    </Button>
                   )}
-                  Play
-                </Button>
-
-                <Button
-                  variant="outline"
-                  onClick={handleDeletePattern}
-                  disabled={!canDelete}
-                  className={`w-full gap-2 ${
-                    canDelete
-                      ? 'border-destructive text-destructive hover:bg-destructive/10'
-                      : 'opacity-50 cursor-not-allowed'
-                  }`}
-                  size="lg"
-                >
-                  <span className="material-icons text-lg">delete</span>
-                  Delete
-                </Button>
+                </div>
 
-                {!canDelete && selectedPattern && (
-                  <p className="text-xs text-muted-foreground text-center">
-                    Only custom patterns can be deleted
-                  </p>
-                )}
+                {/* Queue buttons */}
+                <div className="flex gap-2">
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    className="flex-1 gap-1.5"
+                    onClick={() => handleAddToQueue('next')}
+                  >
+                    <span className="material-icons-outlined text-base">playlist_play</span>
+                    Play Next
+                  </Button>
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    className="flex-1 gap-1.5"
+                    onClick={() => handleAddToQueue('end')}
+                  >
+                    <span className="material-icons-outlined text-base">playlist_add</span>
+                    Add to Queue
+                  </Button>
+                </div>
               </div>
             </div>
           )}
-        </div>
-      </div>
-
-      {/* Backdrop for mobile panel */}
-      {isPanelOpen && (
-        <div
-          className="fixed inset-0 bg-black/50 z-30 lg:hidden"
-          onClick={handleClosePanel}
-        />
-      )}
+        </SheetContent>
+      </Sheet>
 
       {/* Animated Preview Modal */}
       {isAnimatedPreviewOpen && (
         <div
-          className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
+          className="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4"
           onClick={handleCloseAnimatedPreview}
         >
           <div
@@ -1198,7 +1309,7 @@ export function BrowsePage() {
                   </span>
                   {isPlaying ? 'Pause' : 'Play'}
                 </Button>
-                <Button variant="outline" onClick={handleReset} className="gap-2">
+                <Button variant="secondary" onClick={handleReset} className="gap-2">
                   <span className="material-icons">replay</span>
                   Reset
                 </Button>
@@ -1216,11 +1327,12 @@ interface PatternCardProps {
   pattern: PatternMetadata
   isSelected: boolean
   isFavorite: boolean
+  playTime: string | null
   onToggleFavorite: (path: string, e: React.MouseEvent) => void
   onClick: () => void
 }
 
-function PatternCard({ pattern, isSelected, isFavorite, onToggleFavorite, onClick }: PatternCardProps) {
+function PatternCard({ pattern, isSelected, isFavorite, playTime, onToggleFavorite, onClick }: PatternCardProps) {
   const [imageLoaded, setImageLoaded] = useState(false)
   const [imageError, setImageError] = useState(false)
   const cardRef = useRef<HTMLButtonElement>(null)
@@ -1253,12 +1365,12 @@ function PatternCard({ pattern, isSelected, isFavorite, onToggleFavorite, onClic
     <button
       ref={cardRef}
       onClick={onClick}
-      className={`group flex flex-col items-center gap-2 p-2 rounded-lg transition-all duration-200 ease-out hover:-translate-y-1 hover:scale-[1.02] hover:shadow-lg hover:bg-accent/30 active:scale-95 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
-        isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background bg-accent/20' : ''
+      className={`group flex flex-col items-center gap-2 p-2.5 rounded-xl bg-card border border-border transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-md active:scale-95 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
+        isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : ''
       }`}
     >
       <div className="relative w-full aspect-square">
-        <div className="w-full h-full rounded-full overflow-hidden border bg-muted">
+        <div className="w-full h-full rounded-full overflow-hidden border border-border bg-muted">
           {previewUrl && !imageError ? (
             <>
               {!imageLoaded && (
@@ -1287,28 +1399,76 @@ function PatternCard({ pattern, isSelected, isFavorite, onToggleFavorite, onClic
             </div>
           )}
         </div>
-        {/* Favorite heart button */}
-        <div
-          className={`absolute -top-1 -right-1 w-6 h-6 rounded-full flex items-center justify-center shadow-sm z-10 transition-opacity duration-200 cursor-pointer bg-white/90 dark:bg-gray-800/90 ${
-            isFavorite ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
+
+        {/* Play time badge */}
+        {playTime && (
+          <div className="absolute -top-1 -right-1 bg-card/90 backdrop-blur-sm text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-border shadow-sm">
+            {(() => {
+              // Parse time and convert to minutes only
+              // Try MM:SS or HH:MM:SS format first (e.g., "15:48" or "1:15:48")
+              const colonMatch = playTime.match(/^(?:(\d+):)?(\d+):(\d+)$/)
+              if (colonMatch) {
+                const hours = colonMatch[1] ? parseInt(colonMatch[1]) : 0
+                const minutes = parseInt(colonMatch[2])
+                const seconds = parseInt(colonMatch[3])
+                const totalMins = hours * 60 + minutes + (seconds >= 30 ? 1 : 0)
+                return totalMins > 0 ? `${totalMins}m` : '<1m'
+              }
+
+              // Try text-based formats
+              const match = playTime.match(/(\d+)h\s*(\d+)m|(\d+)\s*min|(\d+)m\s*(\d+)s|(\d+)\s*sec/)
+              if (match) {
+                if (match[1] && match[2]) {
+                  // "Xh Ym" format
+                  return `${parseInt(match[1]) * 60 + parseInt(match[2])}m`
+                } else if (match[3]) {
+                  // "X min" format
+                  return `${match[3]}m`
+                } else if (match[4] && match[5]) {
+                  // "Xm Ys" format - round to minutes
+                  const mins = parseInt(match[4])
+                  return mins > 0 ? `${mins}m` : '<1m'
+                } else if (match[6]) {
+                  // seconds only
+                  return '<1m'
+                }
+              }
+              // Fallback: show original
+              return playTime
+            })()}
+          </div>
+        )}
+      </div>
+
+      {/* Name and favorite row */}
+      <div className="flex items-center justify-between w-full gap-1 px-0.5">
+        <span className="text-xs font-bold text-foreground truncate" title={pattern.name}>
+          {pattern.name}
+        </span>
+        <span
+          role="button"
+          tabIndex={0}
+          className={`shrink-0 transition-colors cursor-pointer ${
+            isFavorite ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-red-500'
           }`}
-          onClick={(e) => onToggleFavorite(pattern.path, e)}
+          onClick={(e) => {
+            e.stopPropagation()
+            onToggleFavorite(pattern.path, e)
+          }}
+          onKeyDown={(e) => {
+            if (e.key === 'Enter' || e.key === ' ') {
+              e.preventDefault()
+              e.stopPropagation()
+              onToggleFavorite(pattern.path, e as unknown as React.MouseEvent)
+            }
+          }}
           title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
         >
-          <span
-            className={`material-icons transition-colors ${
-              isFavorite ? 'text-red-500 hover:text-red-600' : 'text-gray-400 hover:text-red-500'
-            }`}
-            style={{ fontSize: '14px' }}
-          >
+          <span className="material-icons" style={{ fontSize: '16px' }}>
             {isFavorite ? 'favorite' : 'favorite_border'}
           </span>
-        </div>
+        </span>
       </div>
-
-      <span className="text-xs font-medium text-center truncate w-full px-1" title={pattern.name}>
-        {pattern.name}
-      </span>
     </button>
   )
 }

+ 90 - 21
frontend/src/pages/LEDPage.tsx

@@ -66,7 +66,9 @@ export function LEDPage() {
   const [palettes, setPalettes] = useState<[number, string][]>([])
   const [brightness, setBrightness] = useState(35)
   const [speed, setSpeed] = useState(128)
+  const [speedInput, setSpeedInput] = useState('128')
   const [intensity, setIntensity] = useState(128)
+  const [intensityInput, setIntensityInput] = useState('128')
   const [selectedEffect, setSelectedEffect] = useState('')
   const [selectedPalette, setSelectedPalette] = useState('')
   const [color1, setColor1] = useState('#ff0000')
@@ -81,6 +83,7 @@ export function LEDPage() {
   const [playingEffect, setPlayingEffect] = useState<EffectSettings | null>(null)
   const [idleTimeoutEnabled, setIdleTimeoutEnabled] = useState(false)
   const [idleTimeoutMinutes, setIdleTimeoutMinutes] = useState(30)
+  const [idleTimeoutInput, setIdleTimeoutInput] = useState('30')
 
   // Fetch LED configuration
   useEffect(() => {
@@ -120,7 +123,9 @@ export function LEDPage() {
       if (data.connected) {
         setBrightness(data.brightness || 35)
         setSpeed(data.speed || 128)
+        setSpeedInput(String(data.speed || 128))
         setIntensity(data.intensity || 128)
+        setIntensityInput(String(data.intensity || 128))
         setSelectedEffect(String(data.current_effect || 0))
         setSelectedPalette(String(data.current_palette || 0))
         if (data.colors) {
@@ -169,6 +174,7 @@ export function LEDPage() {
       const data = await apiClient.get<{ enabled?: boolean; minutes?: number }>('/api/dw_leds/idle_timeout')
       setIdleTimeoutEnabled(data.enabled || false)
       setIdleTimeoutMinutes(data.minutes || 30)
+      setIdleTimeoutInput(String(data.minutes || 30))
     } catch (error) {
       console.error('Error fetching idle timeout:', error)
     }
@@ -205,6 +211,7 @@ export function LEDPage() {
 
   const handleSpeedChange = useCallback((value: number[]) => {
     setSpeed(value[0])
+    setSpeedInput(String(value[0]))
   }, [])
 
   const handleSpeedCommit = async (value: number[]) => {
@@ -218,6 +225,7 @@ export function LEDPage() {
 
   const handleIntensityChange = useCallback((value: number[]) => {
     setIntensity(value[0])
+    setIntensityInput(String(value[0]))
   }, [])
 
   const handleIntensityCommit = async (value: number[]) => {
@@ -393,11 +401,11 @@ export function LEDPage() {
 
   // DW LEDs control panel
   return (
-    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6 px-4">
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
       {/* Page Header */}
-      <div className="space-y-0.5 sm:space-y-1">
-        <h1 className="text-xl sm:text-3xl font-bold tracking-tight">LED Control</h1>
-        <p className="text-xs sm:text-base text-muted-foreground">DW LEDs - GPIO controlled LED strip</p>
+      <div className="space-y-0.5 sm:space-y-1 pl-1">
+        <h1 className="text-xl font-semibold tracking-tight">LED Control</h1>
+        <p className="text-xs text-muted-foreground">DW LEDs - GPIO controlled LED strip</p>
       </div>
 
       <Separator />
@@ -443,9 +451,9 @@ export function LEDPage() {
 
                   {/* Brightness Slider */}
                   <div className="space-y-2">
-                    <div className="flex justify-between">
-                      <Label className="flex items-center gap-2">
-                        <span className="material-icons-outlined text-base text-muted-foreground">brightness_6</span>
+                    <div className="flex justify-between items-center">
+                      <Label>
+                        <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">brightness_6</span>
                         Brightness
                       </Label>
                       <span className="text-sm font-medium">{brightness}%</span>
@@ -507,14 +515,37 @@ export function LEDPage() {
               </div>
 
               {/* Speed and Intensity in styled boxes */}
-              <div className="grid grid-cols-2 gap-4">
+              <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex justify-between items-center">
-                    <Label className="flex items-center gap-2">
-                      <span className="material-icons-outlined text-base text-muted-foreground">speed</span>
+                    <Label>
+                      <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">speed</span>
                       Speed
                     </Label>
-                    <span className="text-sm font-medium">{speed}</span>
+                    <Input
+                      type="text"
+                      inputMode="numeric"
+                      value={speedInput}
+                      onChange={(e) => {
+                        const val = e.target.value.replace(/[^0-9]/g, '')
+                        setSpeedInput(val)
+                      }}
+                      onBlur={() => {
+                        const num = Math.min(255, Math.max(0, parseInt(speedInput) || 0))
+                        setSpeed(num)
+                        setSpeedInput(String(num))
+                        handleSpeedCommit([num])
+                      }}
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter') {
+                          const num = Math.min(255, Math.max(0, parseInt(speedInput) || 0))
+                          setSpeed(num)
+                          setSpeedInput(String(num))
+                          handleSpeedCommit([num])
+                        }
+                      }}
+                      className="w-16 h-7 text-center text-sm font-medium px-2"
+                    />
                   </div>
                   <Slider
                     value={[speed]}
@@ -526,11 +557,34 @@ export function LEDPage() {
                 </div>
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex justify-between items-center">
-                    <Label className="flex items-center gap-2">
-                      <span className="material-icons-outlined text-base text-muted-foreground">tungsten</span>
+                    <Label>
+                      <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">tungsten</span>
                       Intensity
                     </Label>
-                    <span className="text-sm font-medium">{intensity}</span>
+                    <Input
+                      type="text"
+                      inputMode="numeric"
+                      value={intensityInput}
+                      onChange={(e) => {
+                        const val = e.target.value.replace(/[^0-9]/g, '')
+                        setIntensityInput(val)
+                      }}
+                      onBlur={() => {
+                        const num = Math.min(255, Math.max(0, parseInt(intensityInput) || 0))
+                        setIntensity(num)
+                        setIntensityInput(String(num))
+                        handleIntensityCommit([num])
+                      }}
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter') {
+                          const num = Math.min(255, Math.max(0, parseInt(intensityInput) || 0))
+                          setIntensity(num)
+                          setIntensityInput(String(num))
+                          handleIntensityCommit([num])
+                        }
+                      }}
+                      className="w-16 h-7 text-center text-sm font-medium px-2"
+                    />
                   </div>
                   <Slider
                     value={[intensity]}
@@ -601,11 +655,26 @@ export function LEDPage() {
               {idleTimeoutEnabled && (
                 <div className="flex items-center gap-2">
                   <Input
-                    type="number"
-                    value={idleTimeoutMinutes}
-                    onChange={(e) => setIdleTimeoutMinutes(parseInt(e.target.value) || 30)}
-                    min={1}
-                    max={1440}
+                    type="text"
+                    inputMode="numeric"
+                    value={idleTimeoutInput}
+                    onChange={(e) => {
+                      const val = e.target.value.replace(/[^0-9]/g, '')
+                      setIdleTimeoutInput(val)
+                    }}
+                    onBlur={() => {
+                      const num = Math.min(1440, Math.max(1, parseInt(idleTimeoutInput) || 30))
+                      setIdleTimeoutMinutes(num)
+                      setIdleTimeoutInput(String(num))
+                    }}
+                    onKeyDown={(e) => {
+                      if (e.key === 'Enter') {
+                        const num = Math.min(1440, Math.max(1, parseInt(idleTimeoutInput) || 30))
+                        setIdleTimeoutMinutes(num)
+                        setIdleTimeoutInput(String(num))
+                        saveIdleTimeout(idleTimeoutEnabled, num)
+                      }
+                    }}
                     className="w-20"
                   />
                   <span className="text-sm text-muted-foreground flex-1">minutes</span>
@@ -654,7 +723,7 @@ export function LEDPage() {
                 </Button>
                 <Button
                   size="sm"
-                  variant="outline"
+                  variant="secondary"
                   onClick={() => clearEffectSettings('playing')}
                 >
                   Clear
@@ -684,7 +753,7 @@ export function LEDPage() {
                 </Button>
                 <Button
                   size="sm"
-                  variant="outline"
+                  variant="secondary"
                   onClick={() => clearEffectSettings('idle')}
                 >
                   Clear

+ 300 - 195
frontend/src/pages/PlaylistsPage.tsx

@@ -13,7 +13,6 @@ import { preExecutionOptions } from '@/lib/types'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
-import { Switch } from '@/components/ui/switch'
 import { Separator } from '@/components/ui/separator'
 import {
   Select,
@@ -51,12 +50,38 @@ export function PlaylistsPage() {
   const [sortBy, setSortBy] = useState<SortOption>('name')
   const [sortAsc, setSortAsc] = useState(true)
 
+  // Favorites state (loaded from "Favorites" playlist)
+  const [favorites, setFavorites] = useState<Set<string>>(new Set())
+
   // Create/Rename playlist modal
   const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
   const [isRenameModalOpen, setIsRenameModalOpen] = useState(false)
   const [newPlaylistName, setNewPlaylistName] = useState('')
   const [playlistToRename, setPlaylistToRename] = useState<string | null>(null)
 
+  // Mobile view state - show content panel when a playlist is selected
+  const [mobileShowContent, setMobileShowContent] = useState(false)
+
+  // Swipe gesture to go back on mobile
+  const swipeTouchStartRef = useRef<{ x: number; y: number } | null>(null)
+  const handleSwipeTouchStart = (e: React.TouchEvent) => {
+    swipeTouchStartRef.current = {
+      x: e.touches[0].clientX,
+      y: e.touches[0].clientY,
+    }
+  }
+  const handleSwipeTouchEnd = (e: React.TouchEvent) => {
+    if (!swipeTouchStartRef.current || !mobileShowContent) return
+    const deltaX = e.changedTouches[0].clientX - swipeTouchStartRef.current.x
+    const deltaY = e.changedTouches[0].clientY - swipeTouchStartRef.current.y
+
+    // Swipe right to go back (positive X, more horizontal than vertical)
+    if (deltaX > 80 && deltaX > Math.abs(deltaY)) {
+      setMobileShowContent(false)
+    }
+    swipeTouchStartRef.current = null
+  }
+
   // Playback settings - initialized from localStorage
   const [runMode, setRunMode] = useState<RunMode>(() => {
     const cached = localStorage.getItem('playlist-runMode')
@@ -157,6 +182,7 @@ export function PlaylistsPage() {
     initPreviewCacheDB().catch(() => {})
     fetchPlaylists()
     fetchAllPatterns()
+    loadFavorites()
 
     // Cleanup on unmount: abort in-flight requests and clear pending queue
     return () => {
@@ -174,6 +200,7 @@ export function PlaylistsPage() {
   useOnBackendConnected(() => {
     fetchPlaylists()
     fetchAllPatterns()
+    loadFavorites()
   })
 
   const fetchPlaylists = async () => {
@@ -195,10 +222,7 @@ export function PlaylistsPage() {
       const data = await apiClient.get<{ files: string[] }>(`/get_playlist?name=${encodeURIComponent(name)}`)
       setPlaylistPatterns(data.files || [])
 
-      // Load previews for playlist patterns
-      if (data.files?.length > 0) {
-        loadPreviewsForPaths(data.files)
-      }
+      // Previews are now lazy-loaded via IntersectionObserver in LazyPatternPreview
     } catch (error) {
       console.error('Error fetching playlist:', error)
       toast.error('Failed to load playlist')
@@ -215,6 +239,16 @@ export function PlaylistsPage() {
     }
   }
 
+  // Load favorites from "Favorites" playlist
+  const loadFavorites = async () => {
+    try {
+      const playlist = await apiClient.get<{ files?: string[] }>('/get_playlist?name=Favorites')
+      setFavorites(new Set(playlist.files || []))
+    } catch {
+      // Favorites playlist doesn't exist yet - that's OK
+    }
+  }
+
   // Preview loading functions (similar to BrowsePage)
   const loadPreviewsForPaths = async (paths: string[]) => {
     const cachedPreviews = await getPreviewsFromCache(paths)
@@ -294,6 +328,12 @@ export function PlaylistsPage() {
   const handleSelectPlaylist = (name: string) => {
     setSelectedPlaylist(name)
     fetchPlaylistPatterns(name)
+    setMobileShowContent(true) // Show content panel on mobile
+  }
+
+  // Go back to playlist list on mobile
+  const handleMobileBack = () => {
+    setMobileShowContent(false)
   }
 
   const handleCreatePlaylist = async () => {
@@ -368,12 +408,7 @@ export function PlaylistsPage() {
     setSelectedPatternPaths(new Set(playlistPatterns))
     setSearchQuery('')
     setIsPickerOpen(true)
-
-    // Load previews for all patterns
-    if (allPatterns.length > 0) {
-      const paths = allPatterns.slice(0, 50).map(p => p.path)
-      loadPreviewsForPaths(paths)
-    }
+    // Previews are lazy-loaded via IntersectionObserver in LazyPatternPreview
   }
 
   const handleSavePatterns = async () => {
@@ -385,7 +420,7 @@ export function PlaylistsPage() {
       setPlaylistPatterns(newPatterns)
       setIsPickerOpen(false)
       toast.success('Playlist updated')
-      loadPreviewsForPaths(newPatterns)
+      // Previews are lazy-loaded via IntersectionObserver
     } catch (error) {
       toast.error('Failed to update playlist')
     }
@@ -452,15 +487,24 @@ export function PlaylistsPage() {
         case 'date':
           cmp = a.date_modified - b.date_modified
           break
-        case 'category':
-          cmp = a.category.localeCompare(b.category)
+        case 'size':
+          cmp = a.coordinates_count - b.coordinates_count
           break
+        case 'favorites': {
+          const aFav = favorites.has(a.path) ? 1 : 0
+          const bFav = favorites.has(b.path) ? 1 : 0
+          cmp = bFav - aFav // Favorites first
+          if (cmp === 0) {
+            cmp = a.name.localeCompare(b.name) // Then by name
+          }
+          break
+        }
       }
       return sortAsc ? cmp : -cmp
     })
 
     return filtered
-  }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc])
+  }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc, favorites])
 
   // Get pattern name from path
   const getPatternName = (path: string) => {
@@ -475,11 +519,11 @@ export function PlaylistsPage() {
   }
 
   return (
-    <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-4 sm:py-6 h-[calc(100dvh-10rem)] sm:h-[calc(100dvh-10.5rem)] overflow-hidden">
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4 h-[calc(100dvh-11rem)] overflow-hidden">
       {/* Page Header */}
-      <div className="space-y-0.5 sm:space-y-1 shrink-0">
-        <h1 className="text-xl sm:text-3xl font-bold tracking-tight">Playlists</h1>
-        <p className="text-sm sm:text-base text-muted-foreground">
+      <div className="space-y-0.5 sm:space-y-1 shrink-0 pl-1">
+        <h1 className="text-xl font-semibold tracking-tight">Playlists</h1>
+        <p className="text-xs text-muted-foreground">
           Create and manage pattern playlists
         </p>
       </div>
@@ -487,11 +531,16 @@ export function PlaylistsPage() {
       <Separator className="shrink-0" />
 
       {/* Main Content Area */}
-      <div className="flex flex-col lg:flex-row gap-4 flex-1 min-h-0">
-        {/* Playlists Sidebar */}
-        <aside className="w-full lg:w-64 shrink-0 bg-card border rounded-lg flex flex-col max-h-48 lg:max-h-none">
+      <div className="flex flex-col lg:flex-row gap-4 flex-1 min-h-0 relative overflow-hidden">
+        {/* Playlists Sidebar - Full screen on mobile, sidebar on desktop */}
+        <aside className={`w-full lg:w-64 shrink-0 bg-card border rounded-lg flex flex-col h-full overflow-hidden transition-transform duration-300 ease-in-out ${
+          mobileShowContent ? '-translate-x-full lg:translate-x-0 absolute lg:relative inset-0 lg:inset-auto' : 'translate-x-0'
+        }`}>
           <div className="flex items-center justify-between px-3 py-2.5 border-b shrink-0">
-            <h2 className="text-lg font-semibold">My Playlists</h2>
+            <div>
+              <h2 className="text-lg font-semibold">My Playlists</h2>
+              <p className="text-sm text-muted-foreground">{playlists.length} playlist{playlists.length !== 1 ? 's' : ''}</p>
+            </div>
             <Button
               variant="ghost"
               size="icon"
@@ -562,11 +611,26 @@ export function PlaylistsPage() {
         </nav>
       </aside>
 
-        {/* Main Content */}
-        <main className="flex-1 bg-card border rounded-lg flex flex-col overflow-hidden min-h-0">
+        {/* Main Content - Slides in from right on mobile, swipe right to go back */}
+        <main
+          className={`flex-1 bg-card border rounded-lg flex flex-col overflow-hidden min-h-0 relative transition-transform duration-300 ease-in-out ${
+            mobileShowContent ? 'translate-x-0' : 'translate-x-full lg:translate-x-0 absolute lg:relative inset-0 lg:inset-auto'
+          }`}
+          onTouchStart={handleSwipeTouchStart}
+          onTouchEnd={handleSwipeTouchEnd}
+        >
           {/* Header */}
-          <header className="flex items-center justify-between px-4 py-3 border-b shrink-0">
-            <div className="flex items-center gap-3 min-w-0">
+          <header className="flex items-center justify-between px-3 py-2.5 border-b shrink-0">
+            <div className="flex items-center gap-2 min-w-0">
+              {/* Back button - mobile only */}
+              <Button
+                variant="ghost"
+                size="icon"
+                className="h-8 w-8 lg:hidden shrink-0"
+                onClick={handleMobileBack}
+              >
+                <span className="material-icons-outlined">arrow_back</span>
+              </Button>
               <div className="min-w-0">
                 <h2 className="text-lg font-semibold truncate">
                   {selectedPlaylist || 'Select a Playlist'}
@@ -590,7 +654,7 @@ export function PlaylistsPage() {
           </header>
 
           {/* Patterns List */}
-          <div className="flex-1 overflow-y-auto p-4 min-h-0">
+          <div className={`flex-1 overflow-y-auto p-4 min-h-0 ${selectedPlaylist ? 'pb-28 sm:pb-24' : ''}`}>
             {!selectedPlaylist ? (
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
                 <div className="p-4 rounded-full bg-muted">
@@ -610,126 +674,128 @@ export function PlaylistsPage() {
                   <p className="font-medium">Empty playlist</p>
                   <p className="text-sm">Add patterns to get started</p>
                 </div>
-                <Button variant="outline" className="mt-2 gap-2" onClick={openPatternPicker}>
+                <Button variant="secondary" className="mt-2 gap-2" onClick={openPatternPicker}>
                   <span className="material-icons-outlined text-base">add</span>
                   Add Patterns
                 </Button>
               </div>
             ) : (
               <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
-                {playlistPatterns.map((path, index) => {
-                  const previewUrl = getPreviewUrl(path)
-                  if (!previewUrl && !previews[path]) {
-                    requestPreview(path)
-                  }
-                  return (
-                    <div
-                      key={`${path}-${index}`}
-                      className="flex flex-col items-center gap-1.5 sm:gap-2 group"
-                    >
-                      <div className="relative w-full aspect-square">
-                        <div className="w-full h-full rounded-full overflow-hidden border bg-muted hover:ring-2 hover:ring-primary hover:ring-offset-2 hover:ring-offset-background transition-all cursor-pointer">
-                          {previewUrl ? (
-                            <img
-                              src={previewUrl}
-                              alt={getPatternName(path)}
-                              className="w-full h-full object-cover pattern-preview"
-                            />
-                          ) : (
-                            <div className="w-full h-full flex items-center justify-center">
-                              <span className="material-icons-outlined text-muted-foreground text-sm sm:text-base">
-                                image
-                              </span>
-                            </div>
-                          )}
-                        </div>
-                        <button
-                          className="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10"
-                          onClick={() => handleRemovePattern(path)}
-                          title="Remove from playlist"
-                        >
-                          <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
-                        </button>
+                {playlistPatterns.map((path, index) => (
+                  <div
+                    key={`${path}-${index}`}
+                    className="flex flex-col items-center gap-1.5 sm:gap-2 group"
+                  >
+                    <div className="relative w-full aspect-square">
+                      <div className="w-full h-full rounded-full overflow-hidden border bg-muted hover:ring-2 hover:ring-primary hover:ring-offset-2 hover:ring-offset-background transition-all cursor-pointer">
+                        <LazyPatternPreview
+                          path={path}
+                          previewUrl={getPreviewUrl(path)}
+                          requestPreview={requestPreview}
+                          alt={getPatternName(path)}
+                        />
                       </div>
-                      <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
+                      <button
+                        className="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10"
+                        onClick={() => handleRemovePattern(path)}
+                        title="Remove from playlist"
+                      >
+                        <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
+                      </button>
                     </div>
-                  )
-                })}
+                    <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
+                  </div>
+                ))}
               </div>
             )}
           </div>
 
-          {/* Playback Settings - Always visible when playlist selected */}
+          {/* Floating Playback Controls */}
           {selectedPlaylist && (
-            <div className="border-t px-3 py-2.5 sm:px-4 sm:py-3 bg-muted/30 shrink-0">
-              {/* Mobile: 2-row layout, Desktop: single row */}
-              <div className="flex flex-col sm:flex-row sm:items-center gap-2.5 sm:gap-3">
-                {/* Top row on mobile: Mode, Shuffle, Pause, Clear */}
-                <div className="flex items-center gap-2 sm:gap-3 flex-wrap">
-                  {/* Run Mode Segmented Control */}
-                  <div className="flex rounded-md border bg-muted/50 p-0.5">
+            <div className="absolute bottom-0 left-0 right-0 pointer-events-none z-20">
+              {/* Blur backdrop */}
+              <div className="h-20 bg-gradient-to-t" />
+
+              {/* Controls container */}
+              <div className="absolute bottom-4 left-0 right-0 flex items-center justify-center gap-3 px-4 pointer-events-auto">
+                {/* Control pill */}
+                <div className="flex items-center h-12 sm:h-14 bg-card rounded-full shadow-xl border px-1.5 sm:px-2">
+                  {/* Shuffle & Loop */}
+                  <div className="flex items-center px-1 sm:px-2 border-r border-border gap-0.5 sm:gap-1">
                     <button
-                      onClick={() => setRunMode('single')}
-                      className={`flex items-center gap-1 px-2 py-1 rounded text-xs sm:text-sm font-medium transition-colors ${
-                        runMode === 'single'
-                          ? 'bg-background text-foreground shadow-sm'
-                          : 'text-muted-foreground hover:text-foreground'
+                      onClick={() => setShuffle(!shuffle)}
+                      className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition ${
+                        shuffle
+                          ? 'text-primary bg-primary/10'
+                          : 'text-muted-foreground hover:bg-muted'
                       }`}
+                      title="Shuffle"
                     >
-                      <span className="material-icons-outlined text-sm">play_circle</span>
-                      Once
+                      <span className="material-icons-outlined text-lg sm:text-xl">shuffle</span>
                     </button>
                     <button
-                      onClick={() => setRunMode('indefinite')}
-                      className={`flex items-center gap-1 px-2 py-1 rounded text-xs sm:text-sm font-medium transition-colors ${
+                      onClick={() => setRunMode(runMode === 'indefinite' ? 'single' : 'indefinite')}
+                      className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition ${
                         runMode === 'indefinite'
-                          ? 'bg-background text-foreground shadow-sm'
-                          : 'text-muted-foreground hover:text-foreground'
+                          ? 'text-primary bg-primary/10'
+                          : 'text-muted-foreground hover:bg-muted'
                       }`}
+                      title={runMode === 'indefinite' ? 'Loop mode' : 'Play once mode'}
                     >
-                      <span className="material-icons-outlined text-sm">repeat</span>
-                      Loop
+                      <span className="material-icons-outlined text-lg sm:text-xl">repeat</span>
                     </button>
                   </div>
 
-                  {/* Shuffle Toggle */}
-                  <div className="flex items-center gap-1.5 h-8 px-2 rounded-md border bg-muted/50">
-                    <span className="material-icons-outlined text-sm text-muted-foreground">shuffle</span>
-                    <Switch
-                      checked={shuffle}
-                      onCheckedChange={setShuffle}
-                      className="scale-90"
-                    />
-                  </div>
-
-                  {/* Pause Time - more compact on mobile */}
-                  <div className="flex items-center gap-1">
-                    <Label className="text-xs text-muted-foreground hidden sm:inline">Pause:</Label>
-                    <Input
-                      type="number"
-                      value={pauseTime}
-                      onChange={(e) => setPauseTime(Number(e.target.value))}
-                      min={0}
-                      className="w-12 sm:w-14 h-8 text-sm"
-                    />
-                    <Select value={pauseUnit} onValueChange={(v) => setPauseUnit(v as 'sec' | 'min' | 'hr')}>
-                      <SelectTrigger className="h-8 w-14 sm:w-16 text-xs sm:text-sm">
-                        <SelectValue />
-                      </SelectTrigger>
-                      <SelectContent>
-                        <SelectItem value="sec">sec</SelectItem>
-                        <SelectItem value="min">min</SelectItem>
-                        <SelectItem value="hr">hr</SelectItem>
-                      </SelectContent>
-                    </Select>
+                  {/* Pause Time */}
+                  <div className="flex items-center px-2 sm:px-3 gap-2 sm:gap-3 border-r border-border">
+                    <span className="text-[10px] sm:text-xs font-semibold text-muted-foreground tracking-wider hidden sm:block">Pause</span>
+                    <div className="flex items-center gap-1">
+                      <Button
+                        variant="secondary"
+                        size="icon"
+                        className="w-7 h-7 sm:w-8 sm:h-8"
+                        onClick={() => {
+                          const step = pauseUnit === 'hr' ? 0.5 : 1
+                          setPauseTime(Math.max(0, pauseTime - step))
+                        }}
+                      >
+                        <span className="material-icons-outlined text-sm">remove</span>
+                      </Button>
+                      <button
+                        onClick={() => {
+                          const units: ('sec' | 'min' | 'hr')[] = ['sec', 'min', 'hr']
+                          const currentIndex = units.indexOf(pauseUnit)
+                          setPauseUnit(units[(currentIndex + 1) % units.length])
+                        }}
+                        className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold hover:text-primary transition"
+                        title="Click to change unit"
+                      >
+                        {pauseTime}{pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}
+                        <span className="material-icons-outlined text-xs opacity-50 scale-75 ml-0.5">swap_vert</span>
+                      </button>
+                      <Button
+                        variant="secondary"
+                        size="icon"
+                        className="w-7 h-7 sm:w-8 sm:h-8"
+                        onClick={() => {
+                          const step = pauseUnit === 'hr' ? 0.5 : 1
+                          setPauseTime(pauseTime + step)
+                        }}
+                      >
+                        <span className="material-icons-outlined text-sm">add</span>
+                      </Button>
+                    </div>
                   </div>
 
-                  {/* Clear Pattern */}
-                  <div className="flex items-center gap-1">
-                    <Label className="text-xs text-muted-foreground hidden sm:inline">Clear:</Label>
+                  {/* Clear Pattern Dropdown */}
+                  <div className="flex items-center px-1 sm:px-2">
                     <Select value={clearPattern} onValueChange={(v) => setClearPattern(v as PreExecution)}>
-                      <SelectTrigger className="h-8 w-24 sm:w-28 text-xs sm:text-sm">
-                        <SelectValue />
+                      <SelectTrigger className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full border-0 p-0 shadow-none focus:ring-0 justify-center [&>svg]:hidden transition ${
+                        clearPattern !== 'none' ? '!bg-primary/10' : '!bg-transparent hover:!bg-muted'
+                      }`}>
+                        <span className={`material-icons-outlined text-lg sm:text-xl ${
+                          clearPattern !== 'none' ? 'text-primary' : 'text-muted-foreground'
+                        }`}>cleaning_services</span>
                       </SelectTrigger>
                       <SelectContent>
                         {preExecutionOptions.map(opt => (
@@ -742,22 +808,19 @@ export function PlaylistsPage() {
                   </div>
                 </div>
 
-                {/* Spacer - only on desktop */}
-                <div className="hidden sm:flex sm:flex-1" />
-
-                {/* Run Button - full width on mobile */}
-                <Button
-                  className="gap-2 w-full sm:w-auto"
+                {/* Play Button */}
+                <button
                   onClick={handleRunPlaylist}
                   disabled={isRunning || playlistPatterns.length === 0}
+                  className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground text-primary-foreground shadow-lg shadow-primary/30 hover:shadow-primary/50 hover:scale-105 disabled:shadow-none disabled:hover:scale-100 transition-all duration-200 flex items-center justify-center"
+                  title="Run Playlist"
                 >
                   {isRunning ? (
-                    <span className="material-icons-outlined animate-spin">sync</span>
+                    <span className="material-icons-outlined text-xl sm:text-2xl animate-spin">sync</span>
                   ) : (
-                    <span className="material-icons-outlined">play_arrow</span>
+                    <span className="material-icons text-xl sm:text-2xl ml-0.5">play_arrow</span>
                   )}
-                  Run Playlist
-                </Button>
+                </button>
               </div>
             </div>
           )}
@@ -787,7 +850,7 @@ export function PlaylistsPage() {
             </div>
           </div>
           <DialogFooter className="gap-2 sm:gap-0">
-            <Button variant="outline" onClick={() => setIsCreateModalOpen(false)}>
+            <Button variant="secondary" onClick={() => setIsCreateModalOpen(false)}>
               Cancel
             </Button>
             <Button onClick={handleCreatePlaylist} className="gap-2">
@@ -821,7 +884,7 @@ export function PlaylistsPage() {
             </div>
           </div>
           <DialogFooter className="gap-2 sm:gap-0">
-            <Button variant="outline" onClick={() => setIsRenameModalOpen(false)}>
+            <Button variant="secondary" onClick={() => setIsRenameModalOpen(false)}>
               Cancel
             </Button>
             <Button onClick={handleRenamePlaylist} className="gap-2">
@@ -864,55 +927,56 @@ export function PlaylistsPage() {
               )}
             </div>
 
-            <div className="flex flex-wrap gap-3 items-center p-3 rounded-lg bg-muted/50">
-              <div className="flex items-center gap-2">
-                <Label className="text-xs text-muted-foreground">Sort:</Label>
-                <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
-                  <SelectTrigger className="h-8 w-28">
-                    <SelectValue />
-                  </SelectTrigger>
-                  <SelectContent>
-                    <SelectItem value="name">Name</SelectItem>
-                    <SelectItem value="date">Date</SelectItem>
-                    <SelectItem value="category">Category</SelectItem>
-                  </SelectContent>
-                </Select>
-                <Button
-                  variant="ghost"
-                  size="icon"
-                  className="h-8 w-8"
-                  onClick={() => setSortAsc(!sortAsc)}
-                >
-                  <span className="material-icons-outlined text-lg">
-                    {sortAsc ? 'arrow_upward' : 'arrow_downward'}
-                  </span>
-                </Button>
-              </div>
-
-              <Separator orientation="vertical" className="h-6" />
-
-              <div className="flex items-center gap-2">
-                <Label className="text-xs text-muted-foreground">Folder:</Label>
-                <Select value={selectedCategory} onValueChange={setSelectedCategory}>
-                  <SelectTrigger className="h-8 w-32">
-                    <SelectValue />
-                  </SelectTrigger>
-                  <SelectContent>
-                    {categories.map(cat => (
-                      <SelectItem key={cat} value={cat}>
-                        {cat === 'all' ? 'All Folders' : cat}
-                      </SelectItem>
-                    ))}
-                  </SelectContent>
-                </Select>
-              </div>
+            <div className="flex flex-wrap gap-2 items-center">
+              {/* Folder dropdown - icon only on mobile, with text on sm+ */}
+              <Select value={selectedCategory} onValueChange={setSelectedCategory}>
+                <SelectTrigger className="h-9 w-9 sm:w-auto rounded-full bg-card border-border shadow-sm text-sm px-0 sm:px-3 justify-center sm:justify-between [&>svg]:hidden sm:[&>svg]:block [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
+                  <span className="material-icons-outlined text-lg shrink-0">folder</span>
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  {categories.map(cat => (
+                    <SelectItem key={cat} value={cat}>
+                      {cat === 'all' ? 'All Folders' : cat}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+
+              {/* Sort dropdown - icon only on mobile, with text on sm+ */}
+              <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
+                <SelectTrigger className="h-9 w-9 sm:w-auto rounded-full bg-card border-border shadow-sm text-sm px-0 sm:px-3 justify-center sm:justify-between [&>svg]:hidden sm:[&>svg]:block [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
+                  <span className="material-icons-outlined text-lg shrink-0">sort</span>
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="favorites">Favorites</SelectItem>
+                  <SelectItem value="name">Name</SelectItem>
+                  <SelectItem value="date">Modified</SelectItem>
+                  <SelectItem value="size">Size</SelectItem>
+                </SelectContent>
+              </Select>
+
+              {/* Sort direction - pill shaped */}
+              <Button
+                variant="outline"
+                size="icon"
+                className="h-9 w-9 rounded-full bg-card shadow-sm"
+                onClick={() => setSortAsc(!sortAsc)}
+                title={sortAsc ? 'Ascending' : 'Descending'}
+              >
+                <span className="material-icons-outlined text-lg">
+                  {sortAsc ? 'arrow_upward' : 'arrow_downward'}
+                </span>
+              </Button>
 
               <div className="flex-1" />
 
-              <div className="flex items-center gap-2 text-sm">
+              {/* Selection count - compact on mobile */}
+              <div className="flex items-center gap-1 sm:gap-2 text-sm bg-card rounded-full px-2 sm:px-3 py-2 shadow-sm border">
                 <span className="material-icons-outlined text-base text-primary">check_circle</span>
                 <span className="font-medium">{selectedPatternPaths.size}</span>
-                <span className="text-muted-foreground">selected</span>
+                <span className="hidden sm:inline text-muted-foreground">selected</span>
               </div>
             </div>
           </div>
@@ -930,10 +994,6 @@ export function PlaylistsPage() {
               <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
                 {filteredPatterns.map(pattern => {
                   const isSelected = selectedPatternPaths.has(pattern.path)
-                  const previewUrl = getPreviewUrl(pattern.path)
-                  if (!previewUrl && !previews[pattern.path]) {
-                    requestPreview(pattern.path)
-                  }
                   return (
                     <div
                       key={pattern.path}
@@ -947,19 +1007,12 @@ export function PlaylistsPage() {
                             : 'border-transparent hover:border-muted-foreground/30'
                         }`}
                       >
-                        {previewUrl ? (
-                          <img
-                            src={previewUrl}
-                            alt={pattern.name}
-                            className="w-full h-full object-cover pattern-preview"
-                          />
-                        ) : (
-                          <div className="w-full h-full flex items-center justify-center">
-                            <span className="material-icons-outlined text-muted-foreground">
-                              image
-                            </span>
-                          </div>
-                        )}
+                        <LazyPatternPreview
+                          path={pattern.path}
+                          previewUrl={getPreviewUrl(pattern.path)}
+                          requestPreview={requestPreview}
+                          alt={pattern.name}
+                        />
                         {isSelected && (
                           <div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-primary flex items-center justify-center shadow-md">
                             <span className="material-icons text-primary-foreground" style={{ fontSize: '14px' }}>
@@ -979,7 +1032,7 @@ export function PlaylistsPage() {
           </div>
 
           <DialogFooter className="gap-2 sm:gap-0">
-            <Button variant="outline" onClick={() => setIsPickerOpen(false)}>
+            <Button variant="secondary" onClick={() => setIsPickerOpen(false)}>
               Cancel
             </Button>
             <Button onClick={handleSavePatterns} className="gap-2">
@@ -992,3 +1045,55 @@ export function PlaylistsPage() {
     </div>
   )
 }
+
+// Lazy-loading pattern preview component
+interface LazyPatternPreviewProps {
+  path: string
+  previewUrl: string | null
+  requestPreview: (path: string) => void
+  alt: string
+  className?: string
+}
+
+function LazyPatternPreview({ path, previewUrl, requestPreview, alt, className = '' }: LazyPatternPreviewProps) {
+  const containerRef = useRef<HTMLDivElement>(null)
+  const hasRequestedRef = useRef(false)
+
+  useEffect(() => {
+    if (!containerRef.current || previewUrl || hasRequestedRef.current) return
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        entries.forEach((entry) => {
+          if (entry.isIntersecting && !hasRequestedRef.current) {
+            hasRequestedRef.current = true
+            requestPreview(path)
+            observer.disconnect()
+          }
+        })
+      },
+      { rootMargin: '100px' }
+    )
+
+    observer.observe(containerRef.current)
+
+    return () => observer.disconnect()
+  }, [path, previewUrl, requestPreview])
+
+  return (
+    <div ref={containerRef} className={`w-full h-full flex items-center justify-center ${className}`}>
+      {previewUrl ? (
+        <img
+          src={previewUrl}
+          alt={alt}
+          loading="lazy"
+          className="w-full h-full object-cover pattern-preview"
+        />
+      ) : (
+        <span className="material-icons-outlined text-muted-foreground text-sm sm:text-base">
+          image
+        </span>
+      )}
+    </div>
+  )
+}

+ 190 - 132
frontend/src/pages/SettingsPage.tsx

@@ -18,7 +18,9 @@ import {
 import {
   Select,
   SelectContent,
+  SelectGroup,
   SelectItem,
+  SelectLabel,
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
@@ -107,6 +109,7 @@ export function SettingsPage() {
   // Settings state
   const [settings, setSettings] = useState<Settings>({})
   const [ledConfig, setLedConfig] = useState<LedConfig>({ provider: 'none', gpio_pin: 18 })
+  const [numLedsInput, setNumLedsInput] = useState('60')
   const [mqttConfig, setMqttConfig] = useState<MqttConfig>({ enabled: false })
 
   // UI state
@@ -132,6 +135,7 @@ export function SettingsPage() {
   })
   const [autoPlayPauseUnit, setAutoPlayPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
   const [autoPlayPauseValue, setAutoPlayPauseValue] = useState(5)
+  const [autoPlayPauseInput, setAutoPlayPauseInput] = useState('5')
   const [playlists, setPlaylists] = useState<string[]>([])
 
   // Convert pause time from seconds to value + unit for display
@@ -289,16 +293,25 @@ export function SettingsPage() {
 
   const fetchPorts = async () => {
     try {
-      // Fetch available ports
+      // Fetch available ports first
       const portsData = await apiClient.get<string[]>('/list_serial_ports')
-      setPorts(portsData || [])
+      const availablePorts = portsData || []
+      setPorts(availablePorts)
 
       // Fetch connection status
       const statusData = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
       setIsConnected(statusData.connected || false)
       setConnectionStatus(statusData.connected ? 'Connected' : 'Disconnected')
-      if (statusData.port) {
+
+      // Only set selectedPort if it exists in the available ports list
+      // This prevents race conditions where stale port data from a different
+      // backend (e.g., Mac port on a Pi) could be set
+      if (statusData.port && availablePorts.includes(statusData.port)) {
         setSelectedPort(statusData.port)
+      } else if (statusData.port && !availablePorts.includes(statusData.port)) {
+        // Port from status doesn't exist on this machine - likely stale data
+        console.warn(`Port ${statusData.port} from status not in available ports, ignoring`)
+        setSelectedPort('')
       }
     } catch (error) {
       console.error('Error fetching ports:', error)
@@ -347,6 +360,7 @@ export function SettingsPage() {
         const pauseSeconds = data.auto_play.pause_time ?? 300 // Default 5 minutes
         const { value, unit } = secondsToDisplayPause(pauseSeconds)
         setAutoPlayPauseValue(value)
+        setAutoPlayPauseInput(String(value))
         setAutoPlayPauseUnit(unit)
         setAutoPlaySettings({
           enabled: data.auto_play.enabled || false,
@@ -396,6 +410,7 @@ export function SettingsPage() {
         gpio_pin: data.dw_led_gpio_pin,
         pixel_order: data.dw_led_pixel_order,
       })
+      setNumLedsInput(String(data.dw_led_num_leds || 60))
     } catch (error) {
       console.error('Error fetching LED config:', error)
     }
@@ -452,16 +467,20 @@ export function SettingsPage() {
   const handleSavePreferredPort = async () => {
     setIsLoading('preferredPort')
     try {
+      // Send the actual value: __auto__, __none__, or specific port
+      const portValue = settings.preferred_port || '__auto__'
       await apiClient.patch('/api/settings', {
-        connection: { preferred_port: settings.preferred_port || null },
+        connection: { preferred_port: portValue },
       })
-      toast.success(
-        settings.preferred_port
-          ? `Auto-connect set to ${settings.preferred_port}`
-          : 'Auto-connect disabled'
-      )
+      if (!settings.preferred_port || settings.preferred_port === '__auto__') {
+        toast.success('Auto-connect: Auto (first available port)')
+      } else if (settings.preferred_port === '__none__') {
+        toast.success('Auto-connect: Disabled')
+      } else {
+        toast.success(`Auto-connect: ${settings.preferred_port}`)
+      }
     } catch (error) {
-      toast.error('Failed to save preferred port')
+      toast.error('Failed to save auto-connect setting')
     } finally {
       setIsLoading(null)
     }
@@ -483,16 +502,16 @@ export function SettingsPage() {
   const updateBranding = (customLogo: string | null) => {
     const timestamp = Date.now() // Cache buster
 
-    // Update favicon links
+    // Update favicon links (use apiClient.getAssetUrl for multi-table support)
     const faviconIco = document.getElementById('favicon-ico') as HTMLLinkElement
     const appleTouchIcon = document.getElementById('apple-touch-icon') as HTMLLinkElement
 
     if (customLogo) {
-      if (faviconIco) faviconIco.href = `/static/custom/favicon.ico?v=${timestamp}`
-      if (appleTouchIcon) appleTouchIcon.href = `/static/custom/${customLogo}?v=${timestamp}`
+      if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/custom/favicon.ico?v=${timestamp}`)
+      if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/custom/${customLogo}?v=${timestamp}`)
     } else {
-      if (faviconIco) faviconIco.href = `/static/favicon.ico?v=${timestamp}`
-      if (appleTouchIcon) appleTouchIcon.href = `/static/apple-touch-icon.png?v=${timestamp}`
+      if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/favicon.ico?v=${timestamp}`)
+      if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/apple-touch-icon.png?v=${timestamp}`)
     }
 
     // Dispatch event for Layout to update header logo
@@ -713,11 +732,11 @@ export function SettingsPage() {
   }
 
   return (
-    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6 px-4">
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
       {/* Page Header */}
-      <div className="space-y-0.5 sm:space-y-1">
-        <h1 className="text-xl sm:text-3xl font-bold tracking-tight">Settings</h1>
-        <p className="text-xs sm:text-base text-muted-foreground">
+      <div className="space-y-0.5 sm:space-y-1 pl-1">
+        <h1 className="text-xl font-semibold tracking-tight">Settings</h1>
+        <p className="text-xs text-muted-foreground">
           Configure your sand table
         </p>
       </div>
@@ -749,7 +768,7 @@ export function SettingsPage() {
             {/* Connection Status */}
             <div className="flex items-center justify-between p-4 rounded-lg border">
               <div className="flex items-center gap-3">
-                <div className={`p-2 rounded-lg ${isConnected ? 'bg-green-100 dark:bg-green-900' : 'bg-muted'}`}>
+                <div className={`w-10 h-10 flex items-center justify-center rounded-lg ${isConnected ? 'bg-green-100 dark:bg-green-900' : 'bg-muted'}`}>
                   <span className={`material-icons ${isConnected ? 'text-green-600' : 'text-muted-foreground'}`}>
                     {isConnected ? 'usb' : 'usb_off'}
                   </span>
@@ -817,24 +836,30 @@ export function SettingsPage() {
 
             {/* Preferred Port for Auto-Connect */}
             <div className="space-y-3">
-              <Label>Preferred Port (Auto-Connect)</Label>
+              <Label>Auto-Connect</Label>
               <div className="flex gap-3">
                 <Select
-                  value={settings.preferred_port || '__none__'}
+                  value={settings.preferred_port || '__auto__'}
                   onValueChange={(value) =>
-                    setSettings({ ...settings, preferred_port: value === '__none__' ? undefined : value })
+                    setSettings({ ...settings, preferred_port: value === '__auto__' ? undefined : value })
                   }
                 >
                   <SelectTrigger className="flex-1">
-                    <SelectValue placeholder="Select preferred port..." />
+                    <SelectValue placeholder="Select auto-connect option..." />
                   </SelectTrigger>
                   <SelectContent>
-                    {ports.map((port) => (
-                      <SelectItem key={port} value={port}>
-                        {port}
-                      </SelectItem>
-                    ))}
-                    <SelectItem value="__none__">None (Disable auto-connect)</SelectItem>
+                    <SelectItem value="__auto__">Auto (pick first available)</SelectItem>
+                    <SelectItem value="__none__">Disabled (no auto-connect)</SelectItem>
+                    {ports.length > 0 && (
+                      <>
+                        <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">Available Ports</div>
+                        {ports.map((port) => (
+                          <SelectItem key={port} value={port}>
+                            {port}
+                          </SelectItem>
+                        ))}
+                      </>
+                    )}
                   </SelectContent>
                 </Select>
                 <Button
@@ -851,7 +876,7 @@ export function SettingsPage() {
                 </Button>
               </div>
               <p className="text-xs text-muted-foreground">
-                When set, the system will automatically connect to this port on startup. Set to "None" to disable auto-connect.
+                Choose how the system connects on startup: Auto picks the first available port, Disabled requires manual connection, or select a specific port.
               </p>
             </div>
           </AccordionContent>
@@ -1096,33 +1121,35 @@ export function SettingsPage() {
             {/* Custom Logo */}
             <div className="space-y-3">
               <Label>Custom Logo</Label>
-              <div className="flex items-center gap-4 p-4 rounded-lg border">
-                <div className="w-16 h-16 rounded-full overflow-hidden border bg-background flex items-center justify-center shrink-0">
-                  {settings.custom_logo ? (
-                    <img
-                      src={`/static/custom/${settings.custom_logo}`}
-                      alt="Custom Logo"
-                      className="w-full h-full object-cover"
-                    />
-                  ) : (
-                    <img
-                      src="/static/android-chrome-192x192.png"
-                      alt="Default Logo"
-                      className="w-full h-full object-cover"
-                    />
-                  )}
-                </div>
-                <div className="flex-1">
-                  <p className="font-medium">
-                    {settings.custom_logo ? 'Custom logo active' : 'Using default logo'}
-                  </p>
-                  <p className="text-sm text-muted-foreground">
-                    PNG, JPG, GIF, WebP or SVG (max 5MB)
-                  </p>
+              <div className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 rounded-lg border">
+                <div className="flex items-center gap-4">
+                  <div className="w-16 h-16 rounded-full overflow-hidden border bg-background flex items-center justify-center shrink-0">
+                    {settings.custom_logo ? (
+                      <img
+                        src={apiClient.getAssetUrl(`/static/custom/${settings.custom_logo}`)}
+                        alt="Custom Logo"
+                        className="w-full h-full object-cover"
+                      />
+                    ) : (
+                      <img
+                        src={apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
+                        alt="Default Logo"
+                        className="w-full h-full object-cover"
+                      />
+                    )}
+                  </div>
+                  <div className="flex-1">
+                    <p className="font-medium">
+                      {settings.custom_logo ? 'Custom logo active' : 'Using default logo'}
+                    </p>
+                    <p className="text-sm text-muted-foreground">
+                      PNG, JPG, GIF, WebP or SVG (max 5MB)
+                    </p>
+                  </div>
                 </div>
-                <div className="flex gap-2">
+                <div className="flex gap-2 sm:ml-auto">
                   <Button
-                    variant="outline"
+                    variant="secondary"
                     size="sm"
                     className="gap-2"
                     disabled={isLoading === 'logo'}
@@ -1137,7 +1164,7 @@ export function SettingsPage() {
                   </Button>
                   {settings.custom_logo && (
                     <Button
-                      variant="outline"
+                      variant="secondary"
                       size="sm"
                       className="gap-2 text-destructive hover:text-destructive"
                       disabled={isLoading === 'logo'}
@@ -1389,13 +1416,25 @@ export function SettingsPage() {
                     <Label htmlFor="numLeds">Number of LEDs</Label>
                     <Input
                       id="numLeds"
-                      type="number"
-                      value={ledConfig.num_leds || 60}
-                      onChange={(e) =>
-                        setLedConfig({ ...ledConfig, num_leds: parseInt(e.target.value) })
-                      }
-                      min={1}
-                      max={1000}
+                      type="text"
+                      inputMode="numeric"
+                      value={numLedsInput}
+                      onChange={(e) => {
+                        const val = e.target.value.replace(/[^0-9]/g, '')
+                        setNumLedsInput(val)
+                      }}
+                      onBlur={() => {
+                        const num = Math.min(1000, Math.max(1, parseInt(numLedsInput) || 60))
+                        setLedConfig({ ...ledConfig, num_leds: num })
+                        setNumLedsInput(String(num))
+                      }}
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter') {
+                          const num = Math.min(1000, Math.max(1, parseInt(numLedsInput) || 60))
+                          setLedConfig({ ...ledConfig, num_leds: num })
+                          setNumLedsInput(String(num))
+                        }
+                      }}
                     />
                   </div>
                   <div className="space-y-3">
@@ -1431,10 +1470,20 @@ export function SettingsPage() {
                       <SelectValue />
                     </SelectTrigger>
                     <SelectContent>
-                      <SelectItem value="RGB">RGB - WS2815/WS2811</SelectItem>
-                      <SelectItem value="GRB">GRB - WS2812/WS2812B</SelectItem>
-                      <SelectItem value="GRBW">GRBW - SK6812 RGBW</SelectItem>
-                      <SelectItem value="RGBW">RGBW - SK6812 variant</SelectItem>
+                      <SelectGroup>
+                        <SelectLabel>RGB Strips (3-channel)</SelectLabel>
+                        <SelectItem value="RGB">RGB - WS2815/WS2811</SelectItem>
+                        <SelectItem value="GRB">GRB - WS2812/WS2812B</SelectItem>
+                        <SelectItem value="BGR">BGR - Some WS2811 variants</SelectItem>
+                        <SelectItem value="RBG">RBG - Rare variant</SelectItem>
+                        <SelectItem value="GBR">GBR - Rare variant</SelectItem>
+                        <SelectItem value="BRG">BRG - Rare variant</SelectItem>
+                      </SelectGroup>
+                      <SelectGroup>
+                        <SelectLabel>RGBW Strips (4-channel)</SelectLabel>
+                        <SelectItem value="GRBW">GRBW - SK6812 RGBW</SelectItem>
+                        <SelectItem value="RGBW">RGBW - SK6812 variant</SelectItem>
+                      </SelectGroup>
                     </SelectContent>
                   </Select>
                 </div>
@@ -1596,7 +1645,7 @@ export function SettingsPage() {
               </Button>
               {mqttConfig.enabled && mqttConfig.broker && (
                 <Button
-                  variant="outline"
+                  variant="secondary"
                   onClick={handleTestMqttConnection}
                   disabled={isLoading === 'mqttTest'}
                   className="gap-2"
@@ -1701,10 +1750,25 @@ export function SettingsPage() {
                     <Label>Pause Between Patterns</Label>
                     <div className="flex gap-2">
                       <Input
-                        type="number"
-                        min="0"
-                        value={autoPlayPauseValue}
-                        onChange={(e) => setAutoPlayPauseValue(Number(e.target.value) || 0)}
+                        type="text"
+                        inputMode="numeric"
+                        value={autoPlayPauseInput}
+                        onChange={(e) => {
+                          const val = e.target.value.replace(/[^0-9]/g, '')
+                          setAutoPlayPauseInput(val)
+                        }}
+                        onBlur={() => {
+                          const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
+                          setAutoPlayPauseValue(num)
+                          setAutoPlayPauseInput(String(num))
+                        }}
+                        onKeyDown={(e) => {
+                          if (e.key === 'Enter') {
+                            const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
+                            setAutoPlayPauseValue(num)
+                            setAutoPlayPauseInput(String(num))
+                          }
+                        }}
                         className="w-20"
                       />
                       <Select
@@ -1869,64 +1933,58 @@ export function SettingsPage() {
                       <div>
                         <p className="text-sm font-medium">Timezone</p>
                         <p className="text-xs text-muted-foreground">
-                          Select or type a timezone (e.g., UTC+5, America/New_York)
+                          Select a timezone for scheduling
                         </p>
                       </div>
                     </div>
-                    <div className="relative">
-                      <Input
-                        list="timezone-options"
-                        value={stillSandsSettings.timezone || ''}
-                        onChange={(e) =>
-                          setStillSandsSettings({ ...stillSandsSettings, timezone: e.target.value })
-                        }
-                        placeholder="System Default"
-                        className="w-full sm:w-[200px]"
-                      />
-                      <datalist id="timezone-options">
-                        {/* UTC Offsets */}
-                        <option value="Etc/GMT+12">UTC-12</option>
-                        <option value="Etc/GMT+11">UTC-11</option>
-                        <option value="Etc/GMT+10">UTC-10</option>
-                        <option value="Etc/GMT+9">UTC-9</option>
-                        <option value="Etc/GMT+8">UTC-8</option>
-                        <option value="Etc/GMT+7">UTC-7</option>
-                        <option value="Etc/GMT+6">UTC-6</option>
-                        <option value="Etc/GMT+5">UTC-5</option>
-                        <option value="Etc/GMT+4">UTC-4</option>
-                        <option value="Etc/GMT+3">UTC-3</option>
-                        <option value="Etc/GMT+2">UTC-2</option>
-                        <option value="Etc/GMT+1">UTC-1</option>
-                        <option value="UTC">UTC</option>
-                        <option value="Etc/GMT-1">UTC+1</option>
-                        <option value="Etc/GMT-2">UTC+2</option>
-                        <option value="Etc/GMT-3">UTC+3</option>
-                        <option value="Etc/GMT-4">UTC+4</option>
-                        <option value="Etc/GMT-5">UTC+5</option>
-                        <option value="Etc/GMT-6">UTC+6</option>
-                        <option value="Etc/GMT-7">UTC+7</option>
-                        <option value="Etc/GMT-8">UTC+8</option>
-                        <option value="Etc/GMT-9">UTC+9</option>
-                        <option value="Etc/GMT-10">UTC+10</option>
-                        <option value="Etc/GMT-11">UTC+11</option>
-                        <option value="Etc/GMT-12">UTC+12</option>
-                        {/* Americas */}
-                        <option value="America/New_York">America/New_York (Eastern)</option>
-                        <option value="America/Chicago">America/Chicago (Central)</option>
-                        <option value="America/Denver">America/Denver (Mountain)</option>
-                        <option value="America/Los_Angeles">America/Los_Angeles (Pacific)</option>
-                        {/* Europe */}
-                        <option value="Europe/London">Europe/London</option>
-                        <option value="Europe/Paris">Europe/Paris</option>
-                        <option value="Europe/Berlin">Europe/Berlin</option>
-                        {/* Asia */}
-                        <option value="Asia/Tokyo">Asia/Tokyo</option>
-                        <option value="Asia/Shanghai">Asia/Shanghai</option>
-                        <option value="Asia/Singapore">Asia/Singapore</option>
-                        {/* Australia */}
-                        <option value="Australia/Sydney">Australia/Sydney</option>
-                      </datalist>
-                    </div>
+                    <SearchableSelect
+                      value={stillSandsSettings.timezone || ''}
+                      onValueChange={(value) =>
+                        setStillSandsSettings({ ...stillSandsSettings, timezone: value })
+                      }
+                      placeholder="System Default"
+                      searchPlaceholder="Search timezones..."
+                      className="w-full sm:w-[200px]"
+                      options={[
+                        { value: '', label: 'System Default' },
+                        { value: 'Etc/GMT+12', label: 'UTC-12' },
+                        { value: 'Etc/GMT+11', label: 'UTC-11' },
+                        { value: 'Etc/GMT+10', label: 'UTC-10' },
+                        { value: 'Etc/GMT+9', label: 'UTC-9' },
+                        { value: 'Etc/GMT+8', label: 'UTC-8' },
+                        { value: 'Etc/GMT+7', label: 'UTC-7' },
+                        { value: 'Etc/GMT+6', label: 'UTC-6' },
+                        { value: 'Etc/GMT+5', label: 'UTC-5' },
+                        { value: 'Etc/GMT+4', label: 'UTC-4' },
+                        { value: 'Etc/GMT+3', label: 'UTC-3' },
+                        { value: 'Etc/GMT+2', label: 'UTC-2' },
+                        { value: 'Etc/GMT+1', label: 'UTC-1' },
+                        { value: 'UTC', label: 'UTC' },
+                        { value: 'Etc/GMT-1', label: 'UTC+1' },
+                        { value: 'Etc/GMT-2', label: 'UTC+2' },
+                        { value: 'Etc/GMT-3', label: 'UTC+3' },
+                        { value: 'Etc/GMT-4', label: 'UTC+4' },
+                        { value: 'Etc/GMT-5', label: 'UTC+5' },
+                        { value: 'Etc/GMT-6', label: 'UTC+6' },
+                        { value: 'Etc/GMT-7', label: 'UTC+7' },
+                        { value: 'Etc/GMT-8', label: 'UTC+8' },
+                        { value: 'Etc/GMT-9', label: 'UTC+9' },
+                        { value: 'Etc/GMT-10', label: 'UTC+10' },
+                        { value: 'Etc/GMT-11', label: 'UTC+11' },
+                        { value: 'Etc/GMT-12', label: 'UTC+12' },
+                        { value: 'America/New_York', label: 'America/New_York (Eastern)' },
+                        { value: 'America/Chicago', label: 'America/Chicago (Central)' },
+                        { value: 'America/Denver', label: 'America/Denver (Mountain)' },
+                        { value: 'America/Los_Angeles', label: 'America/Los_Angeles (Pacific)' },
+                        { value: 'Europe/London', label: 'Europe/London' },
+                        { value: 'Europe/Paris', label: 'Europe/Paris' },
+                        { value: 'Europe/Berlin', label: 'Europe/Berlin' },
+                        { value: 'Asia/Tokyo', label: 'Asia/Tokyo' },
+                        { value: 'Asia/Shanghai', label: 'Asia/Shanghai' },
+                        { value: 'Asia/Singapore', label: 'Asia/Singapore' },
+                        { value: 'Australia/Sydney', label: 'Australia/Sydney' },
+                      ]}
+                    />
                   </div>
                 </div>
 
@@ -1934,7 +1992,7 @@ export function SettingsPage() {
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex items-center justify-between">
                     <h4 className="font-medium">Still Periods</h4>
-                    <Button onClick={addTimeSlot} size="sm" variant="outline" className="gap-1">
+                    <Button onClick={addTimeSlot} size="sm" variant="secondary" className="gap-1">
                       <span className="material-icons text-base">add</span>
                       Add Period
                     </Button>
@@ -2064,7 +2122,7 @@ export function SettingsPage() {
           </AccordionTrigger>
           <AccordionContent className="pt-4 pb-6 space-y-3">
             <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
-              <div className="p-2 bg-background rounded-lg">
+              <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
                 <span className="material-icons text-muted-foreground">terminal</span>
               </div>
               <div className="flex-1">
@@ -2076,7 +2134,7 @@ export function SettingsPage() {
             </div>
 
             <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
-              <div className="p-2 bg-background rounded-lg">
+              <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
                 <span className="material-icons text-muted-foreground">system_update</span>
               </div>
               <div className="flex-1">
@@ -2092,7 +2150,7 @@ export function SettingsPage() {
               <Alert className="flex items-start">
                 <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
                 <AlertDescription>
-                  To update, run <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">dw update</code> from the host machine.
+                  To update, SSH into your Raspberry Pi and run <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">dw update</code>
                 </AlertDescription>
               </Alert>
             )}

+ 125 - 54
frontend/src/pages/TableControlPage.tsx

@@ -27,6 +27,13 @@ import {
   TooltipProvider,
   TooltipTrigger,
 } from '@/components/ui/tooltip'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
 import { apiClient } from '@/lib/apiClient'
 
 export function TableControlPage() {
@@ -43,7 +50,6 @@ export function TableControlPage() {
   const [serialCommand, setSerialCommand] = useState('')
   const [serialHistory, setSerialHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
   const [serialLoading, setSerialLoading] = useState(false)
-  const [mainConnectionPort, setMainConnectionPort] = useState<string | null>(null)
   const serialOutputRef = useRef<HTMLDivElement>(null)
   const serialInputRef = useRef<HTMLInputElement>(null)
 
@@ -147,7 +153,6 @@ export function TableControlPage() {
   }
 
   const handleHome = async () => {
-    if (checkPatternRunning('home')) return
     try {
       await handleAction('home', '/send_home')
       toast.success('Moving to home position...')
@@ -161,7 +166,22 @@ export function TableControlPage() {
       await handleAction('stop', '/stop_execution')
       toast.success('Execution stopped')
     } catch {
-      toast.error('Failed to stop execution')
+      // Normal stop failed, try force stop
+      try {
+        await handleAction('stop', '/force_stop')
+        toast.success('Force stopped')
+      } catch {
+        toast.error('Failed to stop execution')
+      }
+    }
+  }
+
+  const handleReset = async () => {
+    try {
+      await handleAction('reset', '/soft_reset')
+      toast.success('Reset sent. Please home the table.')
+    } catch {
+      toast.error('Failed to send reset command')
     }
   }
 
@@ -242,20 +262,29 @@ export function TableControlPage() {
 
   const fetchMainConnectionStatus = async () => {
     try {
+      // Fetch available ports first to validate against
+      const portsData = await apiClient.get<string[]>('/list_serial_ports')
+      const availablePorts = Array.isArray(portsData) ? portsData : []
+
       const data = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
       if (data.connected && data.port) {
-        setMainConnectionPort(data.port)
-        // Auto-select the connected port
-        setSelectedSerialPort(data.port)
+        // Only set port if it exists in available ports
+        // This prevents race conditions where stale port data from a different
+        // backend (e.g., Mac port on a Pi) could be set and auto-connected
+        if (availablePorts.includes(data.port)) {
+          setSelectedSerialPort(data.port)
+        } else {
+          console.warn(`Port ${data.port} from status not in available ports, ignoring`)
+        }
       }
     } catch {
       // Ignore errors
     }
   }
 
-  const handleSerialConnect = async () => {
+  const handleSerialConnect = async (silent = false) => {
     if (!selectedSerialPort) {
-      toast.error('Please select a serial port')
+      if (!silent) toast.error('Please select a serial port')
       return
     }
     setSerialLoading(true)
@@ -263,11 +292,11 @@ export function TableControlPage() {
       await apiClient.post('/api/debug-serial/open', { port: selectedSerialPort })
       setSerialConnected(true)
       addSerialHistory('resp', `Connected to ${selectedSerialPort}`)
-      toast.success(`Connected to ${selectedSerialPort}`)
+      if (!silent) toast.success(`Connected to ${selectedSerialPort}`)
     } catch (error) {
       const errorMsg = error instanceof Error ? error.message : 'Unknown error'
       addSerialHistory('error', `Failed to connect: ${errorMsg}`)
-      toast.error('Failed to connect to serial port')
+      if (!silent) toast.error('Failed to connect to serial port')
     } finally {
       setSerialLoading(false)
     }
@@ -362,20 +391,13 @@ export function TableControlPage() {
     fetchMainConnectionStatus()
   }, [])
 
-  // Auto-connect to the main connection port
-  useEffect(() => {
-    if (mainConnectionPort && selectedSerialPort === mainConnectionPort && !serialConnected && !serialLoading) {
-      handleSerialConnect()
-    }
-  }, [mainConnectionPort, selectedSerialPort])
-
   return (
     <TooltipProvider>
-      <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6">
+      <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
         {/* Page Header */}
-        <div className="space-y-0.5 sm:space-y-1">
-          <h1 className="text-xl sm:text-3xl font-bold tracking-tight">Table Control</h1>
-          <p className="text-xs sm:text-base text-muted-foreground">
+        <div className="space-y-0.5 sm:space-y-1 pl-1">
+          <h1 className="text-xl font-semibold tracking-tight">Table Control</h1>
+          <p className="text-xs text-muted-foreground">
             Manual controls for your sand table
           </p>
         </div>
@@ -391,12 +413,13 @@ export function TableControlPage() {
               <CardDescription>Calibrate or stop the table</CardDescription>
             </CardHeader>
             <CardContent>
-              <div className="grid grid-cols-2 gap-3">
+              <div className="grid grid-cols-3 gap-3">
                 <Tooltip>
                   <TooltipTrigger asChild>
                     <Button
                       onClick={handleHome}
                       disabled={isLoading === 'home'}
+                      variant="primary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'home' ? (
@@ -426,8 +449,54 @@ export function TableControlPage() {
                       <span className="text-xs">Stop</span>
                     </Button>
                   </TooltipTrigger>
-                  <TooltipContent>Emergency stop</TooltipContent>
+                  <TooltipContent>Gracefully stop</TooltipContent>
                 </Tooltip>
+
+                <Dialog>
+                  <Tooltip>
+                    <TooltipTrigger asChild>
+                      <DialogTrigger asChild>
+                        <Button
+                          disabled={isLoading === 'reset'}
+                          variant="secondary"
+                          className="h-16 gap-1 flex-col items-center justify-center"
+                        >
+                          {isLoading === 'reset' ? (
+                            <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                          ) : (
+                            <span className="material-icons-outlined text-2xl">restart_alt</span>
+                          )}
+                          <span className="text-xs">Reset</span>
+                        </Button>
+                      </DialogTrigger>
+                    </TooltipTrigger>
+                    <TooltipContent>Send Ctrl+X soft reset</TooltipContent>
+                  </Tooltip>
+                  <DialogContent className="sm:max-w-md">
+                    <DialogHeader>
+                      <DialogTitle>Reset Controller?</DialogTitle>
+                      <DialogDescription>
+                        This will send a soft reset (Ctrl+X) to the controller.
+                      </DialogDescription>
+                    </DialogHeader>
+                    <Alert className="flex items-center border-amber-500/50">
+                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
+                      <AlertDescription className="text-amber-600 dark:text-amber-400">
+                        Homing is required after resetting. The table will lose its position reference.
+                      </AlertDescription>
+                    </Alert>
+                    <DialogFooter className="gap-2 sm:gap-0">
+                      <DialogTrigger asChild>
+                        <Button variant="outline">Cancel</Button>
+                      </DialogTrigger>
+                      <DialogTrigger asChild>
+                        <Button variant="destructive" onClick={handleReset}>
+                          Reset Controller
+                        </Button>
+                      </DialogTrigger>
+                    </DialogFooter>
+                  </DialogContent>
+                </Dialog>
               </div>
             </CardContent>
           </Card>
@@ -486,7 +555,7 @@ export function TableControlPage() {
                     <Button
                       onClick={handleMoveToCenter}
                       disabled={isLoading === 'center'}
-                      variant="outline"
+                      variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'center' ? (
@@ -505,7 +574,7 @@ export function TableControlPage() {
                     <Button
                       onClick={handleMoveToPerimeter}
                       disabled={isLoading === 'perimeter'}
-                      variant="outline"
+                      variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'perimeter' ? (
@@ -524,7 +593,7 @@ export function TableControlPage() {
                     <TooltipTrigger asChild>
                       <DialogTrigger asChild>
                         <Button
-                          variant="outline"
+                          variant="secondary"
                           className="h-16 gap-1 flex-col items-center justify-center"
                         >
                           <span className="material-icons-outlined text-2xl">screen_rotation</span>
@@ -551,7 +620,7 @@ export function TableControlPage() {
                       ].map((step, i) => (
                         <li key={i} className="flex gap-3">
                           <Badge
-                            variant="outline"
+                            variant="secondary"
                             className="h-6 w-6 shrink-0 items-center justify-center rounded-full p-0"
                           >
                             {i + 1}
@@ -576,7 +645,7 @@ export function TableControlPage() {
                       <p className="text-sm font-medium text-center">Fine Adjustment</p>
                       <div className="flex justify-center gap-2">
                         <Button
-                          variant="outline"
+                          variant="secondary"
                           onClick={() => handleRotate(-10)}
                           disabled={isLoading === 'rotate'}
                         >
@@ -584,7 +653,7 @@ export function TableControlPage() {
                           CCW 10°
                         </Button>
                         <Button
-                          variant="outline"
+                          variant="secondary"
                           onClick={() => handleRotate(10)}
                           disabled={isLoading === 'rotate'}
                         >
@@ -621,7 +690,7 @@ export function TableControlPage() {
                     <Button
                       onClick={() => handleClearPattern('clear_from_in.thr', 'clear from center')}
                       disabled={isLoading === 'clear_from_in.thr'}
-                      variant="outline"
+                      variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'clear_from_in.thr' ? (
@@ -640,7 +709,7 @@ export function TableControlPage() {
                     <Button
                       onClick={() => handleClearPattern('clear_from_out.thr', 'clear from perimeter')}
                       disabled={isLoading === 'clear_from_out.thr'}
-                      variant="outline"
+                      variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'clear_from_out.thr' ? (
@@ -659,7 +728,7 @@ export function TableControlPage() {
                     <Button
                       onClick={() => handleClearPattern('clear_sideway.thr', 'clear sideways')}
                       disabled={isLoading === 'clear_sideway.thr'}
-                      variant="outline"
+                      variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'clear_sideway.thr' ? (
@@ -681,12 +750,19 @@ export function TableControlPage() {
         <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
           <CardHeader className="pb-3 space-y-3">
             <div className="flex items-start justify-between gap-2">
-              <div className="min-w-0">
+              <div className="min-w-0 space-y-2">
                 <CardTitle className="text-lg flex items-center gap-2">
                   <span className="material-icons-outlined text-xl">terminal</span>
                   Serial Terminal
                 </CardTitle>
                 <CardDescription className="hidden sm:block">Send raw commands to the table controller</CardDescription>
+                {/* Warning about pattern interference */}
+                <Alert className="flex items-center border-amber-500/50 py-2">
+                  <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
+                  <AlertDescription className="text-xs text-amber-600 dark:text-amber-400">
+                    Do not use while a pattern is running. This will interfere with the main connection.
+                  </AlertDescription>
+                </Alert>
               </div>
               {/* Clear button - only show on desktop in header */}
               <div className="hidden sm:flex items-center gap-1">
@@ -697,38 +773,33 @@ export function TableControlPage() {
                     onClick={() => setSerialHistory([])}
                     title="Clear history"
                   >
-                    <span className="material-icons-outlined">delete</span>
+                    <span className="material-icons-outlined">delete_sweep</span>
                   </Button>
                 )}
               </div>
             </div>
             {/* Controls row - stacks better on mobile */}
             <div className="flex flex-wrap items-center gap-2">
-              {/* Port selector */}
-              <select
-                className="h-9 flex-1 min-w-[140px] max-w-[200px] rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring"
+              {/* Port selector - auto-refreshes on open */}
+              <Select
                 value={selectedSerialPort}
-                onChange={(e) => setSelectedSerialPort(e.target.value)}
+                onValueChange={setSelectedSerialPort}
+                onOpenChange={(open) => open && fetchSerialPorts()}
                 disabled={serialConnected || serialLoading}
               >
-                <option value="">Select port...</option>
-                {serialPorts.map((port) => (
-                  <option key={port} value={port}>{port}</option>
-                ))}
-              </select>
-              <Button
-                variant="ghost"
-                size="icon"
-                onClick={fetchSerialPorts}
-                disabled={serialConnected || serialLoading}
-                title="Refresh ports"
-              >
-                <span className="material-icons-outlined">refresh</span>
-              </Button>
+                <SelectTrigger className="h-9 flex-1 min-w-[180px] max-w-[280px]">
+                  <SelectValue placeholder="Select port..." />
+                </SelectTrigger>
+                <SelectContent>
+                  {serialPorts.map((port) => (
+                    <SelectItem key={port} value={port}>{port}</SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
               {!serialConnected ? (
                 <Button
                   size="sm"
-                  onClick={handleSerialConnect}
+                  onClick={() => handleSerialConnect()}
                   disabled={!selectedSerialPort || serialLoading}
                   title="Connect"
                 >
@@ -753,7 +824,7 @@ export function TableControlPage() {
                   </Button>
                   <Button
                     size="sm"
-                    variant="outline"
+                    variant="secondary"
                     onClick={handleSerialReset}
                     disabled={serialLoading}
                     title="Send Ctrl+X soft reset"

+ 60 - 1
frontend/vite.config.ts

@@ -1,10 +1,65 @@
 import { defineConfig } from 'vite'
 import react from '@vitejs/plugin-react'
+import { VitePWA } from 'vite-plugin-pwa'
 import path from 'path'
 
 // https://vite.dev/config/
 export default defineConfig({
-  plugins: [react()],
+  plugins: [
+    react(),
+    VitePWA({
+      registerType: 'autoUpdate',
+      includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'android-chrome-192x192.png', 'android-chrome-512x512.png'],
+      manifest: false, // We use our own manifest at /static/site.webmanifest
+      workbox: {
+        // Cache static assets
+        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
+        // Runtime caching rules
+        runtimeCaching: [
+          {
+            // Cache pattern preview images
+            urlPattern: /\/static\/.*\.webp$/,
+            handler: 'CacheFirst',
+            options: {
+              cacheName: 'pattern-previews',
+              expiration: {
+                maxEntries: 500,
+                maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
+              },
+            },
+          },
+          {
+            // Cache static assets from backend
+            urlPattern: /\/static\/.*\.(png|jpg|ico|svg)$/,
+            handler: 'CacheFirst',
+            options: {
+              cacheName: 'static-assets',
+              expiration: {
+                maxEntries: 100,
+                maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days
+              },
+            },
+          },
+          {
+            // Network-first for API calls (always want fresh data, but cache as fallback)
+            urlPattern: /\/api\//,
+            handler: 'NetworkFirst',
+            options: {
+              cacheName: 'api-cache',
+              expiration: {
+                maxEntries: 50,
+                maxAgeSeconds: 60 * 5, // 5 minutes
+              },
+              networkTimeoutSeconds: 10,
+            },
+          },
+        ],
+      },
+      devOptions: {
+        enabled: false, // Disable in dev mode to avoid caching issues
+      },
+    }),
+  ],
   resolve: {
     alias: {
       '@': path.resolve(__dirname, './src'),
@@ -67,10 +122,14 @@ export default defineConfig({
       '/send_home': 'http://localhost:8080',
       '/send_coordinate': 'http://localhost:8080',
       '/stop_execution': 'http://localhost:8080',
+      '/force_stop': 'http://localhost:8080',
+      '/soft_reset': 'http://localhost:8080',
+      '/controller_restart': 'http://localhost:8080',
       '/pause_execution': 'http://localhost:8080',
       '/resume_execution': 'http://localhost:8080',
       '/skip_pattern': 'http://localhost:8080',
       '/reorder_playlist': 'http://localhost:8080',
+      '/add_to_queue': 'http://localhost:8080',
       '/run_theta_rho': 'http://localhost:8080',
       '/run_playlist': 'http://localhost:8080',
       // Movement

+ 545 - 65
main.py

@@ -288,10 +288,11 @@ app = FastAPI(lifespan=lifespan)
 
 # Add CORS middleware to allow cross-origin requests from other Dune Weaver frontends
 # This enables multi-table control from a single frontend
+# Note: allow_credentials must be False when allow_origins=["*"] (browser security requirement)
 app.add_middleware(
     CORSMiddleware,
     allow_origins=["*"],  # Allow all origins for local network access
-    allow_credentials=True,
+    allow_credentials=False,
     allow_methods=["*"],
     allow_headers=["*"],
 )
@@ -722,6 +723,50 @@ async def get_all_settings():
         }
     }
 
+@app.get("/api/manifest.webmanifest", tags=["settings"])
+async def get_dynamic_manifest():
+    """
+    Get a dynamically generated web manifest.
+
+    Returns manifest with custom icons and app name if custom branding is configured,
+    otherwise returns defaults.
+    """
+    # Determine icon paths based on whether custom logo exists
+    if state.custom_logo:
+        icon_base = "/static/custom"
+    else:
+        icon_base = "/static"
+
+    # Use custom app name or default
+    app_name = state.app_name or "Dune Weaver"
+
+    return {
+        "name": app_name,
+        "short_name": app_name,
+        "description": "Control your kinetic sand table",
+        "icons": [
+            {
+                "src": f"{icon_base}/android-chrome-192x192.png",
+                "sizes": "192x192",
+                "type": "image/png",
+                "purpose": "any"
+            },
+            {
+                "src": f"{icon_base}/android-chrome-512x512.png",
+                "sizes": "512x512",
+                "type": "image/png",
+                "purpose": "any"
+            }
+        ],
+        "start_url": "/",
+        "scope": "/",
+        "display": "standalone",
+        "orientation": "any",
+        "theme_color": "#0a0a0a",
+        "background_color": "#0a0a0a",
+        "categories": ["utilities", "entertainment"]
+    }
+
 @app.patch("/api/settings", tags=["settings"])
 async def update_settings(settings_update: SettingsUpdate):
     """
@@ -730,7 +775,7 @@ async def update_settings(settings_update: SettingsUpdate):
     Only include the categories and fields you want to update.
     All fields are optional - only provided values will be updated.
 
-    Example: {"app": {"name": "My Sand Table"}, "auto_play": {"enabled": true}}
+    Example: {"app": {"name": "Dune Weaver"}, "auto_play": {"enabled": true}}
     """
     updated_categories = []
     requires_restart = False
@@ -748,8 +793,8 @@ async def update_settings(settings_update: SettingsUpdate):
     # Connection settings
     if settings_update.connection:
         if settings_update.connection.preferred_port is not None:
-            port = settings_update.connection.preferred_port
-            state.preferred_port = None if port in ("", "none") else port
+            # Store exactly what frontend sends: "__auto__", "__none__", or specific port
+            state.preferred_port = settings_update.connection.preferred_port
         updated_categories.append("connection")
 
     # Pattern settings
@@ -920,6 +965,17 @@ async def update_settings(settings_update: SettingsUpdate):
 class TableInfoUpdate(BaseModel):
     name: Optional[str] = None
 
+class KnownTableAdd(BaseModel):
+    id: str
+    name: str
+    url: str
+    host: Optional[str] = None
+    port: Optional[int] = None
+    version: Optional[str] = None
+
+class KnownTableUpdate(BaseModel):
+    name: Optional[str] = None
+
 @app.get("/api/table-info", tags=["multi-table"])
 async def get_table_info():
     """
@@ -942,7 +998,7 @@ async def update_table_info(update: TableInfoUpdate):
     The table ID is immutable after generation.
     """
     if update.name is not None:
-        state.table_name = update.name.strip() or "My Sand Table"
+        state.table_name = update.name.strip() or "Dune Weaver"
         state.save()
         logger.info(f"Table name updated to: {state.table_name}")
 
@@ -952,6 +1008,83 @@ async def update_table_info(update: TableInfoUpdate):
         "name": state.table_name
     }
 
+@app.get("/api/known-tables", tags=["multi-table"])
+async def get_known_tables():
+    """
+    Get list of known remote tables.
+
+    These are tables that have been manually added and are persisted
+    for multi-table management.
+    """
+    return {"tables": state.known_tables}
+
+@app.post("/api/known-tables", tags=["multi-table"])
+async def add_known_table(table: KnownTableAdd):
+    """
+    Add a known remote table.
+
+    This persists the table information so it's available across
+    browser sessions and devices.
+    """
+    # Check if table with same ID already exists
+    existing_ids = [t.get("id") for t in state.known_tables]
+    if table.id in existing_ids:
+        raise HTTPException(status_code=400, detail="Table with this ID already exists")
+
+    # Check if table with same URL already exists
+    existing_urls = [t.get("url") for t in state.known_tables]
+    if table.url in existing_urls:
+        raise HTTPException(status_code=400, detail="Table with this URL already exists")
+
+    new_table = {
+        "id": table.id,
+        "name": table.name,
+        "url": table.url,
+    }
+    if table.host:
+        new_table["host"] = table.host
+    if table.port:
+        new_table["port"] = table.port
+    if table.version:
+        new_table["version"] = table.version
+
+    state.known_tables.append(new_table)
+    state.save()
+    logger.info(f"Added known table: {table.name} ({table.url})")
+
+    return {"success": True, "table": new_table}
+
+@app.delete("/api/known-tables/{table_id}", tags=["multi-table"])
+async def remove_known_table(table_id: str):
+    """
+    Remove a known remote table by ID.
+    """
+    original_count = len(state.known_tables)
+    state.known_tables = [t for t in state.known_tables if t.get("id") != table_id]
+
+    if len(state.known_tables) == original_count:
+        raise HTTPException(status_code=404, detail="Table not found")
+
+    state.save()
+    logger.info(f"Removed known table: {table_id}")
+
+    return {"success": True}
+
+@app.patch("/api/known-tables/{table_id}", tags=["multi-table"])
+async def update_known_table(table_id: str, update: KnownTableUpdate):
+    """
+    Update a known remote table's name.
+    """
+    for table in state.known_tables:
+        if table.get("id") == table_id:
+            if update.name is not None:
+                table["name"] = update.name.strip()
+            state.save()
+            logger.info(f"Updated known table {table_id}: name={update.name}")
+            return {"success": True, "table": table}
+
+    raise HTTPException(status_code=404, detail="Table not found")
+
 # ============================================================================
 # Individual Settings Endpoints (Deprecated - use /api/settings instead)
 # ============================================================================
@@ -1253,29 +1386,51 @@ async def debug_serial_send(request: DebugSerialCommand):
             await asyncio.to_thread(ser.write, command.encode())
             await asyncio.to_thread(ser.flush)
 
-            # Read response lines with timeout
+            # Read response with timeout - use read() for more reliable data capture
             responses = []
             start_time = time.time()
-            original_timeout = ser.timeout
-            ser.timeout = 0.1  # Short timeout for reading
+            buffer = ""
+
+            # Small delay to let response arrive
+            await asyncio.sleep(0.05)
 
             while time.time() - start_time < request.timeout:
                 try:
-                    line = await asyncio.to_thread(ser.readline)
-                    if line:
-                        decoded = line.decode('utf-8', errors='replace').strip()
-                        if decoded:
-                            responses.append(decoded)
-                            # Check for ok/error to know command completed
-                            if decoded.lower() in ['ok', 'error'] or decoded.lower().startswith('error:'):
-                                break
+                    # Read all available bytes
+                    waiting = ser.in_waiting
+                    if waiting > 0:
+                        data = await asyncio.to_thread(ser.read, waiting)
+                        if data:
+                            buffer += data.decode('utf-8', errors='replace')
+
+                            # Process complete lines from buffer
+                            while '\n' in buffer:
+                                line, buffer = buffer.split('\n', 1)
+                                line = line.strip()
+                                if line:
+                                    responses.append(line)
+                                    # Check for ok/error to know command completed
+                                    if line.lower() in ['ok', 'error'] or line.lower().startswith('error:'):
+                                        # Give a tiny bit more time for any trailing data
+                                        await asyncio.sleep(0.02)
+                                        # Read any remaining data
+                                        if ser.in_waiting > 0:
+                                            extra = await asyncio.to_thread(ser.read, ser.in_waiting)
+                                            if extra:
+                                                for extra_line in extra.decode('utf-8', errors='replace').strip().split('\n'):
+                                                    if extra_line.strip():
+                                                        responses.append(extra_line.strip())
+                                        break
                     else:
-                        # No data, small delay
-                        await asyncio.sleep(0.05)
-                except:
+                        # No data waiting, small delay
+                        await asyncio.sleep(0.02)
+                except Exception as read_error:
+                    logger.warning(f"Read error: {read_error}")
                     break
 
-            ser.timeout = original_timeout
+            # Add any remaining buffer content
+            if buffer.strip():
+                responses.append(buffer.strip())
 
             return {
                 "success": True,
@@ -1503,26 +1658,39 @@ async def get_theta_rho_coordinates(request: GetCoordinatesRequest):
         # Normalize file path for cross-platform compatibility and remove prefixes
         file_name = normalize_file_path(request.file_name)
         file_path = os.path.join(THETA_RHO_DIR, file_name)
-        
+
+        # Check if we can use cached coordinates (already loaded for current playback)
+        # This avoids re-parsing large files (2MB+) which can cause issues on Pi Zero 2W
+        current_file = state.current_playing_file
+        if current_file and state._current_coordinates:
+            # Normalize current file path for comparison
+            current_normalized = normalize_file_path(current_file)
+            if current_normalized == file_name:
+                logger.debug(f"Using cached coordinates for {file_name}")
+                return {
+                    "success": True,
+                    "coordinates": state._current_coordinates,
+                    "total_points": len(state._current_coordinates)
+                }
+
         # Check file existence asynchronously
         exists = await asyncio.to_thread(os.path.exists, file_path)
         if not exists:
             raise HTTPException(status_code=404, detail=f"File {file_name} not found")
 
-        # Parse the theta-rho file in a separate process for CPU-intensive work
-        # This prevents blocking the motion control thread
-        loop = asyncio.get_running_loop()
-        coordinates = await loop.run_in_executor(pool_module.get_pool(), parse_theta_rho_file, file_path)
-        
+        # Parse the theta-rho file in a thread (not process) to avoid memory pressure
+        # on resource-constrained devices like Pi Zero 2W
+        coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
+
         if not coordinates:
             raise HTTPException(status_code=400, detail="No valid coordinates found in file")
-        
+
         return {
             "success": True,
             "coordinates": coordinates,
             "total_points": len(coordinates)
         }
-        
+
     except Exception as e:
         logger.error(f"Error getting coordinates for {request.file_name}: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
@@ -1584,9 +1752,111 @@ async def stop_execution():
     if not (state.conn.is_connected() if state.conn else False):
         logger.warning("Attempted to stop without a connection")
         raise HTTPException(status_code=400, detail="Connection not established")
-    await pattern_manager.stop_actions()
+    success = await pattern_manager.stop_actions()
+    if not success:
+        raise HTTPException(status_code=500, detail="Stop timed out - use force_stop")
     return {"success": True}
 
+@app.post("/force_stop")
+async def force_stop():
+    """Force stop all pattern execution and clear all state. Use when normal stop doesn't work."""
+    logger.info("Force stop requested - clearing all pattern state")
+
+    # Set stop flag first
+    state.stop_requested = True
+    state.pause_requested = False
+
+    # Clear all pattern-related state
+    state.current_playing_file = None
+    state.execution_progress = None
+    state.is_running = False
+    state.is_clearing = False
+    state.is_homing = False
+    state.current_playlist = None
+    state.current_playlist_index = None
+    state.playlist_mode = None
+    state.pause_time_remaining = 0
+
+    # Wake up any waiting tasks
+    try:
+        pattern_manager.get_pause_event().set()
+    except:
+        pass
+
+    # Stop motion controller and clear its queue
+    if pattern_manager.motion_controller.running:
+        pattern_manager.motion_controller.command_queue.put(
+            pattern_manager.MotionCommand('stop')
+        )
+
+    # Force release pattern lock by recreating it
+    pattern_manager.pattern_lock = None  # Will be recreated on next use
+
+    logger.info("Force stop completed - all pattern state cleared")
+    return {"success": True, "message": "Force stop completed"}
+
+@app.post("/soft_reset")
+async def soft_reset():
+    """Send Ctrl+X soft reset to the controller (DLC32/ESP32). Requires re-homing after."""
+    if not (state.conn and state.conn.is_connected()):
+        logger.warning("Attempted to soft reset without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+
+    try:
+        # Stop any running patterns first
+        await pattern_manager.stop_actions()
+
+        # Access the underlying serial object directly for more reliable reset
+        # This bypasses the connection abstraction which may have buffering issues
+        from modules.connection.connection_manager import SerialConnection
+        if isinstance(state.conn, SerialConnection) and state.conn.ser:
+            state.conn.ser.reset_input_buffer()  # Clear any pending data
+            state.conn.ser.write(b'\x18')  # Ctrl+X as bytes
+            state.conn.ser.flush()
+            logger.info(f"Soft reset command (Ctrl+X) sent directly via serial to {state.port}")
+        else:
+            # Fallback for WebSocket or other connection types
+            state.conn.send('\x18')
+            logger.info("Soft reset command (Ctrl+X) sent via connection abstraction")
+
+        # Mark as needing homing since position is now unknown
+        state.is_homed = False
+
+        return {"success": True, "message": "Soft reset sent. Homing required."}
+    except Exception as e:
+        logger.error(f"Error sending soft reset: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/controller_restart")
+async def controller_restart():
+    """Send $System/Control=RESTART to restart the FluidNC controller."""
+    if not (state.conn and state.conn.is_connected()):
+        logger.warning("Attempted to restart controller without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+
+    try:
+        # Stop any running patterns first
+        await pattern_manager.stop_actions()
+
+        # Send the FluidNC restart command
+        from modules.connection.connection_manager import SerialConnection
+        restart_cmd = "$System/Control=RESTART\n"
+        if isinstance(state.conn, SerialConnection) and state.conn.ser:
+            state.conn.ser.write(restart_cmd.encode())
+            state.conn.ser.flush()
+            logger.info(f"Controller restart command sent via serial to {state.port}")
+        else:
+            state.conn.send(restart_cmd)
+            logger.info("Controller restart command sent via connection abstraction")
+
+        # Mark as needing homing since position is now unknown
+        state.is_homed = False
+
+        return {"success": True, "message": "Controller restart command sent. Homing required."}
+    except Exception as e:
+        logger.error(f"Error sending controller restart: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
 @app.post("/send_home")
 async def send_home():
     try:
@@ -1680,6 +1950,9 @@ async def move_to_center():
 
         check_homing_in_progress()
 
+        # Clear stop_requested to ensure manual move works after pattern stop
+        state.stop_requested = False
+
         logger.info("Moving device to center position")
         await pattern_manager.reset_theta()
         await pattern_manager.move_polar(0, 0)
@@ -1699,6 +1972,9 @@ async def move_to_perimeter():
 
         check_homing_in_progress()
 
+        # Clear stop_requested to ensure manual move works after pattern stop
+        state.stop_requested = False
+
         await pattern_manager.reset_theta()
         await pattern_manager.move_polar(0, 1)
         return {"success": True}
@@ -1771,6 +2047,63 @@ async def preview_thr(request: DeleteFileRequest):
         logger.error(f"Failed to generate or serve preview for {request.file_name}: {str(e)}")
         raise HTTPException(status_code=500, detail=f"Failed to serve preview image: {str(e)}")
 
+@app.get("/api/pattern_history/{pattern_name:path}")
+async def get_pattern_history(pattern_name: str):
+    """Get the most recent execution history for a pattern.
+
+    Returns the last completed execution time and speed for the given pattern.
+    """
+    from modules.core.pattern_manager import get_pattern_execution_history
+
+    # Get just the filename if a full path was provided
+    filename = os.path.basename(pattern_name)
+    if not filename.endswith('.thr'):
+        filename = f"{filename}.thr"
+
+    history = get_pattern_execution_history(filename)
+    if history:
+        return history
+    return {"actual_time_seconds": None, "actual_time_formatted": None, "speed": None, "timestamp": None}
+
+@app.get("/api/pattern_history_all")
+async def get_all_pattern_history():
+    """Get execution history for all patterns in a single request.
+
+    Returns a dict mapping pattern names to their most recent execution history.
+    """
+    from modules.core.pattern_manager import EXECUTION_LOG_FILE
+    import json
+
+    if not os.path.exists(EXECUTION_LOG_FILE):
+        return {}
+
+    try:
+        history_map = {}
+        with open(EXECUTION_LOG_FILE, 'r') as f:
+            for line in f:
+                line = line.strip()
+                if not line:
+                    continue
+                try:
+                    entry = json.loads(line)
+                    # Only consider fully completed patterns
+                    if entry.get('completed', False):
+                        pattern_name = entry.get('pattern_name')
+                        if pattern_name:
+                            # Keep the most recent match (last one in file wins)
+                            history_map[pattern_name] = {
+                                "actual_time_seconds": entry.get('actual_time_seconds'),
+                                "actual_time_formatted": entry.get('actual_time_formatted'),
+                                "speed": entry.get('speed'),
+                                "timestamp": entry.get('timestamp')
+                            }
+                except json.JSONDecodeError:
+                    continue
+        return history_map
+    except Exception as e:
+        logger.error(f"Failed to read execution time log: {e}")
+        return {}
+
 @app.get("/preview/{encoded_filename}")
 async def serve_preview(encoded_filename: str):
     """Serve a preview image for a pattern file."""
@@ -1817,6 +2150,9 @@ async def send_coordinate(request: CoordinateRequest):
 
     check_homing_in_progress()
 
+    # Clear stop_requested to ensure manual move works after pattern stop
+    state.stop_requested = False
+
     try:
         logger.debug(f"Sending coordinate: theta={request.theta}, rho={request.rho}")
         await pattern_manager.move_polar(request.theta, request.rho)
@@ -1894,7 +2230,10 @@ async def get_playlist(name: str):
 
     playlist = playlist_manager.get_playlist(name)
     if not playlist:
-        raise HTTPException(status_code=404, detail=f"Playlist '{name}' not found")
+        # Auto-create empty playlist if not found
+        logger.info(f"Playlist '{name}' not found, creating empty playlist")
+        playlist_manager.create_playlist(name, [])
+        playlist = {"name": name, "files": []}
 
     return playlist
 
@@ -2064,8 +2403,8 @@ async def set_led_config(request: LEDConfigRequest):
         old_gpio_pin = state.dw_led_gpio_pin
         old_pixel_order = state.dw_led_pixel_order
         hardware_changed = (
-            old_gpio_pin != (request.gpio_pin or 12) or
-            old_pixel_order != (request.pixel_order or "GRB")
+            old_gpio_pin != (request.gpio_pin or 18) or
+            old_pixel_order != (request.pixel_order or "RGB")
         )
 
         # Stop existing DW LED controller if hardware settings changed
@@ -2078,10 +2417,13 @@ async def set_led_config(request: LEDConfigRequest):
                     logger.info("LED controller stopped successfully")
                 except Exception as e:
                     logger.error(f"Error stopping LED controller: {e}")
+            # Clear the reference and give hardware time to release
+            state.led_controller = None
+            await asyncio.sleep(0.5)
 
         state.dw_led_num_leds = request.num_leds or 60
-        state.dw_led_gpio_pin = request.gpio_pin or 12
-        state.dw_led_pixel_order = request.pixel_order or "GRB"
+        state.dw_led_gpio_pin = request.gpio_pin or 18
+        state.dw_led_pixel_order = request.pixel_order or "RGB"
         state.dw_led_brightness = request.brightness or 35
         state.wled_ip = None
 
@@ -2221,6 +2563,43 @@ async def reorder_playlist(request: dict):
 
     return {"success": True}
 
+@app.post("/add_to_queue")
+async def add_to_queue(request: dict):
+    """Add a pattern to the current playlist queue.
+
+    Args:
+        pattern: The pattern file path to add (e.g., 'circle.thr' or 'subdirectory/pattern.thr')
+        position: 'next' to play after current pattern, 'end' to add to end of queue
+    """
+    if not state.current_playlist:
+        raise HTTPException(status_code=400, detail="No playlist is currently running")
+
+    pattern = request.get("pattern")
+    position = request.get("position", "end")  # 'next' or 'end'
+
+    if not pattern:
+        raise HTTPException(status_code=400, detail="pattern is required")
+
+    # Verify the pattern file exists
+    pattern_path = os.path.join(pattern_manager.THETA_RHO_DIR, pattern)
+    if not os.path.exists(pattern_path):
+        raise HTTPException(status_code=404, detail="Pattern file not found")
+
+    playlist = list(state.current_playlist)
+    current_index = state.current_playlist_index
+
+    if position == "next":
+        # Insert right after the current pattern
+        insert_index = current_index + 1
+    else:
+        # Add to end
+        insert_index = len(playlist)
+
+    playlist.insert(insert_index, pattern)
+    state.current_playlist = playlist
+
+    return {"success": True, "position": insert_index}
+
 @app.get("/api/custom_clear_patterns", deprecated=True, tags=["settings-deprecated"])
 async def get_custom_clear_patterns():
     """Get the currently configured custom clear patterns."""
@@ -2327,26 +2706,26 @@ CUSTOM_BRANDING_DIR = os.path.join("static", "custom")
 ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
 MAX_LOGO_SIZE = 5 * 1024 * 1024  # 5MB
 
-def generate_favicon_from_logo(logo_path: str, favicon_path: str) -> bool:
-    """Generate a circular-cropped favicon from the uploaded logo using PIL.
+def generate_favicon_from_logo(logo_path: str, output_dir: str) -> bool:
+    """Generate circular favicons with transparent background from the uploaded logo.
+
+    Creates:
+    - favicon.ico (multi-size: 256, 128, 64, 48, 32, 16)
+    - favicon-16x16.png, favicon-32x32.png, favicon-96x96.png, favicon-128x128.png
 
-    Creates a multi-size ICO file (16x16, 32x32, 48x48) with circular crop.
     Returns True on success, False on failure.
     """
     try:
         from PIL import Image, ImageDraw
 
-        def create_circular_image(img, size):
-            """Create a circular-cropped image at the specified size."""
-            # Resize to target size
+        def create_circular_transparent(img, size):
+            """Create circular image with transparent background."""
             resized = img.resize((size, size), Image.Resampling.LANCZOS)
 
-            # Create circular mask
             mask = Image.new('L', (size, size), 0)
             draw = ImageDraw.Draw(mask)
             draw.ellipse((0, 0, size - 1, size - 1), fill=255)
 
-            # Apply circular mask - create transparent background
             output = Image.new('RGBA', (size, size), (0, 0, 0, 0))
             output.paste(resized, (0, 0), mask)
             return output
@@ -2363,16 +2742,25 @@ def generate_favicon_from_logo(logo_path: str, favicon_path: str) -> bool:
             top = (height - min_dim) // 2
             img = img.crop((left, top, left + min_dim, top + min_dim))
 
-            # Create circular images at each favicon size
-            sizes = [48, 32, 16]
-            circular_images = [create_circular_image(img, size) for size in sizes]
-
-            # Save as ICO - first image is the main one, rest are appended
-            circular_images[0].save(
-                favicon_path,
+            # Generate circular favicon PNGs with transparent background
+            png_sizes = {
+                "favicon-16x16.png": 16,
+                "favicon-32x32.png": 32,
+                "favicon-96x96.png": 96,
+                "favicon-128x128.png": 128,
+            }
+            for filename, size in png_sizes.items():
+                icon = create_circular_transparent(img, size)
+                icon.save(os.path.join(output_dir, filename), format='PNG')
+
+            # Generate high-resolution favicon.ico
+            ico_sizes = [256, 128, 64, 48, 32, 16]
+            ico_images = [create_circular_transparent(img, s) for s in ico_sizes]
+            ico_images[0].save(
+                os.path.join(output_dir, "favicon.ico"),
                 format='ICO',
-                append_images=circular_images[1:],
-                sizes=[(s, s) for s in sizes]
+                append_images=ico_images[1:],
+                sizes=[(s, s) for s in ico_sizes]
             )
 
         return True
@@ -2380,6 +2768,51 @@ def generate_favicon_from_logo(logo_path: str, favicon_path: str) -> bool:
         logger.error(f"Failed to generate favicon: {str(e)}")
         return False
 
+def generate_pwa_icons_from_logo(logo_path: str, output_dir: str) -> bool:
+    """Generate square PWA app icons from the uploaded logo.
+
+    Creates square icons (no circular crop) - OS will apply its own mask.
+
+    Generates:
+    - apple-touch-icon.png (180x180)
+    - android-chrome-192x192.png (192x192)
+    - android-chrome-512x512.png (512x512)
+
+    Returns True on success, False on failure.
+    """
+    try:
+        from PIL import Image
+
+        with Image.open(logo_path) as img:
+            # Convert to RGBA if needed
+            if img.mode != 'RGBA':
+                img = img.convert('RGBA')
+
+            # Crop to square (center crop)
+            width, height = img.size
+            min_dim = min(width, height)
+            left = (width - min_dim) // 2
+            top = (height - min_dim) // 2
+            img = img.crop((left, top, left + min_dim, top + min_dim))
+
+            # Generate square icons at each required size
+            icon_sizes = {
+                "apple-touch-icon.png": 180,
+                "android-chrome-192x192.png": 192,
+                "android-chrome-512x512.png": 512,
+            }
+
+            for filename, size in icon_sizes.items():
+                resized = img.resize((size, size), Image.Resampling.LANCZOS)
+                icon_path = os.path.join(output_dir, filename)
+                resized.save(icon_path, format='PNG')
+                logger.info(f"Generated PWA icon: {filename}")
+
+        return True
+    except Exception as e:
+        logger.error(f"Failed to generate PWA icons: {str(e)}")
+        return False
+
 @app.post("/api/upload-logo", tags=["settings"])
 async def upload_logo(file: UploadFile = File(...)):
     """Upload a custom logo image.
@@ -2429,22 +2862,24 @@ async def upload_logo(file: UploadFile = File(...)):
         with open(file_path, "wb") as f:
             f.write(content)
 
-        # Generate favicon from logo (for non-SVG files)
+        # Generate favicon and PWA icons from logo (for non-SVG files)
         favicon_generated = False
+        pwa_icons_generated = False
         if file_ext != ".svg":
-            favicon_path = os.path.join(CUSTOM_BRANDING_DIR, "favicon.ico")
-            favicon_generated = generate_favicon_from_logo(file_path, favicon_path)
+            favicon_generated = generate_favicon_from_logo(file_path, CUSTOM_BRANDING_DIR)
+            pwa_icons_generated = generate_pwa_icons_from_logo(file_path, CUSTOM_BRANDING_DIR)
 
         # Update state
         state.custom_logo = filename
         state.save()
 
-        logger.info(f"Custom logo uploaded: {filename}, favicon generated: {favicon_generated}")
+        logger.info(f"Custom logo uploaded: {filename}, favicon generated: {favicon_generated}, PWA icons generated: {pwa_icons_generated}")
         return {
             "success": True,
             "filename": filename,
             "url": f"/static/custom/{filename}",
-            "favicon_generated": favicon_generated
+            "favicon_generated": favicon_generated,
+            "pwa_icons_generated": pwa_icons_generated
         }
 
     except HTTPException:
@@ -2455,7 +2890,7 @@ async def upload_logo(file: UploadFile = File(...)):
 
 @app.delete("/api/custom-logo", tags=["settings"])
 async def delete_custom_logo():
-    """Remove custom logo and favicon, reverting to defaults."""
+    """Remove custom logo, favicon, and PWA icons, reverting to defaults."""
     try:
         if state.custom_logo:
             # Remove logo
@@ -2463,14 +2898,33 @@ async def delete_custom_logo():
             if os.path.exists(logo_path):
                 os.remove(logo_path)
 
-            # Remove generated favicon
-            favicon_path = os.path.join(CUSTOM_BRANDING_DIR, "favicon.ico")
-            if os.path.exists(favicon_path):
-                os.remove(favicon_path)
+            # Remove generated favicons
+            favicon_files = [
+                "favicon.ico",
+                "favicon-16x16.png",
+                "favicon-32x32.png",
+                "favicon-96x96.png",
+                "favicon-128x128.png",
+            ]
+            for favicon_name in favicon_files:
+                favicon_path = os.path.join(CUSTOM_BRANDING_DIR, favicon_name)
+                if os.path.exists(favicon_path):
+                    os.remove(favicon_path)
+
+            # Remove generated PWA icons
+            pwa_icons = [
+                "apple-touch-icon.png",
+                "android-chrome-192x192.png",
+                "android-chrome-512x512.png",
+            ]
+            for icon_name in pwa_icons:
+                icon_path = os.path.join(CUSTOM_BRANDING_DIR, icon_name)
+                if os.path.exists(icon_path):
+                    os.remove(icon_path)
 
             state.custom_logo = None
             state.save()
-            logger.info("Custom logo and favicon removed")
+            logger.info("Custom logo, favicon, and PWA icons removed")
         return {"success": True}
     except Exception as e:
         logger.error(f"Error removing logo: {str(e)}")
@@ -2647,6 +3101,15 @@ async def preview_thr_batch(request: dict):
 
     async def process_single_file(file_name):
         """Process a single file and return its preview data."""
+        # Check in-memory cache first (for current and next playing patterns)
+        normalized_for_cache = normalize_file_path(file_name)
+        if state._current_preview and state._current_preview[0] == normalized_for_cache:
+            logger.debug(f"Using cached preview for current: {file_name}")
+            return file_name, state._current_preview[1]
+        if state._next_preview and state._next_preview[0] == normalized_for_cache:
+            logger.debug(f"Using cached preview for next: {file_name}")
+            return file_name, state._next_preview[1]
+
         # Acquire semaphore to limit concurrent processing
         async with get_preview_semaphore():
             t1 = time.time()
@@ -2679,9 +3142,8 @@ async def preview_thr_batch(request: dict):
                     last_coord_obj = metadata.get('last_coordinate')
                 else:
                     logger.debug(f"Metadata cache miss for {file_name}, parsing file")
-                    # Use process pool for CPU-intensive parsing
-                    loop = asyncio.get_running_loop()
-                    coordinates = await loop.run_in_executor(pool_module.get_pool(), parse_theta_rho_file, pattern_file_path)
+                    # Use thread pool to avoid memory pressure on resource-constrained devices
+                    coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_file_path)
                     first_coord = coordinates[0] if coordinates else None
                     last_coord = coordinates[-1] if coordinates else None
                     first_coord_obj = {"x": first_coord[0], "y": first_coord[1]} if first_coord else None
@@ -2695,6 +3157,24 @@ async def preview_thr_batch(request: dict):
                     "first_coordinate": first_coord_obj,
                     "last_coordinate": last_coord_obj
                 }
+
+                # Cache preview for current/next pattern to speed up subsequent requests
+                current_file = state.current_playing_file
+                if current_file:
+                    current_normalized = normalize_file_path(current_file)
+                    if normalized_file_name == current_normalized:
+                        state._current_preview = (normalized_file_name, result)
+                        logger.debug(f"Cached preview for current: {file_name}")
+                    elif state.current_playlist:
+                        # Check if this is the next pattern in playlist
+                        playlist = state.current_playlist
+                        idx = state.current_playlist_index
+                        if idx is not None and idx + 1 < len(playlist):
+                            next_file = normalize_file_path(playlist[idx + 1])
+                            if normalized_file_name == next_file:
+                                state._next_preview = (normalized_file_name, result)
+                                logger.debug(f"Cached preview for next: {file_name}")
+
                 logger.debug(f"Processed {file_name} in {time.time() - t1:.2f}s")
                 return file_name, result
             except Exception as e:

+ 70 - 32
modules/connection/connection_manager.py

@@ -16,6 +16,9 @@ logger = logging.getLogger(__name__)
 
 IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
 
+# Ports to deprioritize during auto-connect (shown in UI but not auto-selected)
+DEPRIORITIZED_PORTS = ['/dev/ttyS0']
+
 
 async def _check_table_is_idle() -> bool:
     """Helper function to check if table is idle."""
@@ -116,8 +119,6 @@ class SerialConnection(BaseConnection):
         with self.lock:
             if self.ser.is_open:
                 self.ser.close()
-        # Release the lock resources
-        self.lock = None
 
 ###############################################################################
 # WebSocket Connection Implementation
@@ -181,9 +182,7 @@ class WebSocketConnection(BaseConnection):
         with self.lock:
             if self.ws:
                 self.ws.close()
-        # Release the lock resources
-        self.lock = None
-                
+
 def list_serial_ports():
     """Return a list of available serial ports."""
     ports = serial.tools.list_ports.comports()
@@ -256,19 +255,31 @@ def connect_device(homing=True):
 
     ports = list_serial_ports()
 
+    # Check auto-connect mode: "__auto__" or None = auto, "__none__" = disabled, else specific port
+    if state.preferred_port == "__none__":
+        logger.info("Auto-connect disabled by user preference")
+        # Skip all auto-connect logic, no connection will be established
     # Priority for auto-connect:
     # 1. Preferred port (user's explicit choice) if available
     # 2. Last used port if available
     # 3. First available port as fallback
-    if state.preferred_port and state.preferred_port in ports:
+    elif state.preferred_port and state.preferred_port not in ("__auto__", None) and state.preferred_port in ports:
         logger.info(f"Connecting to preferred port: {state.preferred_port}")
         state.conn = SerialConnection(state.preferred_port)
     elif state.port and state.port in ports:
         logger.info(f"Connecting to last used port: {state.port}")
         state.conn = SerialConnection(state.port)
     elif ports:
-        logger.info(f"Connecting to first available port: {ports[0]}")
-        state.conn = SerialConnection(ports[0])
+        # Prefer non-deprioritized ports (e.g., USB serial over hardware UART)
+        preferred_ports = [p for p in ports if p not in DEPRIORITIZED_PORTS]
+        fallback_ports = [p for p in ports if p in DEPRIORITIZED_PORTS]
+
+        if preferred_ports:
+            logger.info(f"Connecting to first available port: {preferred_ports[0]}")
+            state.conn = SerialConnection(preferred_ports[0])
+        elif fallback_ports:
+            logger.info(f"Connecting to deprioritized port (no better option): {fallback_ports[0]}")
+            state.conn = SerialConnection(fallback_ports[0])
     else:
         logger.error("Auto connect failed: No serial ports available")
         # state.conn = WebSocketConnection('ws://fluidnc.local:81')
@@ -376,6 +387,10 @@ def get_status_response() -> str:
     """
     Send a status query ('?') and return the response if available.
     """
+    if state.conn is None or not state.conn.is_connected():
+        logger.warning("Cannot get status response: no active connection")
+        return False
+
     while True:
         try:
             state.conn.send('?')
@@ -983,34 +998,38 @@ def home(timeout=90):
                     homing_complete.set()
                     return
 
-                # Send x0 y0 to zero both positions using send_grbl_coordinates
-                logger.info(f"Zeroing positions with x0 y0 f{homing_speed}")
+                # Skip zeroing if X homed but Y failed - moving Y to 0 would crash it
+                # (Y controls rho/radial position which is unknown if Y didn't home)
+                if state.homed_x and not state.homed_y:
+                    logger.warning("Skipping position zeroing - X homed but Y failed (would crash Y axis)")
+                else:
+                    # Send x0 y0 to zero both positions using send_grbl_coordinates
+                    logger.info(f"Zeroing positions with x0 y0 f{homing_speed}")
 
-                # Run async function in new event loop
-                loop = asyncio.new_event_loop()
-                asyncio.set_event_loop(loop)
-                try:
-                    # Send G1 X0 Y0 F{homing_speed}
-                    result = loop.run_until_complete(send_grbl_coordinates(0, 0, homing_speed))
-                    if result == False:
-                        logger.error("Position zeroing failed - send_grbl_coordinates returned False")
-                        homing_complete.set()
-                        return
-                    logger.info("Position zeroing completed successfully")
-                finally:
-                    loop.close()
+                    # Run async function in new event loop
+                    loop = asyncio.new_event_loop()
+                    asyncio.set_event_loop(loop)
+                    try:
+                        # Send G1 X0 Y0 F{homing_speed}
+                        result = loop.run_until_complete(send_grbl_coordinates(0, 0, homing_speed))
+                        if result == False:
+                            logger.error("Position zeroing failed - send_grbl_coordinates returned False")
+                            homing_complete.set()
+                            return
+                        logger.info("Position zeroing completed successfully")
+                    finally:
+                        loop.close()
 
-                # Wait for device to reach idle state after zeroing movement
-                logger.info("Waiting for device to reach idle state after zeroing...")
-                idle_reached = check_idle()
+                    # Wait for device to reach idle state after zeroing movement
+                    logger.info("Waiting for device to reach idle state after zeroing...")
+                    idle_reached = check_idle()
 
-                if not idle_reached:
-                    logger.error("Device did not reach idle state after zeroing")
-                    homing_complete.set()
-                    return
+                    if not idle_reached:
+                        logger.error("Device did not reach idle state after zeroing")
+                        homing_complete.set()
+                        return
 
                 # Set current position based on compass reference point (sensor mode only)
-                # Only set AFTER x0 y0 is confirmed and device is idle
                 offset_radians = math.radians(state.angular_homing_offset_degrees)
                 state.current_theta = offset_radians
                 state.current_rho = 0
@@ -1126,12 +1145,31 @@ def check_idle():
             return True
         time.sleep(1)
 
-async def check_idle_async():
+async def check_idle_async(timeout: float = 30.0):
     """
     Continuously check if the device is idle (async version).
+
+    Args:
+        timeout: Maximum seconds to wait for idle state (default 30s)
+
+    Returns:
+        True if device became idle, False if timeout or stop requested
     """
     logger.info("Checking idle (async)")
+    start_time = asyncio.get_event_loop().time()
+
     while True:
+        # Check if stop was requested - exit early
+        if state.stop_requested:
+            logger.info("Stop requested during idle check, exiting early")
+            return False
+
+        # Check timeout
+        elapsed = asyncio.get_event_loop().time() - start_time
+        if elapsed > timeout:
+            logger.warning(f"Timeout ({timeout}s) waiting for device idle state")
+            return False
+
         response = await asyncio.to_thread(get_status_response)
         if response and "Idle" in response:
             logger.info("Device is idle")

+ 215 - 33
modules/core/pattern_manager.py

@@ -16,7 +16,7 @@ from modules.led.led_controller import effect_playing, effect_idle
 from modules.led.idle_timeout_manager import idle_timeout_manager
 import queue
 from dataclasses import dataclass
-from typing import Optional, Callable
+from typing import Optional, Callable, Literal
 
 # Configure logging
 logger = logging.getLogger(__name__)
@@ -28,6 +28,54 @@ os.makedirs(THETA_RHO_DIR, exist_ok=True)
 # Execution time log file (JSON Lines format - one JSON object per line)
 EXECUTION_LOG_FILE = './execution_times.jsonl'
 
+
+async def wait_with_interrupt(
+    condition_fn: Callable[[], bool],
+    check_stop: bool = True,
+    check_skip: bool = True,
+    interval: float = 1.0,
+) -> Literal['completed', 'stopped', 'skipped']:
+    """
+    Wait while condition_fn() returns True, with instant interrupt support.
+
+    Uses asyncio.Event for instant response to stop/skip requests rather than
+    polling at fixed intervals. This ensures users get immediate feedback when
+    pressing stop or skip buttons.
+
+    Args:
+        condition_fn: Function that returns True while waiting should continue
+        check_stop: Whether to respond to stop requests (default True)
+        check_skip: Whether to respond to skip requests (default True)
+        interval: How often to re-check condition_fn in seconds (default 1.0)
+
+    Returns:
+        'completed' - condition_fn() returned False (normal completion)
+        'stopped' - stop was requested
+        'skipped' - skip was requested
+
+    Example:
+        result = await wait_with_interrupt(
+            lambda: state.pause_requested or is_in_scheduled_pause_period()
+        )
+        if result == 'stopped':
+            return  # Exit pattern execution
+        if result == 'skipped':
+            break  # Skip to next pattern
+    """
+    while condition_fn():
+        result = await state.wait_for_interrupt(
+            timeout=interval,
+            check_stop=check_stop,
+            check_skip=check_skip,
+        )
+        if result == 'stopped':
+            return 'stopped'
+        if result == 'skipped':
+            return 'skipped'
+        # 'timeout' means we should re-check condition_fn
+    return 'completed'
+
+
 def log_execution_time(pattern_name: str, table_type: str, speed: int, actual_time: float,
                        total_coordinates: int, was_completed: bool):
     """Log pattern execution time to JSON Lines file for analysis.
@@ -107,6 +155,49 @@ def get_last_completed_execution_time(pattern_name: str, speed: float) -> Option
         logger.error(f"Failed to read execution time log: {e}")
         return None
 
+def get_pattern_execution_history(pattern_name: str) -> Optional[dict]:
+    """Get the most recent completed execution for a pattern (any speed).
+
+    Args:
+        pattern_name: Name of the pattern file (e.g., 'circle.thr')
+
+    Returns:
+        Dict with execution time info if found, None otherwise.
+        Format: {"actual_time_seconds": float, "actual_time_formatted": str,
+                 "speed": int, "timestamp": str}
+    """
+    if not os.path.exists(EXECUTION_LOG_FILE):
+        return None
+
+    try:
+        matching_entry = None
+        with open(EXECUTION_LOG_FILE, 'r') as f:
+            for line in f:
+                line = line.strip()
+                if not line:
+                    continue
+                try:
+                    entry = json.loads(line)
+                    # Only consider fully completed patterns
+                    if (entry.get('completed', False) and
+                        entry.get('pattern_name') == pattern_name):
+                        # Keep the most recent match (last one in file)
+                        matching_entry = entry
+                except json.JSONDecodeError:
+                    continue
+
+        if matching_entry:
+            return {
+                "actual_time_seconds": matching_entry.get('actual_time_seconds'),
+                "actual_time_formatted": matching_entry.get('actual_time_formatted'),
+                "speed": matching_entry.get('speed'),
+                "timestamp": matching_entry.get('timestamp')
+            }
+        return None
+    except Exception as e:
+        logger.error(f"Failed to read execution time log: {e}")
+        return None
+
 # Asyncio primitives - initialized lazily to avoid event loop issues
 # These must be created in the context of the running event loop
 pause_event: Optional[asyncio.Event] = None
@@ -425,22 +516,37 @@ class MotionControlThread:
         state.machine_y = new_y_abs
 
     def _send_grbl_coordinates_sync(self, x: float, y: float, speed: int = 600, timeout: int = 2, home: bool = False):
-        """Synchronous version of send_grbl_coordinates for motion thread."""
-        logger.debug(f"Motion thread sending G-code: X{x} Y{y} at F{speed}")
+        """Synchronous version of send_grbl_coordinates for motion thread.
+
+        Waits indefinitely for 'ok' because GRBL only responds after the move completes,
+        which can take many seconds at slow speeds.
+        """
+        gcode = f"$J=G91 G21 Y{y} F{speed}" if home else f"G1 G53 X{x} Y{y} F{speed}"
 
         while True:
-            try:
-                if not state.stop_requested:
-                    gcode = f"$J=G91 G21 Y{y} F{speed}" if home else f"G1 G53 X{x} Y{y} F{speed}"
-                    state.conn.send(gcode + "\n")
-                    logger.debug(f"Motion thread sent command: {gcode}")
+            # Check stop_requested at the start of each iteration
+            if state.stop_requested:
+                logger.debug("Motion thread: Stop requested, aborting command")
+                return False
 
-                    while True:
-                        response = state.conn.readline()
+            try:
+                logger.debug(f"Motion thread sending G-code: {gcode}")
+                state.conn.send(gcode + "\n")
+
+                # Wait indefinitely for 'ok' - GRBL sends it after move completes
+                while True:
+                    # Check stop_requested while waiting
+                    if state.stop_requested:
+                        logger.debug("Motion thread: Stop requested while waiting for response")
+                        return False
+                    response = state.conn.readline()
+                    if response:
                         logger.debug(f"Motion thread response: {response}")
                         if response.lower() == "ok":
                             logger.debug("Motion thread: Command execution confirmed.")
-                            return
+                            return True
+                    # Small sleep to prevent CPU spin when readline() times out
+                    time.sleep(0.01)
 
             except Exception as e:
                 error_str = str(e)
@@ -455,7 +561,8 @@ class MotionControlThread:
                     logger.info("Connection marked as disconnected due to device error")
                     return False
 
-            logger.warning(f"Motion thread: No 'ok' received for X{x} Y{y}, speed {speed}. Retrying...")
+            # Only retry on exception (not on timeout)
+            logger.warning(f"Motion thread: Error sending {gcode}, retrying...")
             time.sleep(0.1)
 
 # Global motion control thread instance
@@ -757,6 +864,9 @@ async def _execute_pattern_internal(file_path):
     coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
     total_coordinates = len(coordinates)
 
+    # Cache coordinates in state for frontend preview (avoids re-parsing large files)
+    state._current_coordinates = coordinates
+
     if total_coordinates < 2:
         logger.warning("Not enough coordinates for interpolation")
         return False
@@ -850,16 +960,61 @@ async def _execute_pattern_internal(file_path):
                 wled_was_off_for_scheduled = scheduled_pause and state.scheduled_pause_control_wled and not manual_pause
 
                 # Wait until both manual pause is released AND we're outside scheduled pause period
+                # Also check for stop/skip requests to allow immediate interruption
+                interrupted = False
                 while state.pause_requested or is_in_scheduled_pause_period():
+                    # Check for stop/skip first
+                    if state.stop_requested:
+                        logger.info("Stop requested during pause, exiting")
+                        interrupted = True
+                        break
+                    if state.skip_requested:
+                        logger.info("Skip requested during pause, skipping pattern")
+                        interrupted = True
+                        break
+
                     if state.pause_requested:
-                        # For manual pause, wait directly on the event for immediate response
-                        # The while loop re-checks state after wake to handle rapid pause/resume
-                        await get_pause_event().wait()
+                        # For manual pause, wait on multiple events for immediate response
+                        # Wake on: resume, stop, skip, or timeout (for flag polling fallback)
+                        pause_event = get_pause_event()
+                        stop_event = state.get_stop_event()
+                        skip_event = state.get_skip_event()
+
+                        wait_tasks = [asyncio.create_task(pause_event.wait(), name='pause')]
+                        if stop_event:
+                            wait_tasks.append(asyncio.create_task(stop_event.wait(), name='stop'))
+                        if skip_event:
+                            wait_tasks.append(asyncio.create_task(skip_event.wait(), name='skip'))
+                        # Add timeout to ensure we periodically check flags even if events aren't set
+                        # This handles the case where stop is called from sync context (no event loop)
+                        timeout_task = asyncio.create_task(asyncio.sleep(1.0), name='timeout')
+                        wait_tasks.append(timeout_task)
+
+                        try:
+                            done, pending = await asyncio.wait(
+                                wait_tasks, return_when=asyncio.FIRST_COMPLETED
+                            )
+                        finally:
+                            for task in pending:
+                                task.cancel()
+                            for task in pending:
+                                try:
+                                    await task
+                                except asyncio.CancelledError:
+                                    pass
                     else:
-                        # For scheduled pause only, check periodically
-                        await asyncio.sleep(1)
+                        # For scheduled pause, use wait_for_interrupt for instant response
+                        result = await state.wait_for_interrupt(timeout=1.0)
+                        if result in ('stopped', 'skipped'):
+                            interrupted = True
+                            break
 
                 total_pause_time += time.time() - pause_start  # Add pause duration
+
+                if interrupted:
+                    # Exit the coordinate loop if we were interrupted
+                    break
+
                 logger.info("Execution resumed...")
                 if state.led_controller:
                     # Turn LED controller back on if it was turned off for scheduled pause
@@ -942,6 +1097,10 @@ async def run_theta_rho_file(file_path, is_playlist=False, clear_pattern=None, c
         return
 
     async with lock:  # This ensures only one pattern can run at a time
+        # Clear any stale pause state from previous playlist
+        state.pause_time_remaining = 0
+        state.original_pause_time = None
+
         # Start progress update task only if not part of a playlist
         global progress_update_task
         if not is_playlist and not progress_update_task:
@@ -1053,22 +1212,10 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                     cache_data=cache_data
                 )
 
-                # Increment pattern counter and check auto-home
+                # Increment pattern counter (auto-home check happens after pause time)
                 state.patterns_since_last_home += 1
                 logger.debug(f"Patterns since last home: {state.patterns_since_last_home}")
 
-                if state.auto_home_enabled and state.patterns_since_last_home >= state.auto_home_after_patterns:
-                    logger.info(f"Auto-homing triggered after {state.patterns_since_last_home} patterns")
-                    try:
-                        success = await asyncio.to_thread(connection_manager.home)
-                        if success:
-                            logger.info("Auto-homing completed successfully")
-                            state.patterns_since_last_home = 0
-                        else:
-                            logger.warning("Auto-homing failed, continuing with playlist")
-                    except Exception as e:
-                        logger.error(f"Error during auto-homing: {e}")
-
                 # Check for scheduled pause after pattern completes (when "finish pattern first" is enabled)
                 if state.scheduled_pause_finish_pattern and is_in_scheduled_pause_period() and not state.stop_requested and not state.skip_requested:
                     logger.info("Pattern completed. Entering Still Sands period (finish pattern first mode)...")
@@ -1082,10 +1229,14 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                         await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
                         start_idle_led_timeout()
 
-                    while is_in_scheduled_pause_period() and not state.stop_requested:
-                        await asyncio.sleep(1)
+                    # Wait for scheduled pause to end, but allow stop/skip to interrupt
+                    result = await wait_with_interrupt(
+                        is_in_scheduled_pause_period,
+                        check_stop=True,
+                        check_skip=True,
+                    )
 
-                    if not state.stop_requested:
+                    if result == 'completed':
                         logger.info("Still Sands period ended. Resuming playlist...")
                         if state.led_controller:
                             if wled_was_off_for_scheduled:
@@ -1106,7 +1257,26 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                             logger.info("Pause interrupted by skip request")
                             break
                         await asyncio.sleep(1)
+                    # Clear both pause state vars immediately (so UI updates right away)
                     state.pause_time_remaining = 0
+                    state.original_pause_time = None
+
+                # Auto-home after pause time, before next clear pattern starts
+                # Only home if there's a next pattern and we haven't been stopped
+                if (state.auto_home_enabled and
+                    state.patterns_since_last_home >= state.auto_home_after_patterns and
+                    state.current_playlist and idx < len(state.current_playlist) - 1 and
+                    not state.stop_requested):
+                    logger.info(f"Auto-homing triggered after {state.patterns_since_last_home} patterns (before next clear pattern)")
+                    try:
+                        success = await asyncio.to_thread(connection_manager.home)
+                        if success:
+                            logger.info("Auto-homing completed successfully")
+                            state.patterns_since_last_home = 0
+                        else:
+                            logger.warning("Auto-homing failed, continuing with playlist")
+                    except Exception as e:
+                        logger.error(f"Error during auto-homing: {e}")
 
                 state.skip_requested = False
                 idx += 1
@@ -1121,7 +1291,9 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                             logger.info("Pause interrupted by skip request")
                             break
                         await asyncio.sleep(1)
+                    # Clear both pause state vars immediately (so UI updates right away)
                     state.pause_time_remaining = 0
+                    state.original_pause_time = None
                 continue
             else:
                 logger.info("Playlist completed")
@@ -1156,7 +1328,11 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
         clear_playlist: Whether to clear playlist state
         wait_for_lock: Whether to wait for pattern_lock to be released. Set to False when
                       called from within pattern execution to avoid deadlock.
+
+    Returns:
+        True if stopped cleanly, False if timed out waiting for pattern lock
     """
+    timed_out = False
     try:
         with state.pause_condition:
             state.pause_requested = False
@@ -1200,6 +1376,7 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
                         logger.info("Pattern lock acquired - pattern has fully stopped")
             except asyncio.TimeoutError:
                 logger.warning("Timeout waiting for pattern to stop - forcing cleanup")
+                timed_out = True
                 # Force cleanup of state even if pattern didn't release lock gracefully
                 state.current_playing_file = None
                 state.execution_progress = None
@@ -1211,6 +1388,7 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
 
         # Call async function directly since we're in async context
         await connection_manager.update_machine_position()
+        return not timed_out
     except Exception as e:
         logger.error(f"Error during stop_actions: {e}")
         # Force cleanup state on error
@@ -1222,6 +1400,7 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
             await connection_manager.update_machine_position()
         except Exception as update_err:
             logger.error(f"Error updating machine position on error: {update_err}")
+        return False
 
 async def move_polar(theta, rho, speed=None):
     """
@@ -1233,6 +1412,9 @@ async def move_polar(theta, rho, speed=None):
         rho (float): Target rho coordinate
         speed (int, optional): Speed override. If None, uses state.speed
     """
+    # Note: stop_requested is cleared once at pattern start (execute_theta_rho_file line 890)
+    # Don't clear it here on every coordinate - causes performance issues with event system
+
     # Ensure motion control thread is running
     if not motion_controller.running:
         motion_controller.start()

+ 5 - 7
modules/core/process_pool.py

@@ -6,7 +6,7 @@ Provides a single ProcessPoolExecutor shared across modules to:
 - Configure CPU affinity to keep workers off CPU 0 (reserved for motion/LED)
 
 Environment variables:
-- POOL_WORKERS: Override worker count (default: 1 for RAM conservation)
+- POOL_WORKERS: Override worker count (default: 3)
 """
 import logging
 import os
@@ -19,17 +19,15 @@ logger = logging.getLogger(__name__)
 _pool: Optional[ProcessPoolExecutor] = None
 _shutdown_in_progress: bool = False
 
-# Default to 1 worker to conserve RAM on low-memory devices (Pi Zero 2 W has only 512MB)
-DEFAULT_WORKERS = 1
+# Default to 3 workers for parallel processing
+DEFAULT_WORKERS = 3
 
 
 def _get_worker_count() -> int:
     """Calculate worker count for the process pool.
 
-    Uses POOL_WORKERS env var if set, otherwise defaults to 1 worker
-    to conserve RAM on memory-constrained devices like Pi Zero 2 W.
-
-    For systems with more RAM, set POOL_WORKERS=2 or POOL_WORKERS=3.
+    Uses POOL_WORKERS env var if set, otherwise defaults to 3 workers.
+    For memory-constrained devices (Pi Zero 2 W), set POOL_WORKERS=1.
     """
     env_workers = os.environ.get('POOL_WORKERS')
     if env_workers is not None:

+ 192 - 9
modules/core/state.py

@@ -1,9 +1,11 @@
 # state.py
+import asyncio
 import threading
 import json
 import os
 import logging
 import uuid
+from typing import Optional, Literal
 
 logger = logging.getLogger(__name__)
 
@@ -15,13 +17,22 @@ class AppState:
     def __init__(self):
         # Private variables for properties
         self._current_playing_file = None
+        self._current_coordinates = None  # Cache parsed coordinates for current file (avoids re-parsing large files)
+        self._current_preview = None  # Cache (file_name, base64_data) for current pattern preview
+        self._next_preview = None  # Cache (file_name, base64_data) for next pattern preview
         self._pause_requested = False
         self._speed = 100
         self._current_playlist = None
         self._current_playlist_name = None  # New variable for playlist name
         
+        # Execution control flags (with event support for async waiting)
+        self._stop_requested = False
+        self._skip_requested = False
+        self._stop_event: Optional[asyncio.Event] = None
+        self._skip_event: Optional[asyncio.Event] = None
+        self._event_loop: Optional[asyncio.AbstractEventLoop] = None
+
         # Regular state variables
-        self.stop_requested = False
         self.pause_condition = threading.Condition()
         self.execution_progress = None
         self.is_clearing = False
@@ -75,9 +86,9 @@ class AppState:
         # DW LED settings
         self.dw_led_num_leds = 60  # Number of LEDs in strip
         self.dw_led_gpio_pin = 18  # GPIO pin (12, 13, 18, or 19)
-        self.dw_led_pixel_order = "GRB"  # Pixel color order for WS281x (GRB, RGB, BGR, etc.)
+        self.dw_led_pixel_order = "RGB"  # Pixel color order for WS281x (RGB for WS2815, GRB for WS2812)
         self.dw_led_brightness = 35  # Brightness 0-100
-        self.dw_led_speed = 128  # Effect speed 0-255
+        self.dw_led_speed = 50  # Effect speed 0-255
         self.dw_led_intensity = 128  # Effect intensity 0-255
 
         # Idle effect settings (all parameters)
@@ -90,7 +101,6 @@ class AppState:
         self.dw_led_idle_timeout_enabled = False  # Enable automatic LED turn off after idle period
         self.dw_led_idle_timeout_minutes = 30  # Idle timeout duration in minutes
         self.dw_led_last_activity_time = None  # Last activity timestamp (runtime only, not persisted)
-        self.skip_requested = False
         self.table_type = None
         self.table_type_override = None  # User override for table type detection
         self._playlist_mode = "loop"
@@ -105,7 +115,11 @@ class AppState:
 
         # Multi-table identity (for network discovery)
         self.table_id = str(uuid.uuid4())  # UUID generated on first run, persistent across restarts
-        self.table_name = "My Sand Table"  # User-customizable table name
+        self.table_name = "Dune Weaver"  # User-customizable table name
+
+        # Known remote tables (for multi-table management)
+        # List of dicts: [{id, name, url, host?, port?, version?}, ...]
+        self.known_tables = []
 
         # Custom branding settings (filenames only, files stored in static/custom/)
         # Favicon is auto-generated from logo as logo-favicon.ico
@@ -152,8 +166,14 @@ class AppState:
 
     @current_playing_file.setter
     def current_playing_file(self, value):
+        # Clear cached data when file changes or is unset
+        if value != self._current_playing_file or value is None:
+            self._current_coordinates = None
+            self._current_preview = None
+            self._next_preview = None
+
         self._current_playing_file = value
-        
+
         # force an empty string (and not None) if we need to unset
         if value == None:
             value = ""
@@ -240,6 +260,167 @@ class AppState:
     def clear_pattern_speed(self, value):
         self._clear_pattern_speed = value
 
+    # --- Execution Control Properties (stop/skip with event support) ---
+
+    def _ensure_events(self):
+        """Lazily create asyncio.Event objects in the current event loop."""
+        try:
+            loop = asyncio.get_running_loop()
+        except RuntimeError:
+            # No running loop - skip event creation (sync code path)
+            return
+
+        # Recreate events if the event loop changed
+        if self._event_loop != loop:
+            self._event_loop = loop
+            self._stop_event = asyncio.Event()
+            self._skip_event = asyncio.Event()
+            # Sync event state with current flags
+            if self._stop_requested:
+                self._stop_event.set()
+            if self._skip_requested:
+                self._skip_event.set()
+
+    @property
+    def stop_requested(self) -> bool:
+        return self._stop_requested
+
+    @stop_requested.setter
+    def stop_requested(self, value: bool):
+        self._stop_requested = value
+        self._ensure_events()
+        if self._stop_event and self._event_loop:
+            # asyncio.Event.set()/clear() are NOT thread-safe
+            # Use call_soon_threadsafe when called from non-async threads (e.g., motion thread)
+            try:
+                if asyncio.get_running_loop() == self._event_loop:
+                    # Same loop - safe to call directly
+                    if value:
+                        self._stop_event.set()
+                    else:
+                        self._stop_event.clear()
+                else:
+                    # Different loop - use thread-safe call
+                    if value:
+                        self._event_loop.call_soon_threadsafe(self._stop_event.set)
+                    else:
+                        self._event_loop.call_soon_threadsafe(self._stop_event.clear)
+            except RuntimeError:
+                # No running loop (sync context) - use thread-safe call
+                if self._event_loop.is_running():
+                    if value:
+                        self._event_loop.call_soon_threadsafe(self._stop_event.set)
+                    else:
+                        self._event_loop.call_soon_threadsafe(self._stop_event.clear)
+
+    @property
+    def skip_requested(self) -> bool:
+        return self._skip_requested
+
+    @skip_requested.setter
+    def skip_requested(self, value: bool):
+        self._skip_requested = value
+        self._ensure_events()
+        if self._skip_event and self._event_loop:
+            # asyncio.Event.set()/clear() are NOT thread-safe
+            # Use call_soon_threadsafe when called from non-async threads (e.g., motion thread)
+            try:
+                if asyncio.get_running_loop() == self._event_loop:
+                    # Same loop - safe to call directly
+                    if value:
+                        self._skip_event.set()
+                    else:
+                        self._skip_event.clear()
+                else:
+                    # Different loop - use thread-safe call
+                    if value:
+                        self._event_loop.call_soon_threadsafe(self._skip_event.set)
+                    else:
+                        self._event_loop.call_soon_threadsafe(self._skip_event.clear)
+            except RuntimeError:
+                # No running loop (sync context) - use thread-safe call
+                if self._event_loop.is_running():
+                    if value:
+                        self._event_loop.call_soon_threadsafe(self._skip_event.set)
+                    else:
+                        self._event_loop.call_soon_threadsafe(self._skip_event.clear)
+
+    def get_stop_event(self) -> Optional[asyncio.Event]:
+        """Get the stop event for async waiting. Returns None if no event loop."""
+        self._ensure_events()
+        return self._stop_event
+
+    def get_skip_event(self) -> Optional[asyncio.Event]:
+        """Get the skip event for async waiting. Returns None if no event loop."""
+        self._ensure_events()
+        return self._skip_event
+
+    async def wait_for_interrupt(
+        self,
+        timeout: float = 1.0,
+        check_stop: bool = True,
+        check_skip: bool = True,
+    ) -> Literal['timeout', 'stopped', 'skipped']:
+        """
+        Wait for a stop/skip interrupt or timeout.
+
+        This provides instant response to stop/skip requests by waiting on
+        asyncio.Event objects rather than polling flags.
+
+        Args:
+            timeout: Maximum time to wait in seconds
+            check_stop: Whether to check for stop requests
+            check_skip: Whether to check for skip requests
+
+        Returns:
+            'stopped' if stop was requested
+            'skipped' if skip was requested
+            'timeout' if timeout elapsed without interrupt
+        """
+        # Quick flag check first (handles edge cases and sync code)
+        if check_stop and self._stop_requested:
+            return 'stopped'
+        if check_skip and self._skip_requested:
+            return 'skipped'
+
+        self._ensure_events()
+
+        # Build list of event wait tasks
+        tasks = []
+        if check_stop and self._stop_event:
+            tasks.append(asyncio.create_task(self._stop_event.wait(), name='stop'))
+        if check_skip and self._skip_event:
+            tasks.append(asyncio.create_task(self._skip_event.wait(), name='skip'))
+
+        if not tasks:
+            # No events available, fall back to simple sleep
+            await asyncio.sleep(timeout)
+            return 'timeout'
+
+        # Add timeout task
+        timeout_task = asyncio.create_task(asyncio.sleep(timeout), name='timeout')
+        tasks.append(timeout_task)
+
+        try:
+            done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
+        finally:
+            # Cancel all pending tasks
+            for task in pending:
+                task.cancel()
+            # Await cancelled tasks to suppress warnings
+            for task in pending:
+                try:
+                    await task
+                except asyncio.CancelledError:
+                    pass
+
+        # Check which event fired (flags are authoritative)
+        if check_stop and self._stop_requested:
+            return 'stopped'
+        if check_skip and self._skip_requested:
+            return 'skipped'
+        return 'timeout'
+
     def to_dict(self):
         """Return a dictionary representation of the state."""
         return {
@@ -287,6 +468,7 @@ class AppState:
             "app_name": self.app_name,
             "table_id": self.table_id,
             "table_name": self.table_name,
+            "known_tables": self.known_tables,
             "custom_logo": self.custom_logo,
             "auto_play_enabled": self.auto_play_enabled,
             "auto_play_playlist": self.auto_play_playlist,
@@ -347,9 +529,9 @@ class AppState:
         self.led_provider = data.get('led_provider', "none")
         self.dw_led_num_leds = data.get('dw_led_num_leds', 60)
         self.dw_led_gpio_pin = data.get('dw_led_gpio_pin', 18)
-        self.dw_led_pixel_order = data.get('dw_led_pixel_order', "GRB")
+        self.dw_led_pixel_order = data.get('dw_led_pixel_order', "RGB")
         self.dw_led_brightness = data.get('dw_led_brightness', 35)
-        self.dw_led_speed = data.get('dw_led_speed', 128)
+        self.dw_led_speed = data.get('dw_led_speed', 50)
         self.dw_led_intensity = data.get('dw_led_intensity', 128)
 
         # Load effect settings (handle both old string format and new dict format)
@@ -378,7 +560,8 @@ class AppState:
         self.table_id = data.get("table_id", None)
         if self.table_id is None:
             self.table_id = str(uuid.uuid4())
-        self.table_name = data.get("table_name", "My Sand Table")
+        self.table_name = data.get("table_name", "Dune Weaver")
+        self.known_tables = data.get("known_tables", [])
         self.custom_logo = data.get("custom_logo", None)
         self.auto_play_enabled = data.get("auto_play_enabled", False)
         self.auto_play_playlist = data.get("auto_play_playlist", None)

+ 12 - 0
modules/core/version_manager.py

@@ -1,6 +1,10 @@
 """
 Version management for Dune Weaver
 Handles current version reading and GitHub API integration for latest version checking
+
+Testing overrides (environment variables):
+  FORCE_UPDATE_AVAILABLE=1  - Force update to appear available
+  FAKE_LATEST_VERSION=5.0.0 - Override the "latest" version for testing
 """
 
 import asyncio
@@ -14,6 +18,14 @@ import logging
 
 logger = logging.getLogger(__name__)
 
+# Testing overrides via environment variables
+FORCE_UPDATE_AVAILABLE = os.environ.get("FORCE_UPDATE_AVAILABLE", "").lower() in ("1", "true", "yes")
+FAKE_LATEST_VERSION = os.environ.get("FAKE_LATEST_VERSION", "")
+
+if FORCE_UPDATE_AVAILABLE or FAKE_LATEST_VERSION:
+    logger.warning(f"Version override active: FORCE_UPDATE_AVAILABLE={FORCE_UPDATE_AVAILABLE}, FAKE_LATEST_VERSION={FAKE_LATEST_VERSION}")
+
+
 class VersionManager:
     def __init__(self):
         self.repo_owner = "tuanchris"

+ 1 - 1
nginx.conf

@@ -42,7 +42,7 @@ server {
     }
 
     # All backend API endpoints (legacy non-/api/ routes)
-    location ~ ^/(list_theta_rho_files|preview_thr_batch|get_theta_rho_coordinates|upload_theta_rho|pause_execution|resume_execution|stop_execution|skip_pattern|set_speed|get_speed|restart|shutdown|run_pattern|run_playlist|connect_device|disconnect_device|home_device|clear_sand|move_to_position|get_playlists|save_playlist|delete_playlist|rename_playlist|get_status|list_serial_ports|serial_status|cache-progress|rebuild_cache|get_led_config|set_led_config|get_wled_ip|set_wled_ip|led|send_home|send_coordinate|move_to_center|move_to_perimeter|run_theta_rho|connect|disconnect|list_theta_rho_files_with_metadata|list_all_playlists|get_playlist|create_playlist|modify_playlist|add_to_playlist|preview_thr|preview) {
+    location ~ ^/(list_theta_rho_files|preview_thr_batch|get_theta_rho_coordinates|upload_theta_rho|pause_execution|resume_execution|stop_execution|force_stop|soft_reset|skip_pattern|set_speed|get_speed|restart|shutdown|run_pattern|run_playlist|connect_device|disconnect_device|home_device|clear_sand|move_to_position|get_playlists|save_playlist|delete_playlist|rename_playlist|reorder_playlist|delete_theta_rho_file|get_status|list_serial_ports|serial_status|cache-progress|rebuild_cache|get_led_config|set_led_config|get_wled_ip|set_wled_ip|led|send_home|send_coordinate|move_to_center|move_to_perimeter|run_theta_rho|connect|disconnect|list_theta_rho_files_with_metadata|list_all_playlists|get_playlist|create_playlist|modify_playlist|add_to_playlist|preview_thr|preview) {
         proxy_pass http://127.0.0.1:8080;
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;

Файловите разлики са ограничени, защото са твърде много
+ 30 - 2242
package-lock.json


BIN
static/android-chrome-192x192.png


BIN
static/android-chrome-512x512.png


BIN
static/apple-touch-icon.png


BIN
static/favicon-16x16.png


BIN
static/favicon-32x32.png


BIN
static/favicon.ico


BIN
static/icon.jpeg


+ 26 - 1
static/site.webmanifest

@@ -1 +1,26 @@
-{"name":"","short_name":"","icons":[{"src":"/static/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+{
+  "name": "Dune Weaver",
+  "short_name": "Dune Weaver",
+  "description": "Control your kinetic sand table",
+  "icons": [
+    {
+      "src": "/static/android-chrome-192x192.png",
+      "sizes": "192x192",
+      "type": "image/png",
+      "purpose": "any"
+    },
+    {
+      "src": "/static/android-chrome-512x512.png",
+      "sizes": "512x512",
+      "type": "image/png",
+      "purpose": "any"
+    }
+  ],
+  "start_url": "/",
+  "scope": "/",
+  "display": "standalone",
+  "orientation": "any",
+  "theme_color": "#0a0a0a",
+  "background_color": "#0a0a0a",
+  "categories": ["utilities", "entertainment"]
+}

Някои файлове не бяха показани, защото твърде много файлове са промени