Explorar o código

frontend: audit-driven fixes — bugs, UX, accessibility, bundle

Code-quality fixes:
- preserve playlist order when editing patterns (Set insertion-order bug)
- sync manual rotation baseline from hardware theta
- fix NowPlayingBar coordinate refetch loop and stale-response race
- fix preview cache eviction over-deleting (~20MB on first eviction)
- identity-stable requestPreview in Browse/Playlists (observer churn)
- fix draggable button snap-back (stale closure on drag offset)
- WebSocket reconnect backoff with jitter; guard against ghost sockets
  on table switch; IndexedDB env guard; log pagination raw-offset fix
- remove unused deps (react-colorful dup, react-resizable-panels)

UX:
- app-wide disconnected banner + disabled hardware actions (Stop exempt)
- escape paths on blocking overlays (cache init, backend disconnect)
- hide Not Playing pill when idle (incl. Still Sands is_paused edge);
  body scroll lock only when player expanded
- replace all six native confirm()s with styled AlertDialogs
  (playlist/pattern delete, logo, security-off, restart, shutdown
  with reconnect/power-off screens)
- LED page: remove success-toast spam, add color failure feedback,
  WLED reachability check, idle-timeout save-on-blur
- Settings: instant-save enable switches, setup wizard surfaced at top,
  Still Sands slot validation, version-check retry, single fetchPorts
- playlists: drag-to-reorder (dnd-kit), picker discard guard,
  double-submit guards, compact clear-pattern menu on mobile
- Browse: incremental grid rendering (60/page sentinel), aggregated
  upload toasts, friendly start/end copy, est. duration, empty-state CTA

Accessibility:
- aria-labels on ~26 icon-only buttons; aria-hidden on ~290 icon spans
- keyboard operability for playlist rows, pattern picker, table switcher
- form label associations; dialog roles on custom overlays
- aria-live playback announcements; prefers-reduced-motion support
- pointer-fine gating for hover-revealed controls; 44px touch targets

Bundle & platform:
- route-level code splitting + vendor chunks: 1041kB -> 280kB main
- react-color -> react-colorful (~140kB saved)
- PWA registerType prompt + UpdatePrompt UI (no mid-session SW swap)
- 100dvh expanded player; theme-color meta follows dark/light

Tests:
- hermetic test setup: module-scope WebSocket stub (real local backend
  was leaking live status into tests), localStorage + baseUrl resets
- updated tests for dialogs, disabled states, new empty-state copy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tuanchris hai 1 mes
pai
achega
a7149c5a39
Modificáronse 64 ficheiros con 2991 adicións e 1360 borrados
  1. 409 143
      frontend/package-lock.json
  2. 2 4
      frontend/package.json
  3. 53 20
      frontend/src/App.tsx
  4. 220 218
      frontend/src/__tests__/integration/patternFlow.test.tsx
  5. 276 274
      frontend/src/__tests__/integration/playbackFlow.test.tsx
  6. 6 7
      frontend/src/__tests__/integration/playlistFlow.test.tsx
  7. 1 1
      frontend/src/__tests__/pages/BrowsePage.test.tsx
  8. 1 1
      frontend/src/__tests__/pages/PlaylistsPage.test.tsx
  9. 6 3
      frontend/src/__tests__/pages/TableControlPage.test.tsx
  10. 61 0
      frontend/src/components/ConfirmDialog.tsx
  11. 40 0
      frontend/src/components/ConnectionBanner.tsx
  12. 105 56
      frontend/src/components/NowPlayingBar.tsx
  13. 7 3
      frontend/src/components/ShinyText.tsx
  14. 17 16
      frontend/src/components/TableSelector.tsx
  15. 31 0
      frontend/src/components/UpdatePrompt.tsx
  16. 232 80
      frontend/src/components/layout/Layout.tsx
  17. 131 0
      frontend/src/components/ui/alert-dialog.tsx
  18. 122 70
      frontend/src/components/ui/color-picker.tsx
  19. 13 1
      frontend/src/index.css
  20. 3 0
      frontend/src/lib/apiClient.ts
  21. 11 1
      frontend/src/lib/previewCache.ts
  22. 208 74
      frontend/src/pages/BrowsePage.tsx
  23. 106 47
      frontend/src/pages/LEDPage.tsx
  24. 331 80
      frontend/src/pages/PlaylistsPage.tsx
  25. 251 155
      frontend/src/pages/SettingsPage.tsx
  26. 2 1
      frontend/src/pages/SetupPage.tsx
  27. 80 45
      frontend/src/pages/TableControlPage.tsx
  28. 157 42
      frontend/src/pages/WiFiSetupPage.tsx
  29. 23 11
      frontend/src/stores/useStatusStore.ts
  30. 16 1
      frontend/src/test/setup.ts
  31. 35 1
      frontend/src/test/utils.tsx
  32. 2 0
      frontend/src/vite-env.d.ts
  33. 18 1
      frontend/vite.config.ts
  34. 0 0
      static/dist/assets/BrowsePage-1r3NAXso.js
  35. 1 0
      static/dist/assets/CaptivePortalPage-DVYLYnAt.js
  36. 1 0
      static/dist/assets/ConnectionBanner-CCnOHE5N.js
  37. 0 0
      static/dist/assets/LEDPage-oDN2VKcW.js
  38. 0 0
      static/dist/assets/PlaylistsPage-CplC-pMF.js
  39. 0 0
      static/dist/assets/SettingsPage-BQ-dtYJf.js
  40. 0 0
      static/dist/assets/SetupPage-C5AL1838.js
  41. 0 0
      static/dist/assets/TableControlPage-DUmfP8p3.js
  42. 0 0
      static/dist/assets/WiFiSetupPage-TJa-Ecum.js
  43. 0 0
      static/dist/assets/accordion-Cq66Jm31.js
  44. 1 0
      static/dist/assets/alert-CjHh5uAz.js
  45. 1 0
      static/dist/assets/card-DweoKf_j.js
  46. 0 0
      static/dist/assets/dnd-Bgdma8TD.js
  47. 1 0
      static/dist/assets/index-B3c97KGp.js
  48. 0 0
      static/dist/assets/index-Bg3BUsME.css
  49. 0 0
      static/dist/assets/index-D9WwIcnt.css
  50. 0 0
      static/dist/assets/index-MuBercVK.js
  51. 0 0
      static/dist/assets/index-Sj6DXm4R.js
  52. 1 0
      static/dist/assets/label-CasawWzc.js
  53. 0 0
      static/dist/assets/motion-8nKk6_RD.js
  54. 0 0
      static/dist/assets/react-DnK_pEU5.js
  55. 0 0
      static/dist/assets/select-CmzQQBWZ.js
  56. 0 0
      static/dist/assets/slider-DrXXF40r.js
  57. 0 0
      static/dist/assets/switch-BkKXBesV.js
  58. 1 0
      static/dist/assets/types-DxHZB39T.js
  59. 1 0
      static/dist/assets/useBackendConnection-DKvfYjhQ.js
  60. 1 0
      static/dist/assets/workbox-window.prod.es5-BIl4cyR9.js
  61. 6 3
      static/dist/index.html
  62. 0 1
      static/dist/registerSW.js
  63. 0 0
      static/dist/sw.js
  64. 0 0
      static/dist/workbox-4424bb96.js

+ 409 - 143
frontend/package-lock.json

@@ -14,6 +14,7 @@
         "@fontsource/noto-sans": "^5.2.10",
         "@fontsource/plus-jakarta-sans": "^5.2.8",
         "@radix-ui/react-accordion": "^1.2.12",
+        "@radix-ui/react-alert-dialog": "^1.1.16",
         "@radix-ui/react-dialog": "^1.1.15",
         "@radix-ui/react-label": "^2.1.8",
         "@radix-ui/react-popover": "^1.1.15",
@@ -32,10 +33,8 @@
         "motion": "^12.27.1",
         "next-themes": "^0.4.6",
         "react": "^19.2.0",
-        "react-color": "^2.19.3",
-        "react-colorful": "^5.6.1",
+        "react-colorful": "^5.7.0",
         "react-dom": "^19.2.0",
-        "react-resizable-panels": "^4.4.0",
         "react-router-dom": "^7.12.0",
         "sonner": "^2.0.7",
         "zustand": "^5.0.9"
@@ -48,7 +47,6 @@
         "@testing-library/user-event": "^14.6.1",
         "@types/node": "^24.10.4",
         "@types/react": "^19.2.5",
-        "@types/react-color": "^3.0.13",
         "@types/react-dom": "^19.2.3",
         "@vitejs/plugin-react": "^5.1.1",
         "@vitest/coverage-v8": "^3.2.4",
@@ -2912,15 +2910,6 @@
         "url": "https://github.com/sponsors/nzakas"
       }
     },
-    "node_modules/@icons/material": {
-      "version": "0.2.4",
-      "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
-      "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==",
-      "license": "MIT",
-      "peerDependencies": {
-        "react": "*"
-      }
-    },
     "node_modules/@inquirer/ansi": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
@@ -3436,6 +3425,111 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-alert-dialog": {
+      "version": "1.1.16",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.16.tgz",
+      "integrity": "sha512-vPaIgo0mxYlvcFaM9jB2Uot9TjGXMuAPEvrc6BOLeV+I5U8s1dkIoouYaa6lmSfc5SPMo5x5djOTOTvaigdGMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.4",
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-context": "1.1.4",
+        "@radix-ui/react-dialog": "1.1.16",
+        "@radix-ui/react-primitive": "2.1.5",
+        "@radix-ui/react-slot": "1.2.5"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/primitive": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
+      "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
+      "license": "MIT"
+    },
+    "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-compose-refs": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
+      "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
+      "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz",
+      "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-slot": "1.2.5"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz",
+      "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-arrow": {
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@@ -3564,25 +3658,216 @@
       }
     },
     "node_modules/@radix-ui/react-dialog": {
-      "version": "1.1.15",
-      "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
-      "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
+      "version": "1.1.16",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.16.tgz",
+      "integrity": "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.4",
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-context": "1.1.4",
+        "@radix-ui/react-dismissable-layer": "1.1.12",
+        "@radix-ui/react-focus-guards": "1.1.4",
+        "@radix-ui/react-focus-scope": "1.1.9",
+        "@radix-ui/react-id": "1.1.2",
+        "@radix-ui/react-portal": "1.1.11",
+        "@radix-ui/react-presence": "1.1.6",
+        "@radix-ui/react-primitive": "2.1.5",
+        "@radix-ui/react-slot": "1.2.5",
+        "@radix-ui/react-use-controllable-state": "1.2.3",
+        "aria-hidden": "^1.2.4",
+        "react-remove-scroll": "^2.7.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
+      "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
+      "license": "MIT"
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
+      "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
+      "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz",
+      "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==",
       "license": "MIT",
       "dependencies": {
-        "@radix-ui/primitive": "1.1.3",
-        "@radix-ui/react-compose-refs": "1.1.2",
-        "@radix-ui/react-context": "1.1.2",
-        "@radix-ui/react-dismissable-layer": "1.1.11",
-        "@radix-ui/react-focus-guards": "1.1.3",
-        "@radix-ui/react-focus-scope": "1.1.7",
-        "@radix-ui/react-id": "1.1.1",
-        "@radix-ui/react-portal": "1.1.9",
-        "@radix-ui/react-presence": "1.1.5",
-        "@radix-ui/react-primitive": "2.1.3",
-        "@radix-ui/react-slot": "1.2.3",
-        "@radix-ui/react-use-controllable-state": "1.2.2",
-        "aria-hidden": "^1.2.4",
-        "react-remove-scroll": "^2.6.3"
+        "@radix-ui/primitive": "1.1.4",
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-primitive": "2.1.5",
+        "@radix-ui/react-use-callback-ref": "1.1.2",
+        "@radix-ui/react-use-escape-keydown": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
+      "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": {
+      "version": "1.1.9",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz",
+      "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.3",
+        "@radix-ui/react-primitive": "2.1.5",
+        "@radix-ui/react-use-callback-ref": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
+      "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz",
+      "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.5",
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
+      "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz",
+      "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-slot": "1.2.5"
       },
       "peerDependencies": {
         "@types/react": "*",
@@ -3600,12 +3885,82 @@
       }
     },
     "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz",
+      "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
+      "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": {
       "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
-      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
+      "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
       "license": "MIT",
       "dependencies": {
-        "@radix-ui/react-compose-refs": "1.1.2"
+        "@radix-ui/react-use-effect-event": "0.0.3",
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-effect-event": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
+      "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-escape-keydown": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz",
+      "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/react-use-callback-ref": "1.1.2"
       },
       "peerDependencies": {
         "@types/react": "*",
@@ -3617,6 +3972,21 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
+      "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-direction": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@@ -5474,19 +5844,6 @@
         "csstype": "^3.2.2"
       }
     },
-    "node_modules/@types/react-color": {
-      "version": "3.0.13",
-      "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.13.tgz",
-      "integrity": "sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/reactcss": "*"
-      },
-      "peerDependencies": {
-        "@types/react": "*"
-      }
-    },
     "node_modules/@types/react-dom": {
       "version": "19.2.3",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
@@ -5497,16 +5854,6 @@
         "@types/react": "^19.2.0"
       }
     },
-    "node_modules/@types/reactcss": {
-      "version": "1.2.13",
-      "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.13.tgz",
-      "integrity": "sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==",
-      "dev": true,
-      "license": "MIT",
-      "peerDependencies": {
-        "@types/react": "*"
-      }
-    },
     "node_modules/@types/resolve": {
       "version": "1.20.2",
       "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -9823,6 +10170,7 @@
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true,
       "license": "MIT"
     },
     "node_modules/js-yaml": {
@@ -10326,12 +10674,7 @@
       "version": "4.17.21",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
-      "license": "MIT"
-    },
-    "node_modules/lodash-es": {
-      "version": "4.17.22",
-      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
-      "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
+      "dev": true,
       "license": "MIT"
     },
     "node_modules/lodash.debounce": {
@@ -10398,18 +10741,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/loose-envify": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
-      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
-      "license": "MIT",
-      "dependencies": {
-        "js-tokens": "^3.0.0 || ^4.0.0"
-      },
-      "bin": {
-        "loose-envify": "cli.js"
-      }
-    },
     "node_modules/loupe": {
       "version": "3.2.1",
       "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -10498,12 +10829,6 @@
         "node": ">=10"
       }
     },
-    "node_modules/material-colors": {
-      "version": "1.2.6",
-      "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz",
-      "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==",
-      "license": "ISC"
-    },
     "node_modules/material-icons": {
       "version": "1.13.14",
       "resolved": "https://registry.npmjs.org/material-icons/-/material-icons-1.13.14.tgz",
@@ -10926,6 +11251,7 @@
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
       "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=0.10.0"
@@ -11584,17 +11910,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/prop-types": {
-      "version": "15.8.1",
-      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
-      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
-      "license": "MIT",
-      "dependencies": {
-        "loose-envify": "^1.4.0",
-        "object-assign": "^4.1.1",
-        "react-is": "^16.13.1"
-      }
-    },
     "node_modules/proxy-addr": {
       "version": "2.0.7",
       "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -11701,28 +12016,10 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/react-color": {
-      "version": "2.19.3",
-      "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
-      "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==",
-      "license": "MIT",
-      "dependencies": {
-        "@icons/material": "^0.2.4",
-        "lodash": "^4.17.15",
-        "lodash-es": "^4.17.15",
-        "material-colors": "^1.2.1",
-        "prop-types": "^15.5.10",
-        "reactcss": "^1.2.0",
-        "tinycolor2": "^1.4.1"
-      },
-      "peerDependencies": {
-        "react": "*"
-      }
-    },
     "node_modules/react-colorful": {
-      "version": "5.6.1",
-      "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
-      "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
+      "version": "5.7.0",
+      "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.7.0.tgz",
+      "integrity": "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==",
       "license": "MIT",
       "peerDependencies": {
         "react": ">=16.8.0",
@@ -11741,12 +12038,6 @@
         "react": "^19.2.3"
       }
     },
-    "node_modules/react-is": {
-      "version": "16.13.1",
-      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
-      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
-      "license": "MIT"
-    },
     "node_modules/react-refresh": {
       "version": "0.18.0",
       "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -11804,16 +12095,6 @@
         }
       }
     },
-    "node_modules/react-resizable-panels": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.4.0.tgz",
-      "integrity": "sha512-vGH1rIhyDOL4RSWYTx3eatjDohDFIRxJCAXUOaeL9HyamptUnUezqndjMtBo9hQeaq1CIP0NBbc7ZV3lBtlgxA==",
-      "license": "MIT",
-      "peerDependencies": {
-        "react": "^18.0.0 || ^19.0.0",
-        "react-dom": "^18.0.0 || ^19.0.0"
-      }
-    },
     "node_modules/react-router": {
       "version": "7.12.0",
       "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
@@ -11874,15 +12155,6 @@
         }
       }
     },
-    "node_modules/reactcss": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
-      "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==",
-      "license": "MIT",
-      "dependencies": {
-        "lodash": "^4.0.1"
-      }
-    },
     "node_modules/recast": {
       "version": "0.23.11",
       "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
@@ -13331,12 +13603,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/tinycolor2": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
-      "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
-      "license": "MIT"
-    },
     "node_modules/tinyexec": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",

+ 2 - 4
frontend/package.json

@@ -22,6 +22,7 @@
     "@fontsource/noto-sans": "^5.2.10",
     "@fontsource/plus-jakarta-sans": "^5.2.8",
     "@radix-ui/react-accordion": "^1.2.12",
+    "@radix-ui/react-alert-dialog": "^1.1.16",
     "@radix-ui/react-dialog": "^1.1.15",
     "@radix-ui/react-label": "^2.1.8",
     "@radix-ui/react-popover": "^1.1.15",
@@ -40,10 +41,8 @@
     "motion": "^12.27.1",
     "next-themes": "^0.4.6",
     "react": "^19.2.0",
-    "react-color": "^2.19.3",
-    "react-colorful": "^5.6.1",
+    "react-colorful": "^5.7.0",
     "react-dom": "^19.2.0",
-    "react-resizable-panels": "^4.4.0",
     "react-router-dom": "^7.12.0",
     "sonner": "^2.0.7",
     "zustand": "^5.0.9"
@@ -56,7 +55,6 @@
     "@testing-library/user-event": "^14.6.1",
     "@types/node": "^24.10.4",
     "@types/react": "^19.2.5",
-    "@types/react-color": "^3.0.13",
     "@types/react-dom": "^19.2.3",
     "@vitejs/plugin-react": "^5.1.1",
     "@vitest/coverage-v8": "^3.2.4",

+ 53 - 20
frontend/src/App.tsx

@@ -1,32 +1,65 @@
+import { lazy, Suspense } from 'react'
 import { Routes, Route } from 'react-router-dom'
 import { Layout } from '@/components/layout/Layout'
-import { BrowsePage } from '@/pages/BrowsePage'
-import { PlaylistsPage } from '@/pages/PlaylistsPage'
-import { TableControlPage } from '@/pages/TableControlPage'
-import { LEDPage } from '@/pages/LEDPage'
-import { SettingsPage } from '@/pages/SettingsPage'
-import { WiFiSetupPage } from '@/pages/WiFiSetupPage'
-import { CaptivePortalPage } from '@/pages/CaptivePortalPage'
-import { SetupPage } from '@/pages/SetupPage'
 import { Toaster } from '@/components/ui/sonner'
 import { TableProvider } from '@/contexts/TableContext'
+import { UpdatePrompt } from '@/components/UpdatePrompt'
+
+const BrowsePage = lazy(() =>
+  import('@/pages/BrowsePage').then((m) => ({ default: m.BrowsePage }))
+)
+const PlaylistsPage = lazy(() =>
+  import('@/pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage }))
+)
+const TableControlPage = lazy(() =>
+  import('@/pages/TableControlPage').then((m) => ({ default: m.TableControlPage }))
+)
+const LEDPage = lazy(() =>
+  import('@/pages/LEDPage').then((m) => ({ default: m.LEDPage }))
+)
+const SettingsPage = lazy(() =>
+  import('@/pages/SettingsPage').then((m) => ({ default: m.SettingsPage }))
+)
+const WiFiSetupPage = lazy(() =>
+  import('@/pages/WiFiSetupPage').then((m) => ({ default: m.WiFiSetupPage }))
+)
+const CaptivePortalPage = lazy(() =>
+  import('@/pages/CaptivePortalPage').then((m) => ({ default: m.CaptivePortalPage }))
+)
+const SetupPage = lazy(() =>
+  import('@/pages/SetupPage').then((m) => ({ default: m.SetupPage }))
+)
+
+function PageFallback() {
+  return (
+    <div className="flex items-center justify-center min-h-[50vh] text-muted-foreground">
+      <span aria-hidden="true" className="material-icons-outlined text-2xl animate-spin">
+        sync
+      </span>
+      <span className="sr-only">Loading…</span>
+    </div>
+  )
+}
 
 function App() {
   return (
     <TableProvider>
-      <Routes>
-        <Route path="/" element={<Layout />}>
-          <Route index element={<BrowsePage />} />
-          <Route path="playlists" element={<PlaylistsPage />} />
-          <Route path="table-control" element={<TableControlPage />} />
-          <Route path="led" element={<LEDPage />} />
-          <Route path="settings" element={<SettingsPage />} />
-          <Route path="wifi-setup" element={<WiFiSetupPage />} />
-          <Route path="captive" element={<CaptivePortalPage />} />
-          <Route path="setup" element={<SetupPage />} />
-        </Route>
-      </Routes>
+      <Suspense fallback={<PageFallback />}>
+        <Routes>
+          <Route path="/" element={<Layout />}>
+            <Route index element={<BrowsePage />} />
+            <Route path="playlists" element={<PlaylistsPage />} />
+            <Route path="table-control" element={<TableControlPage />} />
+            <Route path="led" element={<LEDPage />} />
+            <Route path="settings" element={<SettingsPage />} />
+            <Route path="wifi-setup" element={<WiFiSetupPage />} />
+            <Route path="captive" element={<CaptivePortalPage />} />
+            <Route path="setup" element={<SetupPage />} />
+          </Route>
+        </Routes>
+      </Suspense>
       <Toaster position="top-center" richColors closeButton />
+      <UpdatePrompt />
     </TableProvider>
   )
 }

+ 220 - 218
frontend/src/__tests__/integration/patternFlow.test.tsx

@@ -1,218 +1,220 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
-import { mockData, apiCallLog, resetApiCallLog } from '../../test/mocks/handlers'
-import { BrowsePage } from '../../pages/BrowsePage'
-
-describe('Pattern Flow Integration', () => {
-  beforeEach(() => {
-    vi.clearAllMocks()
-    resetApiCallLog()
-  })
-
-  describe('Browse -> Select -> Run Flow', () => {
-    it('displays pattern list from API', async () => {
-      renderWithProviders(<BrowsePage />)
-
-      await waitFor(() => {
-        expect(screen.getByText('star.thr')).toBeInTheDocument()
-        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
-      })
-    })
-
-    it('opens pattern detail when clicking pattern card', async () => {
-      const user = userEvent.setup()
-      renderWithProviders(<BrowsePage />)
-
-      await waitFor(() => {
-        expect(screen.getByText('star.thr')).toBeInTheDocument()
-      })
-
-      // Click on pattern card
-      await user.click(screen.getByText('star.thr'))
-
-      // Detail sheet should open - pattern name appears twice (grid + sheet title)
-      await waitFor(() => {
-        const patternNames = screen.getAllByText('star.thr')
-        expect(patternNames.length).toBeGreaterThan(1)
-      })
-    })
-
-    it('runs pattern and verifies API call with correct file', async () => {
-      const user = userEvent.setup()
-      renderWithProviders(<BrowsePage />)
-
-      // Wait for patterns to load
-      await waitFor(() => {
-        expect(screen.getByText('star.thr')).toBeInTheDocument()
-      })
-
-      // Pre-condition: not running
-      expect(mockData.status.is_running).toBe(false)
-
-      // Click pattern to open detail sheet
-      await user.click(screen.getByText('star.thr'))
-
-      // Wait for sheet to open and find the main Play button (lg size, not the smaller ones)
-      await waitFor(() => {
-        // The main Play button has "Play" text and play_arrow icon
-        const buttons = screen.getAllByRole('button')
-        const playButton = buttons.find(btn =>
-          btn.textContent?.trim() === 'Play' ||
-          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-        )
-        expect(playButton).toBeTruthy()
-      })
-
-      // Click the main Play button
-      const buttons = screen.getAllByRole('button')
-      const playButton = buttons.find(btn =>
-        btn.textContent?.trim() === 'Play' ||
-        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-      )
-      await user.click(playButton!)
-
-      // Verify API was called with correct file
-      await waitFor(() => {
-        const runCall = apiCallLog.find(c => c.endpoint === '/run_theta_rho')
-        expect(runCall).toBeDefined()
-        expect(runCall?.body).toMatchObject({
-          file_name: expect.stringContaining('star')
-        })
-      })
-
-      // Verify mock state was updated
-      expect(mockData.status.is_running).toBe(true)
-      expect(mockData.status.current_file).toContain('star')
-    })
-
-    it('updates mock state after pattern starts running', async () => {
-      const user = userEvent.setup()
-      renderWithProviders(<BrowsePage />)
-
-      await waitFor(() => {
-        expect(screen.getByText('star.thr')).toBeInTheDocument()
-      })
-
-      // Pre-condition: not running
-      expect(mockData.status.is_running).toBe(false)
-
-      // Click pattern to open detail
-      await user.click(screen.getByText('star.thr'))
-
-      // Wait for sheet to open and find main Play button
-      await waitFor(() => {
-        const buttons = screen.getAllByRole('button')
-        const playButton = buttons.find(btn =>
-          btn.textContent?.trim() === 'Play' ||
-          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-        )
-        expect(playButton).toBeTruthy()
-      })
-
-      // Click the main Play button
-      const buttons = screen.getAllByRole('button')
-      const playButton = buttons.find(btn =>
-        btn.textContent?.trim() === 'Play' ||
-        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-      )
-      await user.click(playButton!)
-
-      // Post-condition: running
-      await waitFor(() => {
-        expect(mockData.status.is_running).toBe(true)
-      })
-    })
-  })
-
-  describe('Search -> Filter -> Run Flow', () => {
-    it('filters patterns by search then runs filtered result', async () => {
-      const user = userEvent.setup()
-      renderWithProviders(<BrowsePage />)
-
-      await waitFor(() => {
-        expect(screen.getByText('star.thr')).toBeInTheDocument()
-        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
-      })
-
-      // Search for "spiral"
-      const searchInput = screen.getByPlaceholderText(/search/i)
-      await user.type(searchInput, 'spiral')
-
-      // Only spiral should be visible
-      await waitFor(() => {
-        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
-        expect(screen.queryByText('star.thr')).not.toBeInTheDocument()
-      })
-
-      // Click and run the filtered pattern
-      await user.click(screen.getByText('spiral.thr'))
-
-      // Wait for sheet and find main Play button
-      await waitFor(() => {
-        const buttons = screen.getAllByRole('button')
-        const playButton = buttons.find(btn =>
-          btn.textContent?.trim() === 'Play' ||
-          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-        )
-        expect(playButton).toBeTruthy()
-      })
-
-      // Click main Play button
-      const buttons = screen.getAllByRole('button')
-      const playButton = buttons.find(btn =>
-        btn.textContent?.trim() === 'Play' ||
-        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-      )
-      await user.click(playButton!)
-
-      // Verify correct pattern was run
-      await waitFor(() => {
-        const runCall = apiCallLog.find(c => c.endpoint === '/run_theta_rho')
-        expect(runCall?.body).toMatchObject({
-          file_name: expect.stringContaining('spiral')
-        })
-      })
-    })
-  })
-
-  describe('API Call Verification', () => {
-    it('logs API call with timestamp and method', async () => {
-      const user = userEvent.setup()
-      renderWithProviders(<BrowsePage />)
-
-      await waitFor(() => {
-        expect(screen.getByText('star.thr')).toBeInTheDocument()
-      })
-
-      // Run a pattern
-      await user.click(screen.getByText('star.thr'))
-
-      // Wait for sheet and find main Play button
-      await waitFor(() => {
-        const buttons = screen.getAllByRole('button')
-        const playButton = buttons.find(btn =>
-          btn.textContent?.trim() === 'Play' ||
-          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-        )
-        expect(playButton).toBeTruthy()
-      })
-
-      // Click main Play button
-      const allButtons = screen.getAllByRole('button')
-      const mainPlayButton = allButtons.find(btn =>
-        btn.textContent?.trim() === 'Play' ||
-        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-      )
-      await user.click(mainPlayButton!)
-
-      // Verify API call log structure
-      await waitFor(() => {
-        const runCall = apiCallLog.find(c => c.endpoint === '/run_theta_rho')
-        expect(runCall).toBeDefined()
-        expect(runCall?.method).toBe('POST')
-        expect(runCall?.timestamp).toBeDefined()
-        expect(typeof runCall?.timestamp).toBe('number')
-      })
-    })
-  })
-})
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent, seedConnectedStatus } from '../../test/utils'
+import { mockData, apiCallLog, resetApiCallLog } from '../../test/mocks/handlers'
+import { BrowsePage } from '../../pages/BrowsePage'
+
+describe('Pattern Flow Integration', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    resetApiCallLog()
+    // Pages gate Play on table connection, which only arrives via /ws/status
+    seedConnectedStatus()
+  })
+
+  describe('Browse -> Select -> Run Flow', () => {
+    it('displays pattern list from API', async () => {
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
+      })
+    })
+
+    it('opens pattern detail when clicking pattern card', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Click on pattern card
+      await user.click(screen.getByText('star.thr'))
+
+      // Detail sheet should open - pattern name appears twice (grid + sheet title)
+      await waitFor(() => {
+        const patternNames = screen.getAllByText('star.thr')
+        expect(patternNames.length).toBeGreaterThan(1)
+      })
+    })
+
+    it('runs pattern and verifies API call with correct file', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      // Wait for patterns to load
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Pre-condition: not running
+      expect(mockData.status.is_running).toBe(false)
+
+      // Click pattern to open detail sheet
+      await user.click(screen.getByText('star.thr'))
+
+      // Wait for sheet to open and find the main Play button (lg size, not the smaller ones)
+      await waitFor(() => {
+        // The main Play button has "Play" text and play_arrow icon
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      // Click the main Play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(playButton!)
+
+      // Verify API was called with correct file
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_theta_rho')
+        expect(runCall).toBeDefined()
+        expect(runCall?.body).toMatchObject({
+          file_name: expect.stringContaining('star')
+        })
+      })
+
+      // Verify mock state was updated
+      expect(mockData.status.is_running).toBe(true)
+      expect(mockData.status.current_file).toContain('star')
+    })
+
+    it('updates mock state after pattern starts running', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Pre-condition: not running
+      expect(mockData.status.is_running).toBe(false)
+
+      // Click pattern to open detail
+      await user.click(screen.getByText('star.thr'))
+
+      // Wait for sheet to open and find main Play button
+      await waitFor(() => {
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      // Click the main Play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(playButton!)
+
+      // Post-condition: running
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(true)
+      })
+    })
+  })
+
+  describe('Search -> Filter -> Run Flow', () => {
+    it('filters patterns by search then runs filtered result', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
+      })
+
+      // Search for "spiral"
+      const searchInput = screen.getByPlaceholderText(/search/i)
+      await user.type(searchInput, 'spiral')
+
+      // Only spiral should be visible
+      await waitFor(() => {
+        expect(screen.getByText('spiral.thr')).toBeInTheDocument()
+        expect(screen.queryByText('star.thr')).not.toBeInTheDocument()
+      })
+
+      // Click and run the filtered pattern
+      await user.click(screen.getByText('spiral.thr'))
+
+      // Wait for sheet and find main Play button
+      await waitFor(() => {
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      // Click main Play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(playButton!)
+
+      // Verify correct pattern was run
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_theta_rho')
+        expect(runCall?.body).toMatchObject({
+          file_name: expect.stringContaining('spiral')
+        })
+      })
+    })
+  })
+
+  describe('API Call Verification', () => {
+    it('logs API call with timestamp and method', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Run a pattern
+      await user.click(screen.getByText('star.thr'))
+
+      // Wait for sheet and find main Play button
+      await waitFor(() => {
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      // Click main Play button
+      const allButtons = screen.getAllByRole('button')
+      const mainPlayButton = allButtons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(mainPlayButton!)
+
+      // Verify API call log structure
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_theta_rho')
+        expect(runCall).toBeDefined()
+        expect(runCall?.method).toBe('POST')
+        expect(runCall?.timestamp).toBeDefined()
+        expect(typeof runCall?.timestamp).toBe('number')
+      })
+    })
+  })
+})

+ 276 - 274
frontend/src/__tests__/integration/playbackFlow.test.tsx

