Browse Source

Improve mobile UI layout and fix pattern stop hanging

Mobile UI improvements:
- Add hamburger menu for header actions on mobile
- Make navbar fixed with safe area insets for iOS
- Use dynamic viewport height (dvh) for proper mobile sizing
- Compact page headers (text-xl on mobile, text-3xl on desktop)
- Improve Playlists page controls layout with stacking
- Make table selector and playlist actions visible on mobile (no hover)
- Compact Browse page filters (search + category on same row)
- Reduce Serial Terminal header overflow on mobile

Pattern execution fix:
- Add stop request checks in motion thread to prevent hanging
- Add 10s timeout on pattern lock acquisition in stop_actions
- Add 30s overall timeout in motion command retry loop
- Clear motion queue when stop is requested

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 3 weeks ago
parent
commit
1b02b8cffe

+ 1 - 1
frontend/index.html

@@ -2,7 +2,7 @@
 <html lang="en">
 <html lang="en">
   <head>
   <head>
     <meta charset="UTF-8" />
     <meta charset="UTF-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
 
 
     <!-- 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" />

+ 6 - 1
frontend/src/components/NowPlayingBar.tsx

@@ -573,7 +573,12 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       {/* Now Playing Bar - slides up to full height on mobile, 50vh on desktop when expanded */}
       {/* Now Playing Bar - slides up to full height on mobile, 50vh on desktop when expanded */}
       <div
       <div
         ref={barRef}
         ref={barRef}
-        className={`fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300 ${isLogsOpen ? 'bottom-80' : isExpanded ? 'bottom-16' : 'bottom-16 max-md:bottom-20'}`}
+        className="fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300"
+        style={{
+          bottom: isLogsOpen
+            ? 'calc(20rem + env(safe-area-inset-bottom, 0px))'
+            : 'calc(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}

+ 8 - 6
frontend/src/components/TableSelector.tsx

@@ -194,30 +194,32 @@ export function TableSelector() {
                     <Check className="h-4 w-4 text-primary flex-shrink-0" />
                     <Check className="h-4 w-4 text-primary flex-shrink-0" />
                   )}
                   )}
 
 
-                  {/* Actions (shown on hover) */}
-                  <div className="hidden group-hover:flex items-center gap-1">
+                  {/* Actions - always visible on mobile, hover on desktop */}
+                  <div className="flex md:opacity-0 md:group-hover:opacity-100 items-center gap-1 transition-opacity">
                     <Button
                     <Button
                       variant="ghost"
                       variant="ghost"
                       size="sm"
                       size="sm"
-                      className="h-6 w-6 p-0"
+                      className="h-7 w-7 p-0"
                       onClick={e => {
                       onClick={e => {
                         e.stopPropagation()
                         e.stopPropagation()
                         openRenameDialog(table)
                         openRenameDialog(table)
                       }}
                       }}
+                      title="Rename"
                     >
                     >
-                      <Pencil className="h-3 w-3" />
+                      <Pencil className="h-3.5 w-3.5" />
                     </Button>
                     </Button>
                     {!table.isCurrent && (
                     {!table.isCurrent && (
                       <Button
                       <Button
                         variant="ghost"
                         variant="ghost"
                         size="sm"
                         size="sm"
-                        className="h-6 w-6 p-0 text-destructive hover:text-destructive"
+                        className="h-7 w-7 p-0 text-destructive hover:text-destructive"
                         onClick={e => {
                         onClick={e => {
                           e.stopPropagation()
                           e.stopPropagation()
                           handleRemove(table)
                           handleRemove(table)
                         }}
                         }}
+                        title="Remove"
                       >
                       >
-                        <Trash2 className="h-3 w-3" />
+                        <Trash2 className="h-3.5 w-3.5" />
                       </Button>
                       </Button>
                     )}
                     )}
                   </div>
                   </div>

+ 83 - 6
frontend/src/components/layout/Layout.tsx

@@ -3,6 +3,8 @@ import { useEffect, useState, useRef } from 'react'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Separator } from '@/components/ui/separator'
 import { cacheAllPreviews } from '@/lib/previewCache'
 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'
@@ -95,6 +97,9 @@ export function Layout() {
     return () => clearTimeout(timer)
     return () => clearTimeout(timer)
   }, [homingJustCompleted, homingCountdown, keepHomingLogsOpen])
   }, [homingJustCompleted, homingCountdown, keepHomingLogsOpen])
 
 
+  // Mobile menu state
+  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
+
   // Logs drawer state
   // Logs drawer state
   const [isLogsOpen, setIsLogsOpen] = useState(false)
   const [isLogsOpen, setIsLogsOpen] = useState(false)
   const [logsDrawerHeight, setLogsDrawerHeight] = useState(256) // Default 256px (h-64)
   const [logsDrawerHeight, setLogsDrawerHeight] = useState(256) // Default 256px (h-64)
@@ -709,7 +714,7 @@ export function Layout() {
     : 0
     : 0
 
 
   return (
   return (
-    <div className="min-h-screen bg-background">
+    <div className="min-h-dvh bg-background flex flex-col overflow-x-hidden">
       {/* 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">
@@ -1027,7 +1032,9 @@ export function Layout() {
               }
               }
             />
             />
           </Link>
           </Link>
-          <div className="flex items-center gap-1">
+
+          {/* Desktop actions */}
+          <div className="hidden md:flex items-center gap-1">
             <TableSelector />
             <TableSelector />
             <Button
             <Button
               variant="ghost"
               variant="ghost"
@@ -1072,6 +1079,72 @@ export function Layout() {
               <span className="material-icons-outlined">power_settings_new</span>
               <span className="material-icons-outlined">power_settings_new</span>
             </Button>
             </Button>
           </div>
           </div>
+
+          {/* Mobile actions */}
+          <div className="flex md:hidden items-center gap-1">
+            <TableSelector />
+            <Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
+              <PopoverTrigger asChild>
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  className="rounded-full"
+                  aria-label="Open menu"
+                >
+                  <span className="material-icons-outlined">
+                    {isMobileMenuOpen ? 'close' : 'menu'}
+                  </span>
+                </Button>
+              </PopoverTrigger>
+              <PopoverContent align="end" className="w-56 p-2">
+                <div className="flex flex-col gap-1">
+                  <button
+                    onClick={() => {
+                      setIsDark(!isDark)
+                      setIsMobileMenuOpen(false)
+                    }}
+                    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={() => {
+                      handleOpenLogs()
+                      setIsMobileMenuOpen(false)
+                    }}
+                    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()
+                      setIsMobileMenuOpen(false)
+                    }}
+                    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()
+                      setIsMobileMenuOpen(false)
+                    }}
+                    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>
         </div>
       </header>
       </header>
 
 
@@ -1104,7 +1177,8 @@ export function Layout() {
       {!isNowPlayingOpen && (
       {!isNowPlayingOpen && (
         <button
         <button
           onClick={() => setIsNowPlayingOpen(true)}
           onClick={() => setIsNowPlayingOpen(true)}
-          className="fixed right-4 bottom-20 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"
+          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"
           title="Now Playing"
         >
         >
           <span className="material-icons">play_circle</span>
           <span className="material-icons">play_circle</span>
@@ -1113,10 +1187,13 @@ export function Layout() {
 
 
       {/* Logs Drawer */}
       {/* Logs Drawer */}
       <div
       <div
-        className={`fixed left-0 right-0 z-30 bg-background border-t border-border bottom-16 ${
+        className={`fixed left-0 right-0 z-30 bg-background border-t border-border ${
           isResizing ? '' : 'transition-[height] duration-300'
           isResizing ? '' : 'transition-[height] duration-300'
         }`}
         }`}
-        style={{ height: isLogsOpen ? logsDrawerHeight : 0 }}
+        style={{
+          height: isLogsOpen ? logsDrawerHeight : 0,
+          bottom: 'calc(4rem + env(safe-area-inset-bottom, 0px))'
+        }}
       >
       >
         {isLogsOpen && (
         {isLogsOpen && (
           <>
           <>
@@ -1211,7 +1288,7 @@ export function Layout() {
       </div>
       </div>
 
 
       {/* Bottom Navigation */}
       {/* Bottom Navigation */}
-      <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-background">
+      <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-background 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

+ 10 - 1
frontend/src/index.css

@@ -83,7 +83,16 @@ body {
   color: var(--color-foreground);
   color: var(--color-foreground);
   font-family: var(--font-family-sans);
   font-family: var(--font-family-sans);
   margin: 0;
   margin: 0;
-  min-height: 100vh;
+  min-height: 100dvh; /* Use dynamic viewport height for mobile */
+}
+
+/* Safe area utilities for iOS notch/home indicator */
+.pb-safe {
+  padding-bottom: env(safe-area-inset-bottom, 0px);
+}
+
+.mb-safe {
+  margin-bottom: env(safe-area-inset-bottom, 0px);
 }
 }
 
 
 /* Material Icons */
 /* Material Icons */

+ 39 - 36
frontend/src/pages/BrowsePage.tsx

@@ -748,7 +748,7 @@ export function BrowsePage() {
   }
   }
 
 
   return (
   return (
-    <div className={`flex flex-col w-full max-w-5xl mx-auto gap-6 py-6 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-3 sm:px-4 transition-all duration-300 ${isPanelOpen ? 'lg:mr-[28rem]' : ''}`}>
       {/* Hidden file input for pattern upload */}
       {/* Hidden file input for pattern upload */}
       <input
       <input
         ref={fileInputRef}
         ref={fileInputRef}
@@ -758,74 +758,77 @@ export function BrowsePage() {
         className="hidden"
         className="hidden"
       />
       />
 
 
-      {/* Page Header */}
-      <div className="flex items-start justify-between gap-4">
-        <div className="space-y-1">
-          <h1 className="text-3xl font-bold tracking-tight">Browse Patterns</h1>
-          <p className="text-muted-foreground">
-            Explore and run patterns on your sand table · {patterns.length} patterns available
+      {/* 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">
+            {patterns.length} patterns available
           </p>
           </p>
         </div>
         </div>
         <Button
         <Button
           variant="outline"
           variant="outline"
+          size="sm"
           onClick={() => fileInputRef.current?.click()}
           onClick={() => fileInputRef.current?.click()}
           disabled={isUploading}
           disabled={isUploading}
-          className="gap-2 shrink-0"
+          className="gap-1.5 sm:gap-2 shrink-0 h-8 sm:h-9"
         >
         >
           {isUploading ? (
           {isUploading ? (
-            <span className="material-icons-outlined animate-spin text-lg">sync</span>
+            <span className="material-icons-outlined animate-spin text-base sm:text-lg">sync</span>
           ) : (
           ) : (
-            <span className="material-icons-outlined text-lg">add</span>
+            <span className="material-icons-outlined text-base sm: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 />
+      <Separator className="my-0" />
 
 
-      {/* Sticky Filters */}
-      <div className="sticky top-14 z-30 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-3">
-          <div className="flex-1">
-            <div className="relative">
-              <span className="material-icons-outlined absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-xl">
+      {/* 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
                 search
               </span>
               </span>
               <Input
               <Input
                 value={searchQuery}
                 value={searchQuery}
                 onChange={(e) => setSearchQuery(e.target.value)}
                 onChange={(e) => setSearchQuery(e.target.value)}
-                placeholder="Search patterns..."
-                className="pl-10 pr-10"
+                placeholder="Search..."
+                className="pl-8 sm:pl-10 pr-8 sm:pr-10 h-9 sm:h-10 text-sm"
               />
               />
               {searchQuery && (
               {searchQuery && (
                 <Button
                 <Button
                   variant="ghost"
                   variant="ghost"
                   size="icon-sm"
                   size="icon-sm"
                   onClick={() => setSearchQuery('')}
                   onClick={() => setSearchQuery('')}
-                  className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                  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-xl">close</span>
+                  <span className="material-icons-outlined text-lg">close</span>
                 </Button>
                 </Button>
               )}
               )}
             </div>
             </div>
-          </div>
 
 
-          <Select value={selectedCategory} onValueChange={setSelectedCategory}>
-            <SelectTrigger className="w-full sm:w-44">
-              <SelectValue placeholder="Category" />
-            </SelectTrigger>
-            <SelectContent>
-              {categories.map((cat) => (
-                <SelectItem key={cat} value={cat}>
-                  {cat === 'all' ? 'All Categories' : cat === 'root' ? 'Uncategorized' : cat}
-                </SelectItem>
-              ))}
-            </SelectContent>
-          </Select>
+            <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>
+          </div>
 
 
+          {/* Sort controls */}
           <div className="flex gap-2">
           <div className="flex gap-2">
             <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
             <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
-              <SelectTrigger className="w-full sm:w-36">
+              <SelectTrigger className="w-24 sm:w-36 h-9 sm:h-10 text-sm">
                 <SelectValue placeholder="Sort by" />
                 <SelectValue placeholder="Sort by" />
               </SelectTrigger>
               </SelectTrigger>
               <SelectContent>
               <SelectContent>
@@ -839,7 +842,7 @@ export function BrowsePage() {
               variant="outline"
               variant="outline"
               size="icon"
               size="icon"
               onClick={() => setSortAsc(!sortAsc)}
               onClick={() => setSortAsc(!sortAsc)}
-              className="shrink-0"
+              className="shrink-0 h-9 w-9 sm:h-10 sm:w-10"
               title={sortAsc ? 'Ascending' : 'Descending'}
               title={sortAsc ? 'Ascending' : 'Descending'}
             >
             >
               <span className="material-icons-outlined text-lg">
               <span className="material-icons-outlined text-lg">

+ 5 - 5
frontend/src/pages/LEDPage.tsx

@@ -352,8 +352,8 @@ export function LEDPage() {
           </span>
           </span>
         </div>
         </div>
         <div className="space-y-2">
         <div className="space-y-2">
-          <h1 className="text-2xl font-bold">LED Controller Not Configured</h1>
-          <p className="text-muted-foreground max-w-md">
+          <h1 className="text-xl sm:text-2xl font-bold">LED Controller Not Configured</h1>
+          <p className="text-sm sm:text-base text-muted-foreground max-w-md">
             Configure your LED controller (WLED or DW LEDs) in the Settings page to control your lights.
             Configure your LED controller (WLED or DW LEDs) in the Settings page to control your lights.
           </p>
           </p>
         </div>
         </div>
@@ -384,9 +384,9 @@ export function LEDPage() {
   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-6 px-4">
       {/* Page Header */}
       {/* Page Header */}
-      <div className="space-y-1">
-        <h1 className="text-3xl font-bold tracking-tight">LED Control</h1>
-        <p className="text-muted-foreground">DW LEDs - GPIO controlled LED strip</p>
+      <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>
       </div>
 
 
       <Separator />
       <Separator />

+ 94 - 90
frontend/src/pages/PlaylistsPage.tsx

@@ -453,12 +453,12 @@ export function PlaylistsPage() {
   }
   }
 
 
   return (
   return (
-    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6 h-[calc(100vh-10.5rem)] overflow-hidden">
+    <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">
       {/* Page Header */}
       {/* Page Header */}
-      <div className="space-y-1 shrink-0">
-        <h1 className="text-3xl font-bold tracking-tight">Playlists</h1>
-        <p className="text-muted-foreground">
-          Create and manage pattern playlists for your sand table
+      <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">
+          Create and manage pattern playlists
         </p>
         </p>
       </div>
       </div>
 
 
@@ -508,7 +508,7 @@ export function PlaylistsPage() {
                   <span className="material-icons-outlined text-lg">playlist_play</span>
                   <span className="material-icons-outlined text-lg">playlist_play</span>
                   <span className="truncate text-sm font-medium">{name}</span>
                   <span className="truncate text-sm font-medium">{name}</span>
                 </div>
                 </div>
-                <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+                <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
                   <Button
                   <Button
                     variant="ghost"
                     variant="ghost"
                     size="icon-sm"
                     size="icon-sm"
@@ -594,7 +594,7 @@ export function PlaylistsPage() {
                 </Button>
                 </Button>
               </div>
               </div>
             ) : (
             ) : (
-              <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-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
                 {playlistPatterns.map((path) => {
                 {playlistPatterns.map((path) => {
                   const previewUrl = getPreviewUrl(path)
                   const previewUrl = getPreviewUrl(path)
                   if (!previewUrl && !previews[path]) {
                   if (!previewUrl && !previews[path]) {
@@ -603,7 +603,7 @@ export function PlaylistsPage() {
                   return (
                   return (
                     <div
                     <div
                       key={path}
                       key={path}
-                      className="flex flex-col items-center gap-2 group"
+                      className="flex flex-col items-center gap-1.5 sm:gap-2 group"
                     >
                     >
                       <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 hover:ring-2 hover:ring-primary hover:ring-offset-2 hover:ring-offset-background transition-all cursor-pointer">
                         <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">
@@ -615,21 +615,21 @@ export function PlaylistsPage() {
                             />
                             />
                           ) : (
                           ) : (
                             <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">
+                              <span className="material-icons-outlined text-muted-foreground text-sm sm:text-base">
                                 image
                                 image
                               </span>
                               </span>
                             </div>
                             </div>
                           )}
                           )}
                         </div>
                         </div>
                         <button
                         <button
-                          className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm z-10"
+                          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)}
                           onClick={() => handleRemovePattern(path)}
                           title="Remove from playlist"
                           title="Remove from playlist"
                         >
                         >
                           <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
                           <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
                         </button>
                         </button>
                       </div>
                       </div>
-                      <p className="text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
+                      <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
                     </div>
                     </div>
                   )
                   )
                 })}
                 })}
@@ -639,89 +639,93 @@ export function PlaylistsPage() {
 
 
           {/* Playback Settings - Always visible when playlist selected */}
           {/* Playback Settings - Always visible when playlist selected */}
           {selectedPlaylist && (
           {selectedPlaylist && (
-            <div className="border-t px-4 py-3 bg-muted/30 shrink-0">
-              <div className="flex flex-wrap items-center gap-3">
-                {/* Run Mode Segmented Control */}
-                <div className="flex rounded-md border bg-muted/50 p-0.5">
-                  <button
-                    onClick={() => setRunMode('single')}
-                    className={`flex items-center gap-1 px-2.5 py-1 rounded text-sm font-medium transition-colors ${
-                      runMode === 'single'
-                        ? 'bg-background text-foreground shadow-sm'
-                        : 'text-muted-foreground hover:text-foreground'
-                    }`}
-                  >
-                    <span className="material-icons-outlined text-sm">play_circle</span>
-                    Once
-                  </button>
-                  <button
-                    onClick={() => setRunMode('indefinite')}
-                    className={`flex items-center gap-1 px-2.5 py-1 rounded text-sm font-medium transition-colors ${
-                      runMode === 'indefinite'
-                        ? 'bg-background text-foreground shadow-sm'
-                        : 'text-muted-foreground hover:text-foreground'
-                    }`}
-                  >
-                    <span className="material-icons-outlined text-sm">repeat</span>
-                    Loop
-                  </button>
-                </div>
-
-                {/* Shuffle Toggle */}
-                <div className="flex items-center gap-1.5 h-8 px-2 rounded-md border bg-muted/50">
-                  <span className="material-icons-outlined text-sm text-muted-foreground">shuffle</span>
-                  <Switch
-                    checked={shuffle}
-                    onCheckedChange={setShuffle}
-                    className="scale-90"
-                  />
-                </div>
-
-                {/* Pause Time */}
-                <div className="flex items-center gap-1.5">
-                  <Label className="text-xs text-muted-foreground">Pause:</Label>
-                  <Input
-                    type="number"
-                    value={pauseTime}
-                    onChange={(e) => setPauseTime(Number(e.target.value))}
-                    min={0}
-                    className="w-14 h-8 text-sm"
-                  />
-                  <Select value={pauseUnit} onValueChange={(v) => setPauseUnit(v as 'sec' | 'min' | 'hr')}>
-                    <SelectTrigger className="h-8 w-16 text-sm">
-                      <SelectValue />
-                    </SelectTrigger>
-                    <SelectContent>
-                      <SelectItem value="sec">sec</SelectItem>
-                      <SelectItem value="min">min</SelectItem>
-                      <SelectItem value="hr">hr</SelectItem>
-                    </SelectContent>
-                  </Select>
-                </div>
-
-                {/* Clear Pattern */}
-                <div className="flex items-center gap-1.5">
-                  <Label className="text-xs text-muted-foreground">Clear:</Label>
-                  <Select value={clearPattern} onValueChange={(v) => setClearPattern(v as PreExecution)}>
-                    <SelectTrigger className="h-8 w-28 text-sm">
-                      <SelectValue />
-                    </SelectTrigger>
-                    <SelectContent>
-                      {preExecutionOptions.map(opt => (
-                        <SelectItem key={opt.value} value={opt.value}>
-                          {opt.label}
-                        </SelectItem>
-                      ))}
-                    </SelectContent>
-                  </Select>
+            <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">
+                    <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'
+                      }`}
+                    >
+                      <span className="material-icons-outlined text-sm">play_circle</span>
+                      Once
+                    </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 ${
+                        runMode === 'indefinite'
+                          ? 'bg-background text-foreground shadow-sm'
+                          : 'text-muted-foreground hover:text-foreground'
+                      }`}
+                    >
+                      <span className="material-icons-outlined text-sm">repeat</span>
+                      Loop
+                    </button>
+                  </div>
+
+                  {/* Shuffle Toggle */}
+                  <div className="flex items-center gap-1.5 h-8 px-2 rounded-md border bg-muted/50">
+                    <span className="material-icons-outlined text-sm text-muted-foreground">shuffle</span>
+                    <Switch
+                      checked={shuffle}
+                      onCheckedChange={setShuffle}
+                      className="scale-90"
+                    />
+                  </div>
+
+                  {/* Pause Time - more compact on mobile */}
+                  <div className="flex items-center gap-1">
+                    <Label className="text-xs text-muted-foreground hidden sm:inline">Pause:</Label>
+                    <Input
+                      type="number"
+                      value={pauseTime}
+                      onChange={(e) => setPauseTime(Number(e.target.value))}
+                      min={0}
+                      className="w-12 sm:w-14 h-8 text-sm"
+                    />
+                    <Select value={pauseUnit} onValueChange={(v) => setPauseUnit(v as 'sec' | 'min' | 'hr')}>
+                      <SelectTrigger className="h-8 w-14 sm:w-16 text-xs sm:text-sm">
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        <SelectItem value="sec">sec</SelectItem>
+                        <SelectItem value="min">min</SelectItem>
+                        <SelectItem value="hr">hr</SelectItem>
+                      </SelectContent>
+                    </Select>
+                  </div>
+
+                  {/* Clear Pattern */}
+                  <div className="flex items-center gap-1">
+                    <Label className="text-xs text-muted-foreground hidden sm:inline">Clear:</Label>
+                    <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>
+                      <SelectContent>
+                        {preExecutionOptions.map(opt => (
+                          <SelectItem key={opt.value} value={opt.value}>
+                            {opt.label}
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                  </div>
                 </div>
                 </div>
 
 
-                {/* Spacer */}
-                <div className="flex-1" />
+                {/* Spacer - only on desktop */}
+                <div className="hidden sm:flex sm:flex-1" />
 
 
-                {/* Run Button */}
+                {/* Run Button - full width on mobile */}
                 <Button
                 <Button
-                  className="gap-2"
+                  className="gap-2 w-full sm:w-auto"
                   onClick={handleRunPlaylist}
                   onClick={handleRunPlaylist}
                   disabled={isRunning || playlistPatterns.length === 0}
                   disabled={isRunning || playlistPatterns.length === 0}
                 >
                 >

+ 4 - 4
frontend/src/pages/SettingsPage.tsx

@@ -715,10 +715,10 @@ 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-6 px-4">
       {/* Page Header */}
       {/* Page Header */}
-      <div className="space-y-1">
-        <h1 className="text-3xl font-bold tracking-tight">Settings</h1>
-        <p className="text-muted-foreground">
-          Configure your sand table and application preferences
+      <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">
+          Configure your sand table
         </p>
         </p>
       </div>
       </div>
 
 

+ 71 - 53
frontend/src/pages/TableControlPage.tsx

@@ -322,9 +322,9 @@ export function TableControlPage() {
     <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-6">
         {/* Page Header */}
         {/* Page Header */}
-        <div className="space-y-1">
-          <h1 className="text-3xl font-bold tracking-tight">Table Control</h1>
-          <p className="text-muted-foreground">
+        <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">
             Manual controls for your sand table
             Manual controls for your sand table
           </p>
           </p>
         </div>
         </div>
@@ -628,61 +628,17 @@ export function TableControlPage() {
 
 
         {/* Serial Terminal */}
         {/* Serial Terminal */}
         <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">
-            <div className="flex items-center justify-between">
-              <div>
+          <CardHeader className="pb-3 space-y-3">
+            <div className="flex items-start justify-between gap-2">
+              <div className="min-w-0">
                 <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>Send raw commands to the table controller</CardDescription>
+                <CardDescription className="hidden sm:block">Send raw commands to the table controller</CardDescription>
               </div>
               </div>
-              <div className="flex items-center gap-2">
-                {/* Port selector */}
-                <select
-                  className="h-9 rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring"
-                  value={selectedSerialPort}
-                  onChange={(e) => setSelectedSerialPort(e.target.value)}
-                  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>
-                {!serialConnected ? (
-                  <Button
-                    size="sm"
-                    onClick={handleSerialConnect}
-                    disabled={!selectedSerialPort || serialLoading}
-                  >
-                    {serialLoading ? (
-                      <span className="material-icons-outlined animate-spin mr-1">sync</span>
-                    ) : (
-                      <span className="material-icons-outlined mr-1">power</span>
-                    )}
-                    Connect
-                  </Button>
-                ) : (
-                  <Button
-                    size="sm"
-                    variant="destructive"
-                    onClick={handleSerialDisconnect}
-                    disabled={serialLoading}
-                  >
-                    <span className="material-icons-outlined mr-1">power_off</span>
-                    Disconnect
-                  </Button>
-                )}
+              {/* Clear button - only show on desktop in header */}
+              <div className="hidden sm:flex items-center gap-1">
                 {serialHistory.length > 0 && (
                 {serialHistory.length > 0 && (
                   <Button
                   <Button
                     variant="ghost"
                     variant="ghost"
@@ -695,6 +651,68 @@ export function TableControlPage() {
                 )}
                 )}
               </div>
               </div>
             </div>
             </div>
+            {/* Controls row - stacks better on mobile */}
+            <div className="flex flex-wrap items-center gap-2">
+              {/* Port selector */}
+              <select
+                className="h-9 flex-1 min-w-[140px] max-w-[200px] rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring"
+                value={selectedSerialPort}
+                onChange={(e) => setSelectedSerialPort(e.target.value)}
+                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>
+              {!serialConnected ? (
+                <Button
+                  size="sm"
+                  onClick={handleSerialConnect}
+                  disabled={!selectedSerialPort || serialLoading}
+                  title="Connect"
+                >
+                  {serialLoading ? (
+                    <span className="material-icons-outlined animate-spin sm:mr-1">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined sm:mr-1">power</span>
+                  )}
+                  <span className="hidden sm:inline">Connect</span>
+                </Button>
+              ) : (
+                <Button
+                  size="sm"
+                  variant="destructive"
+                  onClick={handleSerialDisconnect}
+                  disabled={serialLoading}
+                  title="Disconnect"
+                >
+                  <span className="material-icons-outlined sm:mr-1">power_off</span>
+                  <span className="hidden sm:inline">Disconnect</span>
+                </Button>
+              )}
+              {/* Clear button - show on mobile in controls row */}
+              {serialHistory.length > 0 && (
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  className="sm:hidden"
+                  onClick={() => setSerialHistory([])}
+                  title="Clear history"
+                >
+                  <span className="material-icons-outlined">delete</span>
+                </Button>
+              )}
+            </div>
           </CardHeader>
           </CardHeader>
           <CardContent>
           <CardContent>
             {/* Output area */}
             {/* Output area */}

+ 61 - 8
modules/core/pattern_manager.py

@@ -318,11 +318,22 @@ class MotionControlThread:
     def _execute_move(self, command: MotionCommand):
     def _execute_move(self, command: MotionCommand):
         """Execute a move command in the motion thread."""
         """Execute a move command in the motion thread."""
         try:
         try:
-            # Wait if paused
+            # Wait if paused, but also check for stop request
             while self.paused and self.running:
             while self.paused and self.running:
+                if state.stop_requested:
+                    logger.info("Motion thread: Stop requested during pause")
+                    if command.future and not command.future.done():
+                        command.future.get_loop().call_soon_threadsafe(
+                            command.future.set_result, None
+                        )
+                    return
                 time.sleep(0.1)
                 time.sleep(0.1)
 
 
-            if not self.running:
+            if not self.running or state.stop_requested:
+                if command.future and not command.future.done():
+                    command.future.get_loop().call_soon_threadsafe(
+                        command.future.set_result, None
+                    )
                 return
                 return
 
 
             # Execute the actual motion using sync version
             # Execute the actual motion using sync version
@@ -387,8 +398,19 @@ class MotionControlThread:
 
 
         # Track overall attempt time
         # Track overall attempt time
         overall_start_time = time.time()
         overall_start_time = time.time()
+        max_total_timeout = 30  # Maximum total time to retry before giving up
 
 
         while True:
         while True:
+            # Check for stop request before each attempt
+            if state.stop_requested:
+                logger.info("Motion thread: Stop requested, aborting command")
+                return False
+
+            # Check for overall timeout
+            if time.time() - overall_start_time > max_total_timeout:
+                logger.error(f"Motion thread: Timeout after {max_total_timeout}s, aborting command")
+                return False
+
             try:
             try:
                 gcode = f"$J=G91 G21 Y{y} F{speed}" if home else f"G1 G53 X{x} Y{y} F{speed}"
                 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")
                 state.conn.send(gcode + "\n")
@@ -396,11 +418,21 @@ class MotionControlThread:
 
 
                 start_time = time.time()
                 start_time = time.time()
                 while True:
                 while True:
+                    # Check for stop request while waiting for response
+                    if state.stop_requested:
+                        logger.info("Motion thread: Stop requested while waiting for response")
+                        return False
+
                     response = state.conn.readline()
                     response = state.conn.readline()
                     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
+
+                    # Timeout waiting for response
+                    if time.time() - start_time > timeout:
+                        logger.warning("Motion thread: Timeout waiting for 'ok' response")
+                        break
 
 
             except Exception as e:
             except Exception as e:
                 error_str = str(e)
                 error_str = str(e)
@@ -1098,8 +1130,6 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
         with state.pause_condition:
         with state.pause_condition:
             state.pause_requested = False
             state.pause_requested = False
             state.stop_requested = True
             state.stop_requested = True
-            state.current_playing_file = None
-            state.execution_progress = None
             state.is_clearing = False
             state.is_clearing = False
 
 
             if clear_playlist:
             if clear_playlist:
@@ -1116,20 +1146,43 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
 
 
             state.pause_condition.notify_all()
             state.pause_condition.notify_all()
 
 
+        # Also set the pause event to wake up any paused patterns
+        get_pause_event().set()
+
+        # Send stop command to motion thread to clear its queue
+        if motion_controller.running:
+            motion_controller.command_queue.put(MotionCommand('stop'))
+
         # Wait for the pattern lock to be released before continuing
         # Wait for the pattern lock to be released before continuing
         # This ensures that when stop_actions completes, the pattern has fully stopped
         # This ensures that when stop_actions completes, the pattern has fully stopped
         # Skip this if called from within pattern execution to avoid deadlock
         # Skip this if called from within pattern execution to avoid deadlock
         lock = get_pattern_lock()
         lock = get_pattern_lock()
         if wait_for_lock and lock.locked():
         if wait_for_lock and lock.locked():
             logger.info("Waiting for pattern to fully stop...")
             logger.info("Waiting for pattern to fully stop...")
-            # Acquire and immediately release the lock to ensure the pattern has exited
-            async with lock:
-                logger.info("Pattern lock acquired - pattern has fully stopped")
+            # Use a timeout to prevent hanging forever
+            try:
+                async with asyncio.timeout(10.0):
+                    async with lock:
+                        logger.info("Pattern lock acquired - pattern has fully stopped")
+            except asyncio.TimeoutError:
+                logger.warning("Timeout waiting for pattern to stop - forcing cleanup")
+                # Force cleanup of state even if pattern didn't release lock gracefully
+                state.current_playing_file = None
+                state.execution_progress = None
+                state.is_running = False
+
+        # Always clear the current playing file after stop
+        state.current_playing_file = None
+        state.execution_progress = None
 
 
         # 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()
     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
+        state.current_playing_file = None
+        state.execution_progress = None
+        state.is_running = False
         # Ensure we still update machine position even if there's an error
         # Ensure we still update machine position even if there's an error
         try:
         try:
             await connection_manager.update_machine_position()
             await connection_manager.update_machine_position()