66 Commits 9af82531e4 ... 10be8dbd62

Auteur SHA1 Message Date
  tuanchris 10be8dbd62 Fix UI showing stale countdown after skip during pause il y a 1 semaine
  tuanchris 8707c4ed58 Remove dangerous lock=None in connection close methods il y a 1 semaine
  tuanchris 50df3a3ec7 Fix CPU spin loop in motion thread readline wait il y a 1 semaine
  tuanchris 3b4f60ca66 Update package-lock.json dependencies il y a 1 semaine
  tuanchris 5b7804685b Fix serial stability issues caused by asyncio event overhead il y a 1 semaine
  tuanchris 2f29ac10ed Restore null check in get_status_response() il y a 1 semaine
  tuanchris 3e6f60a3be Revert retry timeout - wait indefinitely for GRBL 'ok' il y a 1 semaine
  tuanchris 9d406586b5 Replace native select with shadcn Select for serial port il y a 1 semaine
  tuanchris 8944764883 Increase serial port selector width to prevent text cutoff il y a 1 semaine
  tuanchris 4c23061112 Add Reset button with confirmation dialog il y a 1 semaine
  tuanchris 276a9a8361 Center warning text vertically in alert box il y a 1 semaine
  tuanchris 1c3f59805d Fix build error and add retry logic for motion commands il y a 1 semaine
  tuanchris 5baed8fd37 Prevent debug terminal from interfering with pattern execution il y a 1 semaine
  tuanchris a2b2cc20ed Center real-time preview canvas in expanded Now Playing view il y a 2 semaines
  tuanchris c3218a4925 Center real-time preview canvas in expanded view il y a 2 semaines
  tuanchris a8577fd408 Remove debug logging from multi-table fix il y a 2 semaines
  tuanchris cf8e31cf89 Fix race condition: pre-initialize apiClient base URL from localStorage il y a 2 semaines
  tuanchris b69f0c67cc Add debug logging for multi-table WebSocket issue il y a 2 semaines
  tuanchris f0d6371d85 Fix multi-table WebSocket connections failing in production il y a 2 semaines
  tuanchris 4292f0bf1f Persist added tables in backend for multi-device access il y a 2 semaines
  tuanchris c59101c55f Improve Now Playing bar mobile UX and PWA safe areas il y a 2 semaines
  tuanchris d4c9c3ee71 Show table icons in table selector dropdown il y a 2 semaines
  tuanchris 51fd92ab10 Improve UI with floating Now Playing button and various fixes il y a 2 semaines
  tuanchris 33c585c313 Add PWA support and improve mobile experience il y a 2 semaines
  tuanchris c6d20bc9b2 minor ui change il y a 2 semaines
  tuanchris f5ecbebb8a Improve UI consistency and mobile experience il y a 2 semaines
  tuanchris 114f756d02 Fix clipboard copy for non-HTTPS contexts (http://dwg.local) il y a 2 semaines
  tuanchris 2f31e8f869 Revert header button to Docker restart, remove TableControl Reset button il y a 2 semaines
  tuanchris af350051d4 Add missing endpoints to nginx proxy config il y a 2 semaines
  tuanchris 6c863793c6 Change header Reset button to do table soft reset instead of Docker restart il y a 2 semaines
  tuanchris 71a043c763 Add sort by favorites option to Browse and Playlist pages il y a 2 semaines
  tuanchris 9aaba5eb93 Fix dw update to re-exec with new CLI after git pull il y a 2 semaines
  tuanchris 717d25c561 Revert auto-update feature, instruct users to SSH and run dw update il y a 2 semaines
  tuanchris 77eb462e77 Fix Python 3.9 compatibility for type hints il y a 2 semaines
  tuanchris 3f3723a4e1 Fix docker-compose.yml invalid empty environment mapping il y a 2 semaines
  tuanchris 46be689ae3 Improve Now Playing Bar positioning and add favorite to pattern panel il y a 2 semaines
  tuanchris 1bf3cd50a4 Add Update Now button to Settings page il y a 2 semaines
  tuanchris 105e44dabe Add host-based update watcher for Docker deployments il y a 2 semaines
  tuanchris b2c0cb9ed0 Improve UI consistency and responsiveness il y a 2 semaines
  tuanchris 766f8c44f3 Fix stop timeout during pause and stale "waiting" UI state il y a 2 semaines
  tuanchris 72ac7669e2 Add event-driven stop/skip for instant interrupt response il y a 2 semaines
  tuanchris 8f118617cd Fix SelectItem highlight not matching pill-shaped dropdown il y a 2 semaines
  tuanchris 0cf2ed321c Fix move_to_center/perimeter not working after pattern stop il y a 2 semaines
  tuanchris d970b0f611 Fix race condition with stale serial port from wrong backend il y a 2 semaines
  tuanchris 61b62dda0c Fix nested button HTML error in PatternCard il y a 2 semaines
  tuanchris d2d34d0070 Fix patterns not loading due to stale localStorage URL initialization il y a 2 semaines
  tuanchris 204240c925 Move auto-homing to after pause time in playlist execution il y a 2 semaines
  tuanchris 44762ed931 Fix WebSocket race condition on page load for multi-table support il y a 2 semaines
  tuanchris 1c7a6798bb Add force stop endpoint and improve pattern execution control il y a 2 semaines
  tuanchris c751266ba7 Prevent crash when sensor homing partially fails il y a 2 semaines
  tuanchris 6816e54457 Revert backend changes to debug serial terminal issue il y a 2 semaines
  tuanchris 6290fc3b25 Fix stuck pattern state and improve stop/reset reliability il y a 2 semaines
  tuanchris 414d984d0c Fix TypeScript error in serial connect button onClick handler il y a 2 semaines
  tuanchris 275211ee73 update il y a 2 semaines
  tuanchris bce8e5f393 Fix playlist page layout and silence auto-connect toast il y a 2 semaines
  tuanchris c6f9c0460b Add play time badges, editable numeric inputs, and UI refinements il y a 2 semaines
  tuanchris 311cdc57f7 Improve mobile responsiveness and unify page styling il y a 2 semaines
  tuanchris c40544c91e Add pattern history display, queue management, and multi-table UX improvements il y a 2 semaines
  tuanchris 19dd6444c3 Redesign playlist controls and improve button styling il y a 2 semaines
  tuanchris 1ecf22cd6c Fix auto-connect settings and SelectLabel error il y a 2 semaines
  tuanchris 47bdf8b9c8 Auto-save auto-connect setting on dropdown change il y a 2 semaines
  tuanchris 7dd6834309 Improve UI responsiveness, auto-connect options, and LED stability il y a 2 semaines
  tuanchris da7b895e92 Fix status indicator not syncing when switching tables il y a 2 semaines
  tuanchris 492507f73f Auto-create playlist if not found on get_playlist il y a 2 semaines
  tuanchris b4a82c7864 Bump version to 4.0.0 il y a 2 semaines
  tuanchris d2d68be821 Fix stop not interrupting motion thread retry loop il y a 2 semaines
47 fichiers modifiés avec 13658 ajouts et 6103 suppressions
  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
     restart: always
     ports:
     ports:
       - "8080:8080"
       - "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:
     volumes:
       # Mount entire app directory for persistence
       # Mount entire app directory for persistence
       - .:/app
       - .:/app

+ 14 - 7
dw

@@ -128,13 +128,20 @@ cmd_update() {
     echo -e "${BLUE}Updating Dune Weaver...${NC}"
     echo -e "${BLUE}Updating Dune Weaver...${NC}"
     cd "$INSTALL_DIR"
     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
     if is_docker_mode; then
         echo "Stopping current container..."
         echo "Stopping current container..."
@@ -318,7 +325,7 @@ case "${1:-help}" in
         cmd_restart
         cmd_restart
         ;;
         ;;
     update)
     update)
-        cmd_update
+        cmd_update "$2"
         ;;
         ;;
     logs)
     logs)
         cmd_logs "$2"
         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>
   <head>
     <meta charset="UTF-8" />
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
     <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 -->
     <!-- 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/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="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="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="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>
     <title>Dune Weaver</title>
 
 
     <!-- Check for custom favicon -->
     <!-- Check for custom favicon -->
     <script>
     <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>
     </script>
   </head>
   </head>
   <body>
   <body>

Fichier diff supprimé car celui-ci est trop grand
+ 10310 - 2729
frontend/package-lock.json


+ 4 - 1
frontend/package.json

@@ -28,6 +28,7 @@
     "@radix-ui/react-tooltip": "^1.2.8",
     "@radix-ui/react-tooltip": "^1.2.8",
     "@tailwindcss/postcss": "^4.1.18",
     "@tailwindcss/postcss": "^4.1.18",
     "@tanstack/react-query": "^5.90.16",
     "@tanstack/react-query": "^5.90.16",
+    "motion": "^12.27.1",
     "next-themes": "^0.4.6",
     "next-themes": "^0.4.6",
     "react": "^19.2.0",
     "react": "^19.2.0",
     "react-color": "^2.19.3",
     "react-color": "^2.19.3",
@@ -54,11 +55,13 @@
     "globals": "^16.5.0",
     "globals": "^16.5.0",
     "lucide-react": "^0.562.0",
     "lucide-react": "^0.562.0",
     "postcss": "^8.5.6",
     "postcss": "^8.5.6",
+    "shadcn": "^3.7.0",
     "tailwind-merge": "^3.4.0",
     "tailwind-merge": "^3.4.0",
     "tailwindcss": "^4.1.18",
     "tailwindcss": "^4.1.18",
     "tailwindcss-animate": "^1.0.7",
     "tailwindcss-animate": "^1.0.7",
     "typescript": "~5.9.3",
     "typescript": "~5.9.3",
     "typescript-eslint": "^8.46.4",
     "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
   file: string
   index: number
   index: number
   previewUrl: string | null
   previewUrl: string | null