@@ -1,274 +1,276 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
-import { mockData, apiCallLog, resetApiCallLog } from '../../test/mocks/handlers'
-import { BrowsePage } from '../../pages/BrowsePage'
-import { PlaylistsPage } from '../../pages/PlaylistsPage'
-import { TableControlPage } from '../../pages/TableControlPage'
-
-describe('Playback Flow Integration', () => {
-  beforeEach(() => {
-    vi.clearAllMocks()
-    resetApiCallLog()
-    localStorage.clear()
-  })
-
-  describe('Pattern Playback Lifecycle', () => {
-    it('starts pattern from browse page', async () => {
-      const user = userEvent.setup()
-      renderWithProviders(<BrowsePage />)
-
-      // Initial state: not running
-      expect(mockData.status.is_running).toBe(false)
-
-      await waitFor(() => {
-        expect(screen.getByText('star.thr')).toBeInTheDocument()
-      })
-
-      // Click pattern to open detail
-      await user.click(screen.getByText('star.thr'))
-
-      // Find and click main Play button
-      await waitFor(() => {
-        const buttons = screen.getAllByRole('button')
-        const playButton = buttons.find(btn =>
-          btn.textContent?.trim() === 'Play' ||
-          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-        )
-        expect(playButton).toBeTruthy()
-      })
-
-      const buttons = screen.getAllByRole('button')
-      const playButton = buttons.find(btn =>
-        btn.textContent?.trim() === 'Play' ||
-        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-      )
-      await user.click(playButton!)
-
-      // Verify state transition
-      await waitFor(() => {
-        expect(mockData.status.is_running).toBe(true)
-        expect(mockData.status.is_paused).toBe(false)
-        expect(mockData.status.current_file).toContain('star')
-      })
-    })
-
-    it('stops playback from table control page', async () => {
-      const user = userEvent.setup()
-
-      // Pre-set running state
-      mockData.status.is_running = true
-      mockData.status.current_file = 'patterns/star.thr'
-
-      renderWithProviders(<TableControlPage />)
-
-      // Find and click stop button
-      await waitFor(() => {
-        expect(screen.getByText('Stop')).toBeInTheDocument()
-      })
-
-      const stopButton = screen.getByText('Stop').closest('button')
-      expect(stopButton).toBeTruthy()
-      await user.click(stopButton!)
-
-      // Verify API call
-      await waitFor(() => {
-        const stopCall = apiCallLog.find(c => c.endpoint === '/stop_execution')
-        expect(stopCall).toBeDefined()
-      })
-
-      // Verify state transition
-      expect(mockData.status.is_running).toBe(false)
-      expect(mockData.status.current_file).toBeNull()
-    })
-  })
-
-  describe('Playlist Playback Lifecycle', () => {
-    it('runs playlist and populates queue', async () => {
-      const user = userEvent.setup()
-      renderWithProviders(<PlaylistsPage />)
-
-      await waitFor(() => {
-        expect(screen.getByText('default')).toBeInTheDocument()
-      })
-
-      // Run default playlist
-      await user.click(screen.getByText('default'))
-
-      await waitFor(() => {
-        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
-      })
-
-      // Find play button
-      const buttons = screen.getAllByRole('button')
-      const playButton = buttons.find(btn =>
-        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
-      )
-      await user.click(playButton!)
-
-      // Verify playlist mode state
-      await waitFor(() => {
-        expect(mockData.status.is_running).toBe(true)
-        expect(mockData.status.playlist_mode).toBe(true)
-        expect(mockData.status.current_file).toBe('patterns/star.thr')
-        expect(mockData.status.queue).toContain('patterns/spiral.thr')
-      })
-    })
-
-    it('stops playlist playback and resets state', async () => {
-      const user = userEvent.setup()
-
-      // Pre-set playlist running state
-      mockData.status.is_running = true
-      mockData.status.playlist_mode = true
-      mockData.status.playlist_name = 'default'
-      mockData.status.current_file = 'patterns/star.thr'
-      mockData.status.queue = ['patterns/spiral.thr']
-
-      renderWithProviders(<TableControlPage />)
-
-      // Stop playback
-      await waitFor(() => {
-        expect(screen.getByText('Stop')).toBeInTheDocument()
-      })
-
-      const stopButton = screen.getByText('Stop').closest('button')
-      await user.click(stopButton!)
-
-      // Verify complete state reset
-      await waitFor(() => {
-        expect(mockData.status.is_running).toBe(false)
-        expect(mockData.status.playlist_mode).toBe(false)
-        expect(mockData.status.queue).toEqual([])
-        expect(mockData.status.current_file).toBeNull()
-      })
-    })
-  })
-
-  describe('State Transitions', () => {
-    it('transitions: idle -> running -> stopped', async () => {
-      const user = userEvent.setup()
-
-      // Step 1: Start from idle
-      expect(mockData.status.is_running).toBe(false)
-
-      renderWithProviders(<BrowsePage />)
-
-      await waitFor(() => {
-        expect(screen.getByText('star.thr')).toBeInTheDocument()
-      })
-
-      // Step 2: Start playback
-      await user.click(screen.getByText('star.thr'))
-
-      await waitFor(() => {
-        const buttons = screen.getAllByRole('button')
-        const playButton = buttons.find(btn =>
-          btn.textContent?.trim() === 'Play' ||
-          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-        )
-        expect(playButton).toBeTruthy()
-      })
-
-      const buttons = screen.getAllByRole('button')
-      const playButton = buttons.find(btn =>
-        btn.textContent?.trim() === 'Play' ||
-        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
-      )
-      await user.click(playButton!)
-
-      await waitFor(() => {
-        expect(mockData.status.is_running).toBe(true)
-      })
-
-      // Step 3: Verify API call sequence
-      const callSequence = apiCallLog.map(c => c.endpoint)
-      expect(callSequence).toContain('/run_theta_rho')
-    })
-
-    it('verifies complete API call sequence for playlist run', async () => {
-      const user = userEvent.setup()
-      renderWithProviders(<PlaylistsPage />)
-
-      await waitFor(() => {
-        expect(screen.getByText('default')).toBeInTheDocument()
-      })
-
-      // Run playlist
-      await user.click(screen.getByText('default'))
-
-      await waitFor(() => {
-        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
-      })
-
-      // Find play button
-      const buttons = screen.getAllByRole('button')
-      const playButton = buttons.find(btn =>
-        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
-      )
-      await user.click(playButton!)
-
-      // Verify run_playlist was called (not run_theta_rho)
-      await waitFor(() => {
-        const runCall = apiCallLog.find(c => c.endpoint === '/run_playlist')
-        expect(runCall).toBeDefined()
-        expect(runCall?.body).toMatchObject({ playlist_name: 'default' })
-      })
-    })
-  })
-
-  describe('Playback Control Actions', () => {
-    it('stop_execution API resets all playback state', async () => {
-      const user = userEvent.setup()
-
-      // Pre-set running state
-      mockData.status.is_running = true
-      mockData.status.is_paused = false
-      mockData.status.playlist_mode = true
-      mockData.status.playlist_name = 'test'
-      mockData.status.current_file = 'patterns/test.thr'
-      mockData.status.queue = ['patterns/next.thr']
-
-      renderWithProviders(<TableControlPage />)
-
-      await waitFor(() => {
-        expect(screen.getByText('Stop')).toBeInTheDocument()
-      })
-
-      const stopButton = screen.getByText('Stop').closest('button')
-      await user.click(stopButton!)
-
-      // Verify all state was reset
-      await waitFor(() => {
-        expect(mockData.status.is_running).toBe(false)
-        expect(mockData.status.is_paused).toBe(false)
-        expect(mockData.status.playlist_mode).toBe(false)
-        expect(mockData.status.playlist_name).toBeNull()
-        expect(mockData.status.current_file).toBeNull()
-        expect(mockData.status.queue).toEqual([])
-      })
-    })
-
-    it('verifies stop API call is logged', async () => {
-      const user = userEvent.setup()
-
-      mockData.status.is_running = true
-      mockData.status.current_file = 'patterns/test.thr'
-
-      renderWithProviders(<TableControlPage />)
-
-      await waitFor(() => {
-        expect(screen.getByText('Stop')).toBeInTheDocument()
-      })
-
-      const stopButton = screen.getByText('Stop').closest('button')
-      await user.click(stopButton!)
-
-      await waitFor(() => {
-        const stopCall = apiCallLog.find(c => c.endpoint === '/stop_execution')
-        expect(stopCall).toBeDefined()
-        expect(stopCall?.method).toBe('POST')
-        expect(stopCall?.timestamp).toBeDefined()
-      })
-    })
-  })
-})
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderWithProviders, screen, waitFor, userEvent, seedConnectedStatus } from '../../test/utils'
+import { mockData, apiCallLog, resetApiCallLog } from '../../test/mocks/handlers'
+import { BrowsePage } from '../../pages/BrowsePage'
+import { PlaylistsPage } from '../../pages/PlaylistsPage'
+import { TableControlPage } from '../../pages/TableControlPage'
+
+describe('Playback Flow Integration', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    resetApiCallLog()
+    // Pages gate Play on table connection, which only arrives via /ws/status
+    seedConnectedStatus()
+    localStorage.clear()
+  })
+
+  describe('Pattern Playback Lifecycle', () => {
+    it('starts pattern from browse page', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<BrowsePage />)
+
+      // Initial state: not running
+      expect(mockData.status.is_running).toBe(false)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Click pattern to open detail
+      await user.click(screen.getByText('star.thr'))
+
+      // Find and click main Play button
+      await waitFor(() => {
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(playButton!)
+
+      // Verify state transition
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(true)
+        expect(mockData.status.is_paused).toBe(false)
+        expect(mockData.status.current_file).toContain('star')
+      })
+    })
+
+    it('stops playback from table control page', async () => {
+      const user = userEvent.setup()
+
+      // Pre-set running state
+      mockData.status.is_running = true
+      mockData.status.current_file = 'patterns/star.thr'
+
+      renderWithProviders(<TableControlPage />)
+
+      // Find and click stop button
+      await waitFor(() => {
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+      })
+
+      const stopButton = screen.getByText('Stop').closest('button')
+      expect(stopButton).toBeTruthy()
+      await user.click(stopButton!)
+
+      // Verify API call
+      await waitFor(() => {
+        const stopCall = apiCallLog.find(c => c.endpoint === '/stop_execution')
+        expect(stopCall).toBeDefined()
+      })
+
+      // Verify state transition
+      expect(mockData.status.is_running).toBe(false)
+      expect(mockData.status.current_file).toBeNull()
+    })
+  })
+
+  describe('Playlist Playback Lifecycle', () => {
+    it('runs playlist and populates queue', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Run default playlist
+      await user.click(screen.getByText('default'))
+
+      await waitFor(() => {
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+
+      // Find play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
+      )
+      await user.click(playButton!)
+
+      // Verify playlist mode state
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(true)
+        expect(mockData.status.playlist_mode).toBe(true)
+        expect(mockData.status.current_file).toBe('patterns/star.thr')
+        expect(mockData.status.queue).toContain('patterns/spiral.thr')
+      })
+    })
+
+    it('stops playlist playback and resets state', async () => {
+      const user = userEvent.setup()
+
+      // Pre-set playlist running state
+      mockData.status.is_running = true
+      mockData.status.playlist_mode = true
+      mockData.status.playlist_name = 'default'
+      mockData.status.current_file = 'patterns/star.thr'
+      mockData.status.queue = ['patterns/spiral.thr']
+
+      renderWithProviders(<TableControlPage />)
+
+      // Stop playback
+      await waitFor(() => {
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+      })
+
+      const stopButton = screen.getByText('Stop').closest('button')
+      await user.click(stopButton!)
+
+      // Verify complete state reset
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(false)
+        expect(mockData.status.playlist_mode).toBe(false)
+        expect(mockData.status.queue).toEqual([])
+        expect(mockData.status.current_file).toBeNull()
+      })
+    })
+  })
+
+  describe('State Transitions', () => {
+    it('transitions: idle -> running -> stopped', async () => {
+      const user = userEvent.setup()
+
+      // Step 1: Start from idle
+      expect(mockData.status.is_running).toBe(false)
+
+      renderWithProviders(<BrowsePage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('star.thr')).toBeInTheDocument()
+      })
+
+      // Step 2: Start playback
+      await user.click(screen.getByText('star.thr'))
+
+      await waitFor(() => {
+        const buttons = screen.getAllByRole('button')
+        const playButton = buttons.find(btn =>
+          btn.textContent?.trim() === 'Play' ||
+          (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+        )
+        expect(playButton).toBeTruthy()
+      })
+
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.textContent?.trim() === 'Play' ||
+        (btn.textContent?.includes('Play') && !btn.textContent?.includes('Next') && !btn.textContent?.includes('Queue'))
+      )
+      await user.click(playButton!)
+
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(true)
+      })
+
+      // Step 3: Verify API call sequence
+      const callSequence = apiCallLog.map(c => c.endpoint)
+      expect(callSequence).toContain('/run_theta_rho')
+    })
+
+    it('verifies complete API call sequence for playlist run', async () => {
+      const user = userEvent.setup()
+      renderWithProviders(<PlaylistsPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('default')).toBeInTheDocument()
+      })
+
+      // Run playlist
+      await user.click(screen.getByText('default'))
+
+      await waitFor(() => {
+        expect(screen.getByText(/2 patterns/i)).toBeInTheDocument()
+      })
+
+      // Find play button
+      const buttons = screen.getAllByRole('button')
+      const playButton = buttons.find(btn =>
+        btn.querySelector('.material-icons')?.textContent === 'play_arrow'
+      )
+      await user.click(playButton!)
+
+      // Verify run_playlist was called (not run_theta_rho)
+      await waitFor(() => {
+        const runCall = apiCallLog.find(c => c.endpoint === '/run_playlist')
+        expect(runCall).toBeDefined()
+        expect(runCall?.body).toMatchObject({ playlist_name: 'default' })
+      })
+    })
+  })
+
+  describe('Playback Control Actions', () => {
+    it('stop_execution API resets all playback state', async () => {
+      const user = userEvent.setup()
+
+      // Pre-set running state
+      mockData.status.is_running = true
+      mockData.status.is_paused = false
+      mockData.status.playlist_mode = true
+      mockData.status.playlist_name = 'test'
+      mockData.status.current_file = 'patterns/test.thr'
+      mockData.status.queue = ['patterns/next.thr']
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+      })
+
+      const stopButton = screen.getByText('Stop').closest('button')
+      await user.click(stopButton!)
+
+      // Verify all state was reset
+      await waitFor(() => {
+        expect(mockData.status.is_running).toBe(false)
+        expect(mockData.status.is_paused).toBe(false)
+        expect(mockData.status.playlist_mode).toBe(false)
+        expect(mockData.status.playlist_name).toBeNull()
+        expect(mockData.status.current_file).toBeNull()
+        expect(mockData.status.queue).toEqual([])
+      })
+    })
+
+    it('verifies stop API call is logged', async () => {
+      const user = userEvent.setup()
+
+      mockData.status.is_running = true
+      mockData.status.current_file = 'patterns/test.thr'
+
+      renderWithProviders(<TableControlPage />)
+
+      await waitFor(() => {
+        expect(screen.getByText('Stop')).toBeInTheDocument()
+      })
+
+      const stopButton = screen.getByText('Stop').closest('button')
+      await user.click(stopButton!)
+
+      await waitFor(() => {
+        const stopCall = apiCallLog.find(c => c.endpoint === '/stop_execution')
+        expect(stopCall).toBeDefined()
+        expect(stopCall?.method).toBe('POST')
+        expect(stopCall?.timestamp).toBeDefined()
+      })
+    })
+  })
+})

+ 6 - 7
frontend/src/__tests__/integration/playlistFlow.test.tsx

@@ -200,13 +200,14 @@ describe('Playlist Flow Integration', () => {
 
       expect(deleteButton).toBeTruthy()
 
-      // Mock window.confirm
-      const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
-
       await user.click(deleteButton!)
 
-      // Verify confirm was called
-      expect(confirmSpy).toHaveBeenCalled()
+      // Styled confirmation dialog appears instead of window.confirm
+      const dialog = await screen.findByRole('alertdialog')
+      expect(dialog).toHaveTextContent('Delete "to-delete"?')
+
+      // Confirm deletion
+      await user.click(screen.getByRole('button', { name: 'Delete' }))
 
       // Verify API call
       await waitFor(() => {
@@ -216,8 +217,6 @@ describe('Playlist Flow Integration', () => {
 
       // Verify mockData was updated
       expect(mockData.playlists['to-delete']).toBeUndefined()
-
-      confirmSpy.mockRestore()
     })
   })
 

+ 1 - 1
frontend/src/__tests__/pages/BrowsePage.test.tsx

@@ -38,7 +38,7 @@ describe('BrowsePage', () => {
       renderWithProviders(<BrowsePage />)
 
       await waitFor(() => {
-        expect(screen.getByText(/no patterns found/i)).toBeInTheDocument()
+        expect(screen.getByText(/no patterns yet/i)).toBeInTheDocument()
       })
     })
 

+ 1 - 1
frontend/src/__tests__/pages/PlaylistsPage.test.tsx

@@ -208,7 +208,7 @@ describe('PlaylistsPage', () => {
 
       // Wait for content to load and find run/play button
       await waitFor(async () => {
-        const runButton = screen.getByRole('button', { name: /play|run|start/i })
+        const runButton = screen.getByRole('button', { name: /^run playlist$/i })
         expect(runButton).toBeInTheDocument()
         await user.click(runButton)
       })

+ 6 - 3
frontend/src/__tests__/pages/TableControlPage.test.tsx

@@ -1,5 +1,5 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { renderWithProviders, screen, waitFor, userEvent } from '../../test/utils'
+import { renderWithProviders, screen, waitFor, userEvent, seedConnectedStatus } from '../../test/utils'
 import { server } from '../../test/mocks/server'
 import { http, HttpResponse } from 'msw'
 import { TableControlPage } from '../../pages/TableControlPage'
@@ -10,6 +10,9 @@ describe('TableControlPage', () => {
     vi.clearAllMocks()
     // Reset Zustand store to prevent real WebSocket data from leaking into tests
     useStatusStore.setState({ status: null, isBackendConnected: false, connectionAttempts: 0 })
+    // Hardware-action buttons (Home, Reset, Center, etc.) are disabled when the
+    // table is disconnected, so seed a connected idle table for these tests
+    seedConnectedStatus()
   })
 
   describe('Rendering', () => {
@@ -264,8 +267,8 @@ describe('TableControlPage', () => {
       renderWithProviders(<TableControlPage />)
 
       await waitFor(() => {
-        // The speed badge shows "-- mm/s" when no speed is set
-        expect(screen.getByText(/mm\/s/)).toBeInTheDocument()
+        // The seeded status reports speed 100, which the badge displays
+        expect(screen.getByText('100 mm/s')).toBeInTheDocument()
       })
     })
   })

+ 61 - 0
frontend/src/components/ConfirmDialog.tsx

@@ -0,0 +1,61 @@
+import * as React from 'react'
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { buttonVariants } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+
+interface ConfirmDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  title: string
+  description?: React.ReactNode
+  confirmLabel?: string
+  cancelLabel?: string
+  /** Renders the confirm button in the destructive variant */
+  destructive?: boolean
+  onConfirm: () => void
+}
+
+/**
+ * App-standard confirmation dialog. Replaces native window.confirm so
+ * confirmations are themed, carry contextual copy, work in kiosk webviews
+ * (which may suppress native dialogs), and trap focus properly.
+ */
+export function ConfirmDialog({
+  open,
+  onOpenChange,
+  title,
+  description,
+  confirmLabel = 'Confirm',
+  cancelLabel = 'Cancel',
+  destructive = false,
+  onConfirm,
+}: ConfirmDialogProps) {
+  return (
+    <AlertDialog open={open} onOpenChange={onOpenChange}>
+      <AlertDialogContent>
+        <AlertDialogHeader>
+          <AlertDialogTitle>{title}</AlertDialogTitle>
+          {description && <AlertDialogDescription>{description}</AlertDialogDescription>}
+        </AlertDialogHeader>
+        <AlertDialogFooter>
+          <AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
+          <AlertDialogAction
+            className={cn(destructive && buttonVariants({ variant: 'destructive' }))}
+            onClick={onConfirm}
+          >
+            {confirmLabel}
+          </AlertDialogAction>
+        </AlertDialogFooter>
+      </AlertDialogContent>
+    </AlertDialog>
+  )
+}

+ 40 - 0
frontend/src/components/ConnectionBanner.tsx

@@ -0,0 +1,40 @@
+import { Link } from 'react-router-dom'
+import { useStatusStore } from '@/stores/useStatusStore'
+
+/**
+ * Whether the sand table hardware (serial/WebSocket controller) is connected.
+ * Distinct from isBackendConnected, which tracks the web backend itself.
+ */
+export function useTableConnected(): boolean {
+  return useStatusStore((s) => Boolean(s.status?.connection_status))
+}
+
+/**
+ * Inline banner shown when the table hardware is disconnected, with a path
+ * to recovery. Render near the top of pages whose primary actions need the
+ * table (Browse, Playlists, Table Control, LED).
+ */
+export function ConnectionBanner() {
+  const connected = useTableConnected()
+  if (connected) return null
+
+  return (
+    <div
+      role="status"
+      className="flex items-center gap-2.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-400"
+    >
+      <span className="material-icons-outlined text-base shrink-0" aria-hidden="true">
+        usb_off
+      </span>
+      <span className="min-w-0">
+        Table not connected — patterns can't play.{' '}
+        <Link
+          to="/settings?section=connection"
+          className="font-medium underline underline-offset-2 hover:text-amber-800 dark:hover:text-amber-300"
+        >
+          Connect
+        </Link>
+      </span>
+    </div>
+  )
+}

+ 105 - 56
frontend/src/components/NowPlayingBar.tsx

@@ -123,7 +123,7 @@ function SortableQueueItem({
         {...listeners}
         className="w-6 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing touch-none"
       >
-        <span className="material-icons-outlined text-muted-foreground text-sm">drag_indicator</span>
+        <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-sm">drag_indicator</span>
       </div>
 
       {/* Preview thumbnail */}
@@ -137,7 +137,7 @@ function SortableQueueItem({
           />
         ) : (
           <div className="w-full h-full flex items-center justify-center">
-            <span className="material-icons-outlined text-muted-foreground text-4xl">image</span>
+            <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-4xl">image</span>
           </div>
         )}
       </div>
@@ -148,23 +148,25 @@ function SortableQueueItem({
         <p className="text-xs text-muted-foreground">#{index + 1}</p>
       </div>
 
-      {/* Move to top/bottom buttons - always visible on mobile, hover on desktop */}
-      <div className="flex flex-col gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity shrink-0">
+      {/* Move to top/bottom buttons - always visible on touch devices, hover-revealed on fine pointers */}
+      <div className="flex flex-col gap-1 opacity-100 pointer-fine:opacity-0 pointer-fine:group-hover:opacity-100 transition-opacity shrink-0">
         <button
           onClick={onMoveToTop}
           disabled={isFirst}
-          className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
+          className="p-2 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
           title="Move to top"
+          aria-label="Move to top"
         >
-          <span className="material-icons-outlined text-sm">vertical_align_top</span>
+          <span aria-hidden="true" className="material-icons-outlined text-sm">vertical_align_top</span>
         </button>
         <button
           onClick={onMoveToBottom}
           disabled={isLast}
-          className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
+          className="p-2 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
           title="Move to bottom"
+          aria-label="Move to bottom"
         >
-          <span className="material-icons-outlined text-sm">vertical_align_bottom</span>
+          <span aria-hidden="true" className="material-icons-outlined text-sm">vertical_align_bottom</span>
         </button>
       </div>
     </div>
@@ -183,6 +185,34 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
   const status: StatusData | null = useStatusStore((s) => s.status)
   const [previewUrl, setPreviewUrl] = useState<string | null>(null)
 
+  // Screen-reader announcement for playback state transitions only
+  // (routine progress updates must not trigger announcements)
+  const [announcement, setAnnouncement] = useState('')
+  const prevPlaybackRef = useRef<{ running: boolean; paused: boolean; file: string | null }>({
+    running: false,
+    paused: false,
+    file: null,
+  })
+  useEffect(() => {
+    const running = status?.is_running ?? false
+    const paused = status?.is_paused ?? false
+    const file = status?.current_file ?? null
+    const prev = prevPlaybackRef.current
+    prevPlaybackRef.current = { running, paused, file }
+
+    if (running && !paused && (!prev.running || prev.paused || file !== prev.file)) {
+      if (!prev.running || file !== prev.file) {
+        setAnnouncement(`Now playing ${formatPatternName(file)}`)
+      } else {
+        setAnnouncement('Playing')
+      }
+    } else if (running && paused && !prev.paused) {
+      setAnnouncement('Paused')
+    } else if (!running && prev.running) {
+      setAnnouncement('Playback stopped')
+    }
+  }, [status?.is_running, status?.is_paused, status?.current_file])
+
   // Expanded state for slide-up view
   const [isExpanded, setIsExpanded] = useState(false)
 
@@ -212,16 +242,17 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
     touchStartY.current = null
   }
 
-  // Prevent background scroll when Now Playing bar is visible
+  // Prevent background scroll when Now Playing bar is expanded (mobile only —
+  // the collapsed bar only occupies the bottom of the screen, so the page
+  // stays scrollable to allow browsing patterns while one plays)
   useEffect(() => {
-    if (isVisible) {
-      // Lock body scroll when bar is visible on mobile
+    if (isVisible && isExpanded && window.matchMedia('(max-width: 767px)').matches) {
       document.body.style.overflow = 'hidden'
       return () => {
         document.body.style.overflow = ''
       }
     }
-  }, [isVisible])
+  }, [isVisible, isExpanded])
 
   // Use native event listener for touchmove to prevent background scroll on the bar itself
   useEffect(() => {
@@ -483,30 +514,34 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
     }
   }, [getThemeColors, initOffscreenCanvas, polarToCartesian])
 
-  // Fetch coordinates when file changes or fullscreen opens
+  // Fetch coordinates when the playing file changes
   useEffect(() => {
     const currentFile = status?.current_file
     if (!currentFile) return
-
-    // Only fetch if file changed or we don't have coordinates yet
-    const needsFetch = currentFile !== lastFileRef.current || coordinates.length === 0
-
-    if (!needsFetch) return
+    if (currentFile === lastFileRef.current) return
 
     lastFileRef.current = currentFile
     lastDrawnIndexRef.current = -1
+    let cancelled = false
 
     apiClient.post<{ coordinates?: Coordinate[] }>('/get_theta_rho_coordinates', { file_name: currentFile })
       .then((data) => {
-        if (data.coordinates && Array.isArray(data.coordinates)) {
-          setCoordinates(data.coordinates)
-        }
+        if (cancelled) return
+        setCoordinates(Array.isArray(data.coordinates) ? data.coordinates : [])
       })
       .catch((err) => {
+        if (cancelled) return
         console.error('Failed to fetch coordinates:', err)
+        // Clear the marker so the next file change retries, but don't
+        // refetch the same failing file on every effect re-run
+        lastFileRef.current = null
         setCoordinates([])
       })
-  }, [status?.current_file, coordinates.length])
+
+    // Ignore responses that land after the file has already changed —
+    // otherwise a slow fetch for the previous pattern overwrites the new one
+    return () => { cancelled = true }
+  }, [status?.current_file])
 
   // Get target index from progress percentage
   const getTargetIndex = useCallback((coords: Coordinate[]): number => {
@@ -880,6 +915,9 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
         onTouchStart={handleTouchStart}
         onTouchEnd={handleTouchEnd}
       >
+        {/* Screen-reader playback announcements */}
+        <div aria-live="polite" role="status" className="sr-only">{announcement}</div>
+
         {/* Max-width container to match page layout */}
         <div className="h-full max-w-5xl mx-auto relative">
           {/* Swipe indicator - only on mobile */}
@@ -894,22 +932,24 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
             <Button
               variant="ghost"
               size="icon"
-              className="md:hidden h-8 w-8"
+              className="md:hidden h-10 w-10"
               onClick={() => setShowQueue(true)}
               title="View queue"
+              aria-label="View queue"
             >
-              <span className="material-icons-outlined text-lg">queue_music</span>
+              <span aria-hidden="true" className="material-icons-outlined text-lg">queue_music</span>
             </Button>
           )}
           {isPlaying && (
             <Button
               variant="ghost"
               size="icon"
-              className="h-8 w-8"
+              className="h-10 w-10"
               onClick={() => setIsExpanded(!isExpanded)}
               title={isExpanded ? 'Collapse' : 'Expand'}
+              aria-label={isExpanded ? 'Collapse' : 'Expand'}
             >
-              <span className="material-icons-outlined text-lg">
+              <span aria-hidden="true" className="material-icons-outlined text-lg">
                 {isExpanded ? 'expand_more' : 'expand_less'}
               </span>
             </Button>
@@ -917,11 +957,12 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
           <Button
             variant="ghost"
             size="icon"
-            className="h-8 w-8"
+            className="h-10 w-10"
             onClick={onClose}
             title="Close"
+            aria-label="Close"
           >
-            <span className="material-icons-outlined text-lg">close</span>
+            <span aria-hidden="true" className="material-icons-outlined text-lg">close</span>
           </Button>
         </div>
 
@@ -946,7 +987,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                     />
                   ) : (
                     <div className="w-full h-full flex items-center justify-center">
-                      <span className="material-icons-outlined text-muted-foreground text-4xl">
+                      <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-4xl">
                         {isPlaying ? 'image' : 'hourglass_empty'}
                       </span>
                     </div>
@@ -989,19 +1030,19 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                       {/* Progress Bar - Desktop only (inline, above controls) */}
                       {isWaiting ? (
                         <div className="hidden md:flex items-center gap-3">
-                          <span className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
-                          <Progress value={waitProgress} className="h-2 flex-1" />
+                          <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
+                          <Progress value={waitProgress} aria-label="Pattern progress" className="h-2 flex-1" />
                           <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
                         </div>
                       ) : (
                         <div className="hidden md:flex items-center gap-3">
                           <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
-                          <Progress value={progressPercent} className="h-2 flex-1" />
+                          <Progress value={progressPercent} aria-label="Pattern progress" className="h-2 flex-1" />
                           <span
                             className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 shrink-0 ${usingHistoricalEta ? 'w-24' : 'w-14'}`}
                             title={usingHistoricalEta ? 'ETA based on last completed run' : 'Estimated time remaining'}
                           >
-                            {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
+                            {usingHistoricalEta && <span aria-hidden="true" className="material-icons-outlined text-sm">history</span>}
                             -{formatTime(remainingTime)}
                           </span>
                         </div>
@@ -1015,16 +1056,18 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                           className="h-10 w-10 rounded-full"
                           onClick={handleStop}
                           title="Stop"
+                          aria-label="Stop pattern"
                         >
-                          <span className="material-icons">stop</span>
+                          <span aria-hidden="true" className="material-icons">stop</span>
                         </Button>
                         <Button
                           variant="default"
                           size="icon"
                           className="h-12 w-12 rounded-full"
                           onClick={handlePause}
+                          aria-label={status.is_paused ? 'Resume pattern' : 'Pause pattern'}
                         >
-                          <span className="material-icons text-xl">
+                          <span aria-hidden="true" className="material-icons text-xl">
                             {status.is_paused ? 'play_arrow' : 'pause'}
                           </span>
                         </Button>
@@ -1035,8 +1078,9 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                             className="h-10 w-10 rounded-full"
                             onClick={handleSkip}
                             title="Skip to next"
+                            aria-label="Skip to next pattern"
                           >
-                            <span className="material-icons">skip_next</span>
+                            <span aria-hidden="true" className="material-icons">skip_next</span>
                           </Button>
                         )}
                       </div>
@@ -1050,7 +1094,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                           value={speedInput}
                           onChange={(e) => setSpeedInput(e.target.value)}
                           onKeyDown={(e) => e.key === 'Enter' && handleSpeedSubmit()}
-                          className="h-7 w-20 text-sm px-2"
+                          className="h-9 w-20 text-sm px-2"
                         />
                         <span className="text-sm text-muted-foreground">mm/s</span>
                       </div>
@@ -1065,18 +1109,18 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                       >
                         <p className="text-xs text-muted-foreground font-medium flex items-center gap-1">
                           Up Next
-                          <span className="material-icons-outlined text-xs">queue_music</span>
+                          <span aria-hidden="true" className="material-icons-outlined text-xs">queue_music</span>
                         </p>
                         <div className="w-24 h-24 rounded-full overflow-hidden bg-muted border-2">
                           {nextPreviewUrl ? (
                             <img
                               src={nextPreviewUrl}
-                              alt="Next pattern"
+                              alt={`Next pattern: ${formatPatternName(status.playlist.next_file)}`}
                               className="w-full h-full object-cover pattern-preview"
                             />
                           ) : (
                             <div className="w-full h-full flex items-center justify-center">
-                              <span className="material-icons-outlined text-muted-foreground text-2xl">image</span>
+                              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-2xl">image</span>
                             </div>
                           )}
                         </div>
@@ -1097,16 +1141,16 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
               {isPlaying && status && (
                 isWaiting ? (
                   <div className="flex md:hidden items-center gap-3 px-6 pb-16">
-                    <span className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
-                    <Progress value={waitProgress} className="h-2 flex-1" />
+                    <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
+                    <Progress value={waitProgress} aria-label="Pattern progress" className="h-2 flex-1" />
                     <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
                   </div>
                 ) : (
                   <div className="flex md:hidden items-center gap-3 px-6 pb-16">
                     <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
-                    <Progress value={progressPercent} className="h-2 flex-1" />
+                    <Progress value={progressPercent} aria-label="Pattern progress" className="h-2 flex-1" />
                     <span className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 shrink-0 ${usingHistoricalEta ? 'w-24' : 'w-14'}`}>
-                      {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
+                      {usingHistoricalEta && <span aria-hidden="true" className="material-icons-outlined text-sm">history</span>}
                       -{formatTime(remainingTime)}
                     </span>
                   </div>
@@ -1129,6 +1173,8 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                     ref={canvasRef}
                     width={600}
                     height={600}
+                    role="img"
+                    aria-label={`Live preview of ${patternName}`}
                     className="rounded-full border-2 hover:border-primary transition-colors w-[40vh] h-[40vh] max-w-[300px] max-h-[300px] md:w-[42vh] md:h-[42vh] md:max-w-[500px] md:max-h-[500px]"
                   />
                 </div>
@@ -1147,7 +1193,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                       />
                     ) : (
                       <div className="w-full h-full flex items-center justify-center">
-                        <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-sm">image</span>
                       </div>
                     )}
                   </div>
@@ -1179,20 +1225,20 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                 {/* Progress */}
                 {isWaiting ? (
                   <div className="space-y-1 md:space-y-2">
-                    <Progress value={waitProgress} className="h-1.5 md:h-2" />
+                    <Progress value={waitProgress} aria-label="Pattern progress" className="h-1.5 md:h-2" />
                     <div className="flex justify-center items-center gap-2 text-xs md:text-sm text-muted-foreground font-mono">
-                      <span className="material-icons-outlined text-base">hourglass_top</span>
+                      <span aria-hidden="true" className="material-icons-outlined text-base">hourglass_top</span>
                       <span>{formatTime(waitTimeRemaining)} remaining</span>
                     </div>
                   </div>
                 ) : (
                   <div className="space-y-1 md:space-y-2">
-                    <Progress value={progressPercent} className="h-1.5 md:h-2" />
+                    <Progress value={progressPercent} aria-label="Pattern progress" className="h-1.5 md:h-2" />
                     <div className="flex justify-between text-xs md:text-sm text-muted-foreground font-mono">
                       <span className="w-16">{formatTime(elapsedTime)}</span>
                       <span>{progressPercent.toFixed(0)}%</span>
                       <span className="w-16 flex items-center justify-end gap-1">
-                        {usingHistoricalEta && <span className="material-icons-outlined text-xs">history</span>}
+                        {usingHistoricalEta && <span aria-hidden="true" className="material-icons-outlined text-xs">history</span>}
                         -{formatTime(remainingTime)}
                       </span>
                     </div>
@@ -1207,16 +1253,18 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                     className="h-10 w-10 md:h-12 md:w-12 rounded-full"
                     onClick={handleStop}
                     title="Stop"
+                    aria-label="Stop pattern"
                   >
-                    <span className="material-icons text-lg md:text-2xl">stop</span>
+                    <span aria-hidden="true" className="material-icons text-lg md:text-2xl">stop</span>
                   </Button>
                   <Button
                     variant="default"
                     size="icon"
                     className="h-12 w-12 md:h-14 md:w-14 rounded-full"
                     onClick={handlePause}
+                    aria-label={status?.is_paused ? 'Resume pattern' : 'Pause pattern'}
                   >
-                    <span className="material-icons text-xl md:text-2xl">
+                    <span aria-hidden="true" className="material-icons text-xl md:text-2xl">
                       {status?.is_paused ? 'play_arrow' : 'pause'}
                     </span>
                   </Button>
@@ -1227,8 +1275,9 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                       className="h-10 w-10 md:h-12 md:w-12 rounded-full"
                       onClick={handleSkip}
                       title="Skip to next"
+                      aria-label="Skip to next pattern"
                     >
-                      <span className="material-icons text-lg md:text-2xl">skip_next</span>
+                      <span aria-hidden="true" className="material-icons text-lg md:text-2xl">skip_next</span>
                     </Button>
                   )}
                 </div>
@@ -1258,12 +1307,12 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                       {nextPreviewUrl ? (
                         <img
                           src={nextPreviewUrl}
-                          alt="Next pattern"
+                          alt={`Next pattern: ${formatPatternName(status.playlist.next_file)}`}
                           className="w-full h-full object-cover pattern-preview"
                         />
                       ) : (
                         <div className="w-full h-full flex items-center justify-center">
-                          <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
+                          <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-sm">image</span>
                         </div>
                       )}
                     </div>
@@ -1273,7 +1322,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                         {formatPatternName(status.playlist.next_file)}
                       </p>
                     </div>
-                    <span className="material-icons-outlined text-muted-foreground text-lg">queue_music</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-lg">queue_music</span>
                   </div>
                 )}
               </div>
@@ -1298,7 +1347,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
           </div>
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
-              <span className="material-icons-outlined">queue_music</span>
+              <span aria-hidden="true" className="material-icons-outlined">queue_music</span>
               Queue
               {status?.playlist?.name && (
                 <span className="text-sm font-normal text-muted-foreground">

+ 7 - 3
frontend/src/components/ShinyText.tsx

@@ -1,5 +1,5 @@
 import React, { useState, useCallback, useEffect, useRef } from 'react';
-import { motion, useMotionValue, useAnimationFrame, useTransform } from 'motion/react';
+import { motion, useMotionValue, useAnimationFrame, useTransform, useReducedMotion } from 'motion/react';
 
 interface ShinyTextProps {
   text: string;
@@ -29,6 +29,8 @@ const ShinyText: React.FC<ShinyTextProps> = ({
   delay = 0
 }) => {
   const [isPaused, setIsPaused] = useState(false);
+  const prefersReducedMotion = useReducedMotion();
+  const isStatic = disabled || prefersReducedMotion;
   const progress = useMotionValue(0);
   const elapsedRef = useRef(0);
   const lastTimeRef = useRef<number | null>(null);
@@ -37,8 +39,10 @@ const ShinyText: React.FC<ShinyTextProps> = ({
   const animationDuration = speed * 1000;
   const delayDuration = delay * 1000;
 
+  // useAnimationFrame can't be conditionally registered, so the callback
+  // exits cheaply when the animation shouldn't run (disabled/reduced motion).
   useAnimationFrame(time => {
-    if (disabled || isPaused) {
+    if (isStatic || isPaused) {
       lastTimeRef.current = null;
       return;
     }
@@ -119,7 +123,7 @@ const ShinyText: React.FC<ShinyTextProps> = ({
   return (
     <motion.span
       className={`inline-block ${className}`}
-      style={{ ...gradientStyle, backgroundPosition }}
+      style={{ ...gradientStyle, backgroundPosition: isStatic ? '150% center' : backgroundPosition }}
       onMouseEnter={handleMouseEnter}
       onMouseLeave={handleMouseLeave}
     >

+ 17 - 16
frontend/src/components/TableSelector.tsx

@@ -147,11 +147,15 @@ export function TableSelector({ children }: TableSelectorProps) {
               {tables.map(table => (
                 <div
                   key={table.id}
-                  className={`flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer hover:bg-accent group ${
+                  className={`flex items-center gap-2 px-2 py-2 rounded-md hover:bg-accent group ${
                     activeTable?.id === table.id ? 'bg-accent' : ''
                   }`}
-                  onClick={() => handleSelectTable(table)}
                 >
+                  <button
+                    type="button"
+                    className="flex-1 flex items-center gap-2 min-w-0 text-left cursor-pointer"
+                    onClick={() => handleSelectTable(table)}
+                  >
                   {/* Table icon with status indicator */}
                   <div className="relative flex-shrink-0">
                     <img
@@ -189,33 +193,30 @@ export function TableSelector({ children }: TableSelectorProps) {
                       {table.host || new URL(table.url).hostname}
                     </span>
                   </div>
+                  </button>
 
-                  {/* 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">
+                  {/* Actions - always visible on touch, hover-revealed with a fine pointer */}
+                  <div className="flex pointer-fine:opacity-0 pointer-fine:group-hover:opacity-100 pointer-fine:group-focus-within:opacity-100 items-center gap-1 transition-opacity">
                     <Button
                       variant="ghost"
                       size="sm"
-                      className="h-7 w-7 p-0"
-                      onClick={e => {
-                        e.stopPropagation()
-                        openRenameDialog(table)
-                      }}
+                      className="h-9 w-9 p-0"
+                      onClick={() => openRenameDialog(table)}
                       title="Rename"
+                      aria-label={`Rename table ${table.name}`}
                     >
-                      <Pencil className="h-3.5 w-3.5" />
+                      <Pencil className="h-3.5 w-3.5" aria-hidden="true" />
                     </Button>
                     {!table.isCurrent && (
                       <Button
                         variant="ghost"
                         size="sm"
-                        className="h-7 w-7 p-0 text-destructive hover:text-destructive"
-                        onClick={e => {
-                          e.stopPropagation()
-                          handleRemove(table)
-                        }}
+                        className="h-9 w-9 p-0 text-destructive hover:text-destructive"
+                        onClick={() => handleRemove(table)}
                         title="Remove"
+                        aria-label={`Remove table ${table.name}`}
                       >
-                        <Trash2 className="h-3.5 w-3.5" />
+                        <Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
                       </Button>
                     )}
                   </div>

+ 31 - 0
frontend/src/components/UpdatePrompt.tsx

@@ -0,0 +1,31 @@
+import { useRegisterSW } from 'virtual:pwa-register/react'
+import { Button } from '@/components/ui/button'
+
+/**
+ * Shows a toast-like card when a new service worker is waiting,
+ * letting the user choose when to apply the update instead of
+ * hot-swapping the SW mid-session (registerType: 'prompt').
+ */
+export function UpdatePrompt() {
+  const {
+    needRefresh: [needRefresh, setNeedRefresh],
+    updateServiceWorker,
+  } = useRegisterSW()
+
+  if (!needRefresh) return null
+
+  return (
+    <div
+      role="alert"
+      className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg border bg-card text-card-foreground shadow-lg px-4 py-3"
+    >
+      <span className="text-sm font-medium">Update available</span>
+      <Button size="sm" onClick={() => updateServiceWorker(true)}>
+        Reload
+      </Button>
+      <Button size="sm" variant="ghost" onClick={() => setNeedRefresh(false)}>
+        Dismiss
+      </Button>
+    </div>
+  )
+}

+ 232 - 80
frontend/src/components/layout/Layout.tsx

@@ -2,6 +2,7 @@ import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
 import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
 import { toast } from 'sonner'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -99,6 +100,7 @@ export function Layout() {
   const statusIsRunning = useStatusStore((s) => s.status?.is_running ?? false)
   const statusIsPaused = useStatusStore((s) => s.status?.is_paused ?? false)
   const statusPauseTimeRemaining = useStatusStore((s) => s.status?.pause_time_remaining ?? 0)
+  const statusHasPlaylist = useStatusStore((s) => Boolean(s.status?.playlist))
 
   // Homing overlay state (local UI state)
   const [homingDismissed, setHomingDismissed] = useState(false)
@@ -110,6 +112,24 @@ export function Layout() {
   // Sensor homing recovery (local UI state)
   const [isRecoveringHoming, setIsRecoveringHoming] = useState(false)
 
+  // Backend-disconnected overlay dismissal (lets multi-table users reach the header underneath)
+  const [backendOverlayDismissed, setBackendOverlayDismissed] = useState(false)
+
+  // Restart/shutdown confirmation and restart-in-progress overlay
+  const [showRestartConfirm, setShowRestartConfirm] = useState(false)
+  const [showShutdownConfirm, setShowShutdownConfirm] = useState(false)
+  const [isRestarting, setIsRestarting] = useState(false)
+
+  // Reset disconnect-overlay dismissal and clear the restarting overlay when the backend reconnects
+  const wasBackendConnectedRef = useRef(isBackendConnected)
+  useEffect(() => {
+    if (!wasBackendConnectedRef.current && isBackendConnected) {
+      setBackendOverlayDismissed(false)
+      setIsRestarting(false)
+    }
+    wasBackendConnectedRef.current = isBackendConnected
+  }, [isBackendConnected])
+
   // Update availability
   const [updateAvailable, setUpdateAvailable] = useState(false)
 
@@ -287,6 +307,10 @@ export function Layout() {
   })
   const [isDraggingButton, setIsDraggingButton] = useState(false)
   const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
+  // Live mirror of dragOffset for the drag-end handler — reading the state
+  // there would capture a stale closure (always the offset at drag start),
+  // making the button snap back instead of to where it was dropped
+  const dragOffsetRef = useRef(0)
   const buttonRef = useRef<HTMLButtonElement>(null)
   const dragStartRef = useRef<{ x: number; y: number; buttonX: number } | null>(null)
   const wasDraggingRef = useRef(false) // Track if a meaningful drag occurred
@@ -296,7 +320,15 @@ export function Layout() {
 
   // Auto-close NowPlayingBar when playback stops (watches store values)
   const wasNpPlayingRef = useRef<boolean | null>(null)
-  const npIsPlaying = Boolean(currentPlayingFile) || statusIsRunning || statusIsPaused || statusPauseTimeRemaining > 0
+  // is_paused alone doesn't mean playback: the backend also reports
+  // is_paused=true during a Still Sands quiet period with nothing queued,
+  // which made the floating pill show "Not Playing" on an idle table.
+  // Count paused state only when there's an actual file or playlist.
+  const npIsPlaying =
+    Boolean(currentPlayingFile) ||
+    statusIsRunning ||
+    (statusIsPaused && (Boolean(currentPlayingFile) || statusHasPlaylist)) ||
+    statusPauseTimeRemaining > 0
   useEffect(() => {
     // Skip first render
     if (wasNpPlayingRef.current !== null) {
@@ -326,7 +358,7 @@ export function Layout() {
   const [isLoadingMoreLogs, setIsLoadingMoreLogs] = useState(false)
   const logsWsRef = useRef<WebSocket | null>(null)
   const logsContainerRef = useRef<HTMLDivElement>(null)
-  const logsLoadedCountRef = useRef(0) // Track how many logs we've loaded (for offset)
+  const logsLoadedCountRef = useRef(0) // Raw API records fetched (backend offset unit), NOT filtered/displayed count
 
   // Connect to logs WebSocket when drawer opens
   useEffect(() => {
@@ -347,15 +379,17 @@ export function Layout() {
         type LogEntry = { timestamp: string; level: string; logger: string; message: string }
         type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
         const data = await apiClient.get<LogsResponse>('/api/logs?limit=200')
+        const rawLogs = data.logs || []
         // Filter out empty/invalid log entries
-        const validLogs = (data.logs || []).filter(
+        const validLogs = rawLogs.filter(
           (log) => log && log.message && log.message.trim() !== ''
         )
         // API returns newest first, reverse to show oldest first (newest at bottom)
         setLogs(validLogs.reverse())
         setLogsTotal(data.total || 0)
         setLogsHasMore(data.has_more || false)
-        logsLoadedCountRef.current = validLogs.length
+        // Backend offset is in RAW records, so track the raw count (before filtering)
+        logsLoadedCountRef.current = rawLogs.length
         // Scroll to bottom after initial load
         setTimeout(() => {
           if (logsContainerRef.current) {
@@ -475,14 +509,18 @@ export function Layout() {
       const offset = logsLoadedCountRef.current
       const data = await apiClient.get<LogsResponse>(`/api/logs?limit=100&offset=${offset}`)
 
-      const validLogs = (data.logs || []).filter(
+      const rawLogs = data.logs || []
+      const validLogs = rawLogs.filter(
         (log) => log && log.message && log.message.trim() !== ''
       )
 
+      // Backend offset is in RAW records - advance by raw count (before filtering)
+      // so subsequent requests don't overlap and return duplicates
+      logsLoadedCountRef.current += rawLogs.length
+
       if (validLogs.length > 0) {
         // Prepend older logs (they come newest-first, so reverse them)
         setLogs((prev) => [...validLogs.reverse(), ...prev])
-        logsLoadedCountRef.current += validLogs.length
         setLogsHasMore(data.has_more || false)
         setLogsTotal(data.total || 0)
 
@@ -495,7 +533,9 @@ export function Layout() {
           }
         }, 10)
       } else {
-        setLogsHasMore(false)
+        // No displayable entries in this page - keep paginating if the backend
+        // says there are more raw records, otherwise stop
+        setLogsHasMore(rawLogs.length > 0 ? (data.has_more || false) : false)
       }
     } catch {
       // Ignore errors
@@ -596,23 +636,32 @@ export function Layout() {
     a.href = url
     a.download = `dune-weaver-logs-${new Date().toISOString().split('T')[0]}.txt`
     a.click()
-    URL.revokeObjectURL(url)
+    // Revoke after the browser has started the download — revoking
+    // synchronously breaks the download on Firefox
+    setTimeout(() => URL.revokeObjectURL(url), 1000)
   }
 
-  const handleRestart = async () => {
-    if (!confirm('Are you sure you want to restart Dune Weaver?')) return
+  const handleRestart = () => {
+    setShowRestartConfirm(true)
+  }
 
+  const confirmRestart = async () => {
+    setShowRestartConfirm(false)
     try {
       await apiClient.post('/api/system/restart')
       toast.success('Dune Weaver is restarting...')
+      setIsRestarting(true)
     } catch {
       toast.error('Failed to restart Dune Weaver')
     }
   }
 
-  const handleShutdown = async () => {
-    if (!confirm('Are you sure you want to shutdown the system?')) return
+  const handleShutdown = () => {
+    setShowShutdownConfirm(true)
+  }
 
+  const confirmShutdown = async () => {
+    setShowShutdownConfirm(false)
     try {
       await apiClient.post('/api/system/shutdown')
       toast.success('System is shutting down...')
@@ -721,6 +770,7 @@ export function Layout() {
       document.documentElement.classList.remove('dark')
       localStorage.setItem('theme', 'light')
     }
+    document.querySelector('meta[name="theme-color"]')?.setAttribute('content', isDark ? '#0a0a0a' : '#f8f9fb')
   }, [isDark])
 
   // Blocking overlay logs state - shows connection attempts
@@ -729,6 +779,17 @@ export function Layout() {
 
   // Cache progress from shared store
   const cacheProgress = useCacheProgressStore((s) => s.cacheProgress)
+  const [cacheOverlayDismissed, setCacheOverlayDismissed] = useState(false)
+
+  // Reset cache-overlay dismissal when a new cache run starts (false→true transition)
+  const wasCacheRunningRef = useRef(false)
+  useEffect(() => {
+    const isRunning = cacheProgress?.is_running ?? false
+    if (!wasCacheRunningRef.current && isRunning) {
+      setCacheOverlayDismissed(false)
+    }
+    wasCacheRunningRef.current = isRunning
+  }, [cacheProgress?.is_running])
 
   // Connect/disconnect cache progress WebSocket based on backend connectivity
   useEffect(() => {
@@ -952,6 +1013,20 @@ export function Layout() {
     setCacheAllProgress(null)
   }
 
+  // Escape dismisses the Cache All prompt (same as Skip / Done), but not mid-caching
+  useEffect(() => {
+    if (!showCacheAllPrompt) return
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key !== 'Escape') return
+      if (cacheAllProgress && !cacheAllProgress.done) return // caching in progress, no dismiss action
+      localStorage.setItem('cacheAllPromptShown', 'true')
+      setShowCacheAllPrompt(false)
+      setCacheAllProgress(null)
+    }
+    window.addEventListener('keydown', handleKeyDown)
+    return () => window.removeEventListener('keydown', handleKeyDown)
+  }, [showCacheAllPrompt, cacheAllProgress])
+
   // Now Playing button drag handlers
   const getSnapPositions = useCallback(() => {
     const padding = 16
@@ -970,6 +1045,7 @@ export function Layout() {
     dragStartRef.current = { x: clientX, y: clientY, buttonX: buttonCenterX }
     wasDraggingRef.current = false // Reset drag flag
     setIsDraggingButton(true)
+    dragOffsetRef.current = 0
     setDragOffset({ x: 0, y: 0 })
   }, [])
 
@@ -980,6 +1056,7 @@ export function Layout() {
     if (Math.abs(deltaX) > 8) {
       wasDraggingRef.current = true
     }
+    dragOffsetRef.current = deltaX
     setDragOffset({ x: deltaX, y: 0 })
   }, [isDraggingButton])
 
@@ -991,7 +1068,7 @@ export function Layout() {
     }
 
     // Calculate current position
-    const currentX = dragStartRef.current.buttonX + dragOffset.x
+    const currentX = dragStartRef.current.buttonX + dragOffsetRef.current
     const snapPositions = getSnapPositions()
 
     // Find nearest snap position
@@ -1017,9 +1094,10 @@ export function Layout() {
 
     // Reset drag state
     setIsDraggingButton(false)
+    dragOffsetRef.current = 0
     setDragOffset({ x: 0, y: 0 })
     dragStartRef.current = null
-  }, [dragOffset.x, getSnapPositions])
+  }, [getSnapPositions])
 
   // Mouse drag handlers
   useEffect(() => {
@@ -1082,10 +1160,10 @@ export function Layout() {
     <div className="min-h-dvh bg-background flex flex-col">
       {/* Security Lockdown Overlay */}
       {isLockdownActive && (
-        <div className="fixed inset-0 z-[60] bg-background flex items-center justify-center p-4">
+        <div role="dialog" aria-modal="true" className="fixed inset-0 z-[60] bg-background flex items-center justify-center p-4">
           <div className="w-full max-w-sm space-y-6 text-center">
             <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-2">
-              <span className="material-icons-outlined text-4xl text-primary">lock</span>
+              <span aria-hidden="true" className="material-icons-outlined text-4xl text-primary">lock</span>
             </div>
             <h2 className="text-2xl font-bold">{displayName}</h2>
             <p className="text-muted-foreground">This table is locked. Enter the password to continue.</p>
@@ -1108,12 +1186,12 @@ export function Layout() {
 
       {/* Security Password Dialog (for play-only mode) */}
       {showPasswordDialog && (
-        <div className="fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
+        <div role="dialog" aria-modal="true" className="fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
           <div className="bg-background rounded-lg shadow-xl w-full max-w-sm">
             <div className="p-6 space-y-4">
               <div className="text-center space-y-2">
                 <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 mb-2">
-                  <span className="material-icons-outlined text-2xl text-primary">lock</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-2xl text-primary">lock</span>
                 </div>
                 <h3 className="text-lg font-semibold">Settings Locked</h3>
                 <p className="text-sm text-muted-foreground">Enter the password to access settings.</p>
@@ -1148,16 +1226,21 @@ export function Layout() {
 
       {/* Sensor Homing Failure Popup */}
       {sensorHomingFailed && (
-        <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
+        <div
+          role="alertdialog"
+          aria-modal="true"
+          aria-labelledby="sensor-homing-failed-title"
+          className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
+        >
           <div className="bg-background rounded-lg shadow-xl w-full max-w-md border border-destructive/30">
             <div className="p-6">
               <div className="text-center space-y-4">
                 <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-destructive/10 mb-2">
-                  <span className="material-icons-outlined text-4xl text-destructive">
+                  <span aria-hidden="true" className="material-icons-outlined text-4xl text-destructive">
                     error_outline
                   </span>
                 </div>
-                <h2 className="text-xl font-semibold">Sensor Homing Failed</h2>
+                <h2 id="sensor-homing-failed-title" className="text-xl font-semibold">Sensor Homing Failed</h2>
                 <p className="text-muted-foreground text-sm">
                   The sensor homing process could not complete. The limit sensors may not be positioned correctly or may be malfunctioning.
                 </p>
@@ -1185,7 +1268,7 @@ export function Layout() {
                       onClick={() => handleSensorHomingRecovery(false)}
                       className="w-full gap-2"
                     >
-                      <span className="material-icons text-base">refresh</span>
+                      <span aria-hidden="true" className="material-icons text-base">refresh</span>
                       Retry Sensor Homing
                     </Button>
                     <Button
@@ -1193,7 +1276,7 @@ export function Layout() {
                       onClick={() => handleSensorHomingRecovery(true)}
                       className="w-full gap-2"
                     >
-                      <span className="material-icons text-base">sync_alt</span>
+                      <span aria-hidden="true" className="material-icons text-base">sync_alt</span>
                       Switch to Crash Homing
                     </Button>
                     <p className="text-xs text-muted-foreground">
@@ -1202,7 +1285,7 @@ export function Layout() {
                   </div>
                 ) : (
                   <div className="flex items-center justify-center gap-2 py-4">
-                    <span className="material-icons-outlined text-primary animate-spin">sync</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-primary animate-spin">sync</span>
                     <span className="text-muted-foreground">Attempting recovery...</span>
                   </div>
                 )}
@@ -1213,12 +1296,12 @@ export function Layout() {
       )}
 
       {/* Cache Progress Blocking Overlay */}
-      {cacheProgress?.is_running && (
+      {cacheProgress?.is_running && !cacheOverlayDismissed && (
         <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
           <div className="w-full max-w-md space-y-6">
             <div className="text-center space-y-4">
               <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-2">
-                <span className="material-icons-outlined text-4xl text-primary animate-pulse">
+                <span aria-hidden="true" className="material-icons-outlined text-4xl text-primary animate-pulse">
                   cached
                 </span>
               </div>
@@ -1258,22 +1341,34 @@ export function Layout() {
             <p className="text-center text-xs text-muted-foreground">
               This only happens once after updates or when new patterns are added
             </p>
+
+            {/* Escape hatch - caching keeps running server-side */}
+            <div className="flex justify-center">
+              <Button variant="outline" onClick={() => setCacheOverlayDismissed(true)}>
+                Continue in background
+              </Button>
+            </div>
           </div>
         </div>
       )}
 
       {/* Cache All Previews Prompt Modal */}
       {showCacheAllPrompt && (
-        <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
+        <div
+          role="dialog"
+          aria-modal="true"
+          aria-labelledby="cache-all-prompt-title"
+          className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
+        >
           <div className="bg-background rounded-lg shadow-xl w-full max-w-md">
             <div className="p-6">
               <div className="text-center space-y-4">
                 <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 mb-2">
-                  <span className="material-icons-outlined text-2xl text-primary">
+                  <span aria-hidden="true" className="material-icons-outlined text-2xl text-primary">
                     download_for_offline
                   </span>
                 </div>
-                <h2 className="text-xl font-semibold">Cache All Pattern Previews?</h2>
+                <h2 id="cache-all-prompt-title" className="text-xl font-semibold">Cache All Pattern Previews?</h2>
                 <p className="text-muted-foreground text-sm">
                   Would you like to cache all pattern previews for faster browsing? This will download and store preview images in your browser for instant loading.
                 </p>
@@ -1291,7 +1386,7 @@ export function Layout() {
                       Skip for now
                     </Button>
                     <Button variant="secondary" onClick={handleCacheAllPreviews} className="gap-2">
-                      <span className="material-icons-outlined text-lg">cached</span>
+                      <span aria-hidden="true" className="material-icons-outlined text-lg">cached</span>
                       Cache All
                     </Button>
                   </div>
@@ -1319,7 +1414,7 @@ export function Layout() {
                 {cacheAllProgress?.done && (
                   <div className="space-y-4">
                     <p className="text-green-600 dark:text-green-400 flex items-center justify-center gap-2">
-                      <span className="material-icons text-base">check_circle</span>
+                      <span aria-hidden="true" className="material-icons text-base">check_circle</span>
                       All {cacheAllProgress.total} previews cached successfully!
                     </p>
                     <Button onClick={handleCloseCacheAllDone} className="w-full">
@@ -1336,7 +1431,7 @@ export function Layout() {
       {/* Backend Connection / Homing Blocking Overlay */}
       {/* Skip in captive portal mode (WebSocket won't connect in sandboxed webview) */}
       {/* Don't show this overlay when sensor homing failed - that has its own dialog */}
-      {!isCaptivePortal && !sensorHomingFailed && (!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
+      {!isCaptivePortal && !sensorHomingFailed && ((!isBackendConnected && !backendOverlayDismissed) || (isHoming && !homingDismissed) || homingJustCompleted) && (
         <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
           <div className="w-full max-w-2xl space-y-6">
             {/* Status Header */}
@@ -1348,7 +1443,7 @@ export function Layout() {
                     ? 'bg-primary/10'
                     : 'bg-amber-500/10'
               }`}>
-                <span className={`material-icons-outlined text-4xl ${
+                <span aria-hidden="true" className={`material-icons-outlined text-4xl ${
                   homingJustCompleted
                     ? 'text-green-500'
                     : isHoming
@@ -1401,7 +1496,7 @@ export function Layout() {
             <div className="bg-muted/50 rounded-lg border overflow-hidden">
               <div className="flex items-center justify-between px-4 py-2 border-b bg-muted">
                 <div className="flex items-center gap-2">
-                  <span className="material-icons-outlined text-base">terminal</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-base">terminal</span>
                   <span className="text-sm font-medium">
                     {isHoming || homingJustCompleted ? 'Homing Log' : 'Connection Log'}
                   </span>
@@ -1417,7 +1512,7 @@ export function Layout() {
                     className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
                     title="Copy logs to clipboard"
                   >
-                    <span className="material-icons text-sm">content_copy</span>
+                    <span aria-hidden="true" className="material-icons text-sm">content_copy</span>
                     Copy
                   </button>
                   <span className="text-xs text-muted-foreground">
@@ -1458,7 +1553,7 @@ export function Layout() {
                       onClick={() => setKeepHomingLogsOpen(true)}
                       className="gap-2"
                     >
-                      <span className="material-icons text-base">visibility</span>
+                      <span aria-hidden="true" className="material-icons text-base">visibility</span>
                       Keep Open
                     </Button>
                     <Button
@@ -1468,7 +1563,7 @@ export function Layout() {
                       }}
                       className="gap-2"
                     >
-                      <span className="material-icons text-base">close</span>
+                      <span aria-hidden="true" className="material-icons text-base">close</span>
                       Dismiss
                     </Button>
                   </>
@@ -1480,13 +1575,27 @@ export function Layout() {
                     }}
                     className="gap-2"
                   >
-                    <span className="material-icons text-base">close</span>
+                    <span aria-hidden="true" className="material-icons text-base">close</span>
                     Close Logs
                   </Button>
                 )}
               </div>
             )}
 
+            {/* Dismiss button while backend is disconnected (e.g. to reach the table selector) */}
+            {!isBackendConnected && !isHoming && !homingJustCompleted && (
+              <div className="flex justify-center">
+                <Button
+                  variant="outline"
+                  onClick={() => setBackendOverlayDismissed(true)}
+                  className="gap-2 text-muted-foreground"
+                >
+                  <span aria-hidden="true" className="material-icons text-base">visibility_off</span>
+                  Dismiss
+                </Button>
+              </div>
+            )}
+
             {/* Dismiss button during homing */}
             {isHoming && !homingJustCompleted && (
               <div className="flex justify-center">
@@ -1495,7 +1604,7 @@ export function Layout() {
                   onClick={() => setHomingDismissed(true)}
                   className="gap-2 text-muted-foreground"
                 >
-                  <span className="material-icons text-base">visibility_off</span>
+                  <span aria-hidden="true" className="material-icons text-base">visibility_off</span>
                   Dismiss
                 </Button>
               </div>
@@ -1514,6 +1623,36 @@ export function Layout() {
         </div>
       )}
 
+      {/* Restart / Shutdown confirmations */}
+      <ConfirmDialog
+        open={showRestartConfirm}
+        onOpenChange={setShowRestartConfirm}
+        title="Restart Dune Weaver?"
+        description="The table will be unavailable for about a minute while the service restarts."
+        confirmLabel="Restart"
+        destructive
+        onConfirm={confirmRestart}
+      />
+      <ConfirmDialog
+        open={showShutdownConfirm}
+        onOpenChange={setShowShutdownConfirm}
+        title="Shut down the system?"
+        description="The table will power off and must be powered back on manually."
+        confirmLabel="Shut Down"
+        destructive
+        onConfirm={confirmShutdown}
+      />
+
+      {/* Restart-in-progress overlay - clears automatically when the backend reconnects */}
+      {isRestarting && (
+        <div className="fixed inset-0 z-[60] bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
+          <div className="text-center space-y-4">
+            <span aria-hidden="true" className="material-icons-outlined text-4xl text-primary animate-spin">sync</span>
+            <p className="text-lg font-medium">Restarting — waiting for reconnection…</p>
+          </div>
+        </div>
+      )}
+
       {/* Header - Floating Pill */}
       <header className="fixed top-0 left-0 right-0 z-40 pt-safe">
         {/* Blurry backdrop behind header - only on Browse page where content scrolls under */}
@@ -1523,8 +1662,8 @@ export function Layout() {
         <div className="relative w-full max-w-5xl mx-auto px-3 sm:px-4 pt-3 pointer-events-none">
           <div className="rounded-full bg-card shadow-lg border border-border pointer-events-auto">
           <div className="flex h-12 items-center justify-between px-4">
-          <div className="flex items-center gap-2">
-            <Link to="/">
+          <div className="flex items-center gap-2 min-w-0">
+            <Link to="/" className="shrink-0">
               <img
                 src={customLogo ? apiClient.getAssetUrl(`/static/custom/${customLogo}`) : apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
                 alt={displayName}
@@ -1532,20 +1671,20 @@ export function Layout() {
               />
             </Link>
             <TableSelector>
-              <button className="flex items-center gap-1.5 hover:opacity-80 transition-opacity group">
+              <button className="flex items-center gap-1.5 min-w-0 hover:opacity-80 transition-opacity group">
                 <ShinyText
                   text={displayName}
-                  className="font-semibold text-lg"
+                  className="font-semibold text-lg min-w-0 max-w-full truncate"
                   speed={4}
                   color={isDark ? '#a8a8a8' : '#555555'}
                   shineColor={isDark ? '#ffffff' : '#999999'}
                   spread={75}
                 />
-                <span className="material-icons-outlined text-muted-foreground text-sm group-hover:text-foreground transition-colors">
+                <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-sm shrink-0 group-hover:text-foreground transition-colors">
                   expand_more
                 </span>
                 <span
-                  className={`w-2 h-2 rounded-full ${
+                  className={`w-2 h-2 rounded-full shrink-0 ${
                     !isBackendConnected
                       ? 'bg-gray-400'
                       : isConnected
@@ -1565,9 +1704,13 @@ export function Layout() {
           </div>
 
           {showFirmwareWarning && (
-            <div className="flex items-center gap-1.5 min-w-0 mx-2 text-amber-500">
-              <span className="material-icons-outlined text-base shrink-0">warning</span>
-              <span className="text-xs truncate">
+            <div
+              className="flex items-center gap-1.5 min-w-0 shrink-0 sm:shrink mx-2 text-amber-500"
+              title={`FluidNC ${firmwareVersion} — change to ${expectedFirmwareVersion}`}
+              aria-label={`FluidNC ${firmwareVersion} — change to ${expectedFirmwareVersion}`}
+            >
+              <span aria-hidden="true" className="material-icons-outlined text-base shrink-0">warning</span>
+              <span className="hidden sm:inline text-xs truncate">
                 FluidNC {firmwareVersion} — change to {expectedFirmwareVersion}
               </span>
             </div>
@@ -1582,14 +1725,15 @@ export function Layout() {
                 className="rounded-full"
                 onClick={handleLock}
                 title="Lock"
+                aria-label="Lock"
               >
-                <span className="material-icons-outlined">lock_open</span>
+                <span aria-hidden="true" className="material-icons-outlined">lock_open</span>
               </Button>
             )}
-            {updateAvailable && (
-              <Link to="/settings?section=version" title="Software update available">
+            {updateAvailable && !isPlayOnlyActive && (
+              <Link to="/settings?section=version" title="Software update available" aria-label="Software update available">
                 <span className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-accent transition-colors">
-                  <span className="material-icons-outlined text-xl">download</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-xl">download</span>
                   <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
                 </span>
               </Link>
@@ -1602,7 +1746,7 @@ export function Layout() {
                   className="rounded-full"
                   aria-label="Open menu"
                 >
-                  <span className="material-icons-outlined">menu</span>
+                  <span aria-hidden="true" className="material-icons-outlined">menu</span>
                 </Button>
               </PopoverTrigger>
               <PopoverContent align="end" className="w-56 p-2">
@@ -1611,7 +1755,7 @@ export function Layout() {
                     onClick={() => setIsDark(!isDark)}
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
                   >
-                    <span className="material-icons-outlined text-xl">
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">
                       {isDark ? 'light_mode' : 'dark_mode'}
                     </span>
                     {isDark ? 'Light Mode' : 'Dark Mode'}
@@ -1620,7 +1764,7 @@ export function Layout() {
                     onClick={handleToggleLogs}
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
                   >
-                    <span className="material-icons-outlined text-xl">article</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">article</span>
                     View Logs
                   </button>
                   <button
@@ -1630,7 +1774,7 @@ export function Layout() {
                     }}
                     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">wifi</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">wifi</span>
                     WiFi Setup
                   </button>
                   <Separator className="my-1" />
@@ -1638,14 +1782,14 @@ export function Layout() {
                     onClick={handleRestart}
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
                   >
-                    <span className="material-icons-outlined text-xl">restart_alt</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">restart_alt</span>
                     Restart
                   </button>
                   <button
                     onClick={handleShutdown}
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-red-500"
                   >
-                    <span className="material-icons-outlined text-xl">power_settings_new</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">power_settings_new</span>
                     Shutdown
                   </button>
                 </div>
@@ -1662,14 +1806,15 @@ export function Layout() {
                 className="rounded-full"
                 onClick={handleLock}
                 title="Lock"
+                aria-label="Lock"
               >
-                <span className="material-icons-outlined">lock_open</span>
+                <span aria-hidden="true" className="material-icons-outlined">lock_open</span>
               </Button>
             )}
-            {updateAvailable && (
-              <Link to="/settings?section=version" title="Software update available">
+            {updateAvailable && !isPlayOnlyActive && (
+              <Link to="/settings?section=version" title="Software update available" aria-label="Software update available">
                 <span className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-accent transition-colors">
-                  <span className="material-icons-outlined text-xl">download</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-xl">download</span>
                   <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
                 </span>
               </Link>
@@ -1682,7 +1827,7 @@ export function Layout() {
                   className="rounded-full"
                   aria-label="Open menu"
                 >
-                  <span className="material-icons-outlined">
+                  <span aria-hidden="true" className="material-icons-outlined">
                     {isMobileMenuOpen ? 'close' : 'menu'}
                   </span>
                 </Button>
@@ -1696,7 +1841,7 @@ export function Layout() {
                     }}
                     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">
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">
                       {isDark ? 'light_mode' : 'dark_mode'}
                     </span>
                     {isDark ? 'Light Mode' : 'Dark Mode'}
@@ -1708,7 +1853,7 @@ export function Layout() {
                     }}
                     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>
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">article</span>
                     View Logs
                   </button>
                   <button
@@ -1718,7 +1863,7 @@ export function Layout() {
                     }}
                     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">wifi</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">wifi</span>
                     WiFi Setup
                   </button>
                   <Separator className="my-1" />
@@ -1729,7 +1874,7 @@ export function Layout() {
                     }}
                     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>
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">restart_alt</span>
                     Restart
                   </button>
                   <button
@@ -1739,7 +1884,7 @@ export function Layout() {
                     }}
                     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>
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">power_settings_new</span>
                     Shutdown
                   </button>
                 </div>
@@ -1792,7 +1937,7 @@ export function Layout() {
           <>
             {/* Resize Handle */}
             <div
-              className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize flex items-center justify-center group hover:bg-primary/10 -translate-y-1/2 z-10"
+              className="absolute top-0 left-0 right-0 h-6 cursor-ns-resize flex items-center justify-center group hover:bg-primary/10 -translate-y-1/2 z-10"
               onMouseDown={handleResizeStart}
               onTouchStart={handleResizeStart}
             >
@@ -1822,8 +1967,8 @@ export function Layout() {
                   className="text-xs bg-background border rounded px-2 py-1 w-28 sm:w-40"
                 />
                 {logSearchQuery && (
-                  <Button variant="ghost" size="icon-sm" onClick={() => setLogSearchQuery('')} className="rounded-full" title="Clear search">
-                    <span className="material-icons-outlined text-sm">close</span>
+                  <Button variant="ghost" size="icon-sm" onClick={() => setLogSearchQuery('')} className="rounded-full" title="Clear search" aria-label="Clear search">
+                    <span aria-hidden="true" className="material-icons-outlined text-sm">close</span>
                   </Button>
                 )}
                 <span className="text-xs text-muted-foreground">
@@ -1839,8 +1984,9 @@ export function Layout() {
                   onClick={handleCopyLogs}
                   className="rounded-full"
                   title="Copy logs"
+                  aria-label="Copy logs"
                 >
-                  <span className="material-icons-outlined text-base">content_copy</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-base">content_copy</span>
                 </Button>
                 <Button
                   variant="ghost"
@@ -1848,8 +1994,9 @@ export function Layout() {
                   onClick={handleDownloadLogs}
                   className="rounded-full"
                   title="Download logs"
+                  aria-label="Download logs"
                 >
-                  <span className="material-icons-outlined text-base">download</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-base">download</span>
                 </Button>
                 <Button
                   variant="ghost"
@@ -1857,8 +2004,9 @@ export function Layout() {
                   onClick={() => setIsLogsOpen(false)}
                   className="rounded-full"
                   title="Close"
+                  aria-label="Close logs"
                 >
-                  <span className="material-icons-outlined text-base">close</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-base">close</span>
                 </Button>
               </div>
             </div>
@@ -1871,7 +2019,7 @@ export function Layout() {
               {/* Loading indicator for older logs */}
               {isLoadingMoreLogs && (
                 <div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
-                  <span className="material-icons-outlined text-sm animate-spin">sync</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-sm animate-spin">sync</span>
                   <span>Loading older logs...</span>
                 </div>
               )}
@@ -1907,7 +2055,8 @@ export function Layout() {
       </div>
 
       {/* Floating Now Playing Button - draggable, snaps to left/center/right */}
-      {!isNowPlayingOpen && (
+      {/* Only rendered while something is playing or paused - when idle it would just overlap page content */}
+      {!isNowPlayingOpen && npIsPlaying && (
         <button
           ref={buttonRef}
           onClick={() => {
@@ -1940,7 +2089,7 @@ export function Layout() {
           style={getButtonPositionStyle()}
           aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
         >
-          <span className={`material-icons-outlined text-xl ${isCurrentlyPlaying ? 'text-primary' : 'text-muted-foreground'}`}>
+          <span aria-hidden="true" className={`material-icons-outlined text-xl ${isCurrentlyPlaying ? 'text-primary' : 'text-muted-foreground'}`}>
             {isCurrentlyPlaying ? 'play_circle' : 'stop_circle'}
           </span>
           <span className="text-sm font-medium">
@@ -1953,7 +2102,10 @@ export function Layout() {
       <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-card pb-safe">
         <div className={`max-w-5xl mx-auto grid h-16`} style={{ gridTemplateColumns: `repeat(${visibleNavItems.length + (isPlayOnlyActive ? 1 : 0)}, minmax(0, 1fr))` }}>
           {visibleNavItems.map((item) => {
-            const isActive = location.pathname === item.path
+            // Settings tab also covers pages reached from it that have no nav entry of their own
+            const isActive = item.path === '/settings'
+              ? ['/settings', '/setup', '/wifi-setup'].includes(location.pathname)
+              : location.pathname === item.path
             return (
               <Link
                 key={item.path}
@@ -1968,7 +2120,7 @@ export function Layout() {
                 {isActive && (
                   <span className="absolute -top-0.5 w-8 h-1 rounded-full bg-primary" />
                 )}
-                <span className={`text-xl ${isActive ? 'material-icons' : 'material-icons-outlined'}`}>
+                <span aria-hidden="true" className={`text-xl ${isActive ? 'material-icons' : 'material-icons-outlined'}`}>
                   {item.icon}
                 </span>
                 <span className="text-xs font-medium">{item.label}</span>
@@ -1981,7 +2133,7 @@ export function Layout() {
               onClick={() => { setShowPasswordDialog(true); setPasswordInput(''); setPasswordError(false) }}
               className="relative flex flex-col items-center justify-center gap-1 transition-all duration-200 text-muted-foreground hover:text-foreground active:scale-95"
             >
-              <span className="material-icons-outlined text-xl">lock</span>
+              <span aria-hidden="true" className="material-icons-outlined text-xl">lock</span>
               <span className="text-xs font-medium">Settings</span>
             </button>
           )}

+ 131 - 0
frontend/src/components/ui/alert-dialog.tsx

@@ -0,0 +1,131 @@
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+const AlertDialog = AlertDialogPrimitive.Root
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal
+
+const AlertDialogOverlay = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPrimitive.Overlay
+    ref={ref}
+    className={cn(
+      "fixed inset-0 z-[100] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      className
+    )}
+    {...props}
+  />
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPortal>
+    <AlertDialogOverlay />
+    <AlertDialogPrimitive.Content
+      ref={ref}
+      className={cn(
+        "fixed left-[50%] top-[50%] z-[100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
+        className
+      )}
+      {...props}
+    />
+  </AlertDialogPortal>
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
+    {...props}
+  />
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 gap-2 sm:gap-0",
+      className
+    )}
+    {...props}
+  />
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Title>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPrimitive.Title
+    ref={ref}
+    className={cn("text-lg font-semibold", className)}
+    {...props}
+  />
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Description>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPrimitive.Description
+    ref={ref}
+    className={cn("text-sm text-muted-foreground", className)}
+    {...props}
+  />
+))
+AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Action>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPrimitive.Action
+    ref={ref}
+    className={cn(buttonVariants(), className)}
+    {...props}
+  />
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPrimitive.Cancel
+    ref={ref}
+    className={cn(buttonVariants({ variant: "outline" }), className)}
+    {...props}
+  />
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+  AlertDialog,
+  AlertDialogTrigger,
+  AlertDialogPortal,
+  AlertDialogOverlay,
+  AlertDialogContent,
+  AlertDialogHeader,
+  AlertDialogFooter,
+  AlertDialogTitle,
+  AlertDialogDescription,
+  AlertDialogAction,
+  AlertDialogCancel,
+}

+ 122 - 70
frontend/src/components/ui/color-picker.tsx

@@ -1,70 +1,122 @@
-import { useState } from 'react'
-import { SketchPicker } from 'react-color'
-import type { ColorResult } from 'react-color'
-import {
-  Popover,
-  PopoverContent,
-  PopoverTrigger,
-} from '@/components/ui/popover'
-import { Button } from '@/components/ui/button'
-import { cn } from '@/lib/utils'
-
-interface ColorPickerProps {
-  value: string
-  onChange: (color: string) => void
-  className?: string
-  presets?: string[]
-}
-
-const defaultPresets = [
-  '#ff0000', // Red
-  '#ff8000', // Orange
-  '#ffff00', // Yellow
-  '#00ff00', // Green
-  '#00ffff', // Cyan
-  '#0000ff', // Blue
-  '#ff00ff', // Magenta
-  '#ffffff', // White
-  '#2a9d8f', // Teal
-  '#e9c46a', // Sand
-  '#dc143c', // Crimson
-  '#000000', // Black
-]
-
-export function ColorPicker({
-  value,
-  onChange,
-  className,
-  presets = defaultPresets,
-}: ColorPickerProps) {
-  const [open, setOpen] = useState(false)
-
-  const handleChange = (color: ColorResult) => {
-    onChange(color.hex)
-  }
-
-  return (
-    <Popover open={open} onOpenChange={setOpen}>
-      <PopoverTrigger asChild>
-        <Button
-          variant="secondary"
-          className={cn(
-            'w-12 h-12 rounded-full p-1 border-2',
-            className
-          )}
-          style={{ backgroundColor: value }}
-        >
-          <span className="sr-only">Pick a color</span>
-        </Button>
-      </PopoverTrigger>
-      <PopoverContent className="w-auto p-0" align="start">
-        <SketchPicker
-          color={value}
-          onChange={handleChange}
-          presetColors={presets}
-          disableAlpha={true}
-        />
-      </PopoverContent>
-    </Popover>
-  )
-}
+import { useEffect, useState } from 'react'
+import { HexColorPicker } from 'react-colorful'
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/popover'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { cn } from '@/lib/utils'
+
+interface ColorPickerProps {
+  value: string
+  onChange: (color: string) => void
+  className?: string
+  presets?: string[]
+}
+
+const defaultPresets = [
+  '#ff0000', // Red
+  '#ff8000', // Orange
+  '#ffff00', // Yellow
+  '#00ff00', // Green
+  '#00ffff', // Cyan
+  '#0000ff', // Blue
+  '#ff00ff', // Magenta
+  '#ffffff', // White
+  '#2a9d8f', // Teal
+  '#e9c46a', // Sand
+  '#dc143c', // Crimson
+  '#000000', // Black
+]
+
+const HEX_RE = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
+
+function normalizeHex(input: string): string | null {
+  const match = HEX_RE.exec(input.trim())
+  if (!match) return null
+  let hex = match[1].toLowerCase()
+  if (hex.length === 3) {
+    hex = hex
+      .split('')
+      .map((c) => c + c)
+      .join('')
+  }
+  return `#${hex}`
+}
+
+export function ColorPicker({
+  value,
+  onChange,
+  className,
+  presets = defaultPresets,
+}: ColorPickerProps) {
+  const [open, setOpen] = useState(false)
+  const [hexInput, setHexInput] = useState(value)
+
+  // Keep the text field in sync with external/picker changes
+  useEffect(() => {
+    setHexInput(value)
+  }, [value])
+
+  const commitHexInput = (input: string) => {
+    const hex = normalizeHex(input)
+    if (hex) {
+      onChange(hex)
+      setHexInput(hex)
+    } else {
+      setHexInput(value)
+    }
+  }
+
+  return (
+    <Popover open={open} onOpenChange={setOpen}>
+      <PopoverTrigger asChild>
+        <Button
+          variant="secondary"
+          className={cn(
+            'w-12 h-12 rounded-full p-1 border-2',
+            className
+          )}
+          style={{ backgroundColor: value }}
+        >
+          <span className="sr-only">Pick a color</span>
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className="w-auto p-3 space-y-3" align="start">
+        <HexColorPicker color={value} onChange={onChange} />
+        <Input
+          value={hexInput}
+          onChange={(e) => setHexInput(e.target.value)}
+          onBlur={(e) => commitHexInput(e.target.value)}
+          onKeyDown={(e) => {
+            if (e.key === 'Enter') {
+              commitHexInput(e.currentTarget.value)
+            }
+          }}
+          spellCheck={false}
+          autoComplete="off"
+          aria-label="Hex color"
+          className="h-8 font-mono text-sm"
+        />
+        <div className="grid grid-cols-6 gap-1.5">
+          {presets.map((preset) => (
+            <button
+              key={preset}
+              type="button"
+              aria-label={`Select color ${preset}`}
+              className={cn(
+                'h-6 w-6 rounded border border-border cursor-pointer transition-transform hover:scale-110',
+                value.toLowerCase() === preset.toLowerCase() &&
+                  'ring-2 ring-ring ring-offset-1'
+              )}
+              style={{ backgroundColor: preset }}
+              onClick={() => onChange(preset)}
+            />
+          ))}
+        </div>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 13 - 1
frontend/src/index.css

@@ -197,7 +197,9 @@ body {
   }
 
   [data-now-playing-bar="expanded"] {
-    height: calc(100vh - 64px - 64px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
+    /* dvh, not vh: vh ignores iOS Safari's collapsing toolbar and clips the
+       bottom controls behind it */
+    height: calc(100dvh - 64px - 64px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
   }
 }
 
@@ -246,3 +248,13 @@ body {
 .animate-slide-in-right {
   animation: slideInRight 0.3s ease-out forwards;
 }
+
+/* Respect user preference for reduced motion */
+@media (prefers-reduced-motion: reduce) {
+  *, *::before, *::after {
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+    transition-duration: 0.01ms !important;
+    scroll-behavior: auto !important;
+  }
+}

+ 3 - 0
frontend/src/lib/apiClient.ts

@@ -73,6 +73,9 @@ class ApiClient {
         'Content-Type': 'application/json',
         ...headers,
       },
+      // No default timeout: some endpoints legitimately block for minutes
+      // (/connect can run homing with a 120s internal timeout). Callers
+      // that need cancellation pass their own AbortSignal.
       signal,
     }
 

+ 11 - 1
frontend/src/lib/previewCache.ts

@@ -32,6 +32,12 @@ const MAX_MEMORY_CACHE_SIZE = 100
 export async function initPreviewCacheDB(): Promise<IDBDatabase> {
   if (previewCacheDB) return previewCacheDB
 
+  // Not available in test (jsdom) or SSR environments — callers already
+  // handle rejection and fall back to uncached fetches
+  if (typeof indexedDB === 'undefined') {
+    return Promise.reject(new Error('IndexedDB not available in this environment'))
+  }
+
   return new Promise((resolve, reject) => {
     const request = indexedDB.open(PREVIEW_CACHE_DB_NAME, PREVIEW_CACHE_DB_VERSION)
 
@@ -212,7 +218,11 @@ async function managePreviewCacheSize(newItemSize: number): Promise<void> {
     entries.sort((a, b) => a.lastAccessed - b.lastAccessed)
 
     let freedSpace = 0
-    const targetSpace = newItemSize + MAX_CACHE_SIZE_BYTES * 0.1 // Free 10% extra buffer
+    // Free the actual overage plus a 10% buffer so eviction doesn't re-run
+    // on every save — not newItemSize-based, which would wipe ~10% of the
+    // cache even when only a few bytes over the limit
+    const targetSpace =
+      currentSize + newItemSize - MAX_CACHE_SIZE_BYTES + MAX_CACHE_SIZE_BYTES * 0.1
 
     for (const entry of entries) {
       if (freedSpace >= targetSpace) break

+ 208 - 74
frontend/src/pages/BrowsePage.tsx

@@ -10,6 +10,9 @@ import {
 import { fuzzyMatch } from '@/lib/utils'
 import { apiClient } from '@/lib/apiClient'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
+import { useStatusStore } from '@/stores/useStatusStore'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
+import { ConnectionBanner, useTableConnected } from '@/components/ConnectionBanner'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
@@ -59,6 +62,9 @@ interface PreviewContextType {
 
 const PreviewContext = createContext<PreviewContextType | null>(null)
 
+// Incremental rendering: number of pattern cards mounted initially and added per scroll step
+const PATTERNS_PAGE_SIZE = 60
+
 export function BrowsePage() {
   const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
 
@@ -73,6 +79,10 @@ export function BrowsePage() {
   const [sortBy, setSortBy] = useState<SortOption>('name')
   const [sortAsc, setSortAsc] = useState(true)
 
+  // Incremental rendering: only mount a slice of the grid, growing as the user scrolls
+  const [visibleCount, setVisibleCount] = useState(PATTERNS_PAGE_SIZE)
+  const sentinelRef = useRef<HTMLDivElement | null>(null)
+
   // Selection and panel state
   const [selectedPattern, setSelectedPattern] = useState<PatternMetadata | null>(null)
   const [isPanelOpen, setIsPanelOpen] = useState(false)
@@ -81,6 +91,11 @@ export function BrowsePage() {
     return (cached as PreExecution) || 'adaptive'
   })
   const [isRunning, setIsRunning] = useState(false)
+  const [patternToDelete, setPatternToDelete] = useState<PatternMetadata | null>(null)
+
+  // Connection/playlist awareness for honest button states
+  const tableConnected = useTableConnected()
+  const playlistActive = useStatusStore((s) => Boolean(s.status?.playlist))
 
   // Animated preview modal state
   const [isAnimatedPreviewOpen, setIsAnimatedPreviewOpen] = useState(false)
@@ -113,6 +128,13 @@ export function BrowsePage() {
   const pendingPreviewsRef = useRef<Set<string>>(new Set())
   const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const abortControllerRef = useRef<AbortController | null>(null)
+  // Mirror of `previews` so requestPreview can stay identity-stable —
+  // depending on the state directly would recreate every card's
+  // IntersectionObserver each time any preview loads
+  const previewsRef = useRef(previews)
+  useEffect(() => {
+    previewsRef.current = previews
+  }, [previews])
 
   // Cache all previews state
   const [isCaching, setIsCaching] = useState(false)
@@ -430,10 +452,52 @@ export function BrowsePage() {
     return result
   }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc, favorites, allPatternHistories])
 
+  // Reset the visible window whenever filter inputs change
+  useEffect(() => {
+    setVisibleCount(PATTERNS_PAGE_SIZE)
+  }, [searchQuery, selectedCategory, sortBy, sortAsc])
+
+  const visiblePatterns = useMemo(
+    () => filteredPatterns.slice(0, visibleCount),
+    [filteredPatterns, visibleCount]
+  )
+
+  // Grow the visible window when the sentinel approaches the viewport.
+  // Re-created on each visibleCount change so a still-visible sentinel fires again.
+  useEffect(() => {
+    // isLoading must be a dep: patterns arrive in an earlier commit than
+    // isLoading=false, so the sentinel isn't in the DOM yet when the
+    // length-change run fires — without re-running on isLoading the
+    // observer would never attach and pagination would stick at one page
+    if (isLoading) return
+    if (visibleCount >= filteredPatterns.length) return
+    const sentinel = sentinelRef.current
+    if (!sentinel) return
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        entries.forEach((entry) => {
+          if (entry.isIntersecting) {
+            // Guard: only grow while there are more patterns to show
+            setVisibleCount((prev) =>
+              prev < filteredPatterns.length
+                ? Math.min(prev + PATTERNS_PAGE_SIZE, filteredPatterns.length)
+                : prev
+            )
+          }
+        })
+      },
+      { rootMargin: '600px' }
+    )
+
+    observer.observe(sentinel)
+    return () => observer.disconnect()
+  }, [visibleCount, filteredPatterns.length, isLoading])
+
   // Batched preview loading - collects requests and fetches in batches
   const requestPreview = useCallback((path: string) => {
     // Skip if already loaded or pending
-    if (previews[path] || pendingPreviewsRef.current.has(path)) return
+    if (previewsRef.current[path] || pendingPreviewsRef.current.has(path)) return
 
     pendingPreviewsRef.current.add(path)
 
@@ -449,7 +513,7 @@ export function BrowsePage() {
         fetchPreviewsBatch(pathsToFetch)
       }
     }, 50) // Batch requests within 50ms window
-  }, [previews])
+  }, [])
 
   // Canvas drawing functions
   const polarToCartesian = useCallback((theta: number, rho: number, size: number) => {
@@ -642,7 +706,6 @@ export function BrowsePage() {
   const handlePatternClick = async (pattern: PatternMetadata) => {
     setSelectedPattern(pattern)
     setIsPanelOpen(true)
-    setPreExecution('adaptive')
     setPatternHistory(null) // Reset while loading
 
     // Fetch pattern execution history
@@ -740,7 +803,7 @@ export function BrowsePage() {
     }
   }
 
-  const handleDeletePattern = async () => {
+  const handleDeletePattern = () => {
     if (!selectedPattern) return
 
     if (!selectedPattern.path.startsWith('custom_patterns/')) {
@@ -748,18 +811,22 @@ export function BrowsePage() {
       return
     }
 
-    if (!confirm(`Delete "${selectedPattern.name}"? This cannot be undone.`)) {
-      return
-    }
+    setPatternToDelete(selectedPattern)
+  }
+
+  const confirmDeletePattern = async () => {
+    if (!patternToDelete) return
 
     try {
-      await apiClient.post('/delete_theta_rho_file', { file_name: selectedPattern.path })
-      toast.success(`Deleted ${selectedPattern.name}`)
+      await apiClient.post('/delete_theta_rho_file', { file_name: patternToDelete.path })
+      toast.success(`Deleted ${patternToDelete.name}`)
       setIsPanelOpen(false)
       setSelectedPattern(null)
       fetchPatterns()
     } catch {
       toast.error('Failed to delete pattern')
+    } finally {
+      setPatternToDelete(null)
     }
   }
 
@@ -787,9 +854,12 @@ export function BrowsePage() {
     return preview?.image_data || null
   }
 
-  const formatCoordinate = (coord: { x: number; y: number } | null) => {
-    if (!coord) return '(-, -)'
-    return `(${coord.x.toFixed(1)}, ${coord.y.toFixed(1)})`
+  const describePosition = (coord: { x: number; y: number } | null | undefined) => {
+    if (!coord) return null
+    const rho = Math.hypot(coord.x, coord.y)
+    if (rho < 0.1) return 'center'
+    if (rho >= 0.9) return 'edge'
+    return 'mid-table'
   }
 
   const canDelete = selectedPattern?.path.startsWith('custom_patterns/')
@@ -846,16 +916,18 @@ export function BrowsePage() {
       } catch (error) {
         console.error(`Upload error for ${file.name}:`, error)
         failCount++
-        toast.error(`Failed to upload "${file.name}"`)
       }
     }
 
+    if (failCount === 0) {
+      toast.success(`${successCount} pattern${successCount > 1 ? 's' : ''} uploaded`)
+    } else if (successCount > 0) {
+      toast.warning(`${successCount} uploaded, ${failCount} failed`)
+    } else {
+      toast.error(`${failCount} upload${failCount > 1 ? 's' : ''} failed`)
+    }
+
     if (successCount > 0) {
-      toast.success(
-        successCount === 1
-          ? `Pattern "${files[0].name}" uploaded successfully`
-          : `${successCount} pattern${successCount > 1 ? 's' : ''} uploaded successfully`
-      )
       await fetchPatterns()
     }
 
@@ -869,7 +941,7 @@ export function BrowsePage() {
   if (isLoading) {
     return (
       <div className="flex items-center justify-center min-h-[60vh]">
-        <span className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
+        <span aria-hidden="true" className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
           sync
         </span>
       </div>
@@ -906,15 +978,17 @@ export function BrowsePage() {
             className="gap-2 shrink-0 h-9 w-9 sm:h-11 sm:w-auto rounded-full px-0 sm:px-4 justify-center bg-card border border-border shadow-sm hover:bg-accent"
           >
             {isUploading ? (
-              <span className="material-icons-outlined animate-spin text-lg">sync</span>
+              <span aria-hidden="true" className="material-icons-outlined animate-spin text-lg">sync</span>
             ) : (
-              <span className="material-icons-outlined text-lg">add</span>
+              <span aria-hidden="true" className="material-icons-outlined text-lg">add</span>
             )}
             <span className="hidden sm:inline">Add Pattern</span>
           </Button>
         )}
       </div>
 
+      <ConnectionBanner />
+
       {/* Filter Bar */}
       <div
         className="sticky z-30 py-3 -mx-0 sm:-mx-4 px-0 sm:px-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
@@ -923,7 +997,7 @@ export function BrowsePage() {
         <div className="flex items-center gap-2 sm:gap-3">
           {/* Search - Pill shaped, white background */}
           <div className="relative flex-1 min-w-0">
-            <span className="material-icons-outlined absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-muted-foreground text-lg sm:text-xl">
+            <span aria-hidden="true" className="material-icons-outlined absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-muted-foreground text-lg sm:text-xl">
               search
             </span>
             <Input
@@ -937,9 +1011,10 @@ export function BrowsePage() {
                 variant="ghost"
                 size="icon"
                 onClick={() => setSearchQuery('')}
+                aria-label="Clear search"
                 className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground h-7 w-7 rounded-full"
               >
-                <span className="material-icons-outlined text-lg">close</span>
+                <span aria-hidden="true" className="material-icons-outlined text-lg">close</span>
               </Button>
             )}
           </div>
@@ -947,7 +1022,7 @@ export function BrowsePage() {
           {/* Category - Icon on mobile, text on desktop */}
           <Select value={selectedCategory} onValueChange={setSelectedCategory}>
             <SelectTrigger className="h-9 w-9 sm:h-11 sm:w-auto rounded-full bg-card border-border shadow-sm text-xs sm:text-sm shrink-0 [&>svg]:hidden sm:[&>svg]:block px-0 sm:px-3 justify-center sm:justify-between [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
-              <span className="material-icons-outlined text-lg shrink-0 sm:hidden">folder</span>
+              <span aria-hidden="true" className="material-icons-outlined text-lg shrink-0 sm:hidden">folder</span>
               <SelectValue placeholder="All" />
             </SelectTrigger>
             <SelectContent>
@@ -967,7 +1042,7 @@ export function BrowsePage() {
             setSortAsc(option !== 'plays' && option !== 'last_played')
           }}>
             <SelectTrigger className="h-9 w-9 sm:h-11 sm:w-auto rounded-full bg-card border-border shadow-sm text-xs sm:text-sm shrink-0 [&>svg]:hidden sm:[&>svg]:block px-0 sm:px-3 justify-center sm:justify-between [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
-              <span className="material-icons-outlined text-lg shrink-0 sm:hidden">sort</span>
+              <span aria-hidden="true" className="material-icons-outlined text-lg shrink-0 sm:hidden">sort</span>
               <SelectValue placeholder="Sort" />
             </SelectTrigger>
             <SelectContent>
@@ -988,7 +1063,7 @@ export function BrowsePage() {
             className="shrink-0 h-9 w-9 sm:h-11 sm:w-11 rounded-full bg-card shadow-sm"
             title={sortAsc ? 'Ascending' : 'Descending'}
           >
-            <span className="material-icons-outlined text-lg sm:text-xl">
+            <span aria-hidden="true" className="material-icons-outlined text-lg sm:text-xl">
               {sortAsc ? 'arrow_upward' : 'arrow_downward'}
             </span>
           </Button>
@@ -1007,12 +1082,12 @@ export function BrowsePage() {
             >
               {isCaching ? (
                 <>
-                  <span className="material-icons-outlined animate-spin text-lg">sync</span>
+                  <span aria-hidden="true" className="material-icons-outlined animate-spin text-lg">sync</span>
                   <span className="text-sm">{cacheProgress}%</span>
                 </>
               ) : (
                 <>
-                  <span className="material-icons-outlined text-lg">cached</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-lg">cached</span>
                   <span className="hidden sm:inline text-sm">Cache</span>
                 </>
               )}
@@ -1028,10 +1103,32 @@ export function BrowsePage() {
       )}
 
       {/* Pattern Grid */}
-      {filteredPatterns.length === 0 ? (
+      {patterns.length === 0 ? (
         <div className="flex flex-col items-center justify-center min-h-[40vh] gap-4 text-center">
           <div className="p-4 rounded-full bg-muted">
-            <span className="material-icons-outlined text-5xl text-muted-foreground">
+            <span aria-hidden="true" className="material-icons-outlined text-5xl text-muted-foreground">
+              gesture
+            </span>
+          </div>
+          <div className="space-y-1">
+            <h2 className="text-xl font-semibold">No patterns yet</h2>
+            <p className="text-muted-foreground">Upload a .thr pattern file to get started</p>
+          </div>
+          {!isPlayOnlyActive && (
+            <Button
+              onClick={() => fileInputRef.current?.click()}
+              disabled={isUploading}
+              className="gap-2"
+            >
+              <span aria-hidden="true" className="material-icons-outlined text-lg">add</span>
+              Add Pattern
+            </Button>
+          )}
+        </div>
+      ) : filteredPatterns.length === 0 ? (
+        <div className="flex flex-col items-center justify-center min-h-[40vh] gap-4 text-center">
+          <div className="p-4 rounded-full bg-muted">
+            <span aria-hidden="true" className="material-icons-outlined text-5xl text-muted-foreground">
               search_off
             </span>
           </div>
@@ -1054,7 +1151,7 @@ export function BrowsePage() {
       ) : (
         <PreviewContext.Provider value={{ requestPreview, previews }}>
           <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 sm:gap-4">
-            {filteredPatterns.map((pattern) => (
+            {visiblePatterns.map((pattern) => (
               <PatternCard
                 key={pattern.path}
                 pattern={pattern}
@@ -1067,6 +1164,15 @@ export function BrowsePage() {
               />
             ))}
           </div>
+          {visibleCount < filteredPatterns.length && (
+            <>
+              {/* Sentinel: triggers loading more cards as the user scrolls */}
+              <div ref={sentinelRef} className="h-1" aria-hidden="true" />
+              <p className="text-xs text-muted-foreground text-center">
+                Showing {visiblePatterns.length} of {filteredPatterns.length} patterns
+              </p>
+            </>
+          )}
         </PreviewContext.Provider>
       )}
 
@@ -1097,7 +1203,7 @@ export function BrowsePage() {
                   }}
                   title={favorites.has(selectedPattern.path) ? 'Remove from favorites' : 'Add to favorites'}
                 >
-                  <span className="material-icons" style={{ fontSize: '20px' }}>
+                  <span aria-hidden="true" className="material-icons" style={{ fontSize: '20px' }}>
                     {favorites.has(selectedPattern.path) ? 'favorite' : 'favorite_border'}
                   </span>
                 </span>
@@ -1110,8 +1216,9 @@ export function BrowsePage() {
             <div className="p-6 overflow-y-auto flex-1">
               {/* Clickable Round Preview Image */}
               <div className="mb-6">
-                <div
-                  className="aspect-square w-full max-w-[280px] mx-auto overflow-hidden rounded-full border bg-muted relative group cursor-pointer"
+                <button
+                  type="button"
+                  className="block aspect-square w-full max-w-[280px] mx-auto overflow-hidden rounded-full border bg-muted relative group cursor-pointer"
                   onClick={handleOpenAnimatedPreview}
                 >
                   {getPreviewUrl(selectedPattern.path) ? (
@@ -1122,57 +1229,62 @@ export function BrowsePage() {
                     />
                   ) : (
                     <div className="w-full h-full flex items-center justify-center">
-                      <span className="material-icons-outlined text-4xl text-muted-foreground">
+                      <span aria-hidden="true" className="material-icons-outlined text-4xl text-muted-foreground">
                         image
                       </span>
                     </div>
                   )}
                   {/* Play badge - always visible */}
                   <div className="absolute bottom-2 right-2 bg-background/90 backdrop-blur-sm rounded-full w-10 h-10 flex items-center justify-center shadow-md border group-hover:scale-110 transition-transform">
-                    <span className="material-icons text-xl">play_arrow</span>
+                    <span aria-hidden="true" className="material-icons text-xl">play_arrow</span>
                   </div>
                   {/* Hover overlay */}
                   <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-black/20 rounded-full" />
-                </div>
+                </button>
                 <p className="text-xs text-muted-foreground text-center mt-2">Tap to preview animation</p>
               </div>
 
-              {/* Coordinates */}
-              <div className="mb-4 flex justify-between text-sm">
-                <div className="flex items-center gap-2">
-                  <span className="material-icons-outlined text-muted-foreground text-base">flag</span>
-                  <span className="text-muted-foreground">First:</span>
-                  <span className="font-semibold">
-                    {formatCoordinate(previews[selectedPattern.path]?.first_coordinate)}
-                  </span>
-                </div>
-                <div className="flex items-center gap-2">
-                  <span className="material-icons-outlined text-muted-foreground text-base">check</span>
-                  <span className="text-muted-foreground">Last:</span>
-                  <span className="font-semibold">
-                    {formatCoordinate(previews[selectedPattern.path]?.last_coordinate)}
-                  </span>
-                </div>
-              </div>
+              {/* Start/end position */}
+              {(() => {
+                const starts = describePosition(previews[selectedPattern.path]?.first_coordinate)
+                const ends = describePosition(previews[selectedPattern.path]?.last_coordinate)
+                if (!starts && !ends) return null
+                return (
+                  <p className="mb-4 text-sm text-muted-foreground">
+                    {starts && `Starts: ${starts}`}
+                    {starts && ends && ' · '}
+                    {ends && `Ends: ${ends}`}
+                  </p>
+                )
+              })()}
 
               {/* Play History Info */}
               {(() => {
                 const historyKey = selectedPattern.path.split('/').pop() || ''
                 const playCount = allPatternHistories[historyKey]?.play_count ?? 0
+                const estMinutes =
+                  !patternHistory?.actual_time_formatted && selectedPattern.coordinates_count > 0
+                    ? Math.max(1, Math.round(selectedPattern.coordinates_count / 18 / 60))
+                    : null
                 return (
                   <>
-                    {(patternHistory?.actual_time_formatted || playCount > 0) && (
+                    {(patternHistory?.actual_time_formatted || estMinutes !== null || playCount > 0) && (
                       <div className="mb-4 flex justify-between text-sm">
-                        {patternHistory?.actual_time_formatted && (
+                        {patternHistory?.actual_time_formatted ? (
                           <div className="flex items-center gap-2">
-                            <span className="material-icons-outlined text-muted-foreground text-base">schedule</span>
+                            <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-base">schedule</span>
                             <span className="text-muted-foreground">Last run:</span>
                             <span className="font-semibold">{patternHistory.actual_time_formatted}</span>
                           </div>
+                        ) : estMinutes !== null && (
+                          <div className="flex items-center gap-2">
+                            <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-base">schedule</span>
+                            <span className="text-muted-foreground">Est. ~{estMinutes} min</span>
+                          </div>
                         )}
                         {playCount > 0 && (
                           <div className="flex items-center gap-2">
-                            <span className="material-icons-outlined text-muted-foreground text-base">play_circle</span>
+                            <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-base">play_circle</span>
                             <span className="text-muted-foreground">Plays:</span>
                             <span className="font-semibold">{playCount}</span>
                           </div>
@@ -1219,14 +1331,15 @@ export function BrowsePage() {
                 <div className="flex gap-2">
                   <Button
                     onClick={handleRunPattern}
-                    disabled={isRunning}
+                    disabled={isRunning || !tableConnected}
+                    title={!tableConnected ? 'Table not connected' : undefined}
                     className="flex-1 gap-2"
                     size="lg"
                   >
                     {isRunning ? (
-                      <span className="material-icons-outlined animate-spin text-lg">sync</span>
+                      <span aria-hidden="true" className="material-icons-outlined animate-spin text-lg">sync</span>
                     ) : (
-                      <span className="material-icons text-lg">play_arrow</span>
+                      <span aria-hidden="true" className="material-icons text-lg">play_arrow</span>
                     )}
                     Play
                   </Button>
@@ -1235,10 +1348,11 @@ export function BrowsePage() {
                     <Button
                       variant="outline"
                       onClick={handleDeletePattern}
+                      aria-label="Delete pattern"
                       className="text-destructive hover:bg-destructive/10 hover:border-destructive px-3"
                       size="lg"
                     >
-                      <span className="material-icons text-lg">delete</span>
+                      <span aria-hidden="true" className="material-icons text-lg">delete</span>
                     </Button>
                   )}
                 </div>
@@ -1250,8 +1364,10 @@ export function BrowsePage() {
                     size="sm"
                     className="flex-1 gap-1.5"
                     onClick={() => handleAddToQueue('next')}
+                    disabled={!playlistActive}
+                    title={!playlistActive ? 'Available while a playlist is playing' : undefined}
                   >
-                    <span className="material-icons-outlined text-base">playlist_play</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-base">playlist_play</span>
                     Play Next
                   </Button>
                   <Button
@@ -1259,8 +1375,10 @@ export function BrowsePage() {
                     size="sm"
                     className="flex-1 gap-1.5"
                     onClick={() => handleAddToQueue('end')}
+                    disabled={!playlistActive}
+                    title={!playlistActive ? 'Available while a playlist is playing' : undefined}
                   >
-                    <span className="material-icons-outlined text-base">playlist_add</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-base">playlist_add</span>
                     Add to Queue
                   </Button>
                 </div>
@@ -1270,6 +1388,19 @@ export function BrowsePage() {
         </SheetContent>
       </Sheet>
 
+      {/* Delete confirmation */}
+      <ConfirmDialog
+        open={patternToDelete !== null}
+        onOpenChange={(open) => {
+          if (!open) setPatternToDelete(null)
+        }}
+        title={`Delete "${patternToDelete?.name}"?`}
+        description="This cannot be undone."
+        confirmLabel="Delete"
+        destructive
+        onConfirm={confirmDeletePattern}
+      />
+
       {/* Animated Preview Modal */}
       {isAnimatedPreviewOpen && (
         <div
@@ -1289,9 +1420,10 @@ export function BrowsePage() {
                 variant="ghost"
                 size="icon"
                 onClick={handleCloseAnimatedPreview}
+                aria-label="Close preview"
                 className="rounded-full"
               >
-                <span className="material-icons text-2xl">close</span>
+                <span aria-hidden="true" className="material-icons text-2xl">close</span>
               </Button>
             </div>
 
@@ -1299,7 +1431,7 @@ export function BrowsePage() {
             <div className="p-6 overflow-y-auto flex-1 flex justify-center items-center">
               {isLoadingCoordinates ? (
                 <div className="w-full max-w-[400px] aspect-square flex items-center justify-center rounded-full bg-muted">
-                  <span className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
+                  <span aria-hidden="true" className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
                     sync
                   </span>
                 </div>
@@ -1309,6 +1441,8 @@ export function BrowsePage() {
                     ref={canvasRef}
                     width={400}
                     height={400}
+                    role="img"
+                    aria-label={`Animated preview of ${selectedPattern?.name || 'pattern'}`}
                     className="rounded-full w-full h-full"
                   />
                   {/* Play/Pause overlay */}
@@ -1317,7 +1451,7 @@ export function BrowsePage() {
                     onClick={handlePlayPause}
                   >
                     <div className="bg-background rounded-full w-16 h-16 flex items-center justify-center shadow-lg">
-                      <span className="material-icons text-3xl">
+                      <span aria-hidden="true" className="material-icons text-3xl">
                         {isPlaying ? 'pause' : 'play_arrow'}
                       </span>
                     </div>
@@ -1363,13 +1497,13 @@ export function BrowsePage() {
               {/* Control Buttons */}
               <div className="flex items-center justify-center gap-4">
                 <Button onClick={handlePlayPause} className="gap-2">
-                  <span className="material-icons">
+                  <span aria-hidden="true" className="material-icons">
                     {isPlaying ? 'pause' : 'play_arrow'}
                   </span>
                   {isPlaying ? 'Pause' : 'Play'}
                 </Button>
                 <Button variant="secondary" onClick={handleReset} className="gap-2">
-                  <span className="material-icons">replay</span>
+                  <span aria-hidden="true" className="material-icons">replay</span>
                   Reset
                 </Button>
               </div>
@@ -1435,7 +1569,7 @@ function PatternCard({ pattern, isSelected, isFavorite, playTime, playCount, onT
             <>
               {!imageLoaded && (
                 <div className="absolute inset-0 flex items-center justify-center">
-                  <span className="material-icons-outlined animate-spin text-xl text-muted-foreground">
+                  <span aria-hidden="true" className="material-icons-outlined animate-spin text-xl text-muted-foreground">
                     sync
                   </span>
                 </div>
@@ -1453,7 +1587,7 @@ function PatternCard({ pattern, isSelected, isFavorite, playTime, playCount, onT
             </>
           ) : (
             <div className="w-full h-full flex items-center justify-center">
-              <span className="material-icons-outlined text-2xl text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-2xl text-muted-foreground">
                 {imageError ? 'broken_image' : 'image'}
               </span>
             </div>
@@ -1466,13 +1600,13 @@ function PatternCard({ pattern, isSelected, isFavorite, playTime, playCount, onT
         <div className="flex items-center w-full px-0.5 -mb-1 justify-between">
           {playCount > 0 && (
             <span className="flex items-center gap-0.5 text-xs text-muted-foreground" title={`Played ${playCount} time${playCount !== 1 ? 's' : ''}`}>
-              <span className="material-icons-outlined" style={{ fontSize: '13px' }}>play_circle</span>
+              <span aria-hidden="true" className="material-icons-outlined" style={{ fontSize: '13px' }}>play_circle</span>
               {playCount}x
             </span>
           )}
           {playTime && (
             <span className="flex items-center gap-0.5 text-xs text-muted-foreground ml-auto" title={`Last run: ${playTime}`}>
-              <span className="material-icons-outlined" style={{ fontSize: '13px' }}>schedule</span>
+              <span aria-hidden="true" className="material-icons-outlined" style={{ fontSize: '13px' }}>schedule</span>
               {(() => {
                 const colonMatch = playTime.match(/^(?:(\d+):)?(\d+):(\d+)$/)
                 if (colonMatch) {
@@ -1520,7 +1654,7 @@ function PatternCard({ pattern, isSelected, isFavorite, playTime, playCount, onT
           }}
           title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
         >
-          <span className="material-icons" style={{ fontSize: '16px' }}>
+          <span aria-hidden="true" className="material-icons" style={{ fontSize: '16px' }}>
             {isFavorite ? 'favorite' : 'favorite_border'}
           </span>
         </span>

+ 106 - 47
frontend/src/pages/LEDPage.tsx

@@ -23,6 +23,7 @@ import {
 } from '@/components/ui/select'
 import { Input } from '@/components/ui/input'
 import { ColorPicker } from '@/components/ui/color-picker'
+import { ConnectionBanner } from '@/components/ConnectionBanner'
 
 // Types
 interface LedConfig {
@@ -77,6 +78,8 @@ export function LEDPage() {
 
   // Ref for debouncing color picker API calls
   const colorDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  // Rate-limit color error toasts (color changes fire rapidly while dragging)
+  const colorErrorToastAtRef = useRef(0)
 
   // LED control mode
   const [controlMode, setControlMode] = useState<'manual' | 'automated'>('automated')
@@ -88,6 +91,27 @@ export function LEDPage() {
   const [idleTimeoutMinutes, setIdleTimeoutMinutes] = useState(30)
   const [idleTimeoutInput, setIdleTimeoutInput] = useState('30')
 
+  // WLED reachability: null = checking, true = reachable, false = unreachable
+  const [wledReachable, setWledReachable] = useState<boolean | null>(null)
+
+  const probeWled = useCallback(async (ip: string) => {
+    setWledReachable(null)
+    try {
+      // no-cors: body is opaque, but a resolved fetch means the host answered
+      await fetch(`http://${ip}/json/info`, { signal: AbortSignal.timeout(4000), mode: 'no-cors' })
+      setWledReachable(true)
+    } catch {
+      setWledReachable(false)
+    }
+  }, [])
+
+  // Probe WLED reachability on mount / when the configured IP changes
+  useEffect(() => {
+    if (ledConfig?.provider === 'wled' && ledConfig.wled_ip) {
+      probeWled(ledConfig.wled_ip)
+    }
+  }, [ledConfig?.provider, ledConfig?.wled_ip, probeWled])
+
   // Fetch LED configuration
   useEffect(() => {
     const fetchConfig = async () => {
@@ -190,11 +214,13 @@ export function LEDPage() {
   }
 
   const handleControlModeChange = async (mode: 'manual' | 'automated') => {
+    const previousMode = controlMode
     setControlMode(mode)
     try {
       await apiClient.patch('/api/settings', { led: { control_mode: mode } })
-      toast.success(mode === 'manual' ? 'Manual / HA mode' : 'DW Automated mode')
     } catch {
+      // Roll back the optimistic update so the UI doesn't desync from the server
+      setControlMode(previousMode)
       toast.error('Failed to update control mode')
     }
   }
@@ -203,7 +229,6 @@ export function LEDPage() {
     try {
       const data = await apiClient.post<{ connected?: boolean; power_on?: boolean; error?: string }>('/api/dw_leds/power', { state: 2 })
       if (data.connected) {
-        toast.success(`Power ${data.power_on ? 'ON' : 'OFF'}`)
         await fetchDWLedsStatus()
       } else {
         toast.error(data.error || 'Failed to toggle power')
@@ -219,10 +244,7 @@ export function LEDPage() {
 
   const handleBrightnessCommit = async (value: number[]) => {
     try {
-      const data = await apiClient.post<{ connected?: boolean }>('/api/dw_leds/brightness', { value: value[0] })
-      if (data.connected) {
-        toast.success(`Brightness: ${value[0]}%`)
-      }
+      await apiClient.post('/api/dw_leds/brightness', { value: value[0] })
     } catch {
       toast.error('Failed to set brightness')
     }
@@ -236,7 +258,6 @@ export function LEDPage() {
   const handleSpeedCommit = async (value: number[]) => {
     try {
       await apiClient.post('/api/dw_leds/speed', { speed: value[0] })
-      toast.success(`Speed: ${value[0]}`)
     } catch {
       toast.error('Failed to set speed')
     }
@@ -250,7 +271,6 @@ export function LEDPage() {
   const handleIntensityCommit = async (value: number[]) => {
     try {
       await apiClient.post('/api/dw_leds/intensity', { intensity: value[0] })
-      toast.success(`Intensity: ${value[0]}`)
     } catch {
       toast.error('Failed to set intensity')
     }
@@ -261,7 +281,6 @@ export function LEDPage() {
     try {
       const data = await apiClient.post<{ connected?: boolean; power_on?: boolean }>('/api/dw_leds/effect', { effect_id: parseInt(value) })
       if (data.connected) {
-        toast.success('Effect changed')
         if (data.power_on !== undefined) {
           const powerOn = data.power_on
           setDwStatus((prev) => prev ? { ...prev, power_on: powerOn } : null)
@@ -275,10 +294,7 @@ export function LEDPage() {
   const handlePaletteChange = async (value: string) => {
     setSelectedPalette(value)
     try {
-      const data = await apiClient.post<{ connected?: boolean }>('/api/dw_leds/palette', { palette_id: parseInt(value) })
-      if (data.connected) {
-        toast.success('Palette changed')
-      }
+      await apiClient.post('/api/dw_leds/palette', { palette_id: parseInt(value) })
     } catch {
       toast.error('Failed to set palette')
     }
@@ -311,6 +327,12 @@ export function LEDPage() {
         await apiClient.post('/api/dw_leds/colors', payload)
       } catch (error) {
         console.error('Failed to set color:', error)
+        // Rate-limit the toast: color changes fire rapidly, one error every 3s is enough
+        const now = Date.now()
+        if (now - colorErrorToastAtRef.current > 3000) {
+          colorErrorToastAtRef.current = now
+          toast.error('Failed to set LED color')
+        }
       }
     }, 300)
   }
@@ -373,7 +395,7 @@ export function LEDPage() {
   if (isLoading) {
     return (
       <div className="flex items-center justify-center min-h-[60vh]">
-        <span className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
+        <span className="material-icons-outlined animate-spin text-4xl text-muted-foreground" aria-hidden="true">
           sync
         </span>
       </div>
@@ -385,7 +407,7 @@ export function LEDPage() {
     return (
       <div className="flex flex-col items-center justify-center min-h-[60vh] gap-6 text-center px-4">
         <div className="p-4 rounded-full bg-muted">
-          <span className="material-icons-outlined text-5xl text-muted-foreground">
+          <span className="material-icons-outlined text-5xl text-muted-foreground" aria-hidden="true">
             lightbulb
           </span>
         </div>
@@ -397,7 +419,7 @@ export function LEDPage() {
         </div>
         <Button asChild className="gap-2">
           <Link to="/settings?section=led">
-            <span className="material-icons-outlined">settings</span>
+            <span className="material-icons-outlined" aria-hidden="true">settings</span>
             Go to Settings
           </Link>
         </Button>
@@ -410,7 +432,7 @@ export function LEDPage() {
     <Card>
       <CardHeader className="pb-3">
         <CardTitle className="text-lg flex items-center gap-2">
-          <span className="material-icons-outlined text-muted-foreground">tune</span>
+          <span className="material-icons-outlined text-muted-foreground" aria-hidden="true">tune</span>
           LED Control Mode
         </CardTitle>
         <CardDescription>
@@ -428,11 +450,11 @@ export function LEDPage() {
             }`}
           >
             <div className="flex items-center gap-2 mb-1">
-              <span className="material-icons-outlined text-base">front_hand</span>
+              <span className="material-icons-outlined text-base" aria-hidden="true">front_hand</span>
               <span className="font-medium text-sm">Manual / Home Assistant</span>
             </div>
             <p className="text-xs text-muted-foreground">
-              Effects persist until changed. Still Sands turns off only.
+              Effects persist until changed. During scheduled quiet hours (Still Sands), lights turn off only.
             </p>
           </button>
           <button
@@ -444,11 +466,11 @@ export function LEDPage() {
             }`}
           >
             <div className="flex items-center gap-2 mb-1">
-              <span className="material-icons-outlined text-base">smart_toy</span>
+              <span className="material-icons-outlined text-base" aria-hidden="true">smart_toy</span>
               <span className="font-medium text-sm">DW Automated</span>
             </div>
             <p className="text-xs text-muted-foreground">
-              Auto-switch effects on play/idle. Still Sands turns off and on.
+              Auto-switch effects on play/idle. During quiet hours, lights turn off and back on.
             </p>
           </button>
         </div>
@@ -458,16 +480,41 @@ export function LEDPage() {
 
   // WLED iframe view
   if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
+    const wledIp = ledConfig.wled_ip
     return (
       <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 py-3 sm:py-6 px-0 sm:px-4">
         {modeSelector}
-        <div style={{ height: 'calc(100vh - 380px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
-          <iframe
-            src={`http://${ledConfig.wled_ip}`}
-            className="w-full h-full rounded-lg border border-border"
-            title="WLED Control"
-          />
-        </div>
+        {wledReachable === null ? (
+          <div className="flex items-center justify-center py-12">
+            <span className="material-icons-outlined animate-spin text-3xl text-muted-foreground" aria-hidden="true">
+              sync
+            </span>
+          </div>
+        ) : wledReachable === false ? (
+          <Card>
+            <CardContent className="pt-6 flex flex-col items-center gap-3 text-center">
+              <span className="material-icons-outlined text-4xl text-muted-foreground" aria-hidden="true">
+                wifi_off
+              </span>
+              <p className="font-medium">Can't reach WLED at {wledIp}</p>
+              <p className="text-sm text-muted-foreground max-w-md">
+                Check that the WLED controller is powered on and the IP is correct in Settings → LED
+              </p>
+              <Button onClick={() => probeWled(wledIp)} className="gap-2">
+                <span className="material-icons-outlined text-base" aria-hidden="true">refresh</span>
+                Retry
+              </Button>
+            </CardContent>
+          </Card>
+        ) : (
+          <div style={{ height: 'calc(100vh - 380px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
+            <iframe
+              src={`http://${wledIp}`}
+              className="w-full h-full rounded-lg border border-border"
+              title="WLED Control"
+            />
+          </div>
+        )}
       </div>
     )
   }
@@ -481,6 +528,8 @@ export function LEDPage() {
         <p className="text-xs text-muted-foreground">DW LEDs - GPIO controlled LED strip</p>
       </div>
 
+      <ConnectionBanner />
+
       <Separator />
 
       {modeSelector}
@@ -503,7 +552,7 @@ export function LEDPage() {
                         : 'bg-muted hover:bg-muted/80'
                     }`}
                   >
-                    <span className={`material-icons text-4xl ${dwStatus?.power_on ? 'text-white' : 'text-muted-foreground'}`}>
+                    <span className={`material-icons text-4xl ${dwStatus?.power_on ? 'text-white' : 'text-muted-foreground'}`} aria-hidden="true">
                       power_settings_new
                     </span>
                   </button>
@@ -516,7 +565,7 @@ export function LEDPage() {
                 <div className="flex-1 w-full space-y-4">
                   {/* Connection Status */}
                   <div className={`flex items-center gap-2 text-sm ${dwStatus?.connected ? 'text-green-600' : 'text-destructive'}`}>
-                    <span className="material-icons-outlined text-base">
+                    <span className="material-icons-outlined text-base" aria-hidden="true">
                       {dwStatus?.connected ? 'check_circle' : 'error'}
                     </span>
                     {dwStatus?.connected
@@ -528,12 +577,13 @@ export function LEDPage() {
                   <div className="space-y-2">
                     <div className="flex justify-between items-center">
                       <Label>
-                        <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">brightness_6</span>
+                        <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground" aria-hidden="true">brightness_6</span>
                         Brightness
                       </Label>
                       <span className="text-sm font-medium">{brightness}%</span>
                     </div>
                     <Slider
+                      aria-label="Brightness"
                       value={[brightness]}
                       onValueChange={handleBrightnessChange}
                       onValueCommit={handleBrightnessCommit}
@@ -550,7 +600,7 @@ export function LEDPage() {
           <Card>
             <CardHeader className="pb-3">
               <CardTitle className="text-lg flex items-center gap-2">
-                <span className="material-icons-outlined text-muted-foreground">auto_awesome</span>
+                <span className="material-icons-outlined text-muted-foreground" aria-hidden="true">auto_awesome</span>
                 Effects & Palettes
               </CardTitle>
             </CardHeader>
@@ -558,9 +608,9 @@ export function LEDPage() {
               {/* Effect & Palette Selects */}
               <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
                 <div className="space-y-2">
-                  <Label>Effect</Label>
+                  <Label htmlFor="led-effect-select">Effect</Label>
                   <Select value={selectedEffect} onValueChange={handleEffectChange}>
-                    <SelectTrigger>
+                    <SelectTrigger id="led-effect-select">
                       <SelectValue placeholder="Select effect..." />
                     </SelectTrigger>
                     <SelectContent>
@@ -573,9 +623,9 @@ export function LEDPage() {
                   </Select>
                 </div>
                 <div className="space-y-2">
-                  <Label>Palette</Label>
+                  <Label htmlFor="led-palette-select">Palette</Label>
                   <Select value={selectedPalette} onValueChange={handlePaletteChange}>
-                    <SelectTrigger>
+                    <SelectTrigger id="led-palette-select">
                       <SelectValue placeholder="Select palette..." />
                     </SelectTrigger>
                     <SelectContent>
@@ -593,11 +643,12 @@ export function LEDPage() {
               <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex justify-between items-center">
-                    <Label>
-                      <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">speed</span>
+                    <Label htmlFor="led-speed-input">
+                      <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground" aria-hidden="true">speed</span>
                       Speed
                     </Label>
                     <Input
+                      id="led-speed-input"
                       type="text"
                       inputMode="numeric"
                       value={speedInput}
@@ -623,6 +674,7 @@ export function LEDPage() {
                     />
                   </div>
                   <Slider
+                    aria-label="Speed"
                     value={[speed]}
                     onValueChange={handleSpeedChange}
                     onValueCommit={handleSpeedCommit}
@@ -632,11 +684,12 @@ export function LEDPage() {
                 </div>
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex justify-between items-center">
-                    <Label>
-                      <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">tungsten</span>
+                    <Label htmlFor="led-intensity-input">
+                      <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground" aria-hidden="true">tungsten</span>
                       Intensity
                     </Label>
                     <Input
+                      id="led-intensity-input"
                       type="text"
                       inputMode="numeric"
                       value={intensityInput}
@@ -662,6 +715,7 @@ export function LEDPage() {
                     />
                   </div>
                   <Slider
+                    aria-label="Intensity"
                     value={[intensity]}
                     onValueChange={handleIntensityChange}
                     onValueCommit={handleIntensityCommit}
@@ -680,7 +734,7 @@ export function LEDPage() {
           <Card className="flex-1 flex flex-col">
             <CardHeader className="pb-3">
               <CardTitle className="text-lg flex items-center gap-2">
-                <span className="material-icons-outlined text-muted-foreground">palette</span>
+                <span className="material-icons-outlined text-muted-foreground" aria-hidden="true">palette</span>
                 Colors
               </CardTitle>
             </CardHeader>
@@ -716,7 +770,7 @@ export function LEDPage() {
           <Card className="flex-1 flex flex-col">
             <CardHeader className="pb-3">
               <CardTitle className="text-lg flex items-center gap-2">
-                <span className="material-icons-outlined text-muted-foreground">schedule</span>
+                <span className="material-icons-outlined text-muted-foreground" aria-hidden="true">schedule</span>
                 Auto Turn Off
               </CardTitle>
             </CardHeader>
@@ -724,6 +778,7 @@ export function LEDPage() {
               <div className="flex items-center justify-between">
                 <span className="text-sm text-muted-foreground">Enable timeout</span>
                 <Switch
+                  aria-label="Enable idle timeout"
                   checked={idleTimeoutEnabled}
                   onCheckedChange={handleIdleTimeoutToggle}
                 />
@@ -731,6 +786,7 @@ export function LEDPage() {
               {idleTimeoutEnabled && (
                 <div className="flex items-center gap-2">
                   <Input
+                    aria-label="Idle timeout in minutes"
                     type="text"
                     inputMode="numeric"
                     value={idleTimeoutInput}
@@ -742,6 +798,9 @@ export function LEDPage() {
                       const num = Math.min(1440, Math.max(1, parseInt(idleTimeoutInput) || 30))
                       setIdleTimeoutMinutes(num)
                       setIdleTimeoutInput(String(num))
+                      if (num !== idleTimeoutMinutes) {
+                        saveIdleTimeout(idleTimeoutEnabled, num)
+                      }
                     }}
                     onKeyDown={(e) => {
                       if (e.key === 'Enter') {
@@ -770,7 +829,7 @@ export function LEDPage() {
       <Card>
         <CardHeader className="pb-3">
           <CardTitle className="text-lg flex items-center gap-2">
-            <span className="material-icons-outlined text-muted-foreground">smart_toy</span>
+            <span className="material-icons-outlined text-muted-foreground" aria-hidden="true">smart_toy</span>
             Effect Automation
           </CardTitle>
           <CardDescription>
@@ -783,7 +842,7 @@ export function LEDPage() {
             <div className="p-4 rounded-lg border space-y-3">
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
-                  <span className="material-icons text-green-600">play_circle</span>
+                  <span className="material-icons text-green-600" aria-hidden="true">play_circle</span>
                   <span className="font-medium">While Playing</span>
                 </div>
               </div>
@@ -796,7 +855,7 @@ export function LEDPage() {
                   onClick={() => saveCurrentEffectSettings('playing')}
                   className="flex-1 gap-1"
                 >
-                  <span className="material-icons text-sm">save</span>
+                  <span className="material-icons text-sm" aria-hidden="true">save</span>
                   Save Current
                 </Button>
                 <Button
@@ -813,7 +872,7 @@ export function LEDPage() {
             <div className="p-4 rounded-lg border space-y-3">
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
-                  <span className="material-icons text-blue-600">bedtime</span>
+                  <span className="material-icons text-blue-600" aria-hidden="true">bedtime</span>
                   <span className="font-medium">When Idle</span>
                 </div>
               </div>
@@ -826,7 +885,7 @@ export function LEDPage() {
                   onClick={() => saveCurrentEffectSettings('idle')}
                   className="flex-1 gap-1"
                 >
-                  <span className="material-icons text-sm">save</span>
+                  <span className="material-icons text-sm" aria-hidden="true">save</span>
                   Save Current
                 </Button>
                 <Button

+ 331 - 80
frontend/src/pages/PlaylistsPage.tsx

@@ -13,6 +13,7 @@ import { useOnBackendConnected } from '@/hooks/useBackendConnection'
 import type { PatternMetadata, PreviewData, SortOption, PreExecution, RunMode } from '@/lib/types'
 import { preExecutionOptions } from '@/lib/types'
 import { Button } from '@/components/ui/button'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Separator } from '@/components/ui/separator'
@@ -30,6 +31,23 @@ import {
   DialogTitle,
   DialogFooter,
 } from '@/components/ui/dialog'
+import {
+  DndContext,
+  closestCenter,
+  KeyboardSensor,
+  PointerSensor,
+  useSensor,
+  useSensors,
+} from '@dnd-kit/core'
+import type { DragEndEvent } from '@dnd-kit/core'
+import {
+  SortableContext,
+  arrayMove,
+  rectSortingStrategy,
+  sortableKeyboardCoordinates,
+  useSortable,
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
 
 export function PlaylistsPage() {
   const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
@@ -53,6 +71,9 @@ export function PlaylistsPage() {
   // Pattern picker modal state
   const [isPickerOpen, setIsPickerOpen] = useState(false)
   const [selectedPatternPaths, setSelectedPatternPaths] = useState<Set<string>>(new Set())
+  // Selection snapshot taken when the picker opens — used to detect unsaved changes
+  const initialPickerSelectionRef = useRef<Set<string>>(new Set())
+  const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
   const [searchQuery, setSearchQuery] = useState('')
   const [selectedCategory, setSelectedCategory] = useState<string>('all')
   const [sortBy, setSortBy] = useState<SortOption>('name')
@@ -67,6 +88,12 @@ export function PlaylistsPage() {
   const [newPlaylistName, setNewPlaylistName] = useState('')
   const [playlistToRename, setPlaylistToRename] = useState<string | null>(null)
 
+  // Guards create/rename/save-patterns confirm buttons against double-submit
+  const [isSubmitting, setIsSubmitting] = useState(false)
+
+  // Delete confirmation dialog
+  const [playlistToDelete, setPlaylistToDelete] = useState<string | null>(null)
+
   // Mobile view state - show content panel when a playlist is selected
   const [mobileShowContent, setMobileShowContent] = useState(false)
 
@@ -187,6 +214,12 @@ export function PlaylistsPage() {
   // Preview loading
   const pendingPreviewsRef = useRef<Set<string>>(new Set())
   const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  // Mirror of `previews` so requestPreview stays identity-stable — see
+  // BrowsePage: depending on the state would churn every observer per load
+  const previewsRef = useRef(previews)
+  useEffect(() => {
+    previewsRef.current = previews
+  }, [previews])
   const abortControllerRef = useRef<AbortController | null>(null)
 
   // Initialize and fetch data
@@ -323,7 +356,7 @@ export function PlaylistsPage() {
   }
 
   const requestPreview = useCallback((path: string) => {
-    if (previews[path] || pendingPreviewsRef.current.has(path)) return
+    if (previewsRef.current[path] || pendingPreviewsRef.current.has(path)) return
 
     pendingPreviewsRef.current.add(path)
 
@@ -338,7 +371,7 @@ export function PlaylistsPage() {
         loadPreviewsForPaths(pathsToFetch)
       }
     }, 100)
-  }, [previews])
+  }, [])
 
   // Playlist CRUD operations
   const handleSelectPlaylist = (name: string) => {
@@ -353,12 +386,14 @@ export function PlaylistsPage() {
   }
 
   const handleCreatePlaylist = async () => {
+    if (isSubmitting) return
     if (!newPlaylistName.trim()) {
       toast.error('Please enter a playlist name')
       return
     }
 
     const name = newPlaylistName.trim()
+    setIsSubmitting(true)
     try {
       await apiClient.post('/create_playlist', { playlist_name: name, files: [] })
       toast.success('Playlist created')
@@ -369,12 +404,16 @@ export function PlaylistsPage() {
     } catch (error) {
       console.error('Create playlist error:', error)
       toast.error(error instanceof Error ? error.message : 'Failed to create playlist')
+    } finally {
+      setIsSubmitting(false)
     }
   }
 
   const handleRenamePlaylist = async () => {
+    if (isSubmitting) return
     if (!playlistToRename || !newPlaylistName.trim()) return
 
+    setIsSubmitting(true)
     try {
       await apiClient.post('/rename_playlist', { old_name: playlistToRename, new_name: newPlaylistName.trim() })
       toast.success('Playlist renamed')
@@ -387,12 +426,12 @@ export function PlaylistsPage() {
       }
     } catch (error) {
       toast.error('Failed to rename playlist')
+    } finally {
+      setIsSubmitting(false)
     }
   }
 
   const handleDeletePlaylist = async (name: string) => {
-    if (!confirm(`Delete playlist "${name}"?`)) return
-
     try {
       await apiClient.delete('/delete_playlist', { playlist_name: name })
       toast.success('Playlist deleted')
@@ -419,18 +458,78 @@ export function PlaylistsPage() {
     }
   }
 
+  // Drag-and-drop sensors for reordering playlist patterns
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: {
+        distance: 8, // Require 8px movement before starting drag
+      },
+    }),
+    useSensor(KeyboardSensor, {
+      coordinateGetter: sortableKeyboardCoordinates,
+    })
+  )
+
+  // Reorder patterns within the selected playlist and persist immediately
+  const handlePatternDragEnd = async (event: DragEndEvent) => {
+    const { active, over } = event
+    if (!selectedPlaylist || !over || active.id === over.id) return
+
+    // Sortable ids are `${path}-${index}` (paths may repeat in a playlist)
+    const ids = playlistPatterns.map((p, i) => `${p}-${i}`)
+    const oldIndex = ids.indexOf(active.id.toString())
+    const newIndex = ids.indexOf(over.id.toString())
+    if (oldIndex === -1 || newIndex === -1) return
+
+    const previousPatterns = playlistPatterns
+    const newPatterns = arrayMove(playlistPatterns, oldIndex, newIndex)
+    setPlaylistPatterns(newPatterns)
+    try {
+      await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
+    } catch (error) {
+      // Roll back the optimistic reorder on failure
+      setPlaylistPatterns(previousPatterns)
+      toast.error('Failed to reorder playlist')
+    }
+  }
+
   // Pattern picker modal
   const openPatternPicker = () => {
     setSelectedPatternPaths(new Set(playlistPatterns))
+    initialPickerSelectionRef.current = new Set(playlistPatterns)
     setSearchQuery('')
     setIsPickerOpen(true)
     // Previews are lazy-loaded via IntersectionObserver in LazyPatternPreview
   }
 
+  // Closing the picker (cancel, escape, overlay click) with unsaved selection
+  // changes prompts for confirmation instead of silently discarding them
+  const handlePickerOpenChange = (open: boolean) => {
+    if (!open) {
+      const initial = initialPickerSelectionRef.current
+      const hasChanges =
+        selectedPatternPaths.size !== initial.size ||
+        Array.from(selectedPatternPaths).some((p) => !initial.has(p))
+      if (hasChanges) {
+        setShowDiscardConfirm(true)
+        return
+      }
+    }
+    setIsPickerOpen(open)
+  }
+
   const handleSavePatterns = async () => {
+    if (isSubmitting) return
     if (!selectedPlaylist) return
 
-    const newPatterns = Array.from(selectedPatternPaths)
+    // Preserve the existing playlist order — Set insertion order would move a
+    // deselected-then-reselected pattern to the end. Keep surviving patterns
+    // in place and append only genuinely new selections.
+    const newPatterns = [
+      ...playlistPatterns.filter((p) => selectedPatternPaths.has(p)),
+      ...Array.from(selectedPatternPaths).filter((p) => !playlistPatterns.includes(p)),
+    ]
+    setIsSubmitting(true)
     try {
       await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
       setPlaylistPatterns(newPatterns)
@@ -439,6 +538,8 @@ export function PlaylistsPage() {
       // Previews are lazy-loaded via IntersectionObserver
     } catch (error) {
       toast.error('Failed to update playlist')
+    } finally {
+      setIsSubmitting(false)
     }
   }
 
@@ -557,6 +658,26 @@ export function PlaylistsPage() {
     return preview?.image_data || null
   }
 
+  // Copy for the delete-confirmation dialog. Pattern count is only known for
+  // the selected playlist (and Favorites, which is loaded separately) — omit
+  // it gracefully otherwise.
+  const deletePatternCount =
+    playlistToDelete !== null && playlistToDelete === selectedPlaylist
+      ? playlistPatterns.length
+      : playlistToDelete === 'Favorites'
+        ? favorites.size
+        : null
+  const deleteDescription = [
+    deletePatternCount !== null
+      ? `This playlist contains ${deletePatternCount} pattern${deletePatternCount === 1 ? '' : 's'}. This cannot be undone.`
+      : 'This cannot be undone.',
+    playlistToDelete === 'Favorites'
+      ? 'This is your Favorites list — deleting it also clears all hearted patterns.'
+      : null,
+  ]
+    .filter(Boolean)
+    .join(' ')
+
   return (
     <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4 overflow-hidden" style={{ height: 'calc(100dvh - 14rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
       {/* Page Header */}
@@ -585,12 +706,13 @@ export function PlaylistsPage() {
                 variant="ghost"
                 size="icon"
                 className="h-8 w-8"
+                aria-label="Create playlist"
                 onClick={() => {
                   setNewPlaylistName('')
                   setIsCreateModalOpen(true)
                 }}
               >
-                <span className="material-icons-outlined text-xl">add</span>
+                <span aria-hidden="true" className="material-icons-outlined text-xl">add</span>
               </Button>
             )}
           </div>
@@ -602,30 +724,34 @@ export function PlaylistsPage() {
             </div>
           ) : playlists.length === 0 ? (
             <div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
-              <span className="material-icons-outlined text-3xl">playlist_add</span>
+              <span aria-hidden="true" className="material-icons-outlined text-3xl">playlist_add</span>
               <span className="text-sm">No playlists yet</span>
             </div>
           ) : (
             playlists.map(name => (
               <div
                 key={name}
-                className={`group flex items-center justify-between rounded-lg px-3 py-2 cursor-pointer transition-colors ${
+                className={`group flex items-center justify-between rounded-lg transition-colors ${
                   selectedPlaylist === name
                     ? 'bg-accent text-accent-foreground'
                     : 'hover:bg-muted text-muted-foreground'
                 }`}
-                onClick={() => handleSelectPlaylist(name)}
               >
-                <div className="flex items-center gap-2 min-w-0">
-                  <span className="material-icons-outlined text-lg">playlist_play</span>
+                <button
+                  type="button"
+                  className="flex items-center gap-2 min-w-0 flex-1 w-full text-left px-3 py-2 cursor-pointer rounded-lg"
+                  onClick={() => handleSelectPlaylist(name)}
+                >
+                  <span aria-hidden="true" className="material-icons-outlined text-lg">playlist_play</span>
                   <span className="truncate text-sm font-medium">{name}</span>
-                </div>
+                </button>
                 {!isPlayOnlyActive && (
-                  <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
+                  <div className="flex items-center gap-1 pr-2 opacity-100 pointer-fine:opacity-0 pointer-fine:group-hover:opacity-100 pointer-fine:group-focus-within:opacity-100 transition-opacity">
                     <Button
                       variant="ghost"
                       size="icon-sm"
                       className="h-7 w-7"
+                      aria-label={`Rename playlist ${name}`}
                       onClick={(e) => {
                         e.stopPropagation()
                         setPlaylistToRename(name)
@@ -633,15 +759,16 @@ export function PlaylistsPage() {
                         setIsRenameModalOpen(true)
                       }}
                     >
-                      <span className="material-icons-outlined text-base">edit</span>
+                      <span aria-hidden="true" className="material-icons-outlined text-base">edit</span>
                     </Button>
                     <Button
                       variant="ghost"
                       size="icon-sm"
                       className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/20"
+                      aria-label={`Delete playlist ${name}`}
                       onClick={(e) => {
                         e.stopPropagation()
-                        handleDeletePlaylist(name)
+                        setPlaylistToDelete(name)
                       }}
                     >
                       <Trash2 className="h-4 w-4" />
@@ -670,9 +797,10 @@ export function PlaylistsPage() {
                 variant="ghost"
                 size="icon"
                 className="h-8 w-8 lg:hidden shrink-0"
+                aria-label="Back to playlists"
                 onClick={handleMobileBack}
               >
-                <span className="material-icons-outlined">arrow_back</span>
+                <span aria-hidden="true" className="material-icons-outlined">arrow_back</span>
               </Button>
               <div className="min-w-0">
                 <h2 className="text-lg font-semibold truncate">
@@ -692,7 +820,7 @@ export function PlaylistsPage() {
                 size="sm"
                 className="gap-2"
               >
-                <span className="material-icons-outlined text-base">add</span>
+                <span aria-hidden="true" className="material-icons-outlined text-base">add</span>
                 <span className="hidden sm:inline">Add Patterns</span>
               </Button>
             )}
@@ -703,7 +831,7 @@ export function PlaylistsPage() {
             {!selectedPlaylist ? (
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
                 <div className="p-4 rounded-full bg-muted">
-                  <span className="material-icons-outlined text-5xl">touch_app</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-5xl">touch_app</span>
                 </div>
                 <div className="text-center">
                   <p className="font-medium">No playlist selected</p>
@@ -713,7 +841,7 @@ export function PlaylistsPage() {
             ) : playlistPatterns.length === 0 ? (
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
                 <div className="p-4 rounded-full bg-muted">
-                  <span className="material-icons-outlined text-5xl">library_music</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-5xl">library_music</span>
                 </div>
                 <div className="text-center">
                   <p className="font-medium">Empty playlist</p>
@@ -721,41 +849,37 @@ export function PlaylistsPage() {
                 </div>
                 {!isPlayOnlyActive && (
                   <Button variant="secondary" className="mt-2 gap-2" onClick={openPatternPicker}>
-                    <span className="material-icons-outlined text-base">add</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-base">add</span>
                     Add Patterns
                   </Button>
                 )}
               </div>
             ) : (
-              <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
-                {playlistPatterns.map((path, index) => (
-                  <div
-                    key={`${path}-${index}`}
-                    className="flex flex-col items-center gap-1.5 sm:gap-2 group"
-                  >
-                    <div className="relative w-full aspect-square">
-                      <div className="w-full h-full rounded-full overflow-hidden border bg-muted hover:ring-2 hover:ring-primary hover:ring-offset-2 hover:ring-offset-background transition-all cursor-pointer">
-                        <LazyPatternPreview
-                          path={path}
-                          previewUrl={getPreviewUrl(path)}
-                          requestPreview={requestPreview}
-                          alt={getPatternName(path)}
-                        />
-                      </div>
-                      {!isPlayOnlyActive && (
-                        <button
-                          className="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10"
-                          onClick={() => handleRemovePattern(path)}
-                          title="Remove from playlist"
-                        >
-                          <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
-                        </button>
-                      )}
-                    </div>
-                    <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
+              <DndContext
+                sensors={sensors}
+                collisionDetection={closestCenter}
+                onDragEnd={handlePatternDragEnd}
+              >
+                <SortableContext
+                  items={playlistPatterns.map((path, index) => `${path}-${index}`)}
+                  strategy={rectSortingStrategy}
+                >
+                  <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
+                    {playlistPatterns.map((path, index) => (
+                      <SortablePlaylistPattern
+                        key={`${path}-${index}`}
+                        id={`${path}-${index}`}
+                        path={path}
+                        name={getPatternName(path)}
+                        previewUrl={getPreviewUrl(path)}
+                        requestPreview={requestPreview}
+                        canEdit={!isPlayOnlyActive}
+                        onRemove={() => handleRemovePattern(path)}
+                      />
+                    ))}
                   </div>
-                ))}
-              </div>
+                </SortableContext>
+              </DndContext>
             )}
           </div>
 
@@ -779,8 +903,10 @@ export function PlaylistsPage() {
                           : 'text-muted-foreground hover:bg-muted'
                       }`}
                       title="Shuffle"
+                      aria-label="Shuffle"
+                      aria-pressed={shuffle}
                     >
-                      <span className="material-icons-outlined text-lg sm:text-xl">shuffle</span>
+                      <span aria-hidden="true" className="material-icons-outlined text-lg sm:text-xl">shuffle</span>
                     </button>
                     <button
                       onClick={() => setRunMode(runMode === 'indefinite' ? 'single' : 'indefinite')}
@@ -790,8 +916,10 @@ export function PlaylistsPage() {
                           : 'text-muted-foreground hover:bg-muted'
                       }`}
                       title={runMode === 'indefinite' ? 'Loop mode' : 'Play once mode'}
+                      aria-label="Loop playlist"
+                      aria-pressed={runMode === 'indefinite'}
                     >
-                      <span className="material-icons-outlined text-lg sm:text-xl">repeat</span>
+                      <span aria-hidden="true" className="material-icons-outlined text-lg sm:text-xl">repeat</span>
                     </button>
                   </div>
 
@@ -803,6 +931,7 @@ export function PlaylistsPage() {
                         variant="secondary"
                         size="icon"
                         className="w-7 h-7 sm:w-8 sm:h-8"
+                        aria-label="Decrease pause time"
                         onClick={() => {
                           const step = pauseUnit === 'hr' ? 0.5 : 1
                           // 'per_day' is plays-per-day, so it must stay >= 1.
@@ -810,7 +939,7 @@ export function PlaylistsPage() {
                           setPauseTime(Math.max(min, pauseTime - step))
                         }}
                       >
-                        <span className="material-icons-outlined text-sm">remove</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-sm">remove</span>
                       </Button>
                       <button
                         onClick={() => {
@@ -825,16 +954,18 @@ export function PlaylistsPage() {
                         }}
                         className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold hover:text-primary transition"
                         title={pauseUnit === 'per_day' ? 'Plays per day (timed from pattern start). Click to change unit.' : 'Click to change unit'}
+                        aria-label={`Pause unit: ${pauseUnit}. Activate to change unit`}
                       >
                         {pauseUnit === 'per_day'
                           ? `${pauseTime}/day`
                           : `${pauseTime}${pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}`}
-                        <span className="material-icons-outlined text-xs opacity-50 scale-75 ml-0.5">swap_vert</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-xs opacity-50 scale-75 ml-0.5">swap_vert</span>
                       </button>
                       <Button
                         variant="secondary"
                         size="icon"
                         className="w-7 h-7 sm:w-8 sm:h-8"
+                        aria-label="Increase pause time"
                         onClick={() => {
                           const step = pauseUnit === 'hr' ? 0.5 : 1
                           // Cap per_day at 24 (one start every hour) — beyond that the cadence
@@ -843,7 +974,7 @@ export function PlaylistsPage() {
                           setPauseTime(Math.min(max, pauseTime + step))
                         }}
                       >
-                        <span className="material-icons-outlined text-sm">add</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-sm">add</span>
                       </Button>
                     </div>
                   </div>
@@ -851,19 +982,22 @@ export function PlaylistsPage() {
                   {/* Clear Pattern Dropdown */}
                   <div className="flex items-center px-1 sm:px-2">
                     <Select value={clearPattern} onValueChange={(v) => setClearPattern(v as PreExecution)}>
-                      <SelectTrigger className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full border-0 p-0 shadow-none focus:ring-0 justify-center [&>svg]:hidden transition ${
+                      <SelectTrigger aria-label="Clear pattern option" className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full border-0 p-0 shadow-none focus:ring-0 justify-center [&>svg]:hidden transition ${
                         clearPattern !== 'none' ? '!bg-primary/10' : '!bg-transparent hover:!bg-muted'
                       }`}>
-                        <span className={`material-icons-outlined text-lg sm:text-xl ${
+                        <span aria-hidden="true" className={`material-icons-outlined text-lg sm:text-xl ${
                           clearPattern !== 'none' ? 'text-primary' : 'text-muted-foreground'
                         }`}>cleaning_services</span>
                       </SelectTrigger>
-                      <SelectContent>
+                      <SelectContent className="max-w-[min(22rem,calc(100vw-2.5rem))]">
                         {preExecutionOptions.map(opt => (
                           <SelectItem key={opt.value} value={opt.value}>
                             <div>
                               <div>{opt.label}</div>
-                              <div className="text-xs text-muted-foreground font-normal">{opt.description}</div>
+                              {/* Descriptions overwhelm small screens — labels only there */}
+                              <div className="hidden sm:block text-xs text-muted-foreground font-normal">
+                                {opt.description}
+                              </div>
                             </div>
                           </SelectItem>
                         ))}
@@ -878,11 +1012,12 @@ export function PlaylistsPage() {
                   disabled={isRunning || playlistPatterns.length === 0}
                   className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground text-primary-foreground shadow-lg shadow-primary/30 hover:shadow-primary/50 hover:scale-105 disabled:shadow-none disabled:hover:scale-100 transition-all duration-200 flex items-center justify-center"
                   title="Run Playlist"
+                  aria-label="Run playlist"
                 >
                   {isRunning ? (
-                    <span className="material-icons-outlined text-xl sm:text-2xl animate-spin">sync</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-xl sm:text-2xl animate-spin">sync</span>
                   ) : (
-                    <span className="material-icons text-xl sm:text-2xl ml-0.5">play_arrow</span>
+                    <span aria-hidden="true" className="material-icons text-xl sm:text-2xl ml-0.5">play_arrow</span>
                   )}
                 </button>
               </div>
@@ -896,7 +1031,7 @@ export function PlaylistsPage() {
         <DialogContent className="sm:max-w-md">
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
-              <span className="material-icons-outlined text-primary">playlist_add</span>
+              <span aria-hidden="true" className="material-icons-outlined text-primary">playlist_add</span>
               Create New Playlist
             </DialogTitle>
           </DialogHeader>
@@ -917,8 +1052,8 @@ export function PlaylistsPage() {
             <Button variant="secondary" onClick={() => setIsCreateModalOpen(false)}>
               Cancel
             </Button>
-            <Button onClick={handleCreatePlaylist} className="gap-2">
-              <span className="material-icons-outlined text-base">add</span>
+            <Button onClick={handleCreatePlaylist} disabled={isSubmitting} className="gap-2">
+              <span aria-hidden="true" className="material-icons-outlined text-base">add</span>
               Create Playlist
             </Button>
           </DialogFooter>
@@ -930,7 +1065,7 @@ export function PlaylistsPage() {
         <DialogContent className="sm:max-w-md">
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
-              <span className="material-icons-outlined text-primary">edit</span>
+              <span aria-hidden="true" className="material-icons-outlined text-primary">edit</span>
               Rename Playlist
             </DialogTitle>
           </DialogHeader>
@@ -951,8 +1086,8 @@ export function PlaylistsPage() {
             <Button variant="secondary" onClick={() => setIsRenameModalOpen(false)}>
               Cancel
             </Button>
-            <Button onClick={handleRenamePlaylist} className="gap-2">
-              <span className="material-icons-outlined text-base">save</span>
+            <Button onClick={handleRenamePlaylist} disabled={isSubmitting} className="gap-2">
+              <span aria-hidden="true" className="material-icons-outlined text-base">save</span>
               Save Name
             </Button>
           </DialogFooter>
@@ -960,11 +1095,11 @@ export function PlaylistsPage() {
       </Dialog>
 
       {/* Pattern Picker Modal */}
-      <Dialog open={isPickerOpen} onOpenChange={setIsPickerOpen}>
+      <Dialog open={isPickerOpen} onOpenChange={handlePickerOpenChange}>
         <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
-              <span className="material-icons-outlined text-primary">playlist_add</span>
+              <span aria-hidden="true" className="material-icons-outlined text-primary">playlist_add</span>
               Add Patterns to {selectedPlaylist}
             </DialogTitle>
           </DialogHeader>
@@ -972,7 +1107,7 @@ export function PlaylistsPage() {
           {/* Search and Filters */}
           <div className="space-y-3 py-2">
             <div className="relative">
-              <span className="material-icons-outlined absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-lg">
+              <span aria-hidden="true" className="material-icons-outlined absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-lg">
                 search
               </span>
               <Input
@@ -984,9 +1119,10 @@ export function PlaylistsPage() {
               {searchQuery && (
                 <button
                   className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                  aria-label="Clear search"
                   onClick={() => setSearchQuery('')}
                 >
-                  <span className="material-icons-outlined text-lg">close</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-lg">close</span>
                 </button>
               )}
             </div>
@@ -995,7 +1131,7 @@ export function PlaylistsPage() {
               {/* Folder dropdown - icon only on mobile, with text on sm+ */}
               <Select value={selectedCategory} onValueChange={setSelectedCategory}>
                 <SelectTrigger className="h-9 w-9 sm:w-auto rounded-full bg-card border-border shadow-sm text-sm px-0 sm:px-3 justify-center sm:justify-between [&>svg]:hidden sm:[&>svg]:block [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
-                  <span className="material-icons-outlined text-lg shrink-0">folder</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-lg shrink-0">folder</span>
                   <SelectValue />
                 </SelectTrigger>
                 <SelectContent>
@@ -1010,7 +1146,7 @@ export function PlaylistsPage() {
               {/* Sort dropdown - icon only on mobile, with text on sm+ */}
               <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
                 <SelectTrigger className="h-9 w-9 sm:w-auto rounded-full bg-card border-border shadow-sm text-sm px-0 sm:px-3 justify-center sm:justify-between [&>svg]:hidden sm:[&>svg]:block [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
-                  <span className="material-icons-outlined text-lg shrink-0">sort</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-lg shrink-0">sort</span>
                   <SelectValue />
                 </SelectTrigger>
                 <SelectContent>
@@ -1031,7 +1167,7 @@ export function PlaylistsPage() {
                 onClick={() => setSortAsc(!sortAsc)}
                 title={sortAsc ? 'Ascending' : 'Descending'}
               >
-                <span className="material-icons-outlined text-lg">
+                <span aria-hidden="true" className="material-icons-outlined text-lg">
                   {sortAsc ? 'arrow_upward' : 'arrow_downward'}
                 </span>
               </Button>
@@ -1057,7 +1193,7 @@ export function PlaylistsPage() {
                   })
                 }}
               >
-                <span className="material-icons-outlined text-base">
+                <span aria-hidden="true" className="material-icons-outlined text-base">
                   {filteredPatterns.length > 0 && filteredPatterns.every(p => selectedPatternPaths.has(p.path)) ? 'deselect' : 'select_all'}
                 </span>
                 <span className="hidden sm:inline">
@@ -1067,7 +1203,7 @@ export function PlaylistsPage() {
 
               {/* Selection count - compact on mobile */}
               <div className="flex items-center gap-1 sm:gap-2 text-sm bg-card rounded-full px-2 sm:px-3 py-2 shadow-sm border">
-                <span className="material-icons-outlined text-base text-primary">check_circle</span>
+                <span aria-hidden="true" className="material-icons-outlined text-base text-primary">check_circle</span>
                 <span className="font-medium">{selectedPatternPaths.size}</span>
                 <span className="hidden sm:inline text-muted-foreground">selected</span>
               </div>
@@ -1079,7 +1215,7 @@ export function PlaylistsPage() {
             {filteredPatterns.length === 0 ? (
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
                 <div className="p-4 rounded-full bg-muted">
-                  <span className="material-icons-outlined text-5xl">search_off</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-5xl">search_off</span>
                 </div>
                 <span className="text-sm">No patterns found</span>
               </div>
@@ -1090,8 +1226,17 @@ export function PlaylistsPage() {
                   return (
                     <div
                       key={pattern.path}
+                      role="checkbox"
+                      aria-checked={isSelected}
+                      tabIndex={0}
                       className="flex flex-col items-center gap-2 cursor-pointer"
                       onClick={() => togglePatternSelection(pattern.path)}
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter' || e.key === ' ') {
+                          e.preventDefault()
+                          togglePatternSelection(pattern.path)
+                        }
+                      }}
                     >
                       <div
                         className={`relative w-full aspect-square rounded-full overflow-hidden border-2 bg-muted transition-all ${
@@ -1108,7 +1253,7 @@ export function PlaylistsPage() {
                         />
                         {isSelected && (
                           <div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-primary flex items-center justify-center shadow-md">
-                            <span className="material-icons text-primary-foreground" style={{ fontSize: '14px' }}>
+                            <span aria-hidden="true" className="material-icons text-primary-foreground" style={{ fontSize: '14px' }}>
                               check
                             </span>
                           </div>
@@ -1125,16 +1270,122 @@ export function PlaylistsPage() {
           </div>
 
           <DialogFooter className="gap-2 sm:gap-0">
-            <Button variant="secondary" onClick={() => setIsPickerOpen(false)}>
+            <Button variant="secondary" onClick={() => handlePickerOpenChange(false)}>
               Cancel
             </Button>
-            <Button onClick={handleSavePatterns} className="gap-2">
-              <span className="material-icons-outlined text-base">save</span>
+            <Button onClick={handleSavePatterns} disabled={isSubmitting} className="gap-2">
+              <span aria-hidden="true" className="material-icons-outlined text-base">save</span>
               Save Selection
             </Button>
           </DialogFooter>
         </DialogContent>
       </Dialog>
+
+      {/* Delete Playlist Confirmation */}
+      <ConfirmDialog
+        open={playlistToDelete !== null}
+        onOpenChange={(open) => {
+          if (!open) setPlaylistToDelete(null)
+        }}
+        title={`Delete "${playlistToDelete ?? ''}"?`}
+        description={deleteDescription}
+        confirmLabel="Delete"
+        destructive
+        onConfirm={() => {
+          if (playlistToDelete) handleDeletePlaylist(playlistToDelete)
+        }}
+      />
+
+      {/* Discard Picker Selection Confirmation */}
+      <ConfirmDialog
+        open={showDiscardConfirm}
+        onOpenChange={setShowDiscardConfirm}
+        title="Discard selection changes?"
+        description="Your unsaved pattern selection changes will be lost."
+        confirmLabel="Discard"
+        destructive
+        onConfirm={() => {
+          setShowDiscardConfirm(false)
+          setIsPickerOpen(false)
+        }}
+      />
+    </div>
+  )
+}
+
+// Sortable pattern card for drag-to-reorder within the selected playlist
+interface SortablePlaylistPatternProps {
+  id: string
+  path: string
+  name: string
+  previewUrl: string | null
+  requestPreview: (path: string) => void
+  canEdit: boolean
+  onRemove: () => void
+}
+
+function SortablePlaylistPattern({
+  id,
+  path,
+  name,
+  previewUrl,
+  requestPreview,
+  canEdit,
+  onRemove,
+}: SortablePlaylistPatternProps) {
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({ id })
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+    opacity: isDragging ? 0.5 : 1,
+    zIndex: isDragging ? 1000 : 'auto',
+  }
+
+  return (
+    <div
+      ref={setNodeRef}
+      style={style}
+      className="flex flex-col items-center gap-1.5 sm:gap-2 group"
+    >
+      <div className="relative w-full aspect-square">
+        <div className="w-full h-full rounded-full overflow-hidden border bg-muted hover:ring-2 hover:ring-primary hover:ring-offset-2 hover:ring-offset-background transition-all cursor-pointer">
+          <LazyPatternPreview
+            path={path}
+            previewUrl={previewUrl}
+            requestPreview={requestPreview}
+            alt={name}
+          />
+        </div>
+        {canEdit && (
+          <>
+            {/* Drag handle */}
+            <div
+              {...attributes}
+              {...listeners}
+              className="absolute -top-0.5 -left-0.5 sm:-top-1 sm:-left-1 w-5 h-5 rounded-full bg-card border flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10 cursor-grab active:cursor-grabbing touch-none"
+              aria-label={`Reorder ${name}`}
+            >
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground" style={{ fontSize: '12px' }}>drag_indicator</span>
+            </div>
+            <button
+              className="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10"
+              onClick={onRemove}
+              title="Remove from playlist"
+            >
+              <span aria-hidden="true" className="material-icons" style={{ fontSize: '12px' }}>close</span>
+            </button>
+          </>
+        )}
+      </div>
+      <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{name}</p>
     </div>
   )
 }
@@ -1183,7 +1434,7 @@ function LazyPatternPreview({ path, previewUrl, requestPreview, alt, className =
           className="w-full h-full object-cover pattern-preview"
         />
       ) : (
-        <span className="material-icons-outlined text-muted-foreground text-sm sm:text-base">
+        <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-sm sm:text-base">
           image
         </span>
       )}

+ 251 - 155
frontend/src/pages/SettingsPage.tsx

@@ -27,6 +27,7 @@ import {
 import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 import { SearchableSelect } from '@/components/ui/searchable-select'
 import { UpdateDialog } from '@/components/UpdateDialog'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
 
 // Types
 
@@ -182,9 +183,14 @@ export function SettingsPage() {
 
   // Security state
   const [securityMode, setSecurityMode] = useState<'off' | 'lockdown' | 'play_only'>('off')
+  const [savedSecurityMode, setSavedSecurityMode] = useState<'off' | 'lockdown' | 'play_only'>('off')
   const [securityPassword, setSecurityPassword] = useState('')
   const [securityPasswordConfirm, setSecurityPasswordConfirm] = useState('')
   const [hasExistingPassword, setHasExistingPassword] = useState(false)
+  const [securityOffConfirmOpen, setSecurityOffConfirmOpen] = useState(false)
+
+  // Logo removal confirmation
+  const [logoConfirmOpen, setLogoConfirmOpen] = useState(false)
 
   // Version state
   const [versionInfo, setVersionInfo] = useState<{
@@ -192,6 +198,7 @@ export function SettingsPage() {
     latest: string
     update_available: boolean
   } | null>(null)
+  const [versionError, setVersionError] = useState(false)
   const [updateDialogOpen, setUpdateDialogOpen] = useState(false)
 
   // Helper to scroll to element with header offset
@@ -273,11 +280,13 @@ export function SettingsPage() {
   }
 
   const fetchVersionInfo = async () => {
+    setVersionError(false)
     try {
       const data = await apiClient.get<{ current: string; latest: string; update_available: boolean }>('/api/version')
       setVersionInfo(data)
     } catch (error) {
       console.error('Failed to fetch version info:', error)
+      setVersionError(true)
     }
   }
 
@@ -337,11 +346,6 @@ export function SettingsPage() {
     }
   }
 
-  // Always fetch ports on mount since connection is the default section
-  useEffect(() => {
-    fetchPorts()
-  }, [])
-
   // Refetch when backend reconnects
   useOnBackendConnected(() => {
     fetchPorts()
@@ -413,6 +417,7 @@ export function SettingsPage() {
       // Set security settings
       if (data.security) {
         setSecurityMode(data.security.mode || 'off')
+        setSavedSecurityMode(data.security.mode || 'off')
         setHasExistingPassword(data.security.has_password || false)
       }
       // Set MQTT config from the same response
@@ -572,8 +577,6 @@ export function SettingsPage() {
   }
 
   const handleDeleteLogo = async () => {
-    if (!confirm('Remove custom logo and revert to default?')) return
-
     setIsLoading('logo')
     try {
       await apiClient.delete('/api/custom-logo')
@@ -732,6 +735,20 @@ export function SettingsPage() {
   }
 
   const handleSaveStillSandsSettings = async () => {
+    // Validate time slots before saving (only when enabled and slots exist)
+    if (stillSandsSettings.enabled && stillSandsSettings.time_slots.length > 0) {
+      for (let i = 0; i < stillSandsSettings.time_slots.length; i++) {
+        const slot = stillSandsSettings.time_slots[i]
+        if (slot.days === 'custom' && (!slot.custom_days || slot.custom_days.length === 0)) {
+          toast.error(`Period ${i + 1}: select at least one day`)
+          return
+        }
+        if (slot.start_time === slot.end_time) {
+          toast.error(`Period ${i + 1}: start and end time are the same`)
+          return
+        }
+      }
+    }
     setIsLoading('stillsands')
     try {
       await apiClient.patch('/api/settings', {
@@ -745,20 +762,49 @@ export function SettingsPage() {
     }
   }
 
-  const handleSaveRebootSettings = async () => {
-    setIsLoading('reboot')
+  const doSaveSecuritySettings = async () => {
+    setIsLoading('security')
     try {
-      await apiClient.patch('/api/settings', {
-        scheduled_reboot: rebootSettings,
-      })
-      toast.success('Scheduled reboot settings saved')
-    } catch (error) {
-      toast.error('Failed to save scheduled reboot settings')
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const payload: any = { security: { mode: securityMode } }
+      if (securityPassword) {
+        payload.security.password = securityPassword
+      }
+      await apiClient.patch('/api/settings', payload)
+      toast.success('Security settings saved')
+      setSecurityPassword('')
+      setSecurityPasswordConfirm('')
+      setHasExistingPassword(securityMode !== 'off')
+      setSavedSecurityMode(securityMode)
+      // Notify Layout to refetch security state
+      window.dispatchEvent(new CustomEvent('security-updated'))
+    } catch {
+      toast.error('Failed to save security settings')
     } finally {
       setIsLoading(null)
     }
   }
 
+  const handleSaveSecuritySettings = () => {
+    // Validate
+    if (securityMode !== 'off') {
+      if (securityPassword && securityPassword !== securityPasswordConfirm) {
+        toast.error('Passwords do not match')
+        return
+      }
+      if (!hasExistingPassword && !securityPassword) {
+        toast.error('Please set a password')
+        return
+      }
+    }
+    // Turning security off actually removes the password — confirm at save time
+    if (securityMode === 'off' && savedSecurityMode !== 'off') {
+      setSecurityOffConfirmOpen(true)
+      return
+    }
+    doSaveSecuritySettings()
+  }
+
   const addTimeSlot = () => {
     setStillSandsSettings({
       ...stillSandsSettings,
@@ -794,6 +840,23 @@ export function SettingsPage() {
 
       <Separator />
 
+      {/* Hardware Setup Wizard — kept above the accordion so first-run users can find it */}
+      <Link
+        to="/setup"
+        className="flex items-center justify-between w-full p-4 rounded-lg border bg-card hover:bg-muted/50 transition-colors"
+      >
+        <div className="flex items-center gap-3">
+          <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">build</span>
+          <div className="text-left">
+            <p className="font-semibold">Hardware Setup Wizard</p>
+            <p className="text-sm text-muted-foreground">
+              Calibrate motor directions and controller settings
+            </p>
+          </div>
+        </div>
+        <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">chevron_right</span>
+      </Link>
+
       <Accordion
         type="multiple"
         value={openSections}
@@ -804,7 +867,7 @@ export function SettingsPage() {
         <AccordionItem value="connection" id="section-connection" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 usb
               </span>
               <div className="text-left">
@@ -820,7 +883,7 @@ export function SettingsPage() {
             <div className="flex items-center justify-between p-4 rounded-lg border">
               <div className="flex items-center gap-3">
                 <div className={`w-10 h-10 flex items-center justify-center rounded-lg ${isConnected ? 'bg-green-100 dark:bg-green-900' : 'bg-muted'}`}>
-                  <span className={`material-icons ${isConnected ? 'text-green-600' : 'text-muted-foreground'}`}>
+                  <span aria-hidden="true" className={`material-icons ${isConnected ? 'text-green-600' : 'text-muted-foreground'}`}>
                     {isConnected ? 'usb' : 'usb_off'}
                   </span>
                 </div>
@@ -871,9 +934,9 @@ export function SettingsPage() {
                   className="gap-2"
                 >
                   {isLoading === 'connect' ? (
-                    <span className="material-icons-outlined animate-spin">sync</span>
+                    <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
                   ) : (
-                    <span className="material-icons-outlined">cable</span>
+                    <span aria-hidden="true" className="material-icons-outlined">cable</span>
                   )}
                   Connect
                 </Button>
@@ -919,9 +982,9 @@ export function SettingsPage() {
                   className="gap-2"
                 >
                   {isLoading === 'preferredPort' ? (
-                    <span className="material-icons-outlined animate-spin">sync</span>
+                    <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
                   ) : (
-                    <span className="material-icons-outlined">save</span>
+                    <span aria-hidden="true" className="material-icons-outlined">save</span>
                   )}
                   Save
                 </Button>
@@ -936,7 +999,7 @@ export function SettingsPage() {
               <div className="flex items-center justify-between">
                 <div>
                   <p className="font-medium flex items-center gap-2">
-                    <span className="material-icons-outlined text-base">power</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-base">power</span>
                     Home on Connect
                   </p>
                   <p className="text-xs text-muted-foreground mt-1">
@@ -944,6 +1007,8 @@ export function SettingsPage() {
                   </p>
                 </div>
                 <Switch
+                  id="home-on-connect"
+                  aria-label="Home on Connect"
                   checked={settings.home_on_connect !== false}
                   onCheckedChange={async (checked) => {
                     setSettings({ ...settings, home_on_connect: checked })
@@ -966,7 +1031,7 @@ export function SettingsPage() {
         <AccordionItem value="machine" id="section-machine" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 precision_manufacturing
               </span>
               <div className="text-left">
@@ -1026,9 +1091,9 @@ export function SettingsPage() {
                   className="gap-2"
                 >
                   {isLoading === 'machine' ? (
-                    <span className="material-icons-outlined animate-spin">sync</span>
+                    <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
                   ) : (
-                    <span className="material-icons-outlined">save</span>
+                    <span aria-hidden="true" className="material-icons-outlined">save</span>
                   )}
                   Save
                 </Button>
@@ -1062,9 +1127,9 @@ export function SettingsPage() {
                   className="gap-2"
                 >
                   {isLoading === 'machine' ? (
-                    <span className="material-icons-outlined animate-spin">sync</span>
+                    <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
                   ) : (
-                    <span className="material-icons-outlined">save</span>
+                    <span aria-hidden="true" className="material-icons-outlined">save</span>
                   )}
                   Save
                 </Button>
@@ -1075,28 +1140,12 @@ export function SettingsPage() {
             </div>
 
             <Alert className="flex items-start">
-              <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+              <span aria-hidden="true" className="material-icons-outlined text-base mr-2 shrink-0">info</span>
               <AlertDescription>
                 Table type is normally detected automatically from GRBL settings. Use override if auto-detection is incorrect for your hardware.
               </AlertDescription>
             </Alert>
 
-            <Link
-              to="/setup"
-              className="flex items-center justify-between w-full p-3 rounded-lg border hover:bg-muted/50 transition-colors"
-            >
-              <div className="flex items-center gap-3">
-                <span className="material-icons-outlined text-muted-foreground">build</span>
-                <div className="text-left">
-                  <p className="font-medium text-sm">Hardware Setup & Calibration</p>
-                  <p className="text-xs text-muted-foreground">
-                    Calibrate motor directions and edit FluidNC settings
-                  </p>
-                </div>
-              </div>
-              <span className="material-icons-outlined text-muted-foreground">arrow_forward</span>
-            </Link>
-
           </AccordionContent>
         </AccordionItem>
 
@@ -1104,7 +1153,7 @@ export function SettingsPage() {
         <AccordionItem value="homing" id="section-homing" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 home
               </span>
               <div className="text-left">
@@ -1181,7 +1230,7 @@ export function SettingsPage() {
               <div className="flex items-center justify-between">
                 <div>
                   <p className="font-medium flex items-center gap-2">
-                    <span className="material-icons-outlined text-base">autorenew</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-base">autorenew</span>
                     Auto-Home During Playlists
                   </p>
                   <p className="text-xs text-muted-foreground mt-1">
@@ -1224,7 +1273,7 @@ export function SettingsPage() {
               <div className="flex items-center justify-between">
                 <div>
                   <p className="font-medium flex items-center gap-2">
-                    <span className="material-icons-outlined text-base">restart_alt</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-base">restart_alt</span>
                     Reset Machine on Theta Normalization
                   </p>
                   <p className="text-xs text-muted-foreground mt-1">
@@ -1250,9 +1299,9 @@ export function SettingsPage() {
               className="gap-2"
             >
               {isLoading === 'homing' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
+                <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
               ) : (
-                <span className="material-icons-outlined">save</span>
+                <span aria-hidden="true" className="material-icons-outlined">save</span>
               )}
               Save Homing Configuration
             </Button>
@@ -1263,7 +1312,7 @@ export function SettingsPage() {
         <AccordionItem value="application" id="section-application" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 tune
               </span>
               <div className="text-left">
@@ -1313,9 +1362,9 @@ export function SettingsPage() {
                     onClick={() => document.getElementById('logo-upload')?.click()}
                   >
                     {isLoading === 'logo' ? (
-                      <span className="material-icons-outlined animate-spin text-base">sync</span>
+                      <span aria-hidden="true" className="material-icons-outlined animate-spin text-base">sync</span>
                     ) : (
-                      <span className="material-icons-outlined text-base">upload</span>
+                      <span aria-hidden="true" className="material-icons-outlined text-base">upload</span>
                     )}
                     Upload
                   </Button>
@@ -1325,9 +1374,9 @@ export function SettingsPage() {
                       size="sm"
                       className="gap-2 text-destructive hover:text-destructive"
                       disabled={isLoading === 'logo'}
-                      onClick={handleDeleteLogo}
+                      onClick={() => setLogoConfirmOpen(true)}
                     >
-                      <span className="material-icons-outlined text-base">delete</span>
+                      <span aria-hidden="true" className="material-icons-outlined text-base">delete</span>
                     </Button>
                   )}
                 </div>
@@ -1365,7 +1414,7 @@ export function SettingsPage() {
                     className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
                     onClick={() => setSettings({ ...settings, app_name: 'Dune Weaver' })}
                   >
-                    <span className="material-icons text-base">restart_alt</span>
+                    <span aria-hidden="true" className="material-icons text-base">restart_alt</span>
                   </Button>
                 </div>
                 <Button
@@ -1374,9 +1423,9 @@ export function SettingsPage() {
                   className="gap-2"
                 >
                   {isLoading === 'appName' ? (
-                    <span className="material-icons-outlined animate-spin">sync</span>
+                    <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
                   ) : (
-                    <span className="material-icons-outlined">save</span>
+                    <span aria-hidden="true" className="material-icons-outlined">save</span>
                   )}
                   Save
                 </Button>
@@ -1392,7 +1441,7 @@ export function SettingsPage() {
         <AccordionItem value="clearing" id="section-clearing" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 cleaning_services
               </span>
               <div className="text-left">
@@ -1490,9 +1539,9 @@ export function SettingsPage() {
               className="gap-2"
             >
               {isLoading === 'clearing' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
+                <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
               ) : (
-                <span className="material-icons-outlined">save</span>
+                <span aria-hidden="true" className="material-icons-outlined">save</span>
               )}
               Save Clearing Settings
             </Button>
@@ -1503,7 +1552,7 @@ export function SettingsPage() {
         <AccordionItem value="led" id="section-led" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 lightbulb
               </span>
               <div className="text-left">
@@ -1562,7 +1611,7 @@ export function SettingsPage() {
             {ledConfig.provider === 'dw_leds' && (
               <div className="space-y-3 p-4 rounded-lg border">
                 <Alert className="flex items-start">
-                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-base mr-2 shrink-0">info</span>
                   <AlertDescription>
                     Supports WS2812, WS2812B, SK6812 and other WS281x LED strips
                   </AlertDescription>
@@ -1653,9 +1702,9 @@ export function SettingsPage() {
               className="gap-2"
             >
               {isLoading === 'led' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
+                <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
               ) : (
-                <span className="material-icons-outlined">save</span>
+                <span aria-hidden="true" className="material-icons-outlined">save</span>
               )}
               Save LED Configuration
             </Button>
@@ -1666,7 +1715,7 @@ export function SettingsPage() {
         <AccordionItem value="mqtt" id="section-mqtt" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 home
               </span>
               <div className="text-left">
@@ -1687,10 +1736,21 @@ export function SettingsPage() {
                 </p>
               </div>
               <Switch
+                id="mqtt-enabled"
+                aria-label="Enable MQTT"
                 checked={mqttConfig.enabled}
-                onCheckedChange={(checked) =>
+                onCheckedChange={async (checked) => {
                   setMqttConfig({ ...mqttConfig, enabled: checked })
-                }
+                  try {
+                    await apiClient.patch('/api/settings', {
+                      mqtt: { enabled: checked },
+                    })
+                    toast.success(checked ? 'MQTT enabled. Restart required.' : 'MQTT disabled. Restart required.')
+                  } catch {
+                    setMqttConfig((prev) => ({ ...prev, enabled: !checked }))
+                    toast.error('Failed to save setting')
+                  }
+                }}
               />
             </div>
 
@@ -1779,7 +1839,7 @@ export function SettingsPage() {
                 </div>
 
                 <Alert className="flex items-start">
-                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-base mr-2 shrink-0">info</span>
                   <AlertDescription>
                     MQTT configuration changes require a restart to take effect.
                   </AlertDescription>
@@ -1794,9 +1854,9 @@ export function SettingsPage() {
                 className="gap-2"
               >
                 {isLoading === 'mqtt' ? (
-                  <span className="material-icons-outlined animate-spin">sync</span>
+                  <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
                 ) : (
-                  <span className="material-icons-outlined">save</span>
+                  <span aria-hidden="true" className="material-icons-outlined">save</span>
                 )}
                 Save MQTT Configuration
               </Button>
@@ -1808,9 +1868,9 @@ export function SettingsPage() {
                   className="gap-2"
                 >
                   {isLoading === 'mqttTest' ? (
-                    <span className="material-icons-outlined animate-spin">sync</span>
+                    <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
                   ) : (
-                    <span className="material-icons-outlined">wifi_tethering</span>
+                    <span aria-hidden="true" className="material-icons-outlined">wifi_tethering</span>
                   )}
                   Test Connection
                 </Button>
@@ -1823,7 +1883,7 @@ export function SettingsPage() {
         <AccordionItem value="autoplay" id="section-autoplay" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 play_circle
               </span>
               <div className="text-left">
@@ -1843,10 +1903,21 @@ export function SettingsPage() {
                 </p>
               </div>
               <Switch
+                id="autoplay-enabled"
+                aria-label="Enable Auto-play"
                 checked={autoPlaySettings.enabled}
-                onCheckedChange={(checked) =>
+                onCheckedChange={async (checked) => {
                   setAutoPlaySettings({ ...autoPlaySettings, enabled: checked })
-                }
+                  try {
+                    await apiClient.patch('/api/settings', {
+                      auto_play: { enabled: checked },
+                    })
+                    toast.success(checked ? 'Auto-play enabled' : 'Auto-play disabled')
+                  } catch {
+                    setAutoPlaySettings((prev) => ({ ...prev, enabled: !checked }))
+                    toast.error('Failed to save setting')
+                  }
+                }}
               />
             </div>
 
@@ -1904,9 +1975,10 @@ export function SettingsPage() {
                     </Select>
                   </div>
                   <div className="space-y-3">
-                    <Label>Pause Between Patterns</Label>
+                    <Label htmlFor="autoplay-pause">Pause Between Patterns</Label>
                     <div className="flex gap-2">
                       <Input
+                        id="autoplay-pause"
                         type="text"
                         inputMode="numeric"
                         value={autoPlayPauseInput}
@@ -1995,9 +2067,9 @@ export function SettingsPage() {
               className="gap-2"
             >
               {isLoading === 'autoplay' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
+                <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
               ) : (
-                <span className="material-icons-outlined">save</span>
+                <span aria-hidden="true" className="material-icons-outlined">save</span>
               )}
               Save Auto-play Settings
             </Button>
@@ -2008,7 +2080,7 @@ export function SettingsPage() {
         <AccordionItem value="stillsands" id="section-stillsands" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 bedtime
               </span>
               <div className="text-left">
@@ -2028,10 +2100,21 @@ export function SettingsPage() {
                 </p>
               </div>
               <Switch
+                id="stillsands-enabled"
+                aria-label="Enable Still Sands"
                 checked={stillSandsSettings.enabled}
-                onCheckedChange={(checked) =>
+                onCheckedChange={async (checked) => {
                   setStillSandsSettings({ ...stillSandsSettings, enabled: checked })
-                }
+                  try {
+                    await apiClient.patch('/api/settings', {
+                      scheduled_pause: { enabled: checked },
+                    })
+                    toast.success(checked ? 'Still Sands enabled' : 'Still Sands disabled')
+                  } catch {
+                    setStillSandsSettings((prev) => ({ ...prev, enabled: !checked }))
+                    toast.error('Failed to save setting')
+                  }
+                }}
               />
             </div>
 
@@ -2041,7 +2124,7 @@ export function SettingsPage() {
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex items-center justify-between">
                     <div className="flex items-center gap-2">
-                      <span className="material-icons-outlined text-base text-muted-foreground">
+                      <span aria-hidden="true" className="material-icons-outlined text-base text-muted-foreground">
                         hourglass_bottom
                       </span>
                       <div>
@@ -2063,7 +2146,7 @@ export function SettingsPage() {
 
                   <div className="flex items-center justify-between">
                     <div className="flex items-center gap-2">
-                      <span className="material-icons-outlined text-base text-muted-foreground">
+                      <span aria-hidden="true" className="material-icons-outlined text-base text-muted-foreground">
                         lightbulb
                       </span>
                       <div>
@@ -2084,7 +2167,7 @@ export function SettingsPage() {
                   {/* Timezone */}
                   <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 pt-3 border-t">
                     <div className="flex items-center gap-3">
-                      <span className="material-icons-outlined text-muted-foreground">
+                      <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                         schedule
                       </span>
                       <div>
@@ -2150,7 +2233,7 @@ export function SettingsPage() {
                   <div className="flex items-center justify-between">
                     <h4 className="font-medium">Still Periods</h4>
                     <Button onClick={addTimeSlot} size="sm" variant="secondary" className="gap-1">
-                      <span className="material-icons text-base">add</span>
+                      <span aria-hidden="true" className="material-icons text-base">add</span>
                       Add Period
                     </Button>
                   </div>
@@ -2161,7 +2244,7 @@ export function SettingsPage() {
 
                   {stillSandsSettings.time_slots.length === 0 ? (
                     <div className="text-center py-6 text-muted-foreground">
-                      <span className="material-icons text-3xl mb-2">schedule</span>
+                      <span aria-hidden="true" className="material-icons text-3xl mb-2">schedule</span>
                       <p className="text-sm">No still periods configured</p>
                       <p className="text-xs">Click "Add Period" to create one</p>
                     </div>
@@ -2180,14 +2263,15 @@ export function SettingsPage() {
                               onClick={() => removeTimeSlot(index)}
                               className="h-7 w-7 text-destructive hover:text-destructive"
                             >
-                              <span className="material-icons text-lg">delete</span>
+                              <span aria-hidden="true" className="material-icons text-lg">delete</span>
                             </Button>
                           </div>
 
                           <div className="grid grid-cols-[1fr_1fr] gap-2">
                             <div className="space-y-1.5 min-w-0 overflow-hidden">
-                              <Label className="text-xs">Start Time</Label>
+                              <Label htmlFor={`slot-start-${index}`} className="text-xs">Start Time</Label>
                               <Input
+                                id={`slot-start-${index}`}
                                 type="time"
                                 value={slot.start_time}
                                 onChange={(e) =>
@@ -2197,8 +2281,9 @@ export function SettingsPage() {
                               />
                             </div>
                             <div className="space-y-1.5 min-w-0 overflow-hidden">
-                              <Label className="text-xs">End Time</Label>
+                              <Label htmlFor={`slot-end-${index}`} className="text-xs">End Time</Label>
                               <Input
+                                id={`slot-end-${index}`}
                                 type="time"
                                 value={slot.end_time}
                                 onChange={(e) =>
@@ -2277,7 +2362,7 @@ export function SettingsPage() {
                 </div>
 
                 <Alert className="flex items-start">
-                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-base mr-2 shrink-0">info</span>
                   <AlertDescription>
                     Times are based on the timezone selected above (or system default). Still
                     periods that span midnight (e.g., 22:00 to 06:00) are supported. Patterns
@@ -2293,9 +2378,9 @@ export function SettingsPage() {
               className="gap-2"
             >
               {isLoading === 'stillsands' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
+                <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
               ) : (
-                <span className="material-icons-outlined">save</span>
+                <span aria-hidden="true" className="material-icons-outlined">save</span>
               )}
               Save Still Sands Settings
             </Button>
@@ -2306,7 +2391,7 @@ export function SettingsPage() {
         <AccordionItem value="reboot" id="section-reboot" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 restart_alt
               </span>
               <div className="text-left">
@@ -2326,10 +2411,21 @@ export function SettingsPage() {
                 </p>
               </div>
               <Switch
+                id="reboot-enabled"
+                aria-label="Enable Scheduled Reboot"
                 checked={rebootSettings.enabled}
-                onCheckedChange={(checked) =>
+                onCheckedChange={async (checked) => {
                   setRebootSettings({ ...rebootSettings, enabled: checked })
-                }
+                  try {
+                    await apiClient.patch('/api/settings', {
+                      scheduled_reboot: { enabled: checked },
+                    })
+                    toast.success(checked ? 'Scheduled reboot enabled' : 'Scheduled reboot disabled')
+                  } catch {
+                    setRebootSettings((prev) => ({ ...prev, enabled: !checked }))
+                    toast.error('Failed to save setting')
+                  }
+                }}
               />
             </div>
 
@@ -2338,7 +2434,7 @@ export function SettingsPage() {
                 <div className="p-4 rounded-lg border">
                   <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
                     <div className="flex items-center gap-3">
-                      <span className="material-icons-outlined text-muted-foreground">
+                      <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                         schedule
                       </span>
                       <div>
@@ -2349,18 +2445,33 @@ export function SettingsPage() {
                       </div>
                     </div>
                     <Input
+                      id="reboot-time"
+                      aria-label="Reboot Time"
                       type="time"
                       value={rebootSettings.time}
                       onChange={(e) =>
                         setRebootSettings({ ...rebootSettings, time: e.target.value })
                       }
+                      onBlur={async (e) => {
+                        const time = e.target.value
+                        // type="time" yields '' while incomplete — don't save that
+                        if (!/^\d{2}:\d{2}$/.test(time)) return
+                        try {
+                          await apiClient.patch('/api/settings', {
+                            scheduled_reboot: { time },
+                          })
+                          toast.success(`Reboot time set to ${time}`)
+                        } catch {
+                          toast.error('Failed to save reboot time')
+                        }
+                      }}
                       className="w-full sm:w-[140px]"
                     />
                   </div>
                 </div>
 
                 <Alert className="flex items-start">
-                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-base mr-2 shrink-0">info</span>
                   <AlertDescription>
                     If a pattern is drawing at the scheduled time, the reboot waits until the
                     table is idle. The table reconnects and homes automatically after rebooting.
@@ -2369,18 +2480,6 @@ export function SettingsPage() {
               </div>
             )}
 
-            <Button
-              onClick={handleSaveRebootSettings}
-              disabled={isLoading === 'reboot'}
-              className="gap-2"
-            >
-              {isLoading === 'reboot' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
-              ) : (
-                <span className="material-icons-outlined">save</span>
-              )}
-              Save Reboot Settings
-            </Button>
           </AccordionContent>
         </AccordionItem>
 
@@ -2388,7 +2487,7 @@ export function SettingsPage() {
         <AccordionItem value="security" id="section-security" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 lock
               </span>
               <div className="text-left">
@@ -2411,9 +2510,6 @@ export function SettingsPage() {
                 value={securityMode}
                 onValueChange={(value) => {
                   const newMode = value as 'off' | 'lockdown' | 'play_only'
-                  if (newMode === 'off' && securityMode !== 'off') {
-                    if (!confirm('Turn off security? This will remove the password and unlock the app.')) return
-                  }
                   setSecurityMode(newMode)
                   // Clear password fields when switching modes
                   setSecurityPassword('')
@@ -2484,39 +2580,7 @@ export function SettingsPage() {
 
             {/* Save button */}
             <Button
-              onClick={async () => {
-                // Validate
-                if (securityMode !== 'off') {
-                  if (securityPassword && securityPassword !== securityPasswordConfirm) {
-                    toast.error('Passwords do not match')
-                    return
-                  }
-                  if (!hasExistingPassword && !securityPassword) {
-                    toast.error('Please set a password')
-                    return
-                  }
-                }
-
-                setIsLoading('security')
-                try {
-                  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-                  const payload: any = { security: { mode: securityMode } }
-                  if (securityPassword) {
-                    payload.security.password = securityPassword
-                  }
-                  await apiClient.patch('/api/settings', payload)
-                  toast.success('Security settings saved')
-                  setSecurityPassword('')
-                  setSecurityPasswordConfirm('')
-                  setHasExistingPassword(securityMode !== 'off')
-                  // Notify Layout to refetch security state
-                  window.dispatchEvent(new CustomEvent('security-updated'))
-                } catch {
-                  toast.error('Failed to save security settings')
-                } finally {
-                  setIsLoading(null)
-                }
-              }}
+              onClick={handleSaveSecuritySettings}
               disabled={
                 isLoading === 'security' ||
                 (securityMode !== 'off' && securityPassword !== '' && securityPassword !== securityPasswordConfirm) ||
@@ -2525,9 +2589,9 @@ export function SettingsPage() {
               className="w-full gap-2"
             >
               {isLoading === 'security' ? (
-                <span className="material-icons-outlined animate-spin">sync</span>
+                <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
               ) : (
-                <span className="material-icons-outlined">save</span>
+                <span aria-hidden="true" className="material-icons-outlined">save</span>
               )}
               Save Security Settings
             </Button>
@@ -2538,7 +2602,7 @@ export function SettingsPage() {
         <AccordionItem value="wifi" id="section-wifi" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 wifi
               </span>
               <div className="text-left">
@@ -2558,7 +2622,7 @@ export function SettingsPage() {
               className="w-full gap-2"
               onClick={() => navigate('/wifi-setup')}
             >
-              <span className="material-icons-outlined">settings</span>
+              <span aria-hidden="true" className="material-icons-outlined">settings</span>
               Open WiFi Setup
             </Button>
           </AccordionContent>
@@ -2568,7 +2632,7 @@ export function SettingsPage() {
         <AccordionItem value="version" id="section-version" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground">
                 info
               </span>
               <div className="text-left">
@@ -2582,19 +2646,19 @@ export function SettingsPage() {
           <AccordionContent className="pt-4 pb-6 space-y-3">
             <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
               <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
-                <span className="material-icons text-muted-foreground">terminal</span>
+                <span aria-hidden="true" className="material-icons text-muted-foreground">terminal</span>
               </div>
               <div className="flex-1">
                 <p className="font-medium">Current Version</p>
                 <p className="text-sm text-muted-foreground">
-                  {versionInfo?.current ? `v${versionInfo.current}` : 'Loading...'}
+                  {versionInfo?.current ? `v${versionInfo.current}` : versionError ? '—' : 'Loading...'}
                 </p>
               </div>
             </div>
 
             <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
               <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
-                <span className="material-icons text-muted-foreground">system_update</span>
+                <span aria-hidden="true" className="material-icons text-muted-foreground">system_update</span>
               </div>
               <div className="flex-1">
                 <p className="font-medium">Latest Version</p>
@@ -2608,6 +2672,18 @@ export function SettingsPage() {
                     >
                       v{versionInfo.latest}
                     </a>
+                  ) : versionError ? (
+                    <span className="inline-flex items-center gap-2">
+                      Couldn't check for updates
+                      <Button
+                        variant="outline"
+                        size="sm"
+                        className="h-6 px-2 text-xs"
+                        onClick={() => fetchVersionInfo()}
+                      >
+                        Retry
+                      </Button>
+                    </span>
                   ) : 'Checking...'}
                   {versionInfo?.update_available && ' (Update available!)'}
                 </p>
@@ -2616,7 +2692,7 @@ export function SettingsPage() {
 
             {versionInfo?.update_available && (
               <Button onClick={() => setUpdateDialogOpen(true)} className="w-full">
-                <span className="material-icons text-base mr-2">system_update</span>
+                <span aria-hidden="true" className="material-icons text-base mr-2">system_update</span>
                 Update Now
               </Button>
             )}
@@ -2630,6 +2706,26 @@ export function SettingsPage() {
           </AccordionContent>
         </AccordionItem>
       </Accordion>
+
+      <ConfirmDialog
+        open={logoConfirmOpen}
+        onOpenChange={setLogoConfirmOpen}
+        title="Remove custom logo?"
+        description="The app will revert to the default logo and favicon."
+        confirmLabel="Remove"
+        destructive
+        onConfirm={handleDeleteLogo}
+      />
+
+      <ConfirmDialog
+        open={securityOffConfirmOpen}
+        onOpenChange={setSecurityOffConfirmOpen}
+        title="Turn off security?"
+        description="This removes the password and unlocks the app for everyone on the network."
+        confirmLabel="Turn off"
+        destructive
+        onConfirm={doSaveSecuritySettings}
+      />
     </div>
   )
 }

+ 2 - 1
frontend/src/pages/SetupPage.tsx

@@ -111,7 +111,8 @@ function CalibrationWizard() {
     async (axis: 'x' | 'y') => {
       setWizard((w) => ({ ...w, fixing: true }))
       try {
-        // Toggle direction, save config
+        // The backend treats direction_inverted as a toggle command — the
+        // value is ignored and the pin's :low modifier is flipped each call
         await apiClient.patch('/api/fluidnc/config', {
           axes: { [axis]: { direction_inverted: true } },
         })

+ 80 - 45
frontend/src/pages/TableControlPage.tsx

@@ -36,15 +36,20 @@ import {
 } from '@/components/ui/select'
 import { apiClient } from '@/lib/apiClient'
 import { useStatusStore } from '@/stores/useStatusStore'
+import { ConnectionBanner, useTableConnected } from '@/components/ConnectionBanner'
 
 export function TableControlPage() {
   const [speedInput, setSpeedInput] = useState('')
   const [currentSpeed, setCurrentSpeed] = useState<number | null>(null)
   const [currentTheta, setCurrentTheta] = useState(0)
   const [isLoading, setIsLoading] = useState<string | null>(null)
+  const tableConnected = useTableConnected()
+  // Tooltip for hardware-action buttons disabled due to no table connection
+  const disconnectedTitle = tableConnected ? undefined : 'Table not connected'
 
   // Subscribe to shared status WebSocket via store
   const speed = useStatusStore((s) => s.status?.speed ?? null)
+  const hardwareTheta = useStatusStore((s) => s.status?.current_theta ?? null)
   const isPatternRunning = useStatusStore((s) =>
     (s.status?.is_running || s.status?.is_paused) ?? false
   )
@@ -54,6 +59,13 @@ export function TableControlPage() {
     if (speed !== null) setCurrentSpeed(speed)
   }, [speed])
 
+  // Keep the rotation baseline anchored to the actual machine position —
+  // rotate commands send absolute theta, so accumulating from a local 0
+  // would diverge from where the table really is
+  useEffect(() => {
+    if (hardwareTheta !== null) setCurrentTheta(hardwareTheta)
+  }, [hardwareTheta])
+
   // Serial terminal state
   const [serialPorts, setSerialPorts] = useState<string[]>([])
   const [selectedSerialPort, setSelectedSerialPort] = useState('')
@@ -153,10 +165,14 @@ export function TableControlPage() {
 
   const handleSetSpeed = async () => {
     const speed = parseFloat(speedInput)
-    if (isNaN(speed) || speed <= 0) {
+    if (isNaN(speed)) {
       toast.error('Please enter a valid speed value')
       return
     }
+    if (speed < 10) {
+      toast.error('Speed must be at least 10 mm/s')
+      return
+    }
     try {
       await handleAction('speed', '/set_speed', { speed })
       setCurrentSpeed(speed)
@@ -348,6 +364,8 @@ export function TableControlPage() {
           </p>
         </div>
 
+        <ConnectionBanner />
+
         <Separator />
 
         {/* Main Controls Grid - 2x2 */}
@@ -364,14 +382,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                     <Button
                       onClick={handleHome}
-                      disabled={isLoading === 'home'}
+                      disabled={isLoading === 'home' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="primary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'home' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                        <span aria-hidden="true" className="material-icons-outlined animate-spin text-2xl">sync</span>
                       ) : (
-                        <span className="material-icons-outlined text-2xl">home</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-2xl">home</span>
                       )}
                       <span className="text-xs">Home</span>
                     </Button>
@@ -388,9 +407,9 @@ export function TableControlPage() {
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'stop' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                        <span aria-hidden="true" className="material-icons-outlined animate-spin text-2xl">sync</span>
                       ) : (
-                        <span className="material-icons-outlined text-2xl">stop_circle</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-2xl">stop_circle</span>
                       )}
                       <span className="text-xs">Stop</span>
                     </Button>
@@ -403,14 +422,15 @@ export function TableControlPage() {
                     <TooltipTrigger asChild>
                       <DialogTrigger asChild>
                         <Button
-                          disabled={isLoading === 'reset'}
+                          disabled={isLoading === 'reset' || !tableConnected}
+                          title={disconnectedTitle}
                           variant="secondary"
                           className="h-16 gap-1 flex-col items-center justify-center"
                         >
                           {isLoading === 'reset' ? (
-                            <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                            <span aria-hidden="true" className="material-icons-outlined animate-spin text-2xl">sync</span>
                           ) : (
-                            <span className="material-icons-outlined text-2xl">restart_alt</span>
+                            <span aria-hidden="true" className="material-icons-outlined text-2xl">restart_alt</span>
                           )}
                           <span className="text-xs">Reset</span>
                         </Button>
@@ -426,7 +446,7 @@ export function TableControlPage() {
                       </DialogDescription>
                     </DialogHeader>
                     <Alert className="flex items-center border-amber-500/50">
-                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
+                      <span aria-hidden="true" className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
                       <AlertDescription className="text-amber-600 dark:text-amber-400">
                         Homing is required after resetting. The table will lose its position reference.
                       </AlertDescription>
@@ -467,24 +487,27 @@ export function TableControlPage() {
                   value={speedInput}
                   onChange={(e) => setSpeedInput(e.target.value)}
                   placeholder="mm/s"
-                  min="1"
+                  min="10"
+                  max="2000"
                   step="1"
                   className="flex-1"
                   onKeyDown={(e) => e.key === 'Enter' && handleSetSpeed()}
                 />
                 <Button
                   onClick={handleSetSpeed}
-                  disabled={isLoading === 'speed' || !speedInput}
+                  disabled={isLoading === 'speed' || !speedInput || !tableConnected}
+                  title={disconnectedTitle}
                   className="gap-2"
                 >
                   {isLoading === 'speed' ? (
-                    <span className="material-icons-outlined animate-spin">sync</span>
+                    <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
                   ) : (
-                    <span className="material-icons-outlined">check</span>
+                    <span aria-hidden="true" className="material-icons-outlined">check</span>
                   )}
                   Set
                 </Button>
               </div>
+              <p className="mt-2 text-xs text-muted-foreground">10–2000 mm/s</p>
             </CardContent>
           </Card>
 
@@ -500,14 +523,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                     <Button
                       onClick={handleMoveToCenter}
-                      disabled={isLoading === 'center'}
+                      disabled={isLoading === 'center' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'center' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                        <span aria-hidden="true" className="material-icons-outlined animate-spin text-2xl">sync</span>
                       ) : (
-                        <span className="material-icons-outlined text-2xl">center_focus_strong</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-2xl">center_focus_strong</span>
                       )}
                       <span className="text-xs">Center</span>
                     </Button>
@@ -519,14 +543,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                     <Button
                       onClick={handleMoveToPerimeter}
-                      disabled={isLoading === 'perimeter'}
+                      disabled={isLoading === 'perimeter' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'perimeter' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                        <span aria-hidden="true" className="material-icons-outlined animate-spin text-2xl">sync</span>
                       ) : (
-                        <span className="material-icons-outlined text-2xl">trip_origin</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-2xl">trip_origin</span>
                       )}
                       <span className="text-xs">Perimeter</span>
                     </Button>
@@ -540,9 +565,11 @@ export function TableControlPage() {
                       <DialogTrigger asChild>
                         <Button
                           variant="secondary"
+                          disabled={!tableConnected}
+                          title={disconnectedTitle}
                           className="h-16 gap-1 flex-col items-center justify-center"
                         >
-                          <span className="material-icons-outlined text-2xl">screen_rotation</span>
+                          <span aria-hidden="true" className="material-icons-outlined text-2xl">screen_rotation</span>
                           <span className="text-xs">Align</span>
                         </Button>
                       </DialogTrigger>
@@ -579,7 +606,7 @@ export function TableControlPage() {
                     <Separator />
 
                     <Alert className="flex items-start border-amber-500/50">
-                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">
+                      <span aria-hidden="true" className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">
                         warning
                       </span>
                       <AlertDescription className="text-amber-600 dark:text-amber-400">
@@ -593,18 +620,20 @@ export function TableControlPage() {
                         <Button
                           variant="secondary"
                           onClick={() => handleRotate(-10)}
-                          disabled={isLoading === 'rotate'}
+                          disabled={isLoading === 'rotate' || !tableConnected}
+                          title={disconnectedTitle}
                         >
-                          <span className="material-icons text-lg mr-1">rotate_left</span>
+                          <span aria-hidden="true" className="material-icons text-lg mr-1">rotate_left</span>
                           CCW 10°
                         </Button>
                         <Button
                           variant="secondary"
                           onClick={() => handleRotate(10)}
-                          disabled={isLoading === 'rotate'}
+                          disabled={isLoading === 'rotate' || !tableConnected}
+                          title={disconnectedTitle}
                         >
                           CW 10°
-                          <span className="material-icons text-lg ml-1">rotate_right</span>
+                          <span aria-hidden="true" className="material-icons text-lg ml-1">rotate_right</span>
                         </Button>
                       </div>
                       <p className="text-xs text-muted-foreground text-center">
@@ -635,14 +664,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                     <Button
                       onClick={() => handleClearPattern('clear_from_in.thr', 'clear from center')}
-                      disabled={isLoading === 'clear_from_in.thr'}
+                      disabled={isLoading === 'clear_from_in.thr' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'clear_from_in.thr' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                        <span aria-hidden="true" className="material-icons-outlined animate-spin text-2xl">sync</span>
                       ) : (
-                        <span className="material-icons-outlined text-2xl">center_focus_strong</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-2xl">center_focus_strong</span>
                       )}
                       <span className="text-xs">Clear Center</span>
                     </Button>
@@ -654,14 +684,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                     <Button
                       onClick={() => handleClearPattern('clear_from_out.thr', 'clear from perimeter')}
-                      disabled={isLoading === 'clear_from_out.thr'}
+                      disabled={isLoading === 'clear_from_out.thr' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'clear_from_out.thr' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                        <span aria-hidden="true" className="material-icons-outlined animate-spin text-2xl">sync</span>
                       ) : (
-                        <span className="material-icons-outlined text-2xl">all_out</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-2xl">all_out</span>
                       )}
                       <span className="text-xs">Clear Edge</span>
                     </Button>
@@ -673,14 +704,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                     <Button
                       onClick={() => handleClearPattern('clear_sideway.thr', 'clear sideways')}
-                      disabled={isLoading === 'clear_sideway.thr'}
+                      disabled={isLoading === 'clear_sideway.thr' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'clear_sideway.thr' ? (
-                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                        <span aria-hidden="true" className="material-icons-outlined animate-spin text-2xl">sync</span>
                       ) : (
-                        <span className="material-icons-outlined text-2xl">swap_horiz</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-2xl">swap_horiz</span>
                       )}
                       <span className="text-xs">Clear Sideways</span>
                     </Button>
@@ -698,13 +730,13 @@ export function TableControlPage() {
             <div className="flex items-start justify-between gap-2">
               <div className="min-w-0 space-y-2">
                 <CardTitle className="text-lg flex items-center gap-2">
-                  <span className="material-icons-outlined text-xl">terminal</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-xl">terminal</span>
                   Serial Terminal
                 </CardTitle>
                 <CardDescription className="hidden sm:block">Send raw commands to the table controller</CardDescription>
                 {/* Warning about pattern interference */}
                 <Alert className="flex items-center border-amber-500/50 py-2">
-                  <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
                   <AlertDescription className="text-xs text-amber-600 dark:text-amber-400">
                     Do not use while a pattern is running. This will interfere with the main connection.
                   </AlertDescription>
@@ -718,8 +750,9 @@ export function TableControlPage() {
                     size="icon"
                     onClick={() => setSerialHistory([])}
                     title="Clear history"
+                    aria-label="Clear history"
                   >
-                    <span className="material-icons-outlined">delete_sweep</span>
+                    <span aria-hidden="true" className="material-icons-outlined">delete_sweep</span>
                   </Button>
                 )}
               </div>
@@ -750,9 +783,9 @@ export function TableControlPage() {
                   title="Connect"
                 >
                   {serialLoading ? (
-                    <span className="material-icons-outlined animate-spin sm:mr-1">sync</span>
+                    <span aria-hidden="true" className="material-icons-outlined animate-spin sm:mr-1">sync</span>
                   ) : (
-                    <span className="material-icons-outlined sm:mr-1">power</span>
+                    <span aria-hidden="true" className="material-icons-outlined sm:mr-1">power</span>
                   )}
                   <span className="hidden sm:inline">Connect</span>
                 </Button>
@@ -765,7 +798,7 @@ export function TableControlPage() {
                     disabled={serialLoading}
                     title="Disconnect"
                   >
-                    <span className="material-icons-outlined sm:mr-1">power_off</span>
+                    <span aria-hidden="true" className="material-icons-outlined sm:mr-1">power_off</span>
                     <span className="hidden sm:inline">Disconnect</span>
                   </Button>
                   <Button
@@ -775,7 +808,7 @@ export function TableControlPage() {
                     disabled={serialLoading}
                     title="Send soft reset to controller"
                   >
-                    <span className="material-icons-outlined sm:mr-1">restart_alt</span>
+                    <span aria-hidden="true" className="material-icons-outlined sm:mr-1">restart_alt</span>
                     <span className="hidden sm:inline">Reset</span>
                   </Button>
                 </>
@@ -788,8 +821,9 @@ export function TableControlPage() {
                   className="sm:hidden"
                   onClick={() => setSerialHistory([])}
                   title="Clear history"
+                  aria-label="Clear history"
                 >
-                  <span className="material-icons-outlined">delete</span>
+                  <span aria-hidden="true" className="material-icons-outlined">delete</span>
                 </Button>
               )}
             </div>
@@ -841,12 +875,13 @@ export function TableControlPage() {
               <Button
                 onClick={handleSerialSend}
                 disabled={!serialConnected || !serialCommand.trim() || serialLoading}
+                aria-label="Send command"
                 className="h-11 px-6"
               >
                 {serialLoading ? (
-                  <span className="material-icons-outlined animate-spin">sync</span>
+                  <span aria-hidden="true" className="material-icons-outlined animate-spin">sync</span>
                 ) : (
-                  <span className="material-icons-outlined">send</span>
+                  <span aria-hidden="true" className="material-icons-outlined">send</span>
                 )}
               </Button>
             </div>

+ 157 - 42
frontend/src/pages/WiFiSetupPage.tsx

@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Card, CardContent } from '@/components/ui/card'
 import { Alert, AlertDescription } from '@/components/ui/alert'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
 import { Badge } from '@/components/ui/badge'
 import { Separator } from '@/components/ui/separator'
 import {
@@ -43,7 +44,7 @@ function SignalIcon({ signal }: { signal: number }) {
                signal >= 25 ? 'network_wifi_2_bar' :
                'network_wifi_1_bar'
   const color = signal >= 50 ? 'text-green-500' : signal >= 25 ? 'text-yellow-500' : 'text-red-500'
-  return <span className={`material-icons text-lg ${color}`}>{bars}</span>
+  return <span className={`material-icons text-lg ${color}`} aria-hidden="true">{bars}</span>
 }
 
 export function WiFiSetupPage() {
@@ -63,6 +64,8 @@ export function WiFiSetupPage() {
   const [apPasswordInput, setApPasswordInput] = useState('')
   const [showApPassword, setShowApPassword] = useState(false)
   const [isSavingApPassword, setIsSavingApPassword] = useState(false)
+  const [confirmAction, setConfirmAction] = useState<'restart' | 'shutdown' | null>(null)
+  const [systemState, setSystemState] = useState<'idle' | 'restarting' | 'shutdown'>('idle')
 
   const fetchStatus = useCallback(async () => {
     try {
@@ -111,10 +114,37 @@ export function WiFiSetupPage() {
     fetchApPassword()
   }, [fetchStatus, scanNetworks, fetchSaved, fetchApPassword])
 
+  // While restarting, poll the backend until it responds again, then reload
+  useEffect(() => {
+    if (systemState !== 'restarting') return
+    let cancelled = false
+    let timer: ReturnType<typeof setTimeout>
+    const poll = async () => {
+      try {
+        const res = await fetch('/api/wifi/status')
+        if (res.ok && !cancelled) {
+          window.location.reload()
+          return
+        }
+      } catch {
+        // Backend still down, keep polling
+      }
+      if (!cancelled) timer = setTimeout(poll, 3000)
+    }
+    // Grace period so we don't catch the server before it goes down
+    timer = setTimeout(poll, 5000)
+    return () => {
+      cancelled = true
+      clearTimeout(timer)
+    }
+  }, [systemState])
+
   const needsPassword = isManualEntry || (selectedNetwork &&
     selectedNetwork.security !== 'Open' &&
     !selectedNetwork.saved)
 
+  const isHotspotMode = status?.mode === 'hotspot'
+
   const handleConnect = async () => {
     const ssid = isManualEntry ? manualSsid.trim() : selectedNetwork?.ssid
     if (!ssid) return
@@ -132,10 +162,19 @@ export function WiFiSetupPage() {
         fetchStatus()
         fetchSaved()
         scanNetworks()
+      } else {
+        toast.error(result.message || 'Failed to connect to network')
       }
     } catch (err) {
-      const message = err instanceof Error ? err.message : 'Connection failed'
-      toast.error(message)
+      // In hotspot mode, a network error/timeout is expected: connecting to
+      // WiFi tears down the hotspot, dropping this page's connection.
+      const isNetworkError = !(err instanceof Error) || !err.message.startsWith('HTTP')
+      if (isHotspotMode && isNetworkError) {
+        toast.info('If the hotspot dropped, the table is connecting to your WiFi — rejoin your network and reload.')
+      } else {
+        const message = err instanceof Error ? err.message : 'Connection failed'
+        toast.error(message)
+      }
     } finally {
       setIsConnecting(false)
     }
@@ -155,6 +194,8 @@ export function WiFiSetupPage() {
         toast.success(result.message)
         closeDialog()
         fetchSaved()
+      } else {
+        toast.error(result.message || 'Failed to save network')
       }
     } catch (err) {
       const message = err instanceof Error ? err.message : 'Failed to save network'
@@ -191,6 +232,8 @@ export function WiFiSetupPage() {
       if (result.success) {
         setApPassword(apPasswordInput)
         toast.success(result.message)
+      } else {
+        toast.error(result.message || 'Failed to update password')
       }
     } catch (err) {
       const message = err instanceof Error ? err.message : 'Failed to update password'
@@ -224,14 +267,32 @@ export function WiFiSetupPage() {
     setSelectedNetwork({ ssid: '', signal: 0, security: 'Manual', saved: false, active: false })
   }
 
-  const isHotspotMode = status?.mode === 'hotspot'
+  const handleRestart = async () => {
+    setConfirmAction(null)
+    try {
+      await apiClient.post('/api/system/restart')
+      setSystemState('restarting')
+    } catch {
+      toast.error('Failed to restart')
+    }
+  }
+
+  const handleShutdown = async () => {
+    setConfirmAction(null)
+    try {
+      await apiClient.post('/api/system/shutdown')
+      setSystemState('shutdown')
+    } catch {
+      toast.error('Failed to shutdown')
+    }
+  }
 
   return (
     <div className="container max-w-lg mx-auto px-3 py-4 space-y-3">
       {/* Hotspot Welcome Banner */}
       {isHotspotMode && (
         <Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950/30 dark:border-blue-800">
-          <span className="material-icons text-blue-500 mr-2">wifi_tethering</span>
+          <span className="material-icons text-blue-500 mr-2" aria-hidden="true">wifi_tethering</span>
           <AlertDescription>
             <strong>Welcome to Dune Weaver!</strong>
             <br />
@@ -244,7 +305,7 @@ export function WiFiSetupPage() {
       <Card>
         <CardContent className="pt-4 pb-3 px-4">
           <div className="flex items-center gap-2 mb-2">
-            <span className="material-icons-outlined text-base text-muted-foreground">info</span>
+            <span className="material-icons-outlined text-base text-muted-foreground" aria-hidden="true">info</span>
             <span className="font-semibold text-sm">WiFi Status</span>
           </div>
           {status ? (
@@ -292,7 +353,7 @@ export function WiFiSetupPage() {
         <Card>
           <CardContent className="pt-4 pb-2 px-4">
             <div className="flex items-center gap-2 mb-2">
-              <span className="material-icons-outlined text-base text-muted-foreground">bookmark</span>
+              <span className="material-icons-outlined text-base text-muted-foreground" aria-hidden="true">bookmark</span>
               <span className="font-semibold text-sm">Saved Networks</span>
             </div>
             <div className="space-y-0.5">
@@ -302,7 +363,7 @@ export function WiFiSetupPage() {
                   className="flex items-center justify-between px-2.5 py-2 rounded-lg hover:bg-muted/50"
                 >
                   <div className="flex items-center gap-2.5">
-                    <span className="material-icons text-muted-foreground text-lg">wifi</span>
+                    <span className="material-icons text-muted-foreground text-lg" aria-hidden="true">wifi</span>
                     <span className="font-medium text-sm">{con.ssid}</span>
                   </div>
                   <Button
@@ -310,8 +371,9 @@ export function WiFiSetupPage() {
                     size="sm"
                     className="h-7 w-7 p-0"
                     onClick={() => setForgetSsid(con.ssid)}
+                    aria-label={`Forget network ${con.ssid}`}
                   >
-                    <span className="material-icons text-sm text-destructive">delete</span>
+                    <span className="material-icons text-sm text-destructive" aria-hidden="true">delete</span>
                   </Button>
                 </div>
               ))}
@@ -324,7 +386,7 @@ export function WiFiSetupPage() {
       <Card>
         <CardContent className="pt-4 pb-3 px-4">
           <div className="flex items-center gap-2 mb-2">
-            <span className="material-icons-outlined text-base text-muted-foreground">wifi_tethering</span>
+            <span className="material-icons-outlined text-base text-muted-foreground" aria-hidden="true">wifi_tethering</span>
             <span className="font-semibold text-sm">Hotspot Password</span>
             {!apPassword && (
               <Badge variant="secondary" className="text-xs px-1.5 py-0">Open</Badge>
@@ -340,6 +402,7 @@ export function WiFiSetupPage() {
                 value={apPasswordInput}
                 onChange={(e) => setApPasswordInput(e.target.value)}
                 placeholder="No password (open)"
+                aria-label="Hotspot password"
                 className="pr-8 h-8 text-sm"
                 onKeyDown={(e) => {
                   if (e.key === 'Enter' && apPasswordInput !== apPassword) handleSaveApPassword()
@@ -349,8 +412,9 @@ export function WiFiSetupPage() {
                 type="button"
                 onClick={() => setShowApPassword(!showApPassword)}
                 className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                aria-label={showApPassword ? 'Hide password' : 'Show password'}
               >
-                <span className="material-icons text-sm">
+                <span className="material-icons text-sm" aria-hidden="true">
                   {showApPassword ? 'visibility_off' : 'visibility'}
                 </span>
               </button>
@@ -362,7 +426,7 @@ export function WiFiSetupPage() {
               disabled={isSavingApPassword || apPasswordInput === apPassword || (apPasswordInput.length > 0 && apPasswordInput.length < 8)}
             >
               {isSavingApPassword ? (
-                <span className="material-icons text-sm animate-spin">refresh</span>
+                <span className="material-icons text-sm animate-spin" aria-hidden="true">refresh</span>
               ) : (
                 'Save'
               )}
@@ -393,7 +457,7 @@ export function WiFiSetupPage() {
       <Card>
         <CardContent className="pt-4 pb-3 px-4">
           <div className="flex items-center gap-2 mb-2">
-            <span className="material-icons-outlined text-base text-muted-foreground">settings_power</span>
+            <span className="material-icons-outlined text-base text-muted-foreground" aria-hidden="true">settings_power</span>
             <span className="font-semibold text-sm">System</span>
           </div>
           <div className="flex gap-2">
@@ -401,30 +465,18 @@ export function WiFiSetupPage() {
               variant="outline"
               size="sm"
               className="flex-1 h-8"
-              onClick={async () => {
-                if (!confirm('Restart Dune Weaver?')) return
-                try {
-                  await apiClient.post('/api/system/restart')
-                  toast.success('Restarting...')
-                } catch { toast.error('Failed to restart') }
-              }}
+              onClick={() => setConfirmAction('restart')}
             >
-              <span className="material-icons-outlined text-sm mr-1.5">restart_alt</span>
+              <span className="material-icons-outlined text-sm mr-1.5" aria-hidden="true">restart_alt</span>
               Restart
             </Button>
             <Button
               variant="outline"
               size="sm"
               className="flex-1 h-8 text-destructive hover:text-destructive"
-              onClick={async () => {
-                if (!confirm('Shutdown the system?')) return
-                try {
-                  await apiClient.post('/api/system/shutdown')
-                  toast.success('Shutting down...')
-                } catch { toast.error('Failed to shutdown') }
-              }}
+              onClick={() => setConfirmAction('shutdown')}
             >
-              <span className="material-icons-outlined text-sm mr-1.5">power_settings_new</span>
+              <span className="material-icons-outlined text-sm mr-1.5" aria-hidden="true">power_settings_new</span>
               Shutdown
             </Button>
           </div>
@@ -436,7 +488,7 @@ export function WiFiSetupPage() {
         <CardContent className="pt-4 pb-2 px-4">
           <div className="flex items-center justify-between mb-2">
             <div className="flex items-center gap-2">
-              <span className="material-icons-outlined text-base text-muted-foreground">wifi_find</span>
+              <span className="material-icons-outlined text-base text-muted-foreground" aria-hidden="true">wifi_find</span>
               <span className="font-semibold text-sm">Networks</span>
               <span className="text-xs text-muted-foreground">
                 {networks.length > 0
@@ -451,8 +503,9 @@ export function WiFiSetupPage() {
                 className="h-7 w-7 p-0"
                 onClick={openManualEntry}
                 title="Add network manually"
+                aria-label="Add network manually"
               >
-                <span className="material-icons text-base">add</span>
+                <span className="material-icons text-base" aria-hidden="true">add</span>
               </Button>
               <Button
                 variant="ghost"
@@ -460,8 +513,9 @@ export function WiFiSetupPage() {
                 className="h-7 w-7 p-0"
                 onClick={scanNetworks}
                 disabled={isScanning}
+                aria-label="Scan for networks"
               >
-                <span className={`material-icons text-base ${isScanning ? 'animate-spin' : ''}`}>
+                <span className={`material-icons text-base ${isScanning ? 'animate-spin' : ''}`} aria-hidden="true">
                   refresh
                 </span>
               </Button>
@@ -480,7 +534,7 @@ export function WiFiSetupPage() {
             )}
             {networks.map((network) => (
               <button
-                key={network.ssid}
+                key={`${network.ssid}-${network.security}-${network.signal}`}
                 onClick={() => openConnectDialog(network)}
                 className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-left transition-colors hover:bg-muted/50
                   ${network.active ? 'bg-green-50 dark:bg-green-950/20' : ''}`}
@@ -496,7 +550,7 @@ export function WiFiSetupPage() {
                 </div>
                 <span className="text-xs text-muted-foreground">{network.signal}%</span>
                 {network.security !== 'Open' && (
-                  <span className="material-icons text-sm text-muted-foreground">lock</span>
+                  <span className="material-icons text-sm text-muted-foreground" aria-hidden="true">lock</span>
                 )}
               </button>
             ))}
@@ -543,7 +597,7 @@ export function WiFiSetupPage() {
             <DialogTitle className="flex items-center gap-2">
               {isManualEntry ? (
                 <>
-                  <span className="material-icons text-lg text-muted-foreground">add_circle_outline</span>
+                  <span className="material-icons text-lg text-muted-foreground" aria-hidden="true">add_circle_outline</span>
                   Add Network
                 </>
               ) : (
@@ -565,6 +619,16 @@ export function WiFiSetupPage() {
           </DialogHeader>
 
           <div className="space-y-4">
+            {isHotspotMode && (
+              <Alert>
+                <span className="material-icons text-base" aria-hidden="true">wifi_off</span>
+                <AlertDescription>
+                  Connecting will turn off the table's hotspot. Rejoin your home WiFi, then open{' '}
+                  http://{status?.hostname || '<hostname>'}.local
+                </AlertDescription>
+              </Alert>
+            )}
+
             {isManualEntry && (
               <div className="space-y-2">
                 <Label htmlFor="wifi-ssid">Network Name (SSID)</Label>
@@ -598,8 +662,9 @@ export function WiFiSetupPage() {
                     type="button"
                     onClick={() => setShowPassword(!showPassword)}
                     className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                    aria-label={showPassword ? 'Hide password' : 'Show password'}
                   >
-                    <span className="material-icons text-sm">
+                    <span className="material-icons text-sm" aria-hidden="true">
                       {showPassword ? 'visibility_off' : 'visibility'}
                     </span>
                   </button>
@@ -617,12 +682,12 @@ export function WiFiSetupPage() {
                 >
                   {isSaving ? (
                     <>
-                      <span className="material-icons text-sm animate-spin mr-2">refresh</span>
+                      <span className="material-icons text-sm animate-spin mr-2" aria-hidden="true">refresh</span>
                       Saving...
                     </>
                   ) : (
                     <>
-                      <span className="material-icons text-sm mr-2">bookmark_add</span>
+                      <span className="material-icons text-sm mr-2" aria-hidden="true">bookmark_add</span>
                       Save
                     </>
                   )}
@@ -634,12 +699,12 @@ export function WiFiSetupPage() {
                 >
                   {isConnecting ? (
                     <>
-                      <span className="material-icons text-sm animate-spin mr-2">refresh</span>
+                      <span className="material-icons text-sm animate-spin mr-2" aria-hidden="true">refresh</span>
                       Connecting...
                     </>
                   ) : (
                     <>
-                      <span className="material-icons text-sm mr-2">wifi</span>
+                      <span className="material-icons text-sm mr-2" aria-hidden="true">wifi</span>
                       Connect
                     </>
                   )}
@@ -653,12 +718,12 @@ export function WiFiSetupPage() {
               >
                 {isConnecting ? (
                   <>
-                    <span className="material-icons text-sm animate-spin mr-2">refresh</span>
+                    <span className="material-icons text-sm animate-spin mr-2" aria-hidden="true">refresh</span>
                     Connecting...
                   </>
                 ) : (
                   <>
-                    <span className="material-icons text-sm mr-2">wifi</span>
+                    <span className="material-icons text-sm mr-2" aria-hidden="true">wifi</span>
                     Connect
                   </>
                 )}
@@ -667,6 +732,56 @@ export function WiFiSetupPage() {
           </div>
         </DialogContent>
       </Dialog>
+
+      {/* Restart Confirmation */}
+      <ConfirmDialog
+        open={confirmAction === 'restart'}
+        onOpenChange={(open) => { if (!open) setConfirmAction(null) }}
+        title="Restart the system?"
+        description="The table will be unavailable for a minute or two while it reboots."
+        confirmLabel="Restart"
+        destructive
+        onConfirm={handleRestart}
+      />
+
+      {/* Shutdown Confirmation */}
+      <ConfirmDialog
+        open={confirmAction === 'shutdown'}
+        onOpenChange={(open) => { if (!open) setConfirmAction(null) }}
+        title="Shut down the system?"
+        description="The table will power off. You'll need to physically unplug and replug it (or use its power switch) to turn it back on."
+        confirmLabel="Shut down"
+        destructive
+        onConfirm={handleShutdown}
+      />
+
+      {/* Restart / Shutdown Overlay */}
+      {systemState !== 'idle' && (
+        <div
+          className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-background/95 px-6 text-center"
+          role="status"
+        >
+          {systemState === 'restarting' ? (
+            <>
+              <span className="material-icons text-3xl text-muted-foreground animate-spin" aria-hidden="true">
+                refresh
+              </span>
+              <p className="text-sm font-medium">
+                Restarting — waiting for the table to come back…
+              </p>
+            </>
+          ) : (
+            <>
+              <span className="material-icons-outlined text-3xl text-muted-foreground" aria-hidden="true">
+                power_settings_new
+              </span>
+              <p className="text-sm font-medium">
+                The system is shutting down. You can close this page.
+              </p>
+            </>
+          )}
+        </div>
+      )}
     </div>
   )
 }

+ 23 - 11
frontend/src/stores/useStatusStore.ts

@@ -71,8 +71,11 @@ function connectWebSocket() {
   const socket = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
   ws = socket
 
+  // Every handler checks `ws === socket` so a socket orphaned by a table
+  // switch (including one still CONNECTING when the switch happened) can
+  // never update the store on behalf of the new connection
   socket.onopen = () => {
-    if (isStopped) {
+    if (isStopped || ws !== socket) {
       socket.close()
       return
     }
@@ -81,7 +84,7 @@ function connectWebSocket() {
   }
 
   socket.onmessage = (event) => {
-    if (isStopped) return
+    if (isStopped || ws !== socket) return
     try {
       const data = JSON.parse(event.data)
       if (data.type === 'status_update' && data.data) {
@@ -93,17 +96,22 @@ function connectWebSocket() {
   }
 
   socket.onclose = () => {
-    if (isStopped) return
+    if (isStopped || ws !== socket) return
     ws = null
-    useStatusStore.setState((prev) => ({
+    const attempts = useStatusStore.getState().connectionAttempts + 1
+    useStatusStore.setState({
       isBackendConnected: false,
-      connectionAttempts: prev.connectionAttempts + 1,
-    }))
-    reconnectTimeout = setTimeout(connectWebSocket, 3000)
+      connectionAttempts: attempts,
+    })
+    // Exponential backoff with jitter, capped at 30s — a flat interval makes
+    // every open tab hammer the backend in lockstep while it's down
+    const delay =
+      Math.min(30_000, 3_000 * 2 ** Math.min(attempts - 1, 4)) + Math.random() * 1_000
+    reconnectTimeout = setTimeout(connectWebSocket, delay)
   }
 
   socket.onerror = () => {
-    if (isStopped) return
+    if (isStopped || ws !== socket) return
     useStatusStore.setState({ isBackendConnected: false })
   }
 }
@@ -115,10 +123,13 @@ apiClient.onBaseUrlChange(() => {
     clearTimeout(reconnectTimeout)
     reconnectTimeout = null
   }
-  // Close existing and reconnect
+  // Close existing and reconnect. Null the ref first so the old socket's
+  // handlers see ws !== socket and stand down; close() is valid (and
+  // necessary) even on a socket still in CONNECTING state.
   if (ws) {
-    if (ws.readyState === WebSocket.OPEN) ws.close()
+    const oldSocket = ws
     ws = null
+    oldSocket.close()
   }
   connectWebSocket()
 })
@@ -158,6 +169,7 @@ apiClient.onBaseUrlChange(() => {
 export function _stopStatusWebSocket() {
   isStopped = true
   if (reconnectTimeout) clearTimeout(reconnectTimeout)
-  if (ws && ws.readyState === WebSocket.OPEN) ws.close()
+  // close() is safe in any readyState, including CONNECTING
+  ws?.close()
   ws = null
 }

+ 16 - 1
frontend/src/test/setup.ts

@@ -5,12 +5,19 @@ import { setupBrowserMocks, cleanupBrowserMocks } from './mocks/browser'
 import { setupMockWebSocket, cleanupMockWebSocket } from './mocks/websocket'
 import { server } from './mocks/server'
 import { resetMockData, resetApiCallLog } from './mocks/handlers'
+import { apiClient } from '@/lib/apiClient'
+
+// Stub WebSocket at MODULE scope, not in beforeAll: useStatusStore opens its
+// socket at import time, which happens before beforeAll runs. Node's native
+// WebSocket would otherwise connect to a real dev backend on this machine
+// (if one is running) and its live status pushes would clobber seeded test
+// state — making the suite pass or fail depending on what's running locally.
+setupMockWebSocket()
 
 // Setup browser mocks FIRST (before MSW starts)
 // This ensures WebSocket mock is in place before MSW tries to intercept
 beforeAll(() => {
   setupBrowserMocks()
-  setupMockWebSocket()
   // Use 'warn' instead of 'error' to avoid failing on WebSocket requests
   // that are handled by our mock WebSocket, not MSW
   server.listen({ onUnhandledRequest: 'warn' })
@@ -20,6 +27,14 @@ beforeAll(() => {
 beforeEach(() => {
   resetMockData()
   resetApiCallLog()
+  // Cross-test pollution guards: TableContext persists tables/active-id to
+  // localStorage, and apiClient's base URL is module state. A leftover base
+  // URL makes TableProvider call setBaseUrl on a later mount, which fires
+  // onBaseUrlChange and wipes the status store mid-test (seeded statuses
+  // vanish, connection-gated buttons disable). Reset both up front — this
+  // runs before each file's own beforeEach, so seeds applied there survive.
+  localStorage.clear()
+  apiClient.setBaseUrl('')
 })
 
 // Cleanup after each test

+ 35 - 1
frontend/src/test/utils.tsx

@@ -1,8 +1,13 @@
 import { render } from '@testing-library/react'
 import type { RenderOptions } from '@testing-library/react'
-import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router'
+// Import from 'react-router-dom' (same as app code) — under vitest the two
+// packages load as separate module instances, so a Router from 'react-router'
+// provides context that app <Link>s from 'react-router-dom' cannot see
+import { BrowserRouter, MemoryRouter, Routes, Route } from 'react-router-dom'
 import type { ReactElement, ReactNode } from 'react'
 import type { PatternMetadata, PreviewData } from '@/lib/types'
+import { useStatusStore } from '@/stores/useStatusStore'
+import type { StatusData } from '@/stores/useStatusStore'
 import { TableProvider } from '@/contexts/TableContext'
 import { Layout } from '@/components/layout/Layout'
 import { BrowsePage } from '@/pages/BrowsePage'
@@ -87,6 +92,35 @@ export function createMockPlaylists(): string[] {
   return ['default', 'favorites', 'geometric', 'relaxing']
 }
 
+// Seed the status store as a connected, idle table. Pages gate actions
+// (e.g. BrowsePage's Play button) on status.connection_status, which in
+// the app only arrives over the /ws/status WebSocket.
+export function seedConnectedStatus(overrides: Partial<StatusData> = {}) {
+  useStatusStore.setState({
+    status: {
+      current_file: null,
+      is_paused: false,
+      manual_pause: false,
+      scheduled_pause: false,
+      is_running: false,
+      is_homing: false,
+      is_clearing: false,
+      sensor_homing_failed: false,
+      progress: null,
+      playlist: null,
+      speed: 100,
+      pause_time_remaining: 0,
+      original_pause_time: null,
+      connection_status: true,
+      current_theta: 0,
+      current_rho: 0,
+      firmware_version: null,
+      table_type: null,
+      ...overrides,
+    },
+  })
+}
+
 // Render full app for integration tests
 export function renderApp(options?: {
   initialRoute?: string

+ 2 - 0
frontend/src/vite-env.d.ts

@@ -0,0 +1,2 @@
+/// <reference types="vite/client" />
+/// <reference types="vite-plugin-pwa/react" />

+ 18 - 1
frontend/vite.config.ts

@@ -8,7 +8,7 @@ export default defineConfig({
   plugins: [
     react(),
     VitePWA({
-      registerType: 'autoUpdate',
+      registerType: 'prompt',
       includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'android-chrome-192x192.png', 'android-chrome-512x512.png'],
       manifest: false, // We use our own manifest at /static/site.webmanifest
       workbox: {
@@ -171,5 +171,22 @@ export default defineConfig({
   build: {
     outDir: '../static/dist',
     emptyOutDir: true,
+    rollupOptions: {
+      output: {
+        manualChunks: (id) => {
+          if (!id.includes('node_modules')) return undefined
+          if (/node_modules\/(react|react-dom|react-router|react-router-dom|scheduler)\//.test(id)) {
+            return 'react'
+          }
+          if (/node_modules\/(motion|framer-motion|motion-dom|motion-utils)\//.test(id)) {
+            return 'motion'
+          }
+          if (id.includes('node_modules/@dnd-kit/')) {
+            return 'dnd'
+          }
+          return undefined
+        },
+      },
+    },
   },
 })

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/BrowsePage-1r3NAXso.js


+ 1 - 0
static/dist/assets/CaptivePortalPage-DVYLYnAt.js

@@ -0,0 +1 @@
+import{d as n,r as a,j as e}from"./react-DnK_pEU5.js";function c(){const o=n(),[r,s]=a.useState(!1),l=()=>{const t=window.open("http://10.42.0.1","_blank");(!t||t.closed)&&s(!0)};return e.jsx("div",{className:"flex flex-col items-center justify-center min-h-[60vh] px-4",children:e.jsxs("div",{className:"w-full max-w-sm text-center space-y-6",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"text-2xl font-semibold",children:"Welcome to Dune Weaver"}),e.jsx("p",{className:"text-muted-foreground mt-2",children:"What would you like to do?"})]}),e.jsxs("div",{className:"flex flex-col gap-3",children:[e.jsx("button",{onClick:()=>o("/wifi-setup"),className:"w-full rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90 transition-colors",children:"Connect to WiFi"}),e.jsx("button",{onClick:l,className:"w-full rounded-lg border border-border bg-muted px-6 py-3 font-medium hover:bg-accent transition-colors",children:"Control Table"})]}),r&&e.jsxs("div",{className:"rounded-lg bg-muted p-3 text-sm text-muted-foreground",children:["Open your browser and go to:",e.jsx("br",{}),e.jsx("code",{className:"mt-1 inline-block rounded bg-background px-2 py-0.5 text-foreground select-all",children:"http://10.42.0.1"})]})]})})}export{c as CaptivePortalPage};

+ 1 - 0
static/dist/assets/ConnectionBanner-CCnOHE5N.js

@@ -0,0 +1 @@
+import{j as e,L as t}from"./react-DnK_pEU5.js";import{u as s}from"./index-B3c97KGp.js";function r(){return s(n=>!!n.status?.connection_status)}function c(){return r()?null:e.jsxs("div",{role:"status",className:"flex items-center gap-2.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-400",children:[e.jsx("span",{className:"material-icons-outlined text-base shrink-0","aria-hidden":"true",children:"usb_off"}),e.jsxs("span",{className:"min-w-0",children:["Table not connected — patterns can't play."," ",e.jsx(t,{to:"/settings?section=connection",className:"font-medium underline underline-offset-2 hover:text-amber-800 dark:hover:text-amber-300",children:"Connect"})]})]})}export{c as C,r as u};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/LEDPage-oDN2VKcW.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/PlaylistsPage-CplC-pMF.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/SettingsPage-BQ-dtYJf.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/SetupPage-C5AL1838.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/TableControlPage-DUmfP8p3.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/WiFiSetupPage-TJa-Ecum.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/accordion-Cq66Jm31.js


+ 1 - 0
static/dist/assets/alert-CjHh5uAz.js

@@ -0,0 +1 @@
+import{r as a,j as s}from"./react-DnK_pEU5.js";import{c as i,b as d}from"./index-B3c97KGp.js";const o=d("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",{variants:{variant:{default:"bg-muted/50 text-foreground",destructive:"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive"}},defaultVariants:{variant:"default"}}),n=a.forwardRef(({className:t,variant:e,...r},l)=>s.jsx("div",{ref:l,role:"alert",className:i(o({variant:e}),t),...r}));n.displayName="Alert";const v=a.forwardRef(({className:t,...e},r)=>s.jsx("h5",{ref:r,className:i("mb-1 font-medium leading-none tracking-tight",t),...e}));v.displayName="AlertTitle";const c=a.forwardRef(({className:t,...e},r)=>s.jsx("div",{ref:r,className:i("text-sm [&_p]:leading-relaxed",t),...e}));c.displayName="AlertDescription";export{n as A,c as a};

+ 1 - 0
static/dist/assets/card-DweoKf_j.js

@@ -0,0 +1 @@
+import{r as s,j as d}from"./react-DnK_pEU5.js";import{c as t}from"./index-B3c97KGp.js";const o=s.forwardRef(({className:a,...e},r)=>d.jsx("div",{ref:r,className:t("rounded-lg border bg-card text-card-foreground shadow-sm",a),...e}));o.displayName="Card";const i=s.forwardRef(({className:a,...e},r)=>d.jsx("div",{ref:r,className:t("flex flex-col space-y-1.5 p-6",a),...e}));i.displayName="CardHeader";const c=s.forwardRef(({className:a,...e},r)=>d.jsx("div",{ref:r,className:t("text-2xl font-semibold leading-none tracking-tight",a),...e}));c.displayName="CardTitle";const n=s.forwardRef(({className:a,...e},r)=>d.jsx("div",{ref:r,className:t("text-sm text-muted-foreground",a),...e}));n.displayName="CardDescription";const l=s.forwardRef(({className:a,...e},r)=>d.jsx("div",{ref:r,className:t("p-6 pt-0",a),...e}));l.displayName="CardContent";const m=s.forwardRef(({className:a,...e},r)=>d.jsx("div",{ref:r,className:t("flex items-center p-6 pt-0",a),...e}));m.displayName="CardFooter";export{o as C,i as a,c as b,n as c,l as d};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/dnd-Bgdma8TD.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 0
static/dist/assets/index-B3c97KGp.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/index-Bg3BUsME.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/index-D9WwIcnt.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/index-MuBercVK.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/index-Sj6DXm4R.js


+ 1 - 0
static/dist/assets/label-CasawWzc.js

@@ -0,0 +1 @@
+import{r as o,j as i}from"./react-DnK_pEU5.js";import{a4 as c,c as p,b}from"./index-B3c97KGp.js";var v=["a","button","div","form","h2","h3","img","input","label","li","nav","ol","p","select","span","svg","ul"],w=v.reduce((t,a)=>{const e=c(`Primitive.${a}`),r=o.forwardRef((n,d)=>{const{asChild:m,...f}=n,u=m?e:a;return typeof window<"u"&&(window[Symbol.for("radix-ui")]=!0),i.jsx(u,{...f,ref:d})});return r.displayName=`Primitive.${a}`,{...t,[a]:r}},{}),x="Label",s=o.forwardRef((t,a)=>i.jsx(w.label,{...t,ref:a,onMouseDown:e=>{e.target.closest("button, input, select, textarea")||(t.onMouseDown?.(e),!e.defaultPrevented&&e.detail>1&&e.preventDefault())}}));s.displayName=x;var l=s;const N=b("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"),y=o.forwardRef(({className:t,...a},e)=>i.jsx(l,{ref:e,className:p(N(),t),...a}));y.displayName=l.displayName;export{y as L};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/motion-8nKk6_RD.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/react-DnK_pEU5.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/select-CmzQQBWZ.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/slider-DrXXF40r.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/assets/switch-BkKXBesV.js


+ 1 - 0
static/dist/assets/types-DxHZB39T.js

@@ -0,0 +1 @@
+const e=[{value:"adaptive",label:"Adaptive",description:"Automatically picks the best clear direction based on where the ball is"},{value:"clear_from_in",label:"From Center",description:"Spirals outward from the center to erase the current pattern"},{value:"clear_from_out",label:"From Perimeter",description:"Spirals inward from the edge to erase the current pattern"},{value:"clear_sideway",label:"Sideways",description:"Sweeps side-to-side across the sand to erase the current pattern"},{value:"none",label:"None",description:"Start drawing immediately without clearing the sand first"}];export{e as p};

+ 1 - 0
static/dist/assets/useBackendConnection-DKvfYjhQ.js

@@ -0,0 +1 @@
+import{r as n}from"./react-DnK_pEU5.js";function o(e){const t=n.useRef(e);n.useEffect(()=>{t.current=e},[e]),n.useEffect(()=>{const c=()=>{t.current()};return window.addEventListener("backend-connected",c),()=>{window.removeEventListener("backend-connected",c)}},[])}export{o as u};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 0
static/dist/assets/workbox-window.prod.es5-BIl4cyR9.js


+ 6 - 3
static/dist/index.html

@@ -58,9 +58,12 @@
           .catch(function() {});
       })();
     </script>
-    <script type="module" crossorigin src="/assets/index-MuBercVK.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Bg3BUsME.css">
-  <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
+    <script type="module" crossorigin src="/assets/index-B3c97KGp.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/react-DnK_pEU5.js">
+    <link rel="modulepreload" crossorigin href="/assets/dnd-Bgdma8TD.js">
+    <link rel="modulepreload" crossorigin href="/assets/motion-8nKk6_RD.js">
+    <link rel="stylesheet" crossorigin href="/assets/index-D9WwIcnt.css">
+  </head>
   <body>
     <div id="root"></div>
   </body>

+ 0 - 1
static/dist/registerSW.js

@@ -1 +0,0 @@
-if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('/sw.js', { scope: '/' })})}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/sw.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/dist/workbox-4424bb96.js


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio