Просмотр исходного кода

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 недель назад
Родитель
Сommit
1b02b8cffe

+ 1 - 1
frontend/index.html

@@ -2,7 +2,7 @@
 <html lang="en">
   <head>
     <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 -->
     <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 */}
       <div
         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'}
         onTouchStart={handleTouchStart}
         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" />
                   )}
 
-                  {/* 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
                       variant="ghost"
                       size="sm"
-                      className="h-6 w-6 p-0"
+                      className="h-7 w-7 p-0"
                       onClick={e => {
                         e.stopPropagation()
                         openRenameDialog(table)
                       }}
+                      title="Rename"
                     >
-                      <Pencil className="h-3 w-3" />
+                      <Pencil className="h-3.5 w-3.5" />
                     </Button>
                     {!table.isCurrent && (
                       <Button
                         variant="ghost"
                         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 => {
                           e.stopPropagation()
                           handleRemove(table)
                         }}
+                        title="Remove"
                       >
-                        <Trash2 className="h-3 w-3" />
+                        <Trash2 className="h-3.5 w-3.5" />
                       </Button>
                     )}
                   </div>

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

@@ -3,6 +3,8 @@ import { useEffect, useState, useRef } from 'react'
 import { toast } from 'sonner'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 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 { TableSelector } from '@/components/TableSelector'
 import { useTable } from '@/contexts/TableContext'
@@ -95,6 +97,9 @@ export function Layout() {
     return () => clearTimeout(timer)
   }, [homingJustCompleted, homingCountdown, keepHomingLogsOpen])
 
+  // Mobile menu state
+  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
+
   // Logs drawer state
   const [isLogsOpen, setIsLogsOpen] = useState(false)
   const [logsDrawerHeight, setLogsDrawerHeight] = useState(256) // Default 256px (h-64)
@@ -709,7 +714,7 @@ export function Layout() {
     : 0
 
   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 */}
       {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">
@@ -1027,7 +1032,9 @@ export function Layout() {
               }
             />
           </Link>
-          <div className="flex items-center gap-1">
+
+          {/* Desktop actions */}
+          <div className="hidden md:flex items-center gap-1">
             <TableSelector />
             <Button
               variant="ghost"
@@ -1072,6 +1079,72 @@ export function Layout() {
               <span className="material-icons-outlined">power_settings_new</span>
             </Button>
           </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>
       </header>
 
@@ -1104,7 +1177,8 @@ export function Layout() {
       {!isNowPlayingOpen && (
         <button
           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"
         >
           <span className="material-icons">play_circle</span>
@@ -1113,10 +1187,13 @@ export function Layout() {
 
       {/* Logs Drawer */}
       <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'
         }`}
-        style={{ height: isLogsOpen ? logsDrawerHeight : 0 }}
+        style={{
+          height: isLogsOpen ? logsDrawerHeight : 0,
+          bottom: 'calc(4rem + env(safe-area-inset-bottom, 0px))'
+        }}
       >
         {isLogsOpen && (
           <>
@@ -1211,7 +1288,7 @@ export function Layout() {
       </div>
 
       {/* 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">
           {navItems.map((item) => {
             const isActive = location.pathname === item.path

+ 10 - 1
frontend/src/index.css

@@ -83,7 +83,16 @@ body {
   color: var(--color-foreground);
   font-family: var(--font-family-sans);
   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 */

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

@@ -748,7 +748,7 @@ export function BrowsePage() {
   }
 
   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 */}
       <input
         ref={fileInputRef}
@@ -758,74 +758,77 @@ export function BrowsePage() {
         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>
         </div>
         <Button
           variant="outline"
+          size="sm"
           onClick={() => fileInputRef.current?.click()}
           disabled={isUploading}
-          className="gap-2 shrink-0"
+          className="gap-1.5 sm:gap-2 shrink-0 h-8 sm:h-9"
         >
           {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>
         </Button>
       </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
               </span>
               <Input
                 value={searchQuery}
                 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 && (
                 <Button
                   variant="ghost"
                   size="icon-sm"
                   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>
               )}
             </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">
             <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" />
               </SelectTrigger>
               <SelectContent>
@@ -839,7 +842,7 @@ export function BrowsePage() {
               variant="outline"
               size="icon"
               onClick={() => setSortAsc(!sortAsc)}
-              className="shrink-0"
+              className="shrink-0 h-9 w-9 sm:h-10 sm:w-10"
               title={sortAsc ? 'Ascending' : 'Descending'}
             >
               <span className="material-icons-outlined text-lg">

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

@@ -352,8 +352,8 @@ export function LEDPage() {
           </span>
         </div>
         <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.
           </p>
         </div>
@@ -384,9 +384,9 @@ export function LEDPage() {
   return (
     <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6 px-4">
       {/* 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>
 
       <Separator />

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

@@ -453,12 +453,12 @@ export function PlaylistsPage() {
   }
 
   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 */}
-      <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>
       </div>
 
@@ -508,7 +508,7 @@ export function PlaylistsPage() {
                   <span className="material-icons-outlined text-lg">playlist_play</span>
                   <span className="truncate text-sm font-medium">{name}</span>
                 </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
                     variant="ghost"
                     size="icon-sm"
@@ -594,7 +594,7 @@ export function PlaylistsPage() {
                 </Button>
               </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) => {
                   const previewUrl = getPreviewUrl(path)
                   if (!previewUrl && !previews[path]) {
@@ -603,7 +603,7 @@ export function PlaylistsPage() {
                   return (
                     <div
                       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="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">
-                              <span className="material-icons-outlined text-muted-foreground">
+                              <span className="material-icons-outlined text-muted-foreground text-sm sm:text-base">
                                 image
                               </span>
                             </div>
                           )}
                         </div>
                         <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)}
                           title="Remove from playlist"
                         >
                           <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
                         </button>
                       </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>
                   )
                 })}
@@ -639,89 +639,93 @@ export function PlaylistsPage() {
 
           {/* Playback Settings - Always visible when playlist selected */}
           {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>
 
-                {/* 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
-                  className="gap-2"
+                  className="gap-2 w-full sm:w-auto"
                   onClick={handleRunPlaylist}
                   disabled={isRunning || playlistPatterns.length === 0}
                 >

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

@@ -715,10 +715,10 @@ export function SettingsPage() {
   return (
     <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6 px-4">
       {/* 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>
       </div>
 

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

@@ -322,9 +322,9 @@ export function TableControlPage() {
     <TooltipProvider>
       <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6">
         {/* 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
           </p>
         </div>
@@ -628,61 +628,17 @@ export function TableControlPage() {
 
         {/* Serial Terminal */}
         <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">
                   <span className="material-icons-outlined text-xl">terminal</span>
                   Serial Terminal
                 </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 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 && (
                   <Button
                     variant="ghost"
@@ -695,6 +651,68 @@ export function TableControlPage() {
                 )}
               </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>
           <CardContent>
             {/* Output area */}

+ 61 - 8
modules/core/pattern_manager.py

@@ -318,11 +318,22 @@ class MotionControlThread:
     def _execute_move(self, command: MotionCommand):
         """Execute a move command in the motion thread."""
         try:
-            # Wait if paused
+            # Wait if paused, but also check for stop request
             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)
 
-            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
 
             # Execute the actual motion using sync version
@@ -387,8 +398,19 @@ class MotionControlThread:
 
         # Track overall attempt time
         overall_start_time = time.time()
+        max_total_timeout = 30  # Maximum total time to retry before giving up
 
         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:
                 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")
@@ -396,11 +418,21 @@ class MotionControlThread:
 
                 start_time = time.time()
                 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()
                     logger.debug(f"Motion thread response: {response}")
                     if response.lower() == "ok":
                         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:
                 error_str = str(e)
@@ -1098,8 +1130,6 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
         with state.pause_condition:
             state.pause_requested = False
             state.stop_requested = True
-            state.current_playing_file = None
-            state.execution_progress = None
             state.is_clearing = False
 
             if clear_playlist:
@@ -1116,20 +1146,43 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
 
             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
         # This ensures that when stop_actions completes, the pattern has fully stopped
         # Skip this if called from within pattern execution to avoid deadlock
         lock = get_pattern_lock()
         if wait_for_lock and lock.locked():
             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
         await connection_manager.update_machine_position()
     except Exception as 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
         try:
             await connection_manager.update_machine_position()