+  isFirst: boolean
+  isLast: boolean
+  onMoveToTop: () => void
+  onMoveToBottom: () => void
+  requestPreview: (file: string) => void
 }
 }
 
 
 function SortableQueueItem({
 function SortableQueueItem({
@@ -91,6 +96,11 @@ function SortableQueueItem({
   file,
   file,
   index,
   index,
   previewUrl,
   previewUrl,
+  isFirst,
+  isLast,
+  onMoveToTop,
+  onMoveToBottom,
+  requestPreview,
 }: SortableQueueItemProps) {
 }: SortableQueueItemProps) {
   const {
   const {
     attributes,
     attributes,
@@ -101,6 +111,31 @@ function SortableQueueItem({
     isDragging,
     isDragging,
   } = useSortable({ id })
   } = 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 = {
   const style = {
     transform: CSS.Transform.toString(transform),
     transform: CSS.Transform.toString(transform),
     transition,
     transition,
@@ -112,7 +147,7 @@ function SortableQueueItem({
     <div
     <div
       ref={setNodeRef}
       ref={setNodeRef}
       style={style}
       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 */}
       {/* Drag handle */}
       <div
       <div
@@ -124,7 +159,7 @@ function SortableQueueItem({
       </div>
       </div>
 
 
       {/* Preview thumbnail */}
       {/* 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 ? (
         {previewUrl ? (
           <img
           <img
             src={previewUrl}
             src={previewUrl}
@@ -134,7 +169,7 @@ function SortableQueueItem({
           />
           />
         ) : (
         ) : (
           <div className="w-full h-full flex items-center justify-center">
           <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>
         )}
         )}
       </div>
       </div>
@@ -144,18 +179,39 @@ function SortableQueueItem({
         <p className="text-sm truncate">{formatPatternName(file)}</p>
         <p className="text-sm truncate">{formatPatternName(file)}</p>
         <p className="text-xs text-muted-foreground">#{index + 1}</p>
         <p className="text-xs text-muted-foreground">#{index + 1}</p>
       </div>
       </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>
     </div>
   )
   )
 }
 }
 
 
 interface NowPlayingBarProps {
 interface NowPlayingBarProps {
   isLogsOpen?: boolean
   isLogsOpen?: boolean
+  logsDrawerHeight?: number
   isVisible: boolean
   isVisible: boolean
   openExpanded?: boolean
   openExpanded?: boolean
   onClose: () => void
   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 [status, setStatus] = useState<PlaybackStatus | null>(null)
   const [previewUrl, setPreviewUrl] = useState<string | null>(null)
   const [previewUrl, setPreviewUrl] = useState<string | null>(null)
   const wsRef = useRef<WebSocket | null>(null)
   const wsRef = useRef<WebSocket | null>(null)
@@ -189,13 +245,29 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
     touchStartY.current = null
     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(() => {
   useEffect(() => {
     const bar = barRef.current
     const bar = barRef.current
     if (!bar) return
     if (!bar) return
 
 
     const handleTouchMove = (e: TouchEvent) => {
     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 })
     bar.addEventListener('touchmove', handleTouchMove, { passive: false })
@@ -636,7 +708,13 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       await apiClient.post('/stop_execution')
       await apiClient.post('/stop_execution')
       toast.success('Stopped')
       toast.success('Stopped')
     } catch {
     } 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 [showQueue, setShowQueue] = useState(false)
   const [queuePreviews, setQueuePreviews] = useState<Record<string, string>>({})
   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
   // Drag and drop sensors
   const sensors = useSensors(
   const sensors = useSensors(
     useSensor(PointerSensor, {
     useSensor(PointerSensor, {
@@ -682,25 +820,28 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
 
   // Track which files we've already requested previews for
   // Track which files we've already requested previews for
   const requestedPreviewsRef = useRef<Set<string>>(new Set())
   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 {
       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> = {}
         const newPreviews: Record<string, string> = {}
         for (const [file, result] of Object.entries(data)) {
         for (const [file, result] of Object.entries(data)) {
           if (result.image_data) {
           if (result.image_data) {
@@ -713,14 +854,16 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       } catch (err) {
       } catch (err) {
         console.error('Failed to fetch queue previews:', 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
   // Handle drag end for reordering queue
   // Since playlist now contains only main patterns, indices map directly
   // Since playlist now contains only main patterns, indices map directly
@@ -747,12 +890,40 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       return
       return
     }
     }
 
 
+    // Optimistically update the queue immediately
+    const currentQueue = optimisticQueue || status.playlist.files
+    const newQueue = reorderArray(currentQueue, fromIndex, toIndex)
+    setOptimisticQueue(newQueue)
+
     try {
     try {
       await apiClient.post('/reorder_playlist', {
       await apiClient.post('/reorder_playlist', {
         from_index: fromIndex,
         from_index: fromIndex,
         to_index: toIndex
         to_index: toIndex
       })
       })
     } catch {
     } 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')
       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"
         className="fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300"
         style={{
         style={{
           bottom: isLogsOpen
           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))'
             : 'calc(4rem + env(safe-area-inset-bottom, 0px))'
         }}
         }}
         data-now-playing-bar={isExpanded ? 'expanded' : 'collapsed'}
         data-now-playing-bar={isExpanded ? 'expanded' : 'collapsed'}
         onTouchStart={handleTouchStart}
         onTouchStart={handleTouchStart}
         onTouchEnd={handleTouchEnd}
         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 */}
           {/* Queue button - mobile only, when playlist exists */}
           {isPlaying && status?.playlist && (
           {isPlaying && status?.playlist && (
             <Button
             <Button
@@ -852,7 +1025,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
           {!isExpanded && (
           {!isExpanded && (
             <div className="flex-1 flex flex-col">
             <div className="flex-1 flex flex-col">
               {/* Main row with preview and controls */}
               {/* 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) */}
                 {/* Current Pattern Preview - Rounded (click to expand) */}
                 <div
                 <div
                   className="w-48 h-48 rounded-full overflow-hidden bg-muted shrink-0 border-2 cursor-pointer hover:border-primary transition-colors"
                   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 */}
                       {/* Playback Controls - Centered */}
                       <div className="flex items-center justify-center gap-3">
                       <div className="flex items-center justify-center gap-3">
                         <Button
                         <Button
-                          variant="outline"
+                          variant="secondary"
                           size="icon"
                           size="icon"
                           className="h-10 w-10 rounded-full"
                           className="h-10 w-10 rounded-full"
                           onClick={handleStop}
                           onClick={handleStop}
@@ -951,7 +1124,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                         </Button>
                         </Button>
                         {status.playlist && (
                         {status.playlist && (
                           <Button
                           <Button
-                            variant="outline"
+                            variant="secondary"
                             size="icon"
                             size="icon"
                             className="h-10 w-10 rounded-full"
                             className="h-10 w-10 rounded-full"
                             onClick={handleSkip}
                             onClick={handleSkip}
@@ -1038,11 +1211,11 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
 
           {/* Expanded view - Real-time canvas preview */}
           {/* Expanded view - Real-time canvas preview */}
           {isExpanded && isPlaying && (
           {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) */}
                 {/* Canvas - full width on mobile (click to collapse) */}
                 <div
                 <div
-                  className="flex items-center justify-center cursor-pointer"
+                  className="flex-1 flex items-center justify-center cursor-pointer"
                   onClick={() => setIsExpanded(false)}
                   onClick={() => setIsExpanded(false)}
                   title="Click to collapse"
                   title="Click to collapse"
                 >
                 >
@@ -1050,35 +1223,51 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                     ref={canvasRef}
                     ref={canvasRef}
                     width={600}
                     width={600}
                     height={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>
                 </div>
 
 
                 {/* Controls */}
                 {/* Controls */}
                 <div className="md:w-80 shrink-0 flex flex-col justify-start md:justify-center gap-2 md:gap-4">
                 <div className="md:w-80 shrink-0 flex flex-col justify-start md:justify-center gap-2 md:gap-4">
                 {/* Pattern Info */}
                 {/* 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>
                 </div>
 
 
                 {/* Progress */}
                 {/* Progress */}
@@ -1107,7 +1296,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                 {/* Playback Controls */}
                 {/* Playback Controls */}
                 <div className="flex items-center justify-center gap-2 md:gap-3">
                 <div className="flex items-center justify-center gap-2 md:gap-3">
                   <Button
                   <Button
-                    variant="outline"
+                    variant="secondary"
                     size="icon"
                     size="icon"
                     className="h-10 w-10 md:h-12 md:w-12 rounded-full"
                     className="h-10 w-10 md:h-12 md:w-12 rounded-full"
                     onClick={handleStop}
                     onClick={handleStop}
@@ -1127,7 +1316,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                   </Button>
                   </Button>
                   {status?.playlist && (
                   {status?.playlist && (
                     <Button
                     <Button
-                      variant="outline"
+                      variant="secondary"
                       size="icon"
                       size="icon"
                       className="h-10 w-10 md:h-12 md:w-12 rounded-full"
                       className="h-10 w-10 md:h-12 md:w-12 rounded-full"
                       onClick={handleSkip}
                       onClick={handleSkip}
@@ -1186,11 +1375,21 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
             </div>
             </div>
           )}
           )}
         </div>
         </div>
+        </div>{/* Close max-width container */}
       </div>
       </div>
 
 
       {/* Queue Dialog */}
       {/* Queue Dialog */}
       <Dialog open={showQueue} onOpenChange={setShowQueue}>
       <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>
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
             <DialogTitle className="flex items-center gap-2">
               <span className="material-icons-outlined">queue_music</span>
               <span className="material-icons-outlined">queue_music</span>
@@ -1202,16 +1401,16 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
               )}
               )}
             </DialogTitle>
             </DialogTitle>
             <DialogDescription className="sr-only">
             <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>
             </DialogDescription>
           </DialogHeader>
           </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)
                 // Only show upcoming patterns (after current)
                 const currentIndex = status.playlist!.current_index
                 const currentIndex = status.playlist!.current_index
-                const upcomingFiles = status.playlist!.files
+                const upcomingFiles = displayQueue
                   .map((file, index) => ({ file, index }))
                   .map((file, index) => ({ file, index }))
                   .filter(({ index }) => index > currentIndex)
                   .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>
                   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 (
                 return (
                   <DndContext
                   <DndContext
                     sensors={sensors}
                     sensors={sensors}
@@ -1237,6 +1439,11 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                             file={file}
                             file={file}
                             index={index}
                             index={index}
                             previewUrl={queuePreviews[file] || null}
                             previewUrl={queuePreviews[file] || null}
+                            isFirst={index === firstUpcomingIndex}
+                            isLast={index === lastUpcomingIndex}
+                            onMoveToTop={() => moveToPosition(index, firstUpcomingIndex)}
+                            requestPreview={requestQueuePreview}
+                            onMoveToBottom={() => moveToPosition(index, lastUpcomingIndex)}
                           />
                           />
                         ))}
                         ))}
                       </div>
                       </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,
   Layers,
   Plus,
   Plus,
   Check,
   Check,
-  Wifi,
-  WifiOff,
   Pencil,
   Pencil,
   Trash2,
   Trash2,
-  ChevronDown,
 } from 'lucide-react'
 } from 'lucide-react'
 
 
-export function TableSelector() {
+interface TableSelectorProps {
+  children?: React.ReactNode
+}
+
+export function TableSelector({ children }: TableSelectorProps) {
   const {
   const {
     tables,
     tables,
     activeTable,
     activeTable,
@@ -121,19 +122,20 @@ export function TableSelector() {
     <>
     <>
       <Popover open={isOpen} onOpenChange={setIsOpen}>
       <Popover open={isOpen} onOpenChange={setIsOpen}>
         <PopoverTrigger asChild>
         <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>
         </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">
           <div className="space-y-2">
             {/* Header */}
             {/* Header */}
             <div className="px-2 py-1">
             <div className="px-2 py-1">
@@ -150,12 +152,28 @@ export function TableSelector() {
                   }`}
                   }`}
                   onClick={() => handleSelectTable(table)}
                   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 */}
                   {/* Name and info */}
                   <div className="flex-1 min-w-0">
                   <div className="flex-1 min-w-0">
@@ -172,11 +190,6 @@ export function TableSelector() {
                     </span>
                     </span>
                   </div>
                   </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 */}
                   {/* 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">
                   <div className="flex md:opacity-0 md:group-hover:opacity-100 items-center gap-1 transition-opacity">
                     <Button
                     <Button
@@ -206,13 +219,18 @@ export function TableSelector() {
                       </Button>
                       </Button>
                     )}
                     )}
                   </div>
                   </div>
+
+                  {/* Selected indicator - far right */}
+                  {activeTable?.id === table.id && (
+                    <Check className="h-4 w-4 text-primary flex-shrink-0" />
+                  )}
                 </div>
                 </div>
               ))}
               ))}
             </div>
             </div>
 
 
             {/* Add table button */}
             {/* Add table button */}
             <Button
             <Button
-              variant="outline"
+              variant="secondary"
               size="sm"
               size="sm"
               className="w-full gap-2"
               className="w-full gap-2"
               onClick={() => setShowAddDialog(true)}
               onClick={() => setShowAddDialog(true)}
@@ -253,8 +271,8 @@ export function TableSelector() {
               />
               />
             </div>
             </div>
           </div>
           </div>
-          <DialogFooter>
-            <Button variant="outline" onClick={() => setShowAddDialog(false)}>
+          <DialogFooter className="gap-2 sm:gap-0">
+            <Button variant="secondary" onClick={() => setShowAddDialog(false)}>
               Cancel
               Cancel
             </Button>
             </Button>
             <Button onClick={handleAddTable} disabled={isAdding}>
             <Button onClick={handleAddTable} disabled={isAdding}>
@@ -279,8 +297,8 @@ export function TableSelector() {
               autoFocus
               autoFocus
             />
             />
           </div>
           </div>
-          <DialogFooter>
-            <Button variant="outline" onClick={() => setShowRenameDialog(false)}>
+          <DialogFooter className="gap-2 sm:gap-0">
+            <Button variant="secondary" onClick={() => setShowRenameDialog(false)}>
               Cancel
               Cancel
             </Button>
             </Button>
             <Button onClick={handleRename}>Save</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 { TableSelector } from '@/components/TableSelector'
 import { useTable } from '@/contexts/TableContext'
 import { useTable } from '@/contexts/TableContext'
 import { apiClient } from '@/lib/apiClient'
 import { apiClient } from '@/lib/apiClient'
+import ShinyText from '@/components/ShinyText'
 
 
 const navItems = [
 const navItems = [
   { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
   { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
@@ -23,8 +24,16 @@ const DEFAULT_APP_NAME = 'Dune Weaver'
 export function Layout() {
 export function Layout() {
   const location = useLocation()
   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
   // 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(() => {
   const [isDark, setIsDark] = useState(() => {
     if (typeof window !== 'undefined') {
     if (typeof window !== 'undefined') {
@@ -39,10 +48,17 @@ export function Layout() {
   const [appName, setAppName] = useState(DEFAULT_APP_NAME)
   const [appName, setAppName] = useState(DEFAULT_APP_NAME)
   const [customLogo, setCustomLogo] = useState<string | null>(null)
   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
   // Connection status
   const [isConnected, setIsConnected] = useState(false)
   const [isConnected, setIsConnected] = useState(false)
   const [isBackendConnected, setIsBackendConnected] = useState(false)
   const [isBackendConnected, setIsBackendConnected] = useState(false)
   const [isHoming, setIsHoming] = useState(false)
   const [isHoming, setIsHoming] = useState(false)
+  const [homingDismissed, setHomingDismissed] = useState(false)
   const [homingJustCompleted, setHomingJustCompleted] = useState(false)
   const [homingJustCompleted, setHomingJustCompleted] = useState(false)
   const [homingCountdown, setHomingCountdown] = useState(0)
   const [homingCountdown, setHomingCountdown] = useState(0)
   const [keepHomingLogsOpen, setKeepHomingLogsOpen] = useState(false)
   const [keepHomingLogsOpen, setKeepHomingLogsOpen] = useState(false)
@@ -153,8 +169,12 @@ export function Layout() {
   // Now Playing bar state
   // Now Playing bar state
   const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
   const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
   const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = 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)
   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)
   // Listen for playback-started event (dispatched when user starts a pattern)
   useEffect(() => {
   useEffect(() => {
     const handlePlaybackStarted = () => {
     const handlePlaybackStarted = () => {
@@ -222,17 +242,26 @@ export function Layout() {
             // Update homing status and detect completion
             // Update homing status and detect completion
             if (data.data.is_homing !== undefined) {
             if (data.data.is_homing !== undefined) {
               const newIsHoming = data.data.is_homing
               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
               // Detect transition from homing to not homing
               if (wasHomingRef.current && !newIsHoming) {
               if (wasHomingRef.current && !newIsHoming) {
                 // Homing just completed - show completion state with countdown
                 // Homing just completed - show completion state with countdown
                 setHomingJustCompleted(true)
                 setHomingJustCompleted(true)
                 setHomingCountdown(5)
                 setHomingCountdown(5)
+                setHomingDismissed(false)
               }
               }
               wasHomingRef.current = newIsHoming
               wasHomingRef.current = newIsHoming
               setIsHoming(newIsHoming)
               setIsHoming(newIsHoming)
             }
             }
             // Auto-open/close Now Playing bar based on playback state
             // 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
             // Skip auto-open on first message (page refresh) - only react to state changes
             if (wasPlayingRef.current !== null) {
             if (wasPlayingRef.current !== null) {
               if (isPlaying && !wasPlayingRef.current) {
               if (isPlaying && !wasPlayingRef.current) {
@@ -283,6 +312,9 @@ export function Layout() {
     const unsubscribe = apiClient.onBaseUrlChange(() => {
     const unsubscribe = apiClient.onBaseUrlChange(() => {
       if (isMounted) {
       if (isMounted) {
         wasPlayingRef.current = null // Reset playing state for new table
         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()
         connectWebSocket()
       }
       }
     })
     })
@@ -437,8 +469,8 @@ export function Layout() {
     // Also reconnect when active table changes
     // Also reconnect when active table changes
   }, [isLogsOpen, activeTable?.id])
   }, [isLogsOpen, activeTable?.id])
 
 
-  const handleOpenLogs = () => {
-    setIsLogsOpen(true)
+  const handleToggleLogs = () => {
+    setIsLogsOpen((prev) => !prev)
   }
   }
 
 
   // Filter logs by level
   // 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 handleCopyLogs = () => {
     const text = filteredLogs
     const text = filteredLogs
       .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`)
       .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`)
       .join('\n')
       .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
   // Download logs as file
@@ -507,11 +564,11 @@ export function Layout() {
   useEffect(() => {
   useEffect(() => {
     const currentNav = navItems.find((item) => item.path === location.pathname)
     const currentNav = navItems.find((item) => item.path === location.pathname)
     if (currentNav) {
     if (currentNav) {
-      document.title = `${currentNav.title} | ${appName}`
+      document.title = `${currentNav.title} | ${displayName}`
     } else {
     } else {
-      document.title = appName
+      document.title = displayName
     }
     }
-  }, [location.pathname, appName])
+  }, [location.pathname, displayName])
 
 
   useEffect(() => {
   useEffect(() => {
     if (isDark) {
     if (isDark) {
@@ -830,7 +887,7 @@ export function Layout() {
     : 0
     : 0
 
 
   return (
   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 */}
       {/* Cache Progress Blocking Overlay */}
       {cacheProgress?.is_running && (
       {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">
         <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}>
                     <Button variant="ghost" onClick={handleSkipCacheAll}>
                       Skip for now
                       Skip for now
                     </Button>
                     </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>
                       <span className="material-icons-outlined text-lg">cached</span>
                       Cache All
                       Cache All
                     </Button>
                     </Button>
@@ -953,7 +1010,7 @@ export function Layout() {
       )}
       )}
 
 
       {/* Backend Connection / Homing Blocking Overlay */}
       {/* 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="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">
           <div className="w-full max-w-2xl space-y-6">
             {/* Status Header */}
             {/* Status Header */}
@@ -1029,11 +1086,7 @@ export function Layout() {
                       const logText = connectionLogs
                       const logText = connectionLogs
                         .map((log) => `[${new Date(log.timestamp).toLocaleTimeString()}] [${log.level}] ${log.message}`)
                         .map((log) => `[${new Date(log.timestamp).toLocaleTimeString()}] [${log.level}] ${log.message}`)
                         .join('\n')
                         .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"
                     className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
                     title="Copy logs to clipboard"
                     title="Copy logs to clipboard"
@@ -1075,7 +1128,7 @@ export function Layout() {
                 {!keepHomingLogsOpen ? (
                 {!keepHomingLogsOpen ? (
                   <>
                   <>
                     <Button
                     <Button
-                      variant="outline"
+                      variant="secondary"
                       onClick={() => setKeepHomingLogsOpen(true)}
                       onClick={() => setKeepHomingLogsOpen(true)}
                       className="gap-2"
                       className="gap-2"
                     >
                     >
@@ -1108,11 +1161,25 @@ export function Layout() {
               </div>
               </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 */}
             {/* Hint */}
             {!homingJustCompleted && (
             {!homingJustCompleted && (
               <p className="text-center text-xs text-muted-foreground">
               <p className="text-center text-xs text-muted-foreground">
                 {isHoming
                 {isHoming
-                  ? 'The table is calibrating its position'
+                  ? 'Homing will continue in the background'
                   : 'Make sure the backend server is running on port 8080'
                   : 'Make sure the backend server is running on port 8080'
                 }
                 }
               </p>
               </p>
@@ -1121,84 +1188,108 @@ export function Layout() {
         </div>
         </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 */}
           {/* 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>
           </div>
 
 
           {/* Mobile actions */}
           {/* 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}>
             <Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
               <PopoverTrigger asChild>
               <PopoverTrigger asChild>
                 <Button
                 <Button
@@ -1228,7 +1319,7 @@ export function Layout() {
                   </button>
                   </button>
                   <button
                   <button
                     onClick={() => {
                     onClick={() => {
-                      handleOpenLogs()
+                      handleToggleLogs()
                       setIsMobileMenuOpen(false)
                       setIsMobileMenuOpen(false)
                     }}
                     }}
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
                     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>
                 </div>
               </PopoverContent>
               </PopoverContent>
             </Popover>
             </Popover>
+            </div>
           </div>
           </div>
         </div>
         </div>
       </header>
       </header>
@@ -1271,6 +1363,7 @@ export function Layout() {
           !isLogsOpen && isNowPlayingOpen ? 'pb-80' : ''
           !isLogsOpen && isNowPlayingOpen ? 'pb-80' : ''
         }`}
         }`}
         style={{
         style={{
+          paddingTop: 'calc(4.5rem + env(safe-area-inset-top, 0px))',
           paddingBottom: isLogsOpen
           paddingBottom: isLogsOpen
             ? isNowPlayingOpen
             ? isNowPlayingOpen
               ? logsDrawerHeight + 256 + 64 // drawer + now playing + nav
               ? logsDrawerHeight + 256 + 64 // drawer + now playing + nav
@@ -1284,22 +1377,12 @@ export function Layout() {
       {/* Now Playing Bar */}
       {/* Now Playing Bar */}
       <NowPlayingBar
       <NowPlayingBar
         isLogsOpen={isLogsOpen}
         isLogsOpen={isLogsOpen}
+        logsDrawerHeight={logsDrawerHeight}
         isVisible={isNowPlayingOpen}
         isVisible={isNowPlayingOpen}
         openExpanded={openNowPlayingExpanded}
         openExpanded={openNowPlayingExpanded}
         onClose={() => setIsNowPlayingOpen(false)}
         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 */}
       {/* Logs Drawer */}
       <div
       <div
@@ -1403,8 +1486,25 @@ export function Layout() {
         )}
         )}
       </div>
       </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 */}
       {/* 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">
         <div className="max-w-5xl mx-auto grid grid-cols-5 h-16">
           {navItems.map((item) => {
           {navItems.map((item) => {
             const isActive = location.pathname === item.path
             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"
 import { cn } from "@/lib/utils"
 
 
 const buttonVariants = cva(
 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: {
     variants: {
       variant: {
       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:
         destructive:
           "bg-destructive text-destructive-foreground hover:bg-destructive/90",
           "bg-destructive text-destructive-foreground hover:bg-destructive/90",
         outline:
         outline:
           "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
           "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
         secondary:
         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",
         ghost: "hover:bg-accent hover:text-accent-foreground",
         link: "text-primary underline-offset-4 hover:underline",
         link: "text-primary underline-offset-4 hover:underline",
       },
       },
       size: {
       size: {
         default: "h-10 px-4 py-2",
         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: "h-10 w-10",
         "icon-sm": "h-8 w-8",
         "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}>
     <Popover open={open} onOpenChange={setOpen}>
       <PopoverTrigger asChild>
       <PopoverTrigger asChild>
         <Button
         <Button
-          variant="outline"
+          variant="secondary"
           className={cn(
           className={cn(
             'w-12 h-12 rounded-full p-1 border-2',
             'w-12 h-12 rounded-full p-1 border-2',
             className
             className

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

@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
       <input
       <input
         type={type}
         type={type}
         className={cn(
         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
           className
         )}
         )}
         ref={ref}
         ref={ref}

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

@@ -17,7 +17,7 @@ const PopoverContent = React.forwardRef<
       align={align}
       align={align}
       sideOffset={sideOffset}
       sideOffset={sideOffset}
       className={cn(
       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
         className
       )}
       )}
       {...props}
       {...props}

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

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

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

@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
   <SelectPrimitive.Trigger
   <SelectPrimitive.Trigger
     ref={ref}
     ref={ref}
     className={cn(
     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
       className
     )}
     )}
     {...props}
     {...props}
@@ -73,7 +73,7 @@ const SelectContent = React.forwardRef<
     <SelectPrimitive.Content
     <SelectPrimitive.Content
       ref={ref}
       ref={ref}
       className={cn(
       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" &&
         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",
           "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
         className
@@ -84,7 +84,7 @@ const SelectContent = React.forwardRef<
       <SelectScrollUpButton />
       <SelectScrollUpButton />
       <SelectPrimitive.Viewport
       <SelectPrimitive.Viewport
         className={cn(
         className={cn(
-          "p-1",
+          "p-1 max-h-[inherit] overflow-y-auto",
           position === "popper" &&
           position === "popper" &&
             "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
             "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
   <SelectPrimitive.Item
     ref={ref}
     ref={ref}
     className={cn(
     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
       className
     )}
     )}
     {...props}
     {...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 Toaster = ({ ...props }: ToasterProps) => {
   const [theme, setTheme] = useState<"light" | "dark">("light")
   const [theme, setTheme] = useState<"light" | "dark">("light")
+  const [isStandalone, setIsStandalone] = useState(false)
 
 
   useEffect(() => {
   useEffect(() => {
     // Check initial theme
     // Check initial theme
     const isDark = document.documentElement.classList.contains("dark")
     const isDark = document.documentElement.classList.contains("dark")
     setTheme(isDark ? "dark" : "light")
     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
     // Watch for theme changes
     const observer = new MutationObserver((mutations) => {
     const observer = new MutationObserver((mutations) => {
       mutations.forEach((mutation) => {
       mutations.forEach((mutation) => {
@@ -25,10 +31,14 @@ const Toaster = ({ ...props }: ToasterProps) => {
     return () => observer.disconnect()
     return () => observer.disconnect()
   }, [])
   }, [])
 
 
+  // Use larger offset for PWA to account for Dynamic Island/notch (59px typical + 16px padding)
+  const offset = isStandalone ? 75 : 16
+
   return (
   return (
     <Sonner
     <Sonner
       theme={theme}
       theme={theme}
       className="toaster group"
       className="toaster group"
+      offset={offset}
       toastOptions={{
       toastOptions={{
         classNames: {
         classNames: {
           toast:
           toast:

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

@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
 >(({ className, ...props }, ref) => (
 >(({ className, ...props }, ref) => (
   <SwitchPrimitives.Root
   <SwitchPrimitives.Root
     className={cn(
     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
       className
     )}
     )}
     {...props}
     {...props}

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

@@ -12,12 +12,14 @@ import { apiClient } from '@/lib/apiClient'
 export interface Table {
 export interface Table {
   id: string
   id: string
   name: string
   name: string
+  appName?: string // Application name from settings (e.g., "Dune Weaver")
   url: string
   url: string
   host?: string
   host?: string
   port?: number
   port?: number
   version?: string
   version?: string
   isOnline?: boolean
   isOnline?: boolean
   isCurrent?: boolean // True if this is the backend serving the frontend
   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 {
 interface TableContextType {
@@ -41,6 +43,19 @@ const TableContext = createContext<TableContextType | null>(null)
 const STORAGE_KEY = 'duneweaver_tables'
 const STORAGE_KEY = 'duneweaver_tables'
 const ACTIVE_TABLE_KEY = 'duneweaver_active_table'
 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 {
 interface StoredTableData {
   tables: Table[]
   tables: Table[]
   activeTableId: string | null
   activeTableId: string | null
@@ -73,8 +88,10 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
           if (active) {
           if (active) {
             restoredActiveIdRef.current = activeId // Mark that we restored a selection
             restoredActiveIdRef.current = activeId // Mark that we restored a selection
             setActiveTableState(active)
             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)
               apiClient.setBaseUrl(active.url)
             }
             }
           }
           }
@@ -125,7 +142,8 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
     }
     }
 
 
     // Update API client base URL
     // 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('')
       apiClient.setBaseUrl('')
     } else {
     } else {
       apiClient.setBaseUrl(table.url)
       apiClient.setBaseUrl(table.url)
@@ -140,13 +158,22 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
     setIsDiscovering(true)
     setIsDiscovering(true)
 
 
     try {
     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) {
       if (!infoResponse.ok) {
         throw new Error('Failed to fetch table info')
         throw new Error('Failed to fetch table info')
       }
       }
 
 
       const info = await infoResponse.json()
       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 = {
       const currentTable: Table = {
         id: info.id,
         id: info.id,
         name: info.name,
         name: info.name,
@@ -154,17 +181,27 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
         version: info.version,
         version: info.version,
         isOnline: true,
         isOnline: true,
         isCurrent: 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
         // Start with current table
         const merged: Table[] = [currentTable]
         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
       restoredActiveIdRef.current = null
 
 
       setLastDiscovery(new Date())
       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) {
     } catch (e) {
       console.error('Table refresh failed:', e)
       console.error('Table refresh failed:', e)
     } finally {
     } finally {
@@ -214,13 +279,19 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
         return null
         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')
         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 = {
       const newTable: Table = {
         id: info.id,
         id: info.id,
         name: name || info.name,
         name: name || info.name,
@@ -228,6 +299,26 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
         version: info.version,
         version: info.version,
         isOnline: true,
         isOnline: true,
         isCurrent: false,
         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])
       setTables(prev => [...prev, newTable])
@@ -239,7 +330,15 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
   }, [tables])
   }, [tables])
 
 
   // Remove a table
   // 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))
     setTables(prev => prev.filter(t => t.id !== id))
 
 
     // If removing active table, switch to another
     // If removing active table, switch to another
@@ -268,6 +367,19 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
       })
       })
 
 
       if (response.ok) {
       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 =>
         setTables(prev =>
           prev.map(t => (t.id === id ? { ...t, name } : t))
           prev.map(t => (t.id === id ? { ...t, name } : t))
         )
         )
@@ -282,17 +394,23 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
     }
     }
   }, [tables, activeTable])
   }, [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> => {
   const refreshTableStatus = useCallback(async (table: Table): Promise<boolean> => {
     try {
     try {
       const baseUrl = table.isCurrent ? '' : table.url
       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 =>
       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
       return isOnline

+ 13 - 5
frontend/src/index.css

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

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

@@ -32,7 +32,10 @@ class ApiClient {
    */
    */
   setBaseUrl(url: string): void {
   setBaseUrl(url: string): void {
     // Remove trailing slash
     // 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
     // Notify listeners
     this._listeners.forEach(listener => listener(this._baseUrl))
     this._listeners.forEach(listener => listener(this._baseUrl))
   }
   }
@@ -196,5 +199,48 @@ class ApiClient {
 // Export singleton instance
 // Export singleton instance
 export const apiClient = new ApiClient()
 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 class for testing
 export { ApiClient }
 export { ApiClient }

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

@@ -20,7 +20,7 @@ export interface Playlist {
   files: string[]
   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 PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
 export type RunMode = 'single' | 'indefinite'
 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 { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
-import { Separator } from '@/components/ui/separator'
 import { Slider } from '@/components/ui/slider'
 import { Slider } from '@/components/ui/slider'
 import {
 import {
   Select,
   Select,
@@ -21,6 +20,12 @@ import {
   SelectTrigger,
   SelectTrigger,
   SelectValue,
   SelectValue,
 } from '@/components/ui/select'
 } from '@/components/ui/select'
+import {
+  Sheet,
+  SheetContent,
+  SheetHeader,
+  SheetTitle,
+} from '@/components/ui/sheet'
 
 
 // Types
 // Types
 interface PatternMetadata {
 interface PatternMetadata {
@@ -41,7 +46,7 @@ interface PreviewData {
 // Coordinates come as [theta, rho] tuples from the backend
 // Coordinates come as [theta, rho] tuples from the backend
 type Coordinate = [number, number]
 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'
 type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
 
 
 const preExecutionOptions: { value: PreExecution; label: string }[] = [
 const preExecutionOptions: { value: PreExecution; label: string }[] = [
@@ -89,6 +94,18 @@ export function BrowsePage() {
   const [speed, setSpeed] = useState(1)
   const [speed, setSpeed] = useState(1)
   const [progress, setProgress] = useState(0)
   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
   // Canvas and animation refs
   const canvasRef = useRef<HTMLCanvasElement>(null)
   const canvasRef = useRef<HTMLCanvasElement>(null)
   const animationRef = useRef<number | null>(null)
   const animationRef = useRef<number | null>(null)
@@ -111,6 +128,29 @@ export function BrowsePage() {
   const fileInputRef = useRef<HTMLInputElement>(null)
   const fileInputRef = useRef<HTMLInputElement>(null)
   const [isUploading, setIsUploading] = useState(false)
   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
   // Close panel when playback starts
   useEffect(() => {
   useEffect(() => {
     const handlePlaybackStarted = () => {
     const handlePlaybackStarted = () => {
@@ -199,8 +239,13 @@ export function BrowsePage() {
   const fetchPatterns = async () => {
   const fetchPatterns = async () => {
     setIsLoading(true)
     setIsLoading(true)
     try {
     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)
       setPatterns(data)
+      setAllPatternHistories(historyData)
 
 
       if (data.length > 0) {
       if (data.length > 0) {
         // Sort patterns by name (default sort) before preloading
         // Sort patterns by name (default sort) before preloading
@@ -344,9 +389,18 @@ export function BrowsePage() {
         case 'date':
         case 'date':
           comparison = a.date_modified - b.date_modified
           comparison = a.date_modified - b.date_modified
           break
           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
           break
+        }
         default:
         default:
           return 0
           return 0
       }
       }
@@ -354,7 +408,7 @@ export function BrowsePage() {
     })
     })
 
 
     return result
     return result
-  }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc])
+  }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc, favorites])
 
 
   // Batched preview loading - collects requests and fetches in batches
   // Batched preview loading - collects requests and fetches in batches
   const requestPreview = useCallback((path: string) => {
   const requestPreview = useCallback((path: string) => {
@@ -565,36 +619,27 @@ export function BrowsePage() {
     }
     }
   }, [coordinates, drawPattern])
   }, [coordinates, drawPattern])
 
 
-  const handlePatternClick = (pattern: PatternMetadata) => {
+  const handlePatternClick = async (pattern: PatternMetadata) => {
     setSelectedPattern(pattern)
     setSelectedPattern(pattern)
     setIsPanelOpen(true)
     setIsPanelOpen(true)
     setPreExecution('adaptive')
     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 () => {
   const handleOpenAnimatedPreview = async () => {
     if (!selectedPattern) return
     if (!selectedPattern) return
+    setIsPanelOpen(false) // Close sheet before opening preview
     setIsAnimatedPreviewOpen(true)
     setIsAnimatedPreviewOpen(true)
     setIsPlaying(false)
     setIsPlaying(false)
     setProgress(0)
     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 getPreviewUrl = (path: string) => {
     const preview = previews[path]
     const preview = previews[path]
     return preview?.image_data || null
     return preview?.image_data || null
@@ -705,7 +769,7 @@ export function BrowsePage() {
 
 
   const formatCoordinate = (coord: { x: number; y: number } | null) => {
   const formatCoordinate = (coord: { x: number; y: number } | null) => {
     if (!coord) return '(-, -)'
     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/')
   const canDelete = selectedPattern?.path.startsWith('custom_patterns/')
@@ -755,9 +819,8 @@ export function BrowsePage() {
       await apiClient.uploadFile('/upload_theta_rho', file)
       await apiClient.uploadFile('/upload_theta_rho', file)
       toast.success(`Pattern "${file.name}" uploaded successfully`)
       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) {
     } catch (error) {
       console.error('Upload error:', error)
       console.error('Upload error:', error)
       toast.error(error instanceof Error ? error.message : 'Failed to upload pattern')
       toast.error(error instanceof Error ? error.message : 'Failed to upload pattern')
@@ -781,7 +844,7 @@ export function BrowsePage() {
   }
   }
 
 
   return (
   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 */}
       {/* Hidden file input for pattern upload */}
       <input
       <input
         ref={fileInputRef}
         ref={fileInputRef}
@@ -791,114 +854,121 @@ export function BrowsePage() {
         className="hidden"
         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
             {patterns.length} patterns available
           </p>
           </p>
         </div>
         </div>
         <Button
         <Button
-          variant="outline"
-          size="sm"
+          variant="ghost"
           onClick={() => fileInputRef.current?.click()}
           onClick={() => fileInputRef.current?.click()}
           disabled={isUploading}
           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 ? (
           {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>
           <span className="hidden sm:inline">Add Pattern</span>
         </Button>
         </Button>
       </div>
       </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>
           </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 && (
           {!allCached && (
             <Button
             <Button
               variant="outline"
               variant="outline"
               onClick={handleCacheAllPreviews}
               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 ? (
               {isCaching ? (
                 <>
                 <>
                   <span className="material-icons-outlined animate-spin text-lg">sync</span>
                   <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="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>
             </Button>
@@ -926,7 +996,7 @@ export function BrowsePage() {
           </div>
           </div>
           {(searchQuery || selectedCategory !== 'all') && (
           {(searchQuery || selectedCategory !== 'all') && (
             <Button
             <Button
-              variant="outline"
+              variant="secondary"
               onClick={() => {
               onClick={() => {
                 setSearchQuery('')
                 setSearchQuery('')
                 setSelectedCategory('all')
                 setSelectedCategory('all')
@@ -945,6 +1015,7 @@ export function BrowsePage() {
                 pattern={pattern}
                 pattern={pattern}
                 isSelected={selectedPattern?.path === pattern.path}
                 isSelected={selectedPattern?.path === pattern.path}
                 isFavorite={favorites.has(pattern.path)}
                 isFavorite={favorites.has(pattern.path)}
+                playTime={allPatternHistories[pattern.path.split('/').pop() || '']?.actual_time_formatted || null}
                 onToggleFavorite={toggleFavorite}
                 onToggleFavorite={toggleFavorite}
                 onClick={() => handlePatternClick(pattern)}
                 onClick={() => handlePatternClick(pattern)}
               />
               />
@@ -955,67 +1026,82 @@ export function BrowsePage() {
 
 
       <div className="h-48" />
       <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 && (
           {selectedPattern && (
             <div className="p-6 overflow-y-auto flex-1">
             <div className="p-6 overflow-y-auto flex-1">
               {/* Clickable Round Preview Image */}
               {/* 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>
                   </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>
                 </div>
+                <p className="text-xs text-muted-foreground text-center mt-2">Tap to preview animation</p>
               </div>
               </div>
 
 
               {/* Coordinates */}
               {/* 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">
                 <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="text-muted-foreground">First:</span>
                   <span className="font-semibold">
                   <span className="font-semibold">
                     {formatCoordinate(previews[selectedPattern.path]?.first_coordinate)}
                     {formatCoordinate(previews[selectedPattern.path]?.first_coordinate)}
                   </span>
                   </span>
                 </div>
                 </div>
                 <div className="flex items-center gap-2">
                 <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="text-muted-foreground">Last:</span>
                   <span className="font-semibold">
                   <span className="font-semibold">
                     {formatCoordinate(previews[selectedPattern.path]?.last_coordinate)}
                     {formatCoordinate(previews[selectedPattern.path]?.last_coordinate)}
@@ -1023,6 +1109,24 @@ export function BrowsePage() {
                 </div>
                 </div>
               </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 */}
               {/* Pre-Execution Options */}
               <div className="mb-6">
               <div className="mb-6">
                 <Label className="text-sm font-semibold mb-3 block">Pre-Execution Action</Label>
                 <Label className="text-sm font-semibold mb-3 block">Pre-Execution Action</Label>
@@ -1052,58 +1156,65 @@ export function BrowsePage() {
 
 
               {/* Action Buttons */}
               {/* Action Buttons */}
               <div className="space-y-3">
               <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>
           )}
           )}
-        </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 */}
       {/* Animated Preview Modal */}
       {isAnimatedPreviewOpen && (
       {isAnimatedPreviewOpen && (
         <div
         <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}
           onClick={handleCloseAnimatedPreview}
         >
         >
           <div
           <div
@@ -1198,7 +1309,7 @@ export function BrowsePage() {
                   </span>
                   </span>
                   {isPlaying ? 'Pause' : 'Play'}
                   {isPlaying ? 'Pause' : 'Play'}
                 </Button>
                 </Button>
-                <Button variant="outline" onClick={handleReset} className="gap-2">
+                <Button variant="secondary" onClick={handleReset} className="gap-2">
                   <span className="material-icons">replay</span>
                   <span className="material-icons">replay</span>
                   Reset
                   Reset
                 </Button>
                 </Button>
@@ -1216,11 +1327,12 @@ interface PatternCardProps {
   pattern: PatternMetadata
   pattern: PatternMetadata
   isSelected: boolean
   isSelected: boolean
   isFavorite: boolean
   isFavorite: boolean
+  playTime: string | null
   onToggleFavorite: (path: string, e: React.MouseEvent) => void
   onToggleFavorite: (path: string, e: React.MouseEvent) => void
   onClick: () => 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 [imageLoaded, setImageLoaded] = useState(false)
   const [imageError, setImageError] = useState(false)
   const [imageError, setImageError] = useState(false)
   const cardRef = useRef<HTMLButtonElement>(null)
   const cardRef = useRef<HTMLButtonElement>(null)
@@ -1253,12 +1365,12 @@ function PatternCard({ pattern, isSelected, isFavorite, onToggleFavorite, onClic
     <button
     <button
       ref={cardRef}
       ref={cardRef}
       onClick={onClick}
       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="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 ? (
           {previewUrl && !imageError ? (
             <>
             <>
               {!imageLoaded && (
               {!imageLoaded && (
@@ -1287,28 +1399,76 @@ function PatternCard({ pattern, isSelected, isFavorite, onToggleFavorite, onClic
             </div>
             </div>
           )}
           )}
         </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'}
           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'}
             {isFavorite ? 'favorite' : 'favorite_border'}
           </span>
           </span>
-        </div>
+        </span>
       </div>
       </div>
-
-      <span className="text-xs font-medium text-center truncate w-full px-1" title={pattern.name}>
-        {pattern.name}
-      </span>
     </button>
     </button>
   )
   )
 }
 }

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

@@ -66,7 +66,9 @@ export function LEDPage() {
   const [palettes, setPalettes] = useState<[number, string][]>([])
   const [palettes, setPalettes] = useState<[number, string][]>([])
   const [brightness, setBrightness] = useState(35)
   const [brightness, setBrightness] = useState(35)
   const [speed, setSpeed] = useState(128)
   const [speed, setSpeed] = useState(128)
+  const [speedInput, setSpeedInput] = useState('128')
   const [intensity, setIntensity] = useState(128)
   const [intensity, setIntensity] = useState(128)
+  const [intensityInput, setIntensityInput] = useState('128')
   const [selectedEffect, setSelectedEffect] = useState('')
   const [selectedEffect, setSelectedEffect] = useState('')
   const [selectedPalette, setSelectedPalette] = useState('')
   const [selectedPalette, setSelectedPalette] = useState('')
   const [color1, setColor1] = useState('#ff0000')
   const [color1, setColor1] = useState('#ff0000')
@@ -81,6 +83,7 @@ export function LEDPage() {
   const [playingEffect, setPlayingEffect] = useState<EffectSettings | null>(null)
   const [playingEffect, setPlayingEffect] = useState<EffectSettings | null>(null)
   const [idleTimeoutEnabled, setIdleTimeoutEnabled] = useState(false)
   const [idleTimeoutEnabled, setIdleTimeoutEnabled] = useState(false)
   const [idleTimeoutMinutes, setIdleTimeoutMinutes] = useState(30)
   const [idleTimeoutMinutes, setIdleTimeoutMinutes] = useState(30)
+  const [idleTimeoutInput, setIdleTimeoutInput] = useState('30')
 
 
   // Fetch LED configuration
   // Fetch LED configuration
   useEffect(() => {
   useEffect(() => {
@@ -120,7 +123,9 @@ export function LEDPage() {
       if (data.connected) {
       if (data.connected) {
         setBrightness(data.brightness || 35)
         setBrightness(data.brightness || 35)
         setSpeed(data.speed || 128)
         setSpeed(data.speed || 128)
+        setSpeedInput(String(data.speed || 128))
         setIntensity(data.intensity || 128)
         setIntensity(data.intensity || 128)
+        setIntensityInput(String(data.intensity || 128))
         setSelectedEffect(String(data.current_effect || 0))
         setSelectedEffect(String(data.current_effect || 0))
         setSelectedPalette(String(data.current_palette || 0))
         setSelectedPalette(String(data.current_palette || 0))
         if (data.colors) {
         if (data.colors) {
@@ -169,6 +174,7 @@ export function LEDPage() {
       const data = await apiClient.get<{ enabled?: boolean; minutes?: number }>('/api/dw_leds/idle_timeout')
       const data = await apiClient.get<{ enabled?: boolean; minutes?: number }>('/api/dw_leds/idle_timeout')
       setIdleTimeoutEnabled(data.enabled || false)
       setIdleTimeoutEnabled(data.enabled || false)
       setIdleTimeoutMinutes(data.minutes || 30)
       setIdleTimeoutMinutes(data.minutes || 30)
+      setIdleTimeoutInput(String(data.minutes || 30))
     } catch (error) {
     } catch (error) {
       console.error('Error fetching idle timeout:', error)
       console.error('Error fetching idle timeout:', error)
     }
     }
@@ -205,6 +211,7 @@ export function LEDPage() {
 
 
   const handleSpeedChange = useCallback((value: number[]) => {
   const handleSpeedChange = useCallback((value: number[]) => {
     setSpeed(value[0])
     setSpeed(value[0])
+    setSpeedInput(String(value[0]))
   }, [])
   }, [])
 
 
   const handleSpeedCommit = async (value: number[]) => {
   const handleSpeedCommit = async (value: number[]) => {
@@ -218,6 +225,7 @@ export function LEDPage() {
 
 
   const handleIntensityChange = useCallback((value: number[]) => {
   const handleIntensityChange = useCallback((value: number[]) => {
     setIntensity(value[0])
     setIntensity(value[0])
+    setIntensityInput(String(value[0]))
   }, [])
   }, [])
 
 
   const handleIntensityCommit = async (value: number[]) => {
   const handleIntensityCommit = async (value: number[]) => {
@@ -393,11 +401,11 @@ export function LEDPage() {
 
 
   // DW LEDs control panel
   // DW LEDs control panel
   return (
   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 */}
       {/* 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>
       </div>
 
 
       <Separator />
       <Separator />
@@ -443,9 +451,9 @@ export function LEDPage() {
 
 
                   {/* Brightness Slider */}
                   {/* Brightness Slider */}
                   <div className="space-y-2">
                   <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
                         Brightness
                       </Label>
                       </Label>
                       <span className="text-sm font-medium">{brightness}%</span>
                       <span className="text-sm font-medium">{brightness}%</span>
@@ -507,14 +515,37 @@ export function LEDPage() {
               </div>
               </div>
 
 
               {/* Speed and Intensity in styled boxes */}
               {/* 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="p-4 rounded-lg border space-y-3">
                   <div className="flex justify-between items-center">
                   <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
                       Speed
                     </Label>
                     </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>
                   </div>
                   <Slider
                   <Slider
                     value={[speed]}
                     value={[speed]}
@@ -526,11 +557,34 @@ export function LEDPage() {
                 </div>
                 </div>
                 <div className="p-4 rounded-lg border space-y-3">
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex justify-between items-center">
                   <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
                       Intensity
                     </Label>
                     </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>
                   </div>
                   <Slider
                   <Slider
                     value={[intensity]}
                     value={[intensity]}
@@ -601,11 +655,26 @@ export function LEDPage() {
               {idleTimeoutEnabled && (
               {idleTimeoutEnabled && (
                 <div className="flex items-center gap-2">
                 <div className="flex items-center gap-2">
                   <Input
                   <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"
                     className="w-20"
                   />
                   />
                   <span className="text-sm text-muted-foreground flex-1">minutes</span>
                   <span className="text-sm text-muted-foreground flex-1">minutes</span>
@@ -654,7 +723,7 @@ export function LEDPage() {
                 </Button>
                 </Button>
                 <Button
                 <Button
                   size="sm"
                   size="sm"
-                  variant="outline"
+                  variant="secondary"
                   onClick={() => clearEffectSettings('playing')}
                   onClick={() => clearEffectSettings('playing')}
                 >
                 >
                   Clear
                   Clear
@@ -684,7 +753,7 @@ export function LEDPage() {
                 </Button>
                 </Button>
                 <Button
                 <Button
                   size="sm"
                   size="sm"
-                  variant="outline"
+                  variant="secondary"
                   onClick={() => clearEffectSettings('idle')}
                   onClick={() => clearEffectSettings('idle')}
                 >
                 >
                   Clear
                   Clear

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

@@ -13,7 +13,6 @@ import { preExecutionOptions } from '@/lib/types'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
-import { Switch } from '@/components/ui/switch'
 import { Separator } from '@/components/ui/separator'
 import { Separator } from '@/components/ui/separator'
 import {
 import {
   Select,
   Select,
@@ -51,12 +50,38 @@ export function PlaylistsPage() {
   const [sortBy, setSortBy] = useState<SortOption>('name')
   const [sortBy, setSortBy] = useState<SortOption>('name')
   const [sortAsc, setSortAsc] = useState(true)
   const [sortAsc, setSortAsc] = useState(true)
 
 
+  // Favorites state (loaded from "Favorites" playlist)
+  const [favorites, setFavorites] = useState<Set<string>>(new Set())
+
   // Create/Rename playlist modal
   // Create/Rename playlist modal
   const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
   const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
   const [isRenameModalOpen, setIsRenameModalOpen] = useState(false)
   const [isRenameModalOpen, setIsRenameModalOpen] = useState(false)
   const [newPlaylistName, setNewPlaylistName] = useState('')
   const [newPlaylistName, setNewPlaylistName] = useState('')
   const [playlistToRename, setPlaylistToRename] = useState<string | null>(null)
   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
   // Playback settings - initialized from localStorage
   const [runMode, setRunMode] = useState<RunMode>(() => {
   const [runMode, setRunMode] = useState<RunMode>(() => {
     const cached = localStorage.getItem('playlist-runMode')
     const cached = localStorage.getItem('playlist-runMode')
@@ -157,6 +182,7 @@ export function PlaylistsPage() {
     initPreviewCacheDB().catch(() => {})
     initPreviewCacheDB().catch(() => {})
     fetchPlaylists()
     fetchPlaylists()
     fetchAllPatterns()
     fetchAllPatterns()
+    loadFavorites()
 
 
     // Cleanup on unmount: abort in-flight requests and clear pending queue
     // Cleanup on unmount: abort in-flight requests and clear pending queue
     return () => {
     return () => {
@@ -174,6 +200,7 @@ export function PlaylistsPage() {
   useOnBackendConnected(() => {
   useOnBackendConnected(() => {
     fetchPlaylists()
     fetchPlaylists()
     fetchAllPatterns()
     fetchAllPatterns()
+    loadFavorites()
   })
   })
 
 
   const fetchPlaylists = async () => {
   const fetchPlaylists = async () => {
@@ -195,10 +222,7 @@ export function PlaylistsPage() {
       const data = await apiClient.get<{ files: string[] }>(`/get_playlist?name=${encodeURIComponent(name)}`)
       const data = await apiClient.get<{ files: string[] }>(`/get_playlist?name=${encodeURIComponent(name)}`)
       setPlaylistPatterns(data.files || [])
       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) {
     } catch (error) {
       console.error('Error fetching playlist:', error)
       console.error('Error fetching playlist:', error)
       toast.error('Failed to load playlist')
       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)
   // Preview loading functions (similar to BrowsePage)
   const loadPreviewsForPaths = async (paths: string[]) => {
   const loadPreviewsForPaths = async (paths: string[]) => {
     const cachedPreviews = await getPreviewsFromCache(paths)
     const cachedPreviews = await getPreviewsFromCache(paths)
@@ -294,6 +328,12 @@ export function PlaylistsPage() {
   const handleSelectPlaylist = (name: string) => {
   const handleSelectPlaylist = (name: string) => {
     setSelectedPlaylist(name)
     setSelectedPlaylist(name)
     fetchPlaylistPatterns(name)
     fetchPlaylistPatterns(name)
+    setMobileShowContent(true) // Show content panel on mobile
+  }
+
+  // Go back to playlist list on mobile
+  const handleMobileBack = () => {
+    setMobileShowContent(false)
   }
   }
 
 
   const handleCreatePlaylist = async () => {
   const handleCreatePlaylist = async () => {
@@ -368,12 +408,7 @@ export function PlaylistsPage() {
     setSelectedPatternPaths(new Set(playlistPatterns))
     setSelectedPatternPaths(new Set(playlistPatterns))
     setSearchQuery('')
     setSearchQuery('')
     setIsPickerOpen(true)
     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 () => {
   const handleSavePatterns = async () => {
@@ -385,7 +420,7 @@ export function PlaylistsPage() {
       setPlaylistPatterns(newPatterns)
       setPlaylistPatterns(newPatterns)
       setIsPickerOpen(false)
       setIsPickerOpen(false)
       toast.success('Playlist updated')
       toast.success('Playlist updated')
-      loadPreviewsForPaths(newPatterns)
+      // Previews are lazy-loaded via IntersectionObserver
     } catch (error) {
     } catch (error) {
       toast.error('Failed to update playlist')
       toast.error('Failed to update playlist')
     }
     }
@@ -452,15 +487,24 @@ export function PlaylistsPage() {
         case 'date':
         case 'date':
           cmp = a.date_modified - b.date_modified
           cmp = a.date_modified - b.date_modified
           break
           break
-        case 'category':
-          cmp = a.category.localeCompare(b.category)
+        case 'size':
+          cmp = a.coordinates_count - b.coordinates_count
           break
           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 sortAsc ? cmp : -cmp
     })
     })
 
 
     return filtered
     return filtered
-  }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc])
+  }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc, favorites])
 
 
   // Get pattern name from path
   // Get pattern name from path
   const getPatternName = (path: string) => {
   const getPatternName = (path: string) => {
@@ -475,11 +519,11 @@ export function PlaylistsPage() {
   }
   }
 
 
   return (
   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 */}
       {/* 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
           Create and manage pattern playlists
         </p>
         </p>
       </div>
       </div>
@@ -487,11 +531,16 @@ export function PlaylistsPage() {
       <Separator className="shrink-0" />
       <Separator className="shrink-0" />
 
 
       {/* Main Content Area */}
       {/* 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">
           <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
             <Button
               variant="ghost"
               variant="ghost"
               size="icon"
               size="icon"
@@ -562,11 +611,26 @@ export function PlaylistsPage() {
         </nav>
         </nav>
       </aside>
       </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 */}
-          <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">
               <div className="min-w-0">
                 <h2 className="text-lg font-semibold truncate">
                 <h2 className="text-lg font-semibold truncate">
                   {selectedPlaylist || 'Select a Playlist'}
                   {selectedPlaylist || 'Select a Playlist'}
@@ -590,7 +654,7 @@ export function PlaylistsPage() {
           </header>
           </header>
 
 
           {/* Patterns List */}
           {/* 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 ? (
             {!selectedPlaylist ? (
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
                 <div className="p-4 rounded-full bg-muted">
                 <div className="p-4 rounded-full bg-muted">
@@ -610,126 +674,128 @@ export function PlaylistsPage() {
                   <p className="font-medium">Empty playlist</p>
                   <p className="font-medium">Empty playlist</p>
                   <p className="text-sm">Add patterns to get started</p>
                   <p className="text-sm">Add patterns to get started</p>
                 </div>
                 </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>
                   <span className="material-icons-outlined text-base">add</span>
                   Add Patterns
                   Add Patterns
                 </Button>
                 </Button>
               </div>
               </div>
             ) : (
             ) : (
               <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
               <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>
                       </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>
                     </div>
-                  )
-                })}
+                    <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
+                  </div>
+                ))}
               </div>
               </div>
             )}
             )}
           </div>
           </div>
 
 
-          {/* Playback Settings - Always visible when playlist selected */}
+          {/* Floating Playback Controls */}
           {selectedPlaylist && (
           {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
                     <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>
                     <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'
                         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>
                     </button>
                   </div>
                   </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>
                   </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)}>
                     <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>
                       </SelectTrigger>
                       <SelectContent>
                       <SelectContent>
                         {preExecutionOptions.map(opt => (
                         {preExecutionOptions.map(opt => (
@@ -742,22 +808,19 @@ export function PlaylistsPage() {
                   </div>
                   </div>
                 </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}
                   onClick={handleRunPlaylist}
                   disabled={isRunning || playlistPatterns.length === 0}
                   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 ? (
                   {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>
             </div>
             </div>
           )}
           )}
@@ -787,7 +850,7 @@ export function PlaylistsPage() {
             </div>
             </div>
           </div>
           </div>
           <DialogFooter className="gap-2 sm:gap-0">
           <DialogFooter className="gap-2 sm:gap-0">
-            <Button variant="outline" onClick={() => setIsCreateModalOpen(false)}>
+            <Button variant="secondary" onClick={() => setIsCreateModalOpen(false)}>
               Cancel
               Cancel
             </Button>
             </Button>
             <Button onClick={handleCreatePlaylist} className="gap-2">
             <Button onClick={handleCreatePlaylist} className="gap-2">
@@ -821,7 +884,7 @@ export function PlaylistsPage() {
             </div>
             </div>
           </div>
           </div>
           <DialogFooter className="gap-2 sm:gap-0">
           <DialogFooter className="gap-2 sm:gap-0">
-            <Button variant="outline" onClick={() => setIsRenameModalOpen(false)}>
+            <Button variant="secondary" onClick={() => setIsRenameModalOpen(false)}>
               Cancel
               Cancel
             </Button>
             </Button>
             <Button onClick={handleRenamePlaylist} className="gap-2">
             <Button onClick={handleRenamePlaylist} className="gap-2">
@@ -864,55 +927,56 @@ export function PlaylistsPage() {
               )}
               )}
             </div>
             </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-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="material-icons-outlined text-base text-primary">check_circle</span>
                 <span className="font-medium">{selectedPatternPaths.size}</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>
             </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">
               <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
                 {filteredPatterns.map(pattern => {
                 {filteredPatterns.map(pattern => {
                   const isSelected = selectedPatternPaths.has(pattern.path)
                   const isSelected = selectedPatternPaths.has(pattern.path)
-                  const previewUrl = getPreviewUrl(pattern.path)
-                  if (!previewUrl && !previews[pattern.path]) {
-                    requestPreview(pattern.path)
-                  }
                   return (
                   return (
                     <div
                     <div
                       key={pattern.path}
                       key={pattern.path}
@@ -947,19 +1007,12 @@ export function PlaylistsPage() {
                             : 'border-transparent hover:border-muted-foreground/30'
                             : '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 && (
                         {isSelected && (
                           <div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-primary flex items-center justify-center shadow-md">
                           <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' }}>
                             <span className="material-icons text-primary-foreground" style={{ fontSize: '14px' }}>
@@ -979,7 +1032,7 @@ export function PlaylistsPage() {
           </div>
           </div>
 
 
           <DialogFooter className="gap-2 sm:gap-0">
           <DialogFooter className="gap-2 sm:gap-0">
-            <Button variant="outline" onClick={() => setIsPickerOpen(false)}>
+            <Button variant="secondary" onClick={() => setIsPickerOpen(false)}>
               Cancel
               Cancel
             </Button>
             </Button>
             <Button onClick={handleSavePatterns} className="gap-2">
             <Button onClick={handleSavePatterns} className="gap-2">
@@ -992,3 +1045,55 @@ export function PlaylistsPage() {
     </div>
     </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 {
 import {
   Select,
   Select,
   SelectContent,
   SelectContent,
+  SelectGroup,
   SelectItem,
   SelectItem,
+  SelectLabel,
   SelectTrigger,
   SelectTrigger,
   SelectValue,
   SelectValue,
 } from '@/components/ui/select'
 } from '@/components/ui/select'
@@ -107,6 +109,7 @@ export function SettingsPage() {
   // Settings state
   // Settings state
   const [settings, setSettings] = useState<Settings>({})
   const [settings, setSettings] = useState<Settings>({})
   const [ledConfig, setLedConfig] = useState<LedConfig>({ provider: 'none', gpio_pin: 18 })
   const [ledConfig, setLedConfig] = useState<LedConfig>({ provider: 'none', gpio_pin: 18 })
+  const [numLedsInput, setNumLedsInput] = useState('60')
   const [mqttConfig, setMqttConfig] = useState<MqttConfig>({ enabled: false })
   const [mqttConfig, setMqttConfig] = useState<MqttConfig>({ enabled: false })
 
 
   // UI state
   // UI state
@@ -132,6 +135,7 @@ export function SettingsPage() {
   })
   })
   const [autoPlayPauseUnit, setAutoPlayPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
   const [autoPlayPauseUnit, setAutoPlayPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
   const [autoPlayPauseValue, setAutoPlayPauseValue] = useState(5)
   const [autoPlayPauseValue, setAutoPlayPauseValue] = useState(5)
+  const [autoPlayPauseInput, setAutoPlayPauseInput] = useState('5')
   const [playlists, setPlaylists] = useState<string[]>([])
   const [playlists, setPlaylists] = useState<string[]>([])
 
 
   // Convert pause time from seconds to value + unit for display
   // Convert pause time from seconds to value + unit for display
@@ -289,16 +293,25 @@ export function SettingsPage() {
 
 
   const fetchPorts = async () => {
   const fetchPorts = async () => {
     try {
     try {
-      // Fetch available ports
+      // Fetch available ports first
       const portsData = await apiClient.get<string[]>('/list_serial_ports')
       const portsData = await apiClient.get<string[]>('/list_serial_ports')
-      setPorts(portsData || [])
+      const availablePorts = portsData || []
+      setPorts(availablePorts)
 
 
       // Fetch connection status
       // Fetch connection status
       const statusData = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
       const statusData = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
       setIsConnected(statusData.connected || false)
       setIsConnected(statusData.connected || false)
       setConnectionStatus(statusData.connected ? 'Connected' : 'Disconnected')
       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)
         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) {
     } catch (error) {
       console.error('Error fetching ports:', 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 pauseSeconds = data.auto_play.pause_time ?? 300 // Default 5 minutes
         const { value, unit } = secondsToDisplayPause(pauseSeconds)
         const { value, unit } = secondsToDisplayPause(pauseSeconds)
         setAutoPlayPauseValue(value)
         setAutoPlayPauseValue(value)
+        setAutoPlayPauseInput(String(value))
         setAutoPlayPauseUnit(unit)
         setAutoPlayPauseUnit(unit)
         setAutoPlaySettings({
         setAutoPlaySettings({
           enabled: data.auto_play.enabled || false,
           enabled: data.auto_play.enabled || false,
@@ -396,6 +410,7 @@ export function SettingsPage() {
         gpio_pin: data.dw_led_gpio_pin,
         gpio_pin: data.dw_led_gpio_pin,
         pixel_order: data.dw_led_pixel_order,
         pixel_order: data.dw_led_pixel_order,
       })
       })
+      setNumLedsInput(String(data.dw_led_num_leds || 60))
     } catch (error) {
     } catch (error) {
       console.error('Error fetching LED config:', error)
       console.error('Error fetching LED config:', error)
     }
     }
@@ -452,16 +467,20 @@ export function SettingsPage() {
   const handleSavePreferredPort = async () => {
   const handleSavePreferredPort = async () => {
     setIsLoading('preferredPort')
     setIsLoading('preferredPort')
     try {
     try {
+      // Send the actual value: __auto__, __none__, or specific port
+      const portValue = settings.preferred_port || '__auto__'
       await apiClient.patch('/api/settings', {
       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) {
     } catch (error) {
-      toast.error('Failed to save preferred port')
+      toast.error('Failed to save auto-connect setting')
     } finally {
     } finally {
       setIsLoading(null)
       setIsLoading(null)
     }
     }
@@ -483,16 +502,16 @@ export function SettingsPage() {
   const updateBranding = (customLogo: string | null) => {
   const updateBranding = (customLogo: string | null) => {
     const timestamp = Date.now() // Cache buster
     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 faviconIco = document.getElementById('favicon-ico') as HTMLLinkElement
     const appleTouchIcon = document.getElementById('apple-touch-icon') as HTMLLinkElement
     const appleTouchIcon = document.getElementById('apple-touch-icon') as HTMLLinkElement
 
 
     if (customLogo) {
     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 {
     } 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
     // Dispatch event for Layout to update header logo
@@ -713,11 +732,11 @@ export function SettingsPage() {
   }
   }
 
 
   return (
   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 */}
       {/* 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
           Configure your sand table
         </p>
         </p>
       </div>
       </div>
@@ -749,7 +768,7 @@ export function SettingsPage() {
             {/* Connection Status */}
             {/* Connection Status */}
             <div className="flex items-center justify-between p-4 rounded-lg border">
             <div className="flex items-center justify-between p-4 rounded-lg border">
               <div className="flex items-center gap-3">
               <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'}`}>
                   <span className={`material-icons ${isConnected ? 'text-green-600' : 'text-muted-foreground'}`}>
                     {isConnected ? 'usb' : 'usb_off'}
                     {isConnected ? 'usb' : 'usb_off'}
                   </span>
                   </span>
@@ -817,24 +836,30 @@ export function SettingsPage() {
 
 
             {/* Preferred Port for Auto-Connect */}
             {/* Preferred Port for Auto-Connect */}
             <div className="space-y-3">
             <div className="space-y-3">
-              <Label>Preferred Port (Auto-Connect)</Label>
+              <Label>Auto-Connect</Label>
               <div className="flex gap-3">
               <div className="flex gap-3">
                 <Select
                 <Select
-                  value={settings.preferred_port || '__none__'}
+                  value={settings.preferred_port || '__auto__'}
                   onValueChange={(value) =>
                   onValueChange={(value) =>
-                    setSettings({ ...settings, preferred_port: value === '__none__' ? undefined : value })
+                    setSettings({ ...settings, preferred_port: value === '__auto__' ? undefined : value })
                   }
                   }
                 >
                 >
                   <SelectTrigger className="flex-1">
                   <SelectTrigger className="flex-1">
-                    <SelectValue placeholder="Select preferred port..." />
+                    <SelectValue placeholder="Select auto-connect option..." />
                   </SelectTrigger>
                   </SelectTrigger>
                   <SelectContent>
                   <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>
                   </SelectContent>
                 </Select>
                 </Select>
                 <Button
                 <Button
@@ -851,7 +876,7 @@ export function SettingsPage() {
                 </Button>
                 </Button>
               </div>
               </div>
               <p className="text-xs text-muted-foreground">
               <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>
               </p>
             </div>
             </div>
           </AccordionContent>
           </AccordionContent>
@@ -1096,33 +1121,35 @@ export function SettingsPage() {
             {/* Custom Logo */}
             {/* Custom Logo */}
             <div className="space-y-3">
             <div className="space-y-3">
               <Label>Custom Logo</Label>
               <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>
-                <div className="flex gap-2">
+                <div className="flex gap-2 sm:ml-auto">
                   <Button
                   <Button
-                    variant="outline"
+                    variant="secondary"
                     size="sm"
                     size="sm"
                     className="gap-2"
                     className="gap-2"
                     disabled={isLoading === 'logo'}
                     disabled={isLoading === 'logo'}
@@ -1137,7 +1164,7 @@ export function SettingsPage() {
                   </Button>
                   </Button>
                   {settings.custom_logo && (
                   {settings.custom_logo && (
                     <Button
                     <Button
-                      variant="outline"
+                      variant="secondary"
                       size="sm"
                       size="sm"
                       className="gap-2 text-destructive hover:text-destructive"
                       className="gap-2 text-destructive hover:text-destructive"
                       disabled={isLoading === 'logo'}
                       disabled={isLoading === 'logo'}
@@ -1389,13 +1416,25 @@ export function SettingsPage() {
                     <Label htmlFor="numLeds">Number of LEDs</Label>
                     <Label htmlFor="numLeds">Number of LEDs</Label>
                     <Input
                     <Input
                       id="numLeds"
                       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>
                   <div className="space-y-3">
                   <div className="space-y-3">
@@ -1431,10 +1470,20 @@ export function SettingsPage() {
                       <SelectValue />
                       <SelectValue />
                     </SelectTrigger>
                     </SelectTrigger>
                     <SelectContent>
                     <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>
                     </SelectContent>
                   </Select>
                   </Select>
                 </div>
                 </div>
@@ -1596,7 +1645,7 @@ export function SettingsPage() {
               </Button>
               </Button>
               {mqttConfig.enabled && mqttConfig.broker && (
               {mqttConfig.enabled && mqttConfig.broker && (
                 <Button
                 <Button
-                  variant="outline"
+                  variant="secondary"
                   onClick={handleTestMqttConnection}
                   onClick={handleTestMqttConnection}
                   disabled={isLoading === 'mqttTest'}
                   disabled={isLoading === 'mqttTest'}
                   className="gap-2"
                   className="gap-2"
@@ -1701,10 +1750,25 @@ export function SettingsPage() {
                     <Label>Pause Between Patterns</Label>
                     <Label>Pause Between Patterns</Label>
                     <div className="flex gap-2">
                     <div className="flex gap-2">
                       <Input
                       <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"
                         className="w-20"
                       />
                       />
                       <Select
                       <Select
@@ -1869,64 +1933,58 @@ export function SettingsPage() {
                       <div>
                       <div>
                         <p className="text-sm font-medium">Timezone</p>
                         <p className="text-sm font-medium">Timezone</p>
                         <p className="text-xs text-muted-foreground">
                         <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>
                         </p>
                       </div>
                       </div>
                     </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>
                 </div>
                 </div>
 
 
@@ -1934,7 +1992,7 @@ export function SettingsPage() {
                 <div className="p-4 rounded-lg border space-y-3">
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex items-center justify-between">
                   <div className="flex items-center justify-between">
                     <h4 className="font-medium">Still Periods</h4>
                     <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>
                       <span className="material-icons text-base">add</span>
                       Add Period
                       Add Period
                     </Button>
                     </Button>
@@ -2064,7 +2122,7 @@ export function SettingsPage() {
           </AccordionTrigger>
           </AccordionTrigger>
           <AccordionContent className="pt-4 pb-6 space-y-3">
           <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="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>
                 <span className="material-icons text-muted-foreground">terminal</span>
               </div>
               </div>
               <div className="flex-1">
               <div className="flex-1">
@@ -2076,7 +2134,7 @@ export function SettingsPage() {
             </div>
             </div>
 
 
             <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
             <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>
                 <span className="material-icons text-muted-foreground">system_update</span>
               </div>
               </div>
               <div className="flex-1">
               <div className="flex-1">
@@ -2092,7 +2150,7 @@ export function SettingsPage() {
               <Alert className="flex items-start">
               <Alert className="flex items-start">
                 <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
                 <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
                 <AlertDescription>
                 <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>
                 </AlertDescription>
               </Alert>
               </Alert>
             )}
             )}

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

@@ -27,6 +27,13 @@ import {
   TooltipProvider,
   TooltipProvider,
   TooltipTrigger,
   TooltipTrigger,
 } from '@/components/ui/tooltip'
 } from '@/components/ui/tooltip'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
 import { apiClient } from '@/lib/apiClient'
 import { apiClient } from '@/lib/apiClient'
 
 
 export function TableControlPage() {
 export function TableControlPage() {
@@ -43,7 +50,6 @@ export function TableControlPage() {
   const [serialCommand, setSerialCommand] = useState('')
   const [serialCommand, setSerialCommand] = useState('')
   const [serialHistory, setSerialHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
   const [serialHistory, setSerialHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
   const [serialLoading, setSerialLoading] = useState(false)
   const [serialLoading, setSerialLoading] = useState(false)
-  const [mainConnectionPort, setMainConnectionPort] = useState<string | null>(null)
   const serialOutputRef = useRef<HTMLDivElement>(null)
   const serialOutputRef = useRef<HTMLDivElement>(null)
   const serialInputRef = useRef<HTMLInputElement>(null)
   const serialInputRef = useRef<HTMLInputElement>(null)
 
 
@@ -147,7 +153,6 @@ export function TableControlPage() {
   }
   }
 
 
   const handleHome = async () => {
   const handleHome = async () => {
-    if (checkPatternRunning('home')) return
     try {
     try {
       await handleAction('home', '/send_home')
       await handleAction('home', '/send_home')
       toast.success('Moving to home position...')
       toast.success('Moving to home position...')
@@ -161,7 +166,22 @@ export function TableControlPage() {
       await handleAction('stop', '/stop_execution')
       await handleAction('stop', '/stop_execution')
       toast.success('Execution stopped')
       toast.success('Execution stopped')
     } catch {
     } 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 () => {
   const fetchMainConnectionStatus = async () => {
     try {
     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')
       const data = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
       if (data.connected && data.port) {
       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 {
     } catch {
       // Ignore errors
       // Ignore errors
     }
     }
   }
   }
 
 
-  const handleSerialConnect = async () => {
+  const handleSerialConnect = async (silent = false) => {
     if (!selectedSerialPort) {
     if (!selectedSerialPort) {
-      toast.error('Please select a serial port')
+      if (!silent) toast.error('Please select a serial port')
       return
       return
     }
     }
     setSerialLoading(true)
     setSerialLoading(true)
@@ -263,11 +292,11 @@ export function TableControlPage() {
       await apiClient.post('/api/debug-serial/open', { port: selectedSerialPort })
       await apiClient.post('/api/debug-serial/open', { port: selectedSerialPort })
       setSerialConnected(true)
       setSerialConnected(true)
       addSerialHistory('resp', `Connected to ${selectedSerialPort}`)
       addSerialHistory('resp', `Connected to ${selectedSerialPort}`)
-      toast.success(`Connected to ${selectedSerialPort}`)
+      if (!silent) toast.success(`Connected to ${selectedSerialPort}`)
     } catch (error) {
     } catch (error) {
       const errorMsg = error instanceof Error ? error.message : 'Unknown error'
       const errorMsg = error instanceof Error ? error.message : 'Unknown error'
       addSerialHistory('error', `Failed to connect: ${errorMsg}`)
       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 {
     } finally {
       setSerialLoading(false)
       setSerialLoading(false)
     }
     }
@@ -362,20 +391,13 @@ export function TableControlPage() {
     fetchMainConnectionStatus()
     fetchMainConnectionStatus()
   }, [])
   }, [])
 
 
-  // Auto-connect to the main connection port
-  useEffect(() => {
-    if (mainConnectionPort && selectedSerialPort === mainConnectionPort && !serialConnected && !serialLoading) {
-      handleSerialConnect()
-    }
-  }, [mainConnectionPort, selectedSerialPort])
-
   return (
   return (
     <TooltipProvider>
     <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 */}
         {/* 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
             Manual controls for your sand table
           </p>
           </p>
         </div>
         </div>
@@ -391,12 +413,13 @@ export function TableControlPage() {
               <CardDescription>Calibrate or stop the table</CardDescription>
               <CardDescription>Calibrate or stop the table</CardDescription>
             </CardHeader>
             </CardHeader>
             <CardContent>
             <CardContent>
-              <div className="grid grid-cols-2 gap-3">
+              <div className="grid grid-cols-3 gap-3">
                 <Tooltip>
                 <Tooltip>
                   <TooltipTrigger asChild>
                   <TooltipTrigger asChild>
                     <Button
                     <Button
                       onClick={handleHome}
                       onClick={handleHome}
                       disabled={isLoading === 'home'}
                       disabled={isLoading === 'home'}
+                      variant="primary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'home' ? (
                       {isLoading === 'home' ? (
@@ -426,8 +449,54 @@ export function TableControlPage() {
                       <span className="text-xs">Stop</span>
                       <span className="text-xs">Stop</span>
                     </Button>
                     </Button>
                   </TooltipTrigger>
                   </TooltipTrigger>
-                  <TooltipContent>Emergency stop</TooltipContent>
+                  <TooltipContent>Gracefully stop</TooltipContent>
                 </Tooltip>
                 </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>
               </div>
             </CardContent>
             </CardContent>
           </Card>
           </Card>
@@ -486,7 +555,7 @@ export function TableControlPage() {
                     <Button
                     <Button
                       onClick={handleMoveToCenter}
                       onClick={handleMoveToCenter}
                       disabled={isLoading === 'center'}
                       disabled={isLoading === 'center'}
-                      variant="outline"
+                      variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'center' ? (
                       {isLoading === 'center' ? (
@@ -505,7 +574,7 @@ export function TableControlPage() {
                     <Button
                     <Button
                       onClick={handleMoveToPerimeter}
                       onClick={handleMoveToPerimeter}
                       disabled={isLoading === 'perimeter'}
                       disabled={isLoading === 'perimeter'}
-                      variant="outline"
+                      variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'perimeter' ? (
                       {isLoading === 'perimeter' ? (
@@ -524,7 +593,7 @@ export function TableControlPage() {
                     <TooltipTrigger asChild>
                     <TooltipTrigger asChild>
                       <DialogTrigger asChild>
                       <DialogTrigger asChild>
                         <Button
                         <Button
-                          variant="outline"
+                          variant="secondary"
                           className="h-16 gap-1 flex-col items-center justify-center"
                           className="h-16 gap-1 flex-col items-center justify-center"
                         >
                         >
                           <span className="material-icons-outlined text-2xl">screen_rotation</span>
                           <span className="material-icons-outlined text-2xl">screen_rotation</span>
@@ -551,7 +620,7 @@ export function TableControlPage() {
                       ].map((step, i) => (
                       ].map((step, i) => (
                         <li key={i} className="flex gap-3">
                         <li key={i} className="flex gap-3">
                           <Badge
                           <Badge
-                            variant="outline"
+                            variant="secondary"
                             className="h-6 w-6 shrink-0 items-center justify-center rounded-full p-0"
                             className="h-6 w-6 shrink-0 items-center justify-center rounded-full p-0"
                           >
                           >
                             {i + 1}
                             {i + 1}
@@ -576,7 +645,7 @@ export function TableControlPage() {
                       <p className="text-sm font-medium text-center">Fine Adjustment</p>
                       <p className="text-sm font-medium text-center">Fine Adjustment</p>
                       <div className="flex justify-center gap-2">
                       <div className="flex justify-center gap-2">
                         <Button
                         <Button
-                          variant="outline"
+                          variant="secondary"
                           onClick={() => handleRotate(-10)}
                           onClick={() => handleRotate(-10)}
                           disabled={isLoading === 'rotate'}
                           disabled={isLoading === 'rotate'}
                         >
                         >
@@ -584,7 +653,7 @@ export function TableControlPage() {
                           CCW 10°
                           CCW 10°
                         </Button>
                         </Button>
                         <Button
                         <Button
-                          variant="outline"
+                          variant="secondary"
                           onClick={() => handleRotate(10)}
                           onClick={() => handleRotate(10)}
                           disabled={isLoading === 'rotate'}
                           disabled={isLoading === 'rotate'}
                         >
                         >
@@ -621,7 +690,7 @@ export function TableControlPage() {
                     <Button
                     <Button
                       onClick={() => handleClearPattern('clear_from_in.thr', 'clear from center')}
                       onClick={() => handleClearPattern('clear_from_in.thr', 'clear from center')}
                       disabled={isLoading === 'clear_from_in.thr'}
                       disabled={isLoading === 'clear_from_in.thr'}
-                      variant="outline"
+                      variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'clear_from_in.thr' ? (
                       {isLoading === 'clear_from_in.thr' ? (
@@ -640,7 +709,7 @@ export function TableControlPage() {
                     <Button
                     <Button
                       onClick={() => handleClearPattern('clear_from_out.thr', 'clear from perimeter')}
                       onClick={() => handleClearPattern('clear_from_out.thr', 'clear from perimeter')}
                       disabled={isLoading === 'clear_from_out.thr'}
                       disabled={isLoading === 'clear_from_out.thr'}
-                      variant="outline"
+                      variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'clear_from_out.thr' ? (
                       {isLoading === 'clear_from_out.thr' ? (
@@ -659,7 +728,7 @@ export function TableControlPage() {
                     <Button
                     <Button
                       onClick={() => handleClearPattern('clear_sideway.thr', 'clear sideways')}
                       onClick={() => handleClearPattern('clear_sideway.thr', 'clear sideways')}
                       disabled={isLoading === 'clear_sideway.thr'}
                       disabled={isLoading === 'clear_sideway.thr'}
-                      variant="outline"
+                      variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'clear_sideway.thr' ? (
                       {isLoading === 'clear_sideway.thr' ? (
@@ -681,12 +750,19 @@ export function TableControlPage() {
         <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
         <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
           <CardHeader className="pb-3 space-y-3">
           <CardHeader className="pb-3 space-y-3">
             <div className="flex items-start justify-between gap-2">
             <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">
                 <CardTitle className="text-lg flex items-center gap-2">
                   <span className="material-icons-outlined text-xl">terminal</span>
                   <span className="material-icons-outlined text-xl">terminal</span>
                   Serial Terminal
                   Serial Terminal
                 </CardTitle>
                 </CardTitle>
                 <CardDescription className="hidden sm:block">Send raw commands to the table controller</CardDescription>
                 <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>
               </div>
               {/* Clear button - only show on desktop in header */}
               {/* Clear button - only show on desktop in header */}
               <div className="hidden sm:flex items-center gap-1">
               <div className="hidden sm:flex items-center gap-1">
@@ -697,38 +773,33 @@ export function TableControlPage() {
                     onClick={() => setSerialHistory([])}
                     onClick={() => setSerialHistory([])}
                     title="Clear history"
                     title="Clear history"
                   >
                   >
-                    <span className="material-icons-outlined">delete</span>
+                    <span className="material-icons-outlined">delete_sweep</span>
                   </Button>
                   </Button>
                 )}
                 )}
               </div>
               </div>
             </div>
             </div>
             {/* Controls row - stacks better on mobile */}
             {/* Controls row - stacks better on mobile */}
             <div className="flex flex-wrap items-center gap-2">
             <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}
                 value={selectedSerialPort}
-                onChange={(e) => setSelectedSerialPort(e.target.value)}
+                onValueChange={setSelectedSerialPort}
+                onOpenChange={(open) => open && fetchSerialPorts()}
                 disabled={serialConnected || serialLoading}
                 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 ? (
               {!serialConnected ? (
                 <Button
                 <Button
                   size="sm"
                   size="sm"
-                  onClick={handleSerialConnect}
+                  onClick={() => handleSerialConnect()}
                   disabled={!selectedSerialPort || serialLoading}
                   disabled={!selectedSerialPort || serialLoading}
                   title="Connect"
                   title="Connect"
                 >
                 >
@@ -753,7 +824,7 @@ export function TableControlPage() {
                   </Button>
                   </Button>
                   <Button
                   <Button
                     size="sm"
                     size="sm"
-                    variant="outline"
+                    variant="secondary"
                     onClick={handleSerialReset}
                     onClick={handleSerialReset}
                     disabled={serialLoading}
                     disabled={serialLoading}
                     title="Send Ctrl+X soft reset"
                     title="Send Ctrl+X soft reset"

+ 60 - 1
frontend/vite.config.ts

@@ -1,10 +1,65 @@
 import { defineConfig } from 'vite'
 import { defineConfig } from 'vite'
 import react from '@vitejs/plugin-react'
 import react from '@vitejs/plugin-react'
+import { VitePWA } from 'vite-plugin-pwa'
 import path from 'path'
 import path from 'path'
 
 
 // https://vite.dev/config/
 // https://vite.dev/config/
 export default defineConfig({
 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: {
   resolve: {
     alias: {
     alias: {
       '@': path.resolve(__dirname, './src'),
       '@': path.resolve(__dirname, './src'),
@@ -67,10 +122,14 @@ export default defineConfig({
       '/send_home': 'http://localhost:8080',
       '/send_home': 'http://localhost:8080',
       '/send_coordinate': 'http://localhost:8080',
       '/send_coordinate': 'http://localhost:8080',
       '/stop_execution': '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',
       '/pause_execution': 'http://localhost:8080',
       '/resume_execution': 'http://localhost:8080',
       '/resume_execution': 'http://localhost:8080',
       '/skip_pattern': 'http://localhost:8080',
       '/skip_pattern': 'http://localhost:8080',
       '/reorder_playlist': 'http://localhost:8080',
       '/reorder_playlist': 'http://localhost:8080',
+      '/add_to_queue': 'http://localhost:8080',
       '/run_theta_rho': 'http://localhost:8080',
       '/run_theta_rho': 'http://localhost:8080',
       '/run_playlist': 'http://localhost:8080',
       '/run_playlist': 'http://localhost:8080',
       // Movement
       // 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
 # Add CORS middleware to allow cross-origin requests from other Dune Weaver frontends
 # This enables multi-table control from a single frontend
 # This enables multi-table control from a single frontend
+# Note: allow_credentials must be False when allow_origins=["*"] (browser security requirement)
 app.add_middleware(
 app.add_middleware(
     CORSMiddleware,
     CORSMiddleware,
     allow_origins=["*"],  # Allow all origins for local network access
     allow_origins=["*"],  # Allow all origins for local network access
-    allow_credentials=True,
+    allow_credentials=False,
     allow_methods=["*"],
     allow_methods=["*"],
     allow_headers=["*"],
     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"])
 @app.patch("/api/settings", tags=["settings"])
 async def update_settings(settings_update: SettingsUpdate):
 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.
     Only include the categories and fields you want to update.
     All fields are optional - only provided values will be updated.
     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 = []
     updated_categories = []
     requires_restart = False
     requires_restart = False
@@ -748,8 +793,8 @@ async def update_settings(settings_update: SettingsUpdate):
     # Connection settings
     # Connection settings
     if settings_update.connection:
     if settings_update.connection:
         if settings_update.connection.preferred_port is not None:
         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")
         updated_categories.append("connection")
 
 
     # Pattern settings
     # Pattern settings
@@ -920,6 +965,17 @@ async def update_settings(settings_update: SettingsUpdate):
 class TableInfoUpdate(BaseModel):
 class TableInfoUpdate(BaseModel):
     name: Optional[str] = None
     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"])
 @app.get("/api/table-info", tags=["multi-table"])
 async def get_table_info():
 async def get_table_info():
     """
     """
@@ -942,7 +998,7 @@ async def update_table_info(update: TableInfoUpdate):
     The table ID is immutable after generation.
     The table ID is immutable after generation.
     """
     """
     if update.name is not None:
     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()
         state.save()
         logger.info(f"Table name updated to: {state.table_name}")
         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
         "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)
 # 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.write, command.encode())
             await asyncio.to_thread(ser.flush)
             await asyncio.to_thread(ser.flush)
 
 
-            # Read response lines with timeout
+            # Read response with timeout - use read() for more reliable data capture
             responses = []
             responses = []
             start_time = time.time()
             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:
             while time.time() - start_time < request.timeout:
                 try:
                 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:
                     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
                     break
 
 
-            ser.timeout = original_timeout
+            # Add any remaining buffer content
+            if buffer.strip():
+                responses.append(buffer.strip())
 
 
             return {
             return {
                 "success": True,
                 "success": True,
@@ -1503,26 +1658,39 @@ async def get_theta_rho_coordinates(request: GetCoordinatesRequest):
         # Normalize file path for cross-platform compatibility and remove prefixes
         # Normalize file path for cross-platform compatibility and remove prefixes
         file_name = normalize_file_path(request.file_name)
         file_name = normalize_file_path(request.file_name)
         file_path = os.path.join(THETA_RHO_DIR, 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
         # Check file existence asynchronously
         exists = await asyncio.to_thread(os.path.exists, file_path)
         exists = await asyncio.to_thread(os.path.exists, file_path)
         if not exists:
         if not exists:
             raise HTTPException(status_code=404, detail=f"File {file_name} not found")
             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:
         if not coordinates:
             raise HTTPException(status_code=400, detail="No valid coordinates found in file")
             raise HTTPException(status_code=400, detail="No valid coordinates found in file")
-        
+
         return {
         return {
             "success": True,
             "success": True,
             "coordinates": coordinates,
             "coordinates": coordinates,
             "total_points": len(coordinates)
             "total_points": len(coordinates)
         }
         }
-        
+
     except Exception as e:
     except Exception as e:
         logger.error(f"Error getting coordinates for {request.file_name}: {str(e)}")
         logger.error(f"Error getting coordinates for {request.file_name}: {str(e)}")
         raise HTTPException(status_code=500, detail=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):
     if not (state.conn.is_connected() if state.conn else False):
         logger.warning("Attempted to stop without a connection")
         logger.warning("Attempted to stop without a connection")
         raise HTTPException(status_code=400, detail="Connection not established")
         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}
     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")
 @app.post("/send_home")
 async def send_home():
 async def send_home():
     try:
     try:
@@ -1680,6 +1950,9 @@ async def move_to_center():
 
 
         check_homing_in_progress()
         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")
         logger.info("Moving device to center position")
         await pattern_manager.reset_theta()
         await pattern_manager.reset_theta()
         await pattern_manager.move_polar(0, 0)
         await pattern_manager.move_polar(0, 0)
@@ -1699,6 +1972,9 @@ async def move_to_perimeter():
 
 
         check_homing_in_progress()
         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.reset_theta()
         await pattern_manager.move_polar(0, 1)
         await pattern_manager.move_polar(0, 1)
         return {"success": True}
         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)}")
         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)}")
         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}")
 @app.get("/preview/{encoded_filename}")
 async def serve_preview(encoded_filename: str):
 async def serve_preview(encoded_filename: str):
     """Serve a preview image for a pattern file."""
     """Serve a preview image for a pattern file."""
@@ -1817,6 +2150,9 @@ async def send_coordinate(request: CoordinateRequest):
 
 
     check_homing_in_progress()
     check_homing_in_progress()
 
 
+    # Clear stop_requested to ensure manual move works after pattern stop
+    state.stop_requested = False
+
     try:
     try:
         logger.debug(f"Sending coordinate: theta={request.theta}, rho={request.rho}")
         logger.debug(f"Sending coordinate: theta={request.theta}, rho={request.rho}")
         await pattern_manager.move_polar(request.theta, 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)
     playlist = playlist_manager.get_playlist(name)
     if not playlist:
     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
     return playlist
 
 
@@ -2064,8 +2403,8 @@ async def set_led_config(request: LEDConfigRequest):
         old_gpio_pin = state.dw_led_gpio_pin
         old_gpio_pin = state.dw_led_gpio_pin
         old_pixel_order = state.dw_led_pixel_order
         old_pixel_order = state.dw_led_pixel_order
         hardware_changed = (
         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
         # 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")
                     logger.info("LED controller stopped successfully")
                 except Exception as e:
                 except Exception as e:
                     logger.error(f"Error stopping LED controller: {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_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.dw_led_brightness = request.brightness or 35
         state.wled_ip = None
         state.wled_ip = None
 
 
@@ -2221,6 +2563,43 @@ async def reorder_playlist(request: dict):
 
 
     return {"success": True}
     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"])
 @app.get("/api/custom_clear_patterns", deprecated=True, tags=["settings-deprecated"])
 async def get_custom_clear_patterns():
 async def get_custom_clear_patterns():
     """Get the currently configured 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"}
 ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
 MAX_LOGO_SIZE = 5 * 1024 * 1024  # 5MB
 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.
     Returns True on success, False on failure.
     """
     """
     try:
     try:
         from PIL import Image, ImageDraw
         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)
             resized = img.resize((size, size), Image.Resampling.LANCZOS)
 
 
-            # Create circular mask
             mask = Image.new('L', (size, size), 0)
             mask = Image.new('L', (size, size), 0)
             draw = ImageDraw.Draw(mask)
             draw = ImageDraw.Draw(mask)
             draw.ellipse((0, 0, size - 1, size - 1), fill=255)
             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 = Image.new('RGBA', (size, size), (0, 0, 0, 0))
             output.paste(resized, (0, 0), mask)
             output.paste(resized, (0, 0), mask)
             return output
             return output
@@ -2363,16 +2742,25 @@ def generate_favicon_from_logo(logo_path: str, favicon_path: str) -> bool:
             top = (height - min_dim) // 2
             top = (height - min_dim) // 2
             img = img.crop((left, top, left + min_dim, top + min_dim))
             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',
                 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
         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)}")
         logger.error(f"Failed to generate favicon: {str(e)}")
         return False
         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"])
 @app.post("/api/upload-logo", tags=["settings"])
 async def upload_logo(file: UploadFile = File(...)):
 async def upload_logo(file: UploadFile = File(...)):
     """Upload a custom logo image.
     """Upload a custom logo image.
@@ -2429,22 +2862,24 @@ async def upload_logo(file: UploadFile = File(...)):
         with open(file_path, "wb") as f:
         with open(file_path, "wb") as f:
             f.write(content)
             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
         favicon_generated = False
+        pwa_icons_generated = False
         if file_ext != ".svg":
         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
         # Update state
         state.custom_logo = filename
         state.custom_logo = filename
         state.save()
         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 {
         return {
             "success": True,
             "success": True,
             "filename": filename,
             "filename": filename,
             "url": f"/static/custom/{filename}",
             "url": f"/static/custom/{filename}",
-            "favicon_generated": favicon_generated
+            "favicon_generated": favicon_generated,
+            "pwa_icons_generated": pwa_icons_generated
         }
         }
 
 
     except HTTPException:
     except HTTPException:
@@ -2455,7 +2890,7 @@ async def upload_logo(file: UploadFile = File(...)):
 
 
 @app.delete("/api/custom-logo", tags=["settings"])
 @app.delete("/api/custom-logo", tags=["settings"])
 async def delete_custom_logo():
 async def delete_custom_logo():
-    """Remove custom logo and favicon, reverting to defaults."""
+    """Remove custom logo, favicon, and PWA icons, reverting to defaults."""
     try:
     try:
         if state.custom_logo:
         if state.custom_logo:
             # Remove logo
             # Remove logo
@@ -2463,14 +2898,33 @@ async def delete_custom_logo():
             if os.path.exists(logo_path):
             if os.path.exists(logo_path):
                 os.remove(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.custom_logo = None
             state.save()
             state.save()
-            logger.info("Custom logo and favicon removed")
+            logger.info("Custom logo, favicon, and PWA icons removed")
         return {"success": True}
         return {"success": True}
     except Exception as e:
     except Exception as e:
         logger.error(f"Error removing logo: {str(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):
     async def process_single_file(file_name):
         """Process a single file and return its preview data."""
         """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
         # Acquire semaphore to limit concurrent processing
         async with get_preview_semaphore():
         async with get_preview_semaphore():
             t1 = time.time()
             t1 = time.time()
@@ -2679,9 +3142,8 @@ async def preview_thr_batch(request: dict):
                     last_coord_obj = metadata.get('last_coordinate')
                     last_coord_obj = metadata.get('last_coordinate')
                 else:
                 else:
                     logger.debug(f"Metadata cache miss for {file_name}, parsing file")
                     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
                     first_coord = coordinates[0] if coordinates else None
                     last_coord = coordinates[-1] 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
                     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,
                     "first_coordinate": first_coord_obj,
                     "last_coordinate": last_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")
                 logger.debug(f"Processed {file_name} in {time.time() - t1:.2f}s")
                 return file_name, result
                 return file_name, result
             except Exception as e:
             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']
 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:
 async def _check_table_is_idle() -> bool:
     """Helper function to check if table is idle."""
     """Helper function to check if table is idle."""
@@ -116,8 +119,6 @@ class SerialConnection(BaseConnection):
         with self.lock:
         with self.lock:
             if self.ser.is_open:
             if self.ser.is_open:
                 self.ser.close()
                 self.ser.close()
-        # Release the lock resources
-        self.lock = None
 
 
 ###############################################################################
 ###############################################################################
 # WebSocket Connection Implementation
 # WebSocket Connection Implementation
@@ -181,9 +182,7 @@ class WebSocketConnection(BaseConnection):
         with self.lock:
         with self.lock:
             if self.ws:
             if self.ws:
                 self.ws.close()
                 self.ws.close()
-        # Release the lock resources
-        self.lock = None
-                
+
 def list_serial_ports():
 def list_serial_ports():
     """Return a list of available serial ports."""
     """Return a list of available serial ports."""
     ports = serial.tools.list_ports.comports()
     ports = serial.tools.list_ports.comports()
@@ -256,19 +255,31 @@ def connect_device(homing=True):
 
 
     ports = list_serial_ports()
     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:
     # Priority for auto-connect:
     # 1. Preferred port (user's explicit choice) if available
     # 1. Preferred port (user's explicit choice) if available
     # 2. Last used port if available
     # 2. Last used port if available
     # 3. First available port as fallback
     # 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}")
         logger.info(f"Connecting to preferred port: {state.preferred_port}")
         state.conn = SerialConnection(state.preferred_port)
         state.conn = SerialConnection(state.preferred_port)
     elif state.port and state.port in ports:
     elif state.port and state.port in ports:
         logger.info(f"Connecting to last used port: {state.port}")
         logger.info(f"Connecting to last used port: {state.port}")
         state.conn = SerialConnection(state.port)
         state.conn = SerialConnection(state.port)
     elif ports:
     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:
     else:
         logger.error("Auto connect failed: No serial ports available")
         logger.error("Auto connect failed: No serial ports available")
         # state.conn = WebSocketConnection('ws://fluidnc.local:81')
         # 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.
     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:
     while True:
         try:
         try:
             state.conn.send('?')
             state.conn.send('?')
@@ -983,34 +998,38 @@ def home(timeout=90):
                     homing_complete.set()
                     homing_complete.set()
                     return
                     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)
                 # 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)
                 offset_radians = math.radians(state.angular_homing_offset_degrees)
                 state.current_theta = offset_radians
                 state.current_theta = offset_radians
                 state.current_rho = 0
                 state.current_rho = 0
@@ -1126,12 +1145,31 @@ def check_idle():
             return True
             return True
         time.sleep(1)
         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).
     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)")
     logger.info("Checking idle (async)")
+    start_time = asyncio.get_event_loop().time()
+
     while True:
     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)
         response = await asyncio.to_thread(get_status_response)
         if response and "Idle" in response:
         if response and "Idle" in response:
             logger.info("Device is idle")
             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
 from modules.led.idle_timeout_manager import idle_timeout_manager
 import queue
 import queue
 from dataclasses import dataclass
 from dataclasses import dataclass
-from typing import Optional, Callable
+from typing import Optional, Callable, Literal
 
 
 # Configure logging
 # Configure logging
 logger = logging.getLogger(__name__)
 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 time log file (JSON Lines format - one JSON object per line)
 EXECUTION_LOG_FILE = './execution_times.jsonl'
 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,
 def log_execution_time(pattern_name: str, table_type: str, speed: int, actual_time: float,
                        total_coordinates: int, was_completed: bool):
                        total_coordinates: int, was_completed: bool):
     """Log pattern execution time to JSON Lines file for analysis.
     """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}")
         logger.error(f"Failed to read execution time log: {e}")
         return None
         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
 # Asyncio primitives - initialized lazily to avoid event loop issues
 # These must be created in the context of the running event loop
 # These must be created in the context of the running event loop
 pause_event: Optional[asyncio.Event] = None
 pause_event: Optional[asyncio.Event] = None
@@ -425,22 +516,37 @@ class MotionControlThread:
         state.machine_y = new_y_abs
         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):
     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:
         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}")
                         logger.debug(f"Motion thread response: {response}")
                         if response.lower() == "ok":
                         if response.lower() == "ok":
                             logger.debug("Motion thread: Command execution confirmed.")
                             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:
             except Exception as e:
                 error_str = str(e)
                 error_str = str(e)
@@ -455,7 +561,8 @@ class MotionControlThread:
                     logger.info("Connection marked as disconnected due to device error")
                     logger.info("Connection marked as disconnected due to device error")
                     return False
                     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)
             time.sleep(0.1)
 
 
 # Global motion control thread instance
 # 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)
     coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
     total_coordinates = len(coordinates)
     total_coordinates = len(coordinates)
 
 
+    # Cache coordinates in state for frontend preview (avoids re-parsing large files)
+    state._current_coordinates = coordinates
+
     if total_coordinates < 2:
     if total_coordinates < 2:
         logger.warning("Not enough coordinates for interpolation")
         logger.warning("Not enough coordinates for interpolation")
         return False
         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
                 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
                 # 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():
                 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:
                     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:
                     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
                 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...")
                 logger.info("Execution resumed...")
                 if state.led_controller:
                 if state.led_controller:
                     # Turn LED controller back on if it was turned off for scheduled pause
                     # 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
         return
 
 
     async with lock:  # This ensures only one pattern can run at a time
     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
         # Start progress update task only if not part of a playlist
         global progress_update_task
         global progress_update_task
         if not is_playlist and not 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
                     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
                 state.patterns_since_last_home += 1
                 logger.debug(f"Patterns since last home: {state.patterns_since_last_home}")
                 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)
                 # 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:
                 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)...")
                     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)
                         await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
                         start_idle_led_timeout()
                         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...")
                         logger.info("Still Sands period ended. Resuming playlist...")
                         if state.led_controller:
                         if state.led_controller:
                             if wled_was_off_for_scheduled:
                             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")
                             logger.info("Pause interrupted by skip request")
                             break
                             break
                         await asyncio.sleep(1)
                         await asyncio.sleep(1)
+                    # Clear both pause state vars immediately (so UI updates right away)
                     state.pause_time_remaining = 0
                     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
                 state.skip_requested = False
                 idx += 1
                 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")
                             logger.info("Pause interrupted by skip request")
                             break
                             break
                         await asyncio.sleep(1)
                         await asyncio.sleep(1)
+                    # Clear both pause state vars immediately (so UI updates right away)
                     state.pause_time_remaining = 0
                     state.pause_time_remaining = 0
+                    state.original_pause_time = None
                 continue
                 continue
             else:
             else:
                 logger.info("Playlist completed")
                 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
         clear_playlist: Whether to clear playlist state
         wait_for_lock: Whether to wait for pattern_lock to be released. Set to False when
         wait_for_lock: Whether to wait for pattern_lock to be released. Set to False when
                       called from within pattern execution to avoid deadlock.
                       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:
     try:
         with state.pause_condition:
         with state.pause_condition:
             state.pause_requested = False
             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")
                         logger.info("Pattern lock acquired - pattern has fully stopped")
             except asyncio.TimeoutError:
             except asyncio.TimeoutError:
                 logger.warning("Timeout waiting for pattern to stop - forcing cleanup")
                 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
                 # Force cleanup of state even if pattern didn't release lock gracefully
                 state.current_playing_file = None
                 state.current_playing_file = None
                 state.execution_progress = 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
         # Call async function directly since we're in async context
         await connection_manager.update_machine_position()
         await connection_manager.update_machine_position()
+        return not timed_out
     except Exception as e:
     except Exception as e:
         logger.error(f"Error during stop_actions: {e}")
         logger.error(f"Error during stop_actions: {e}")
         # Force cleanup state on error
         # 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()
             await connection_manager.update_machine_position()
         except Exception as update_err:
         except Exception as update_err:
             logger.error(f"Error updating machine position on error: {update_err}")
             logger.error(f"Error updating machine position on error: {update_err}")
+        return False
 
 
 async def move_polar(theta, rho, speed=None):
 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
         rho (float): Target rho coordinate
         speed (int, optional): Speed override. If None, uses state.speed
         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
     # Ensure motion control thread is running
     if not motion_controller.running:
     if not motion_controller.running:
         motion_controller.start()
         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)
 - Configure CPU affinity to keep workers off CPU 0 (reserved for motion/LED)
 
 
 Environment variables:
 Environment variables:
-- POOL_WORKERS: Override worker count (default: 1 for RAM conservation)
+- POOL_WORKERS: Override worker count (default: 3)
 """
 """
 import logging
 import logging
 import os
 import os
@@ -19,17 +19,15 @@ logger = logging.getLogger(__name__)
 _pool: Optional[ProcessPoolExecutor] = None
 _pool: Optional[ProcessPoolExecutor] = None
 _shutdown_in_progress: bool = False
 _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:
 def _get_worker_count() -> int:
     """Calculate worker count for the process pool.
     """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')
     env_workers = os.environ.get('POOL_WORKERS')
     if env_workers is not None:
     if env_workers is not None:

+ 192 - 9
modules/core/state.py

@@ -1,9 +1,11 @@
 # state.py
 # state.py
+import asyncio
 import threading
 import threading
 import json
 import json
 import os
 import os
 import logging
 import logging
 import uuid
 import uuid
+from typing import Optional, Literal
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -15,13 +17,22 @@ class AppState:
     def __init__(self):
     def __init__(self):
         # Private variables for properties
         # Private variables for properties
         self._current_playing_file = None
         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._pause_requested = False
         self._speed = 100
         self._speed = 100
         self._current_playlist = None
         self._current_playlist = None
         self._current_playlist_name = None  # New variable for playlist name
         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
         # Regular state variables
-        self.stop_requested = False
         self.pause_condition = threading.Condition()
         self.pause_condition = threading.Condition()
         self.execution_progress = None
         self.execution_progress = None
         self.is_clearing = False
         self.is_clearing = False
@@ -75,9 +86,9 @@ class AppState:
         # DW LED settings
         # DW LED settings
         self.dw_led_num_leds = 60  # Number of LEDs in strip
         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_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_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
         self.dw_led_intensity = 128  # Effect intensity 0-255
 
 
         # Idle effect settings (all parameters)
         # 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_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_idle_timeout_minutes = 30  # Idle timeout duration in minutes
         self.dw_led_last_activity_time = None  # Last activity timestamp (runtime only, not persisted)
         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 = None
         self.table_type_override = None  # User override for table type detection
         self.table_type_override = None  # User override for table type detection
         self._playlist_mode = "loop"
         self._playlist_mode = "loop"
@@ -105,7 +115,11 @@ class AppState:
 
 
         # Multi-table identity (for network discovery)
         # Multi-table identity (for network discovery)
         self.table_id = str(uuid.uuid4())  # UUID generated on first run, persistent across restarts
         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/)
         # Custom branding settings (filenames only, files stored in static/custom/)
         # Favicon is auto-generated from logo as logo-favicon.ico
         # Favicon is auto-generated from logo as logo-favicon.ico
@@ -152,8 +166,14 @@ class AppState:
 
 
     @current_playing_file.setter
     @current_playing_file.setter
     def current_playing_file(self, value):
     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
         self._current_playing_file = value
-        
+
         # force an empty string (and not None) if we need to unset
         # force an empty string (and not None) if we need to unset
         if value == None:
         if value == None:
             value = ""
             value = ""
@@ -240,6 +260,167 @@ class AppState:
     def clear_pattern_speed(self, value):
     def clear_pattern_speed(self, value):
         self._clear_pattern_speed = 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):
     def to_dict(self):
         """Return a dictionary representation of the state."""
         """Return a dictionary representation of the state."""
         return {
         return {
@@ -287,6 +468,7 @@ class AppState:
             "app_name": self.app_name,
             "app_name": self.app_name,
             "table_id": self.table_id,
             "table_id": self.table_id,
             "table_name": self.table_name,
             "table_name": self.table_name,
+            "known_tables": self.known_tables,
             "custom_logo": self.custom_logo,
             "custom_logo": self.custom_logo,
             "auto_play_enabled": self.auto_play_enabled,
             "auto_play_enabled": self.auto_play_enabled,
             "auto_play_playlist": self.auto_play_playlist,
             "auto_play_playlist": self.auto_play_playlist,
@@ -347,9 +529,9 @@ class AppState:
         self.led_provider = data.get('led_provider', "none")
         self.led_provider = data.get('led_provider', "none")
         self.dw_led_num_leds = data.get('dw_led_num_leds', 60)
         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_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_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)
         self.dw_led_intensity = data.get('dw_led_intensity', 128)
 
 
         # Load effect settings (handle both old string format and new dict format)
         # 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)
         self.table_id = data.get("table_id", None)
         if self.table_id is None:
         if self.table_id is None:
             self.table_id = str(uuid.uuid4())
             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.custom_logo = data.get("custom_logo", None)
         self.auto_play_enabled = data.get("auto_play_enabled", False)
         self.auto_play_enabled = data.get("auto_play_enabled", False)
         self.auto_play_playlist = data.get("auto_play_playlist", None)
         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
 Version management for Dune Weaver
 Handles current version reading and GitHub API integration for latest version checking
 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
 import asyncio
@@ -14,6 +18,14 @@ import logging
 
 
 logger = logging.getLogger(__name__)
 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:
 class VersionManager:
     def __init__(self):
     def __init__(self):
         self.repo_owner = "tuanchris"
         self.repo_owner = "tuanchris"

+ 1 - 1
nginx.conf

@@ -42,7 +42,7 @@ server {
     }
     }
 
 
     # All backend API endpoints (legacy non-/api/ routes)
     # 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_pass http://127.0.0.1:8080;
         proxy_set_header Host $host;
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Real-IP $remote_addr;

Fichier diff supprimé car celui-ci est trop grand
+ 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"]
+}

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff