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

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 1 месяц назад
Родитель
Сommit
a7149c5a39
64 измененных файлов с 2991 добавлено и 1360 удалено
  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/noto-sans": "^5.2.10",
         "@fontsource/plus-jakarta-sans": "^5.2.8",
         "@fontsource/plus-jakarta-sans": "^5.2.8",
         "@radix-ui/react-accordion": "^1.2.12",
         "@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-dialog": "^1.1.15",
         "@radix-ui/react-label": "^2.1.8",
         "@radix-ui/react-label": "^2.1.8",
         "@radix-ui/react-popover": "^1.1.15",
         "@radix-ui/react-popover": "^1.1.15",
@@ -32,10 +33,8 @@
         "motion": "^12.27.1",
         "motion": "^12.27.1",
         "next-themes": "^0.4.6",
         "next-themes": "^0.4.6",
         "react": "^19.2.0",
         "react": "^19.2.0",
-        "react-color": "^2.19.3",
-        "react-colorful": "^5.6.1",
+        "react-colorful": "^5.7.0",
         "react-dom": "^19.2.0",
         "react-dom": "^19.2.0",
-        "react-resizable-panels": "^4.4.0",
         "react-router-dom": "^7.12.0",
         "react-router-dom": "^7.12.0",
         "sonner": "^2.0.7",
         "sonner": "^2.0.7",
         "zustand": "^5.0.9"
         "zustand": "^5.0.9"
@@ -48,7 +47,6 @@
         "@testing-library/user-event": "^14.6.1",
         "@testing-library/user-event": "^14.6.1",
         "@types/node": "^24.10.4",
         "@types/node": "^24.10.4",
         "@types/react": "^19.2.5",
         "@types/react": "^19.2.5",
-        "@types/react-color": "^3.0.13",
         "@types/react-dom": "^19.2.3",
         "@types/react-dom": "^19.2.3",
         "@vitejs/plugin-react": "^5.1.1",
         "@vitejs/plugin-react": "^5.1.1",
         "@vitest/coverage-v8": "^3.2.4",
         "@vitest/coverage-v8": "^3.2.4",
@@ -2912,15 +2910,6 @@
         "url": "https://github.com/sponsors/nzakas"
         "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": {
     "node_modules/@inquirer/ansi": {
       "version": "1.0.2",
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
       "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": {
     "node_modules/@radix-ui/react-arrow": {
       "version": "1.1.7",
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
       "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": {
     "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",
       "license": "MIT",
       "dependencies": {
       "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": {
       "peerDependencies": {
         "@types/react": "*",
         "@types/react": "*",
@@ -3600,12 +3885,82 @@
       }
       }
     },
     },
     "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
     "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",
       "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",
       "license": "MIT",
       "dependencies": {
       "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": {
       "peerDependencies": {
         "@types/react": "*",
         "@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": {
     "node_modules/@radix-ui/react-direction": {
       "version": "1.1.1",
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@@ -5474,19 +5844,6 @@
         "csstype": "^3.2.2"
         "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": {
     "node_modules/@types/react-dom": {
       "version": "19.2.3",
       "version": "19.2.3",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
@@ -5497,16 +5854,6 @@
         "@types/react": "^19.2.0"
         "@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": {
     "node_modules/@types/resolve": {
       "version": "1.20.2",
       "version": "1.20.2",
       "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
       "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -9823,6 +10170,7 @@
       "version": "4.0.0",
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/js-yaml": {
     "node_modules/js-yaml": {
@@ -10326,12 +10674,7 @@
       "version": "4.17.21",
       "version": "4.17.21",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
       "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"
       "license": "MIT"
     },
     },
     "node_modules/lodash.debounce": {
     "node_modules/lodash.debounce": {
@@ -10398,18 +10741,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
         "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": {
     "node_modules/loupe": {
       "version": "3.2.1",
       "version": "3.2.1",
       "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
       "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -10498,12 +10829,6 @@
         "node": ">=10"
         "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": {
     "node_modules/material-icons": {
       "version": "1.13.14",
       "version": "1.13.14",
       "resolved": "https://registry.npmjs.org/material-icons/-/material-icons-1.13.14.tgz",
       "resolved": "https://registry.npmjs.org/material-icons/-/material-icons-1.13.14.tgz",
@@ -10926,6 +11251,7 @@
       "version": "4.1.1",
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
       "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
       "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "dev": true,
       "license": "MIT",
       "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=0.10.0"
         "node": ">=0.10.0"
@@ -11584,17 +11910,6 @@
         "node": ">=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": {
     "node_modules/proxy-addr": {
       "version": "2.0.7",
       "version": "2.0.7",
       "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
       "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -11701,28 +12016,10 @@
         "node": ">=0.10.0"
         "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": {
     "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",
       "license": "MIT",
       "peerDependencies": {
       "peerDependencies": {
         "react": ">=16.8.0",
         "react": ">=16.8.0",
@@ -11741,12 +12038,6 @@
         "react": "^19.2.3"
         "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": {
     "node_modules/react-refresh": {
       "version": "0.18.0",
       "version": "0.18.0",
       "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
       "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": {
     "node_modules/react-router": {
       "version": "7.12.0",
       "version": "7.12.0",
       "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
       "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": {
     "node_modules/recast": {
       "version": "0.23.11",
       "version": "0.23.11",
       "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
       "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
@@ -13331,12 +13603,6 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "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": {
     "node_modules/tinyexec": {
       "version": "1.0.2",
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
       "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/noto-sans": "^5.2.10",
     "@fontsource/plus-jakarta-sans": "^5.2.8",
     "@fontsource/plus-jakarta-sans": "^5.2.8",
     "@radix-ui/react-accordion": "^1.2.12",
     "@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-dialog": "^1.1.15",
     "@radix-ui/react-label": "^2.1.8",
     "@radix-ui/react-label": "^2.1.8",
     "@radix-ui/react-popover": "^1.1.15",
     "@radix-ui/react-popover": "^1.1.15",
@@ -40,10 +41,8 @@
     "motion": "^12.27.1",
     "motion": "^12.27.1",
     "next-themes": "^0.4.6",
     "next-themes": "^0.4.6",
     "react": "^19.2.0",
     "react": "^19.2.0",
-    "react-color": "^2.19.3",
-    "react-colorful": "^5.6.1",
+    "react-colorful": "^5.7.0",
     "react-dom": "^19.2.0",
     "react-dom": "^19.2.0",
-    "react-resizable-panels": "^4.4.0",
     "react-router-dom": "^7.12.0",
     "react-router-dom": "^7.12.0",
     "sonner": "^2.0.7",
     "sonner": "^2.0.7",
     "zustand": "^5.0.9"
     "zustand": "^5.0.9"
@@ -56,7 +55,6 @@
     "@testing-library/user-event": "^14.6.1",
     "@testing-library/user-event": "^14.6.1",
     "@types/node": "^24.10.4",
     "@types/node": "^24.10.4",
     "@types/react": "^19.2.5",
     "@types/react": "^19.2.5",
-    "@types/react-color": "^3.0.13",
     "@types/react-dom": "^19.2.3",
     "@types/react-dom": "^19.2.3",
     "@vitejs/plugin-react": "^5.1.1",
     "@vitejs/plugin-react": "^5.1.1",
     "@vitest/coverage-v8": "^3.2.4",
     "@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 { Routes, Route } from 'react-router-dom'
 import { Layout } from '@/components/layout/Layout'
 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 { Toaster } from '@/components/ui/sonner'
 import { TableProvider } from '@/contexts/TableContext'
 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() {
 function App() {
   return (
   return (
     <TableProvider>
     <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 />
       <Toaster position="top-center" richColors closeButton />
+      <UpdatePrompt />
     </TableProvider>
     </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()
       expect(deleteButton).toBeTruthy()
 
 
-      // Mock window.confirm
-      const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
-
       await user.click(deleteButton!)
       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
       // Verify API call
       await waitFor(() => {
       await waitFor(() => {
@@ -216,8 +217,6 @@ describe('Playlist Flow Integration', () => {
 
 
       // Verify mockData was updated
       // Verify mockData was updated
       expect(mockData.playlists['to-delete']).toBeUndefined()
       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 />)
       renderWithProviders(<BrowsePage />)
 
 
       await waitFor(() => {
       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
       // Wait for content to load and find run/play button
       await waitFor(async () => {
       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()
         expect(runButton).toBeInTheDocument()
         await user.click(runButton)
         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 { 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 { server } from '../../test/mocks/server'
 import { http, HttpResponse } from 'msw'
 import { http, HttpResponse } from 'msw'
 import { TableControlPage } from '../../pages/TableControlPage'
 import { TableControlPage } from '../../pages/TableControlPage'
@@ -10,6 +10,9 @@ describe('TableControlPage', () => {
     vi.clearAllMocks()
     vi.clearAllMocks()
     // Reset Zustand store to prevent real WebSocket data from leaking into tests
     // Reset Zustand store to prevent real WebSocket data from leaking into tests
     useStatusStore.setState({ status: null, isBackendConnected: false, connectionAttempts: 0 })
     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', () => {
   describe('Rendering', () => {
@@ -264,8 +267,8 @@ describe('TableControlPage', () => {
       renderWithProviders(<TableControlPage />)
       renderWithProviders(<TableControlPage />)
 
 
       await waitFor(() => {
       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}
         {...listeners}
         className="w-6 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing touch-none"
         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>
       </div>
 
 
       {/* Preview thumbnail */}
       {/* Preview thumbnail */}
@@ -137,7 +137,7 @@ function SortableQueueItem({
           />
           />
         ) : (
         ) : (
           <div className="w-full h-full flex items-center justify-center">
           <div className="w-full h-full flex items-center justify-center">
-            <span className="material-icons-outlined text-muted-foreground text-4xl">image</span>
+            <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-4xl">image</span>
           </div>
           </div>
         )}
         )}
       </div>
       </div>
@@ -148,23 +148,25 @@ function SortableQueueItem({
         <p className="text-xs text-muted-foreground">#{index + 1}</p>
         <p className="text-xs text-muted-foreground">#{index + 1}</p>
       </div>
       </div>
 
 
-      {/* Move to top/bottom buttons - always visible on mobile, hover on desktop */}
-      <div className="flex flex-col gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity shrink-0">
+      {/* 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
         <button
           onClick={onMoveToTop}
           onClick={onMoveToTop}
           disabled={isFirst}
           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"
           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>
         <button
         <button
           onClick={onMoveToBottom}
           onClick={onMoveToBottom}
           disabled={isLast}
           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"
           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>
         </button>
       </div>
       </div>
     </div>
     </div>
@@ -183,6 +185,34 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
   const status: StatusData | null = useStatusStore((s) => s.status)
   const status: StatusData | null = useStatusStore((s) => s.status)
   const [previewUrl, setPreviewUrl] = useState<string | null>(null)
   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
   // Expanded state for slide-up view
   const [isExpanded, setIsExpanded] = useState(false)
   const [isExpanded, setIsExpanded] = useState(false)
 
 
@@ -212,16 +242,17 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
     touchStartY.current = null
     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(() => {
   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'
       document.body.style.overflow = 'hidden'
       return () => {
       return () => {
         document.body.style.overflow = ''
         document.body.style.overflow = ''
       }
       }
     }
     }
-  }, [isVisible])
+  }, [isVisible, isExpanded])
 
 
   // Use native event listener for touchmove to prevent background scroll on the bar itself
   // Use native event listener for touchmove to prevent background scroll on the bar itself
   useEffect(() => {
   useEffect(() => {
@@ -483,30 +514,34 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
     }
     }
   }, [getThemeColors, initOffscreenCanvas, polarToCartesian])
   }, [getThemeColors, initOffscreenCanvas, polarToCartesian])
 
 
-  // Fetch coordinates when file changes or fullscreen opens
+  // Fetch coordinates when the playing file changes
   useEffect(() => {
   useEffect(() => {
     const currentFile = status?.current_file
     const currentFile = status?.current_file
     if (!currentFile) return
     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
     lastFileRef.current = currentFile
     lastDrawnIndexRef.current = -1
     lastDrawnIndexRef.current = -1
+    let cancelled = false
 
 
     apiClient.post<{ coordinates?: Coordinate[] }>('/get_theta_rho_coordinates', { file_name: currentFile })
     apiClient.post<{ coordinates?: Coordinate[] }>('/get_theta_rho_coordinates', { file_name: currentFile })
       .then((data) => {
       .then((data) => {
-        if (data.coordinates && Array.isArray(data.coordinates)) {
-          setCoordinates(data.coordinates)
-        }
+        if (cancelled) return
+        setCoordinates(Array.isArray(data.coordinates) ? data.coordinates : [])
       })
       })
       .catch((err) => {
       .catch((err) => {
+        if (cancelled) return
         console.error('Failed to fetch coordinates:', err)
         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([])
         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
   // Get target index from progress percentage
   const getTargetIndex = useCallback((coords: Coordinate[]): number => {
   const getTargetIndex = useCallback((coords: Coordinate[]): number => {
@@ -880,6 +915,9 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
         onTouchStart={handleTouchStart}
         onTouchStart={handleTouchStart}
         onTouchEnd={handleTouchEnd}
         onTouchEnd={handleTouchEnd}
       >
       >
+        {/* Screen-reader playback announcements */}
+        <div aria-live="polite" role="status" className="sr-only">{announcement}</div>
+
         {/* Max-width container to match page layout */}
         {/* Max-width container to match page layout */}
         <div className="h-full max-w-5xl mx-auto relative">
         <div className="h-full max-w-5xl mx-auto relative">
           {/* Swipe indicator - only on mobile */}
           {/* Swipe indicator - only on mobile */}
@@ -894,22 +932,24 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
             <Button
             <Button
               variant="ghost"
               variant="ghost"
               size="icon"
               size="icon"
-              className="md:hidden h-8 w-8"
+              className="md:hidden h-10 w-10"
               onClick={() => setShowQueue(true)}
               onClick={() => setShowQueue(true)}
               title="View queue"
               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>
             </Button>
           )}
           )}
           {isPlaying && (
           {isPlaying && (
             <Button
             <Button
               variant="ghost"
               variant="ghost"
               size="icon"
               size="icon"
-              className="h-8 w-8"
+              className="h-10 w-10"
               onClick={() => setIsExpanded(!isExpanded)}
               onClick={() => setIsExpanded(!isExpanded)}
               title={isExpanded ? 'Collapse' : 'Expand'}
               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'}
                 {isExpanded ? 'expand_more' : 'expand_less'}
               </span>
               </span>
             </Button>
             </Button>
@@ -917,11 +957,12 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
           <Button
           <Button
             variant="ghost"
             variant="ghost"
             size="icon"
             size="icon"
-            className="h-8 w-8"
+            className="h-10 w-10"
             onClick={onClose}
             onClick={onClose}
             title="Close"
             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>
           </Button>
         </div>
         </div>
 
 
@@ -946,7 +987,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                     />
                     />
                   ) : (
                   ) : (
                     <div className="w-full h-full flex items-center justify-center">
                     <div className="w-full h-full flex items-center justify-center">
-                      <span className="material-icons-outlined text-muted-foreground text-4xl">
+                      <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-4xl">
                         {isPlaying ? 'image' : 'hourglass_empty'}
                         {isPlaying ? 'image' : 'hourglass_empty'}
                       </span>
                       </span>
                     </div>
                     </div>
@@ -989,19 +1030,19 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                       {/* Progress Bar - Desktop only (inline, above controls) */}
                       {/* Progress Bar - Desktop only (inline, above controls) */}
                       {isWaiting ? (
                       {isWaiting ? (
                         <div className="hidden md:flex items-center gap-3">
                         <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>
                           <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
                         </div>
                         </div>
                       ) : (
                       ) : (
                         <div className="hidden md:flex items-center gap-3">
                         <div className="hidden md:flex items-center gap-3">
                           <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
                           <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
                           <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'}`}
                             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'}
                             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)}
                             -{formatTime(remainingTime)}
                           </span>
                           </span>
                         </div>
                         </div>
@@ -1015,16 +1056,18 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                           className="h-10 w-10 rounded-full"
                           className="h-10 w-10 rounded-full"
                           onClick={handleStop}
                           onClick={handleStop}
                           title="Stop"
                           title="Stop"
+                          aria-label="Stop pattern"
                         >
                         >
-                          <span className="material-icons">stop</span>
+                          <span aria-hidden="true" className="material-icons">stop</span>
                         </Button>
                         </Button>
                         <Button
                         <Button
                           variant="default"
                           variant="default"
                           size="icon"
                           size="icon"
                           className="h-12 w-12 rounded-full"
                           className="h-12 w-12 rounded-full"
                           onClick={handlePause}
                           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'}
                             {status.is_paused ? 'play_arrow' : 'pause'}
                           </span>
                           </span>
                         </Button>
                         </Button>
@@ -1035,8 +1078,9 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                             className="h-10 w-10 rounded-full"
                             className="h-10 w-10 rounded-full"
                             onClick={handleSkip}
                             onClick={handleSkip}
                             title="Skip to next"
                             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>
                           </Button>
                         )}
                         )}
                       </div>
                       </div>
@@ -1050,7 +1094,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                           value={speedInput}
                           value={speedInput}
                           onChange={(e) => setSpeedInput(e.target.value)}
                           onChange={(e) => setSpeedInput(e.target.value)}
                           onKeyDown={(e) => e.key === 'Enter' && handleSpeedSubmit()}
                           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>
                         <span className="text-sm text-muted-foreground">mm/s</span>
                       </div>
                       </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">
                         <p className="text-xs text-muted-foreground font-medium flex items-center gap-1">
                           Up Next
                           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>
                         </p>
                         <div className="w-24 h-24 rounded-full overflow-hidden bg-muted border-2">
                         <div className="w-24 h-24 rounded-full overflow-hidden bg-muted border-2">
                           {nextPreviewUrl ? (
                           {nextPreviewUrl ? (
                             <img
                             <img
                               src={nextPreviewUrl}
                               src={nextPreviewUrl}
-                              alt="Next pattern"
+                              alt={`Next pattern: ${formatPatternName(status.playlist.next_file)}`}
                               className="w-full h-full object-cover pattern-preview"
                               className="w-full h-full object-cover pattern-preview"
                             />
                             />
                           ) : (
                           ) : (
                             <div className="w-full h-full flex items-center justify-center">
                             <div className="w-full h-full flex items-center justify-center">
-                              <span className="material-icons-outlined text-muted-foreground text-2xl">image</span>
+                              <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-2xl">image</span>
                             </div>
                             </div>
                           )}
                           )}
                         </div>
                         </div>
@@ -1097,16 +1141,16 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
               {isPlaying && status && (
               {isPlaying && status && (
                 isWaiting ? (
                 isWaiting ? (
                   <div className="flex md:hidden items-center gap-3 px-6 pb-16">
                   <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>
                     <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
                   </div>
                   </div>
                 ) : (
                 ) : (
                   <div className="flex md:hidden items-center gap-3 px-6 pb-16">
                   <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>
                     <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'}`}>
                     <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)}
                       -{formatTime(remainingTime)}
                     </span>
                     </span>
                   </div>
                   </div>
@@ -1129,6 +1173,8 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                     ref={canvasRef}
                     ref={canvasRef}
                     width={600}
                     width={600}
                     height={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]"
                     className="rounded-full border-2 hover:border-primary transition-colors w-[40vh] h-[40vh] max-w-[300px] max-h-[300px] md:w-[42vh] md:h-[42vh] md:max-w-[500px] md:max-h-[500px]"
                   />
                   />
                 </div>
                 </div>
@@ -1147,7 +1193,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                       />
                       />
                     ) : (
                     ) : (
                       <div className="w-full h-full flex items-center justify-center">
                       <div className="w-full h-full flex items-center justify-center">
-                        <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
+                        <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-sm">image</span>
                       </div>
                       </div>
                     )}
                     )}
                   </div>
                   </div>
@@ -1179,20 +1225,20 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                 {/* Progress */}
                 {/* Progress */}
                 {isWaiting ? (
                 {isWaiting ? (
                   <div className="space-y-1 md:space-y-2">
                   <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">
                     <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>
                       <span>{formatTime(waitTimeRemaining)} remaining</span>
                     </div>
                     </div>
                   </div>
                   </div>
                 ) : (
                 ) : (
                   <div className="space-y-1 md:space-y-2">
                   <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">
                     <div className="flex justify-between text-xs md:text-sm text-muted-foreground font-mono">
                       <span className="w-16">{formatTime(elapsedTime)}</span>
                       <span className="w-16">{formatTime(elapsedTime)}</span>
                       <span>{progressPercent.toFixed(0)}%</span>
                       <span>{progressPercent.toFixed(0)}%</span>
                       <span className="w-16 flex items-center justify-end gap-1">
                       <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)}
                         -{formatTime(remainingTime)}
                       </span>
                       </span>
                     </div>
                     </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"
                     className="h-10 w-10 md:h-12 md:w-12 rounded-full"
                     onClick={handleStop}
                     onClick={handleStop}
                     title="Stop"
                     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>
                   <Button
                   <Button
                     variant="default"
                     variant="default"
                     size="icon"
                     size="icon"
                     className="h-12 w-12 md:h-14 md:w-14 rounded-full"
                     className="h-12 w-12 md:h-14 md:w-14 rounded-full"
                     onClick={handlePause}
                     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'}
                       {status?.is_paused ? 'play_arrow' : 'pause'}
                     </span>
                     </span>
                   </Button>
                   </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"
                       className="h-10 w-10 md:h-12 md:w-12 rounded-full"
                       onClick={handleSkip}
                       onClick={handleSkip}
                       title="Skip to next"
                       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>
                     </Button>
                   )}
                   )}
                 </div>
                 </div>
@@ -1258,12 +1307,12 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                       {nextPreviewUrl ? (
                       {nextPreviewUrl ? (
                         <img
                         <img
                           src={nextPreviewUrl}
                           src={nextPreviewUrl}
-                          alt="Next pattern"
+                          alt={`Next pattern: ${formatPatternName(status.playlist.next_file)}`}
                           className="w-full h-full object-cover pattern-preview"
                           className="w-full h-full object-cover pattern-preview"
                         />
                         />
                       ) : (
                       ) : (
                         <div className="w-full h-full flex items-center justify-center">
                         <div className="w-full h-full flex items-center justify-center">
-                          <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
+                          <span aria-hidden="true" className="material-icons-outlined text-muted-foreground text-sm">image</span>
                         </div>
                         </div>
                       )}
                       )}
                     </div>
                     </div>
@@ -1273,7 +1322,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                         {formatPatternName(status.playlist.next_file)}
                         {formatPatternName(status.playlist.next_file)}
                       </p>
                       </p>
                     </div>
                     </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>
                 )}
                 )}
               </div>
               </div>
@@ -1298,7 +1347,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
           </div>
           </div>
           <DialogHeader>
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
             <DialogTitle className="flex items-center gap-2">
-              <span className="material-icons-outlined">queue_music</span>
+              <span aria-hidden="true" className="material-icons-outlined">queue_music</span>
               Queue
               Queue
               {status?.playlist?.name && (
               {status?.playlist?.name && (
                 <span className="text-sm font-normal text-muted-foreground">
                 <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 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 {
 interface ShinyTextProps {
   text: string;
   text: string;
@@ -29,6 +29,8 @@ const ShinyText: React.FC<ShinyTextProps> = ({
   delay = 0
   delay = 0
 }) => {
 }) => {
   const [isPaused, setIsPaused] = useState(false);
   const [isPaused, setIsPaused] = useState(false);
+  const prefersReducedMotion = useReducedMotion();
+  const isStatic = disabled || prefersReducedMotion;
   const progress = useMotionValue(0);
   const progress = useMotionValue(0);
   const elapsedRef = useRef(0);
   const elapsedRef = useRef(0);
   const lastTimeRef = useRef<number | null>(null);
   const lastTimeRef = useRef<number | null>(null);
@@ -37,8 +39,10 @@ const ShinyText: React.FC<ShinyTextProps> = ({
   const animationDuration = speed * 1000;
   const animationDuration = speed * 1000;
   const delayDuration = delay * 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 => {
   useAnimationFrame(time => {
-    if (disabled || isPaused) {
+    if (isStatic || isPaused) {
       lastTimeRef.current = null;
       lastTimeRef.current = null;
       return;
       return;
     }
     }
@@ -119,7 +123,7 @@ const ShinyText: React.FC<ShinyTextProps> = ({
   return (
   return (
     <motion.span
     <motion.span
       className={`inline-block ${className}`}
       className={`inline-block ${className}`}
-      style={{ ...gradientStyle, backgroundPosition }}
+      style={{ ...gradientStyle, backgroundPosition: isStatic ? '150% center' : backgroundPosition }}
       onMouseEnter={handleMouseEnter}
       onMouseEnter={handleMouseEnter}
       onMouseLeave={handleMouseLeave}
       onMouseLeave={handleMouseLeave}
     >
     >

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

@@ -147,11 +147,15 @@ export function TableSelector({ children }: TableSelectorProps) {
               {tables.map(table => (
               {tables.map(table => (
                 <div
                 <div
                   key={table.id}
                   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' : ''
                     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 */}
                   {/* Table icon with status indicator */}
                   <div className="relative flex-shrink-0">
                   <div className="relative flex-shrink-0">
                     <img
                     <img
@@ -189,33 +193,30 @@ export function TableSelector({ children }: TableSelectorProps) {
                       {table.host || new URL(table.url).hostname}
                       {table.host || new URL(table.url).hostname}
                     </span>
                     </span>
                   </div>
                   </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
                     <Button
                       variant="ghost"
                       variant="ghost"
                       size="sm"
                       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"
                       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>
                     </Button>
                     {!table.isCurrent && (
                     {!table.isCurrent && (
                       <Button
                       <Button
                         variant="ghost"
                         variant="ghost"
                         size="sm"
                         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"
                         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>
                       </Button>
                     )}
                     )}
                   </div>
                   </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 { useEffect, useState, useRef, useCallback, useMemo } from 'react'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 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 statusIsRunning = useStatusStore((s) => s.status?.is_running ?? false)
   const statusIsPaused = useStatusStore((s) => s.status?.is_paused ?? false)
   const statusIsPaused = useStatusStore((s) => s.status?.is_paused ?? false)
   const statusPauseTimeRemaining = useStatusStore((s) => s.status?.pause_time_remaining ?? 0)
   const statusPauseTimeRemaining = useStatusStore((s) => s.status?.pause_time_remaining ?? 0)
+  const statusHasPlaylist = useStatusStore((s) => Boolean(s.status?.playlist))
 
 
   // Homing overlay state (local UI state)
   // Homing overlay state (local UI state)
   const [homingDismissed, setHomingDismissed] = useState(false)
   const [homingDismissed, setHomingDismissed] = useState(false)
@@ -110,6 +112,24 @@ export function Layout() {
   // Sensor homing recovery (local UI state)
   // Sensor homing recovery (local UI state)
   const [isRecoveringHoming, setIsRecoveringHoming] = useState(false)
   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
   // Update availability
   const [updateAvailable, setUpdateAvailable] = useState(false)
   const [updateAvailable, setUpdateAvailable] = useState(false)
 
 
@@ -287,6 +307,10 @@ export function Layout() {
   })
   })
   const [isDraggingButton, setIsDraggingButton] = useState(false)
   const [isDraggingButton, setIsDraggingButton] = useState(false)
   const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
   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 buttonRef = useRef<HTMLButtonElement>(null)
   const dragStartRef = useRef<{ x: number; y: number; buttonX: number } | null>(null)
   const dragStartRef = useRef<{ x: number; y: number; buttonX: number } | null>(null)
   const wasDraggingRef = useRef(false) // Track if a meaningful drag occurred
   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)
   // Auto-close NowPlayingBar when playback stops (watches store values)
   const wasNpPlayingRef = useRef<boolean | null>(null)
   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(() => {
   useEffect(() => {
     // Skip first render
     // Skip first render
     if (wasNpPlayingRef.current !== null) {
     if (wasNpPlayingRef.current !== null) {
@@ -326,7 +358,7 @@ export function Layout() {
   const [isLoadingMoreLogs, setIsLoadingMoreLogs] = useState(false)
   const [isLoadingMoreLogs, setIsLoadingMoreLogs] = useState(false)
   const logsWsRef = useRef<WebSocket | null>(null)
   const logsWsRef = useRef<WebSocket | null>(null)
   const logsContainerRef = useRef<HTMLDivElement>(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
   // Connect to logs WebSocket when drawer opens
   useEffect(() => {
   useEffect(() => {
@@ -347,15 +379,17 @@ export function Layout() {
         type LogEntry = { timestamp: string; level: string; logger: string; message: string }
         type LogEntry = { timestamp: string; level: string; logger: string; message: string }
         type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
         type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
         const data = await apiClient.get<LogsResponse>('/api/logs?limit=200')
         const data = await apiClient.get<LogsResponse>('/api/logs?limit=200')
+        const rawLogs = data.logs || []
         // Filter out empty/invalid log entries
         // Filter out empty/invalid log entries
-        const validLogs = (data.logs || []).filter(
+        const validLogs = rawLogs.filter(
           (log) => log && log.message && log.message.trim() !== ''
           (log) => log && log.message && log.message.trim() !== ''
         )
         )
         // API returns newest first, reverse to show oldest first (newest at bottom)
         // API returns newest first, reverse to show oldest first (newest at bottom)
         setLogs(validLogs.reverse())
         setLogs(validLogs.reverse())
         setLogsTotal(data.total || 0)
         setLogsTotal(data.total || 0)
         setLogsHasMore(data.has_more || false)
         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
         // Scroll to bottom after initial load
         setTimeout(() => {
         setTimeout(() => {
           if (logsContainerRef.current) {
           if (logsContainerRef.current) {
@@ -475,14 +509,18 @@ export function Layout() {
       const offset = logsLoadedCountRef.current
       const offset = logsLoadedCountRef.current
       const data = await apiClient.get<LogsResponse>(`/api/logs?limit=100&offset=${offset}`)
       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() !== ''
         (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) {
       if (validLogs.length > 0) {
         // Prepend older logs (they come newest-first, so reverse them)
         // Prepend older logs (they come newest-first, so reverse them)
         setLogs((prev) => [...validLogs.reverse(), ...prev])
         setLogs((prev) => [...validLogs.reverse(), ...prev])
-        logsLoadedCountRef.current += validLogs.length
         setLogsHasMore(data.has_more || false)
         setLogsHasMore(data.has_more || false)
         setLogsTotal(data.total || 0)
         setLogsTotal(data.total || 0)
 
 
@@ -495,7 +533,9 @@ export function Layout() {
           }
           }
         }, 10)
         }, 10)
       } else {
       } 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 {
     } catch {
       // Ignore errors
       // Ignore errors
@@ -596,23 +636,32 @@ export function Layout() {
     a.href = url
     a.href = url
     a.download = `dune-weaver-logs-${new Date().toISOString().split('T')[0]}.txt`
     a.download = `dune-weaver-logs-${new Date().toISOString().split('T')[0]}.txt`
     a.click()
     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 {
     try {
       await apiClient.post('/api/system/restart')
       await apiClient.post('/api/system/restart')
       toast.success('Dune Weaver is restarting...')
       toast.success('Dune Weaver is restarting...')
+      setIsRestarting(true)
     } catch {
     } catch {
       toast.error('Failed to restart Dune Weaver')
       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 {
     try {
       await apiClient.post('/api/system/shutdown')
       await apiClient.post('/api/system/shutdown')
       toast.success('System is shutting down...')
       toast.success('System is shutting down...')
@@ -721,6 +770,7 @@ export function Layout() {
       document.documentElement.classList.remove('dark')
       document.documentElement.classList.remove('dark')
       localStorage.setItem('theme', 'light')
       localStorage.setItem('theme', 'light')
     }
     }
+    document.querySelector('meta[name="theme-color"]')?.setAttribute('content', isDark ? '#0a0a0a' : '#f8f9fb')
   }, [isDark])
   }, [isDark])
 
 
   // Blocking overlay logs state - shows connection attempts
   // Blocking overlay logs state - shows connection attempts
@@ -729,6 +779,17 @@ export function Layout() {
 
 
   // Cache progress from shared store
   // Cache progress from shared store
   const cacheProgress = useCacheProgressStore((s) => s.cacheProgress)
   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
   // Connect/disconnect cache progress WebSocket based on backend connectivity
   useEffect(() => {
   useEffect(() => {
@@ -952,6 +1013,20 @@ export function Layout() {
     setCacheAllProgress(null)
     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
   // Now Playing button drag handlers
   const getSnapPositions = useCallback(() => {
   const getSnapPositions = useCallback(() => {
     const padding = 16
     const padding = 16
@@ -970,6 +1045,7 @@ export function Layout() {
     dragStartRef.current = { x: clientX, y: clientY, buttonX: buttonCenterX }
     dragStartRef.current = { x: clientX, y: clientY, buttonX: buttonCenterX }
     wasDraggingRef.current = false // Reset drag flag
     wasDraggingRef.current = false // Reset drag flag
     setIsDraggingButton(true)
     setIsDraggingButton(true)
+    dragOffsetRef.current = 0
     setDragOffset({ x: 0, y: 0 })
     setDragOffset({ x: 0, y: 0 })
   }, [])
   }, [])
 
 
@@ -980,6 +1056,7 @@ export function Layout() {
     if (Math.abs(deltaX) > 8) {
     if (Math.abs(deltaX) > 8) {
       wasDraggingRef.current = true
       wasDraggingRef.current = true
     }
     }
+    dragOffsetRef.current = deltaX
     setDragOffset({ x: deltaX, y: 0 })
     setDragOffset({ x: deltaX, y: 0 })
   }, [isDraggingButton])
   }, [isDraggingButton])
 
 
@@ -991,7 +1068,7 @@ export function Layout() {
     }
     }
 
 
     // Calculate current position
     // Calculate current position
-    const currentX = dragStartRef.current.buttonX + dragOffset.x
+    const currentX = dragStartRef.current.buttonX + dragOffsetRef.current
     const snapPositions = getSnapPositions()
     const snapPositions = getSnapPositions()
 
 
     // Find nearest snap position
     // Find nearest snap position
@@ -1017,9 +1094,10 @@ export function Layout() {
 
 
     // Reset drag state
     // Reset drag state
     setIsDraggingButton(false)
     setIsDraggingButton(false)
+    dragOffsetRef.current = 0
     setDragOffset({ x: 0, y: 0 })
     setDragOffset({ x: 0, y: 0 })
     dragStartRef.current = null
     dragStartRef.current = null
-  }, [dragOffset.x, getSnapPositions])
+  }, [getSnapPositions])
 
 
   // Mouse drag handlers
   // Mouse drag handlers
   useEffect(() => {
   useEffect(() => {
@@ -1082,10 +1160,10 @@ export function Layout() {
     <div className="min-h-dvh bg-background flex flex-col">
     <div className="min-h-dvh bg-background flex flex-col">
       {/* Security Lockdown Overlay */}
       {/* Security Lockdown Overlay */}
       {isLockdownActive && (
       {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="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">
             <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>
             </div>
             <h2 className="text-2xl font-bold">{displayName}</h2>
             <h2 className="text-2xl font-bold">{displayName}</h2>
             <p className="text-muted-foreground">This table is locked. Enter the password to continue.</p>
             <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) */}
       {/* Security Password Dialog (for play-only mode) */}
       {showPasswordDialog && (
       {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="bg-background rounded-lg shadow-xl w-full max-w-sm">
             <div className="p-6 space-y-4">
             <div className="p-6 space-y-4">
               <div className="text-center space-y-2">
               <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">
                 <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>
                 </div>
                 <h3 className="text-lg font-semibold">Settings Locked</h3>
                 <h3 className="text-lg font-semibold">Settings Locked</h3>
                 <p className="text-sm text-muted-foreground">Enter the password to access settings.</p>
                 <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 */}
       {/* Sensor Homing Failure Popup */}
       {sensorHomingFailed && (
       {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="bg-background rounded-lg shadow-xl w-full max-w-md border border-destructive/30">
             <div className="p-6">
             <div className="p-6">
               <div className="text-center space-y-4">
               <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">
                 <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
                     error_outline
                   </span>
                   </span>
                 </div>
                 </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">
                 <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.
                   The sensor homing process could not complete. The limit sensors may not be positioned correctly or may be malfunctioning.
                 </p>
                 </p>
@@ -1185,7 +1268,7 @@ export function Layout() {
                       onClick={() => handleSensorHomingRecovery(false)}
                       onClick={() => handleSensorHomingRecovery(false)}
                       className="w-full gap-2"
                       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
                       Retry Sensor Homing
                     </Button>
                     </Button>
                     <Button
                     <Button
@@ -1193,7 +1276,7 @@ export function Layout() {
                       onClick={() => handleSensorHomingRecovery(true)}
                       onClick={() => handleSensorHomingRecovery(true)}
                       className="w-full gap-2"
                       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
                       Switch to Crash Homing
                     </Button>
                     </Button>
                     <p className="text-xs text-muted-foreground">
                     <p className="text-xs text-muted-foreground">
@@ -1202,7 +1285,7 @@ export function Layout() {
                   </div>
                   </div>
                 ) : (
                 ) : (
                   <div className="flex items-center justify-center gap-2 py-4">
                   <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>
                     <span className="text-muted-foreground">Attempting recovery...</span>
                   </div>
                   </div>
                 )}
                 )}
@@ -1213,12 +1296,12 @@ export function Layout() {
       )}
       )}
 
 
       {/* Cache Progress Blocking Overlay */}
       {/* 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="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="w-full max-w-md space-y-6">
             <div className="text-center space-y-4">
             <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">
               <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
                   cached
                 </span>
                 </span>
               </div>
               </div>
@@ -1258,22 +1341,34 @@ export function Layout() {
             <p className="text-center text-xs text-muted-foreground">
             <p className="text-center text-xs text-muted-foreground">
               This only happens once after updates or when new patterns are added
               This only happens once after updates or when new patterns are added
             </p>
             </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>
         </div>
         </div>
       )}
       )}
 
 
       {/* Cache All Previews Prompt Modal */}
       {/* Cache All Previews Prompt Modal */}
       {showCacheAllPrompt && (
       {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="bg-background rounded-lg shadow-xl w-full max-w-md">
             <div className="p-6">
             <div className="p-6">
               <div className="text-center space-y-4">
               <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">
                 <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
                     download_for_offline
                   </span>
                   </span>
                 </div>
                 </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">
                 <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.
                   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>
                 </p>
@@ -1291,7 +1386,7 @@ export function Layout() {
                       Skip for now
                       Skip for now
                     </Button>
                     </Button>
                     <Button variant="secondary" onClick={handleCacheAllPreviews} className="gap-2">
                     <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
                       Cache All
                     </Button>
                     </Button>
                   </div>
                   </div>
@@ -1319,7 +1414,7 @@ export function Layout() {
                 {cacheAllProgress?.done && (
                 {cacheAllProgress?.done && (
                   <div className="space-y-4">
                   <div className="space-y-4">
                     <p className="text-green-600 dark:text-green-400 flex items-center justify-center gap-2">
                     <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!
                       All {cacheAllProgress.total} previews cached successfully!
                     </p>
                     </p>
                     <Button onClick={handleCloseCacheAllDone} className="w-full">
                     <Button onClick={handleCloseCacheAllDone} className="w-full">
@@ -1336,7 +1431,7 @@ export function Layout() {
       {/* Backend Connection / Homing Blocking Overlay */}
       {/* Backend Connection / Homing Blocking Overlay */}
       {/* Skip in captive portal mode (WebSocket won't connect in sandboxed webview) */}
       {/* 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 */}
       {/* 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="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
           <div className="w-full max-w-2xl space-y-6">
           <div className="w-full max-w-2xl space-y-6">
             {/* Status Header */}
             {/* Status Header */}
@@ -1348,7 +1443,7 @@ export function Layout() {
                     ? 'bg-primary/10'
                     ? 'bg-primary/10'
                     : 'bg-amber-500/10'
                     : 'bg-amber-500/10'
               }`}>
               }`}>
-                <span className={`material-icons-outlined text-4xl ${
+                <span aria-hidden="true" className={`material-icons-outlined text-4xl ${
                   homingJustCompleted
                   homingJustCompleted
                     ? 'text-green-500'
                     ? 'text-green-500'
                     : isHoming
                     : isHoming
@@ -1401,7 +1496,7 @@ export function Layout() {
             <div className="bg-muted/50 rounded-lg border overflow-hidden">
             <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 justify-between px-4 py-2 border-b bg-muted">
                 <div className="flex items-center gap-2">
                 <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">
                   <span className="text-sm font-medium">
                     {isHoming || homingJustCompleted ? 'Homing Log' : 'Connection Log'}
                     {isHoming || homingJustCompleted ? 'Homing Log' : 'Connection Log'}
                   </span>
                   </span>
@@ -1417,7 +1512,7 @@ export function Layout() {
                     className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
                     className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
                     title="Copy logs to clipboard"
                     title="Copy logs to clipboard"
                   >
                   >
-                    <span className="material-icons text-sm">content_copy</span>
+                    <span aria-hidden="true" className="material-icons text-sm">content_copy</span>
                     Copy
                     Copy
                   </button>
                   </button>
                   <span className="text-xs text-muted-foreground">
                   <span className="text-xs text-muted-foreground">
@@ -1458,7 +1553,7 @@ export function Layout() {
                       onClick={() => setKeepHomingLogsOpen(true)}
                       onClick={() => setKeepHomingLogsOpen(true)}
                       className="gap-2"
                       className="gap-2"
                     >
                     >
-                      <span className="material-icons text-base">visibility</span>
+                      <span aria-hidden="true" className="material-icons text-base">visibility</span>
                       Keep Open
                       Keep Open
                     </Button>
                     </Button>
                     <Button
                     <Button
@@ -1468,7 +1563,7 @@ export function Layout() {
                       }}
                       }}
                       className="gap-2"
                       className="gap-2"
                     >
                     >
-                      <span className="material-icons text-base">close</span>
+                      <span aria-hidden="true" className="material-icons text-base">close</span>
                       Dismiss
                       Dismiss
                     </Button>
                     </Button>
                   </>
                   </>
@@ -1480,13 +1575,27 @@ export function Layout() {
                     }}
                     }}
                     className="gap-2"
                     className="gap-2"
                   >
                   >
-                    <span className="material-icons text-base">close</span>
+                    <span aria-hidden="true" className="material-icons text-base">close</span>
                     Close Logs
                     Close Logs
                   </Button>
                   </Button>
                 )}
                 )}
               </div>
               </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 */}
             {/* Dismiss button during homing */}
             {isHoming && !homingJustCompleted && (
             {isHoming && !homingJustCompleted && (
               <div className="flex justify-center">
               <div className="flex justify-center">
@@ -1495,7 +1604,7 @@ export function Layout() {
                   onClick={() => setHomingDismissed(true)}
                   onClick={() => setHomingDismissed(true)}
                   className="gap-2 text-muted-foreground"
                   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
                   Dismiss
                 </Button>
                 </Button>
               </div>
               </div>
@@ -1514,6 +1623,36 @@ export function Layout() {
         </div>
         </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 - Floating Pill */}
       <header className="fixed top-0 left-0 right-0 z-40 pt-safe">
       <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 */}
         {/* 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="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="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 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
               <img
                 src={customLogo ? apiClient.getAssetUrl(`/static/custom/${customLogo}`) : apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
                 src={customLogo ? apiClient.getAssetUrl(`/static/custom/${customLogo}`) : apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
                 alt={displayName}
                 alt={displayName}
@@ -1532,20 +1671,20 @@ export function Layout() {
               />
               />
             </Link>
             </Link>
             <TableSelector>
             <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
                 <ShinyText
                   text={displayName}
                   text={displayName}
-                  className="font-semibold text-lg"
+                  className="font-semibold text-lg min-w-0 max-w-full truncate"
                   speed={4}
                   speed={4}
                   color={isDark ? '#a8a8a8' : '#555555'}
                   color={isDark ? '#a8a8a8' : '#555555'}
                   shineColor={isDark ? '#ffffff' : '#999999'}
                   shineColor={isDark ? '#ffffff' : '#999999'}
                   spread={75}
                   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
                   expand_more
                 </span>
                 </span>
                 <span
                 <span
-                  className={`w-2 h-2 rounded-full ${
+                  className={`w-2 h-2 rounded-full shrink-0 ${
                     !isBackendConnected
                     !isBackendConnected
                       ? 'bg-gray-400'
                       ? 'bg-gray-400'
                       : isConnected
                       : isConnected
@@ -1565,9 +1704,13 @@ export function Layout() {
           </div>
           </div>
 
 
           {showFirmwareWarning && (
           {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}
                 FluidNC {firmwareVersion} — change to {expectedFirmwareVersion}
               </span>
               </span>
             </div>
             </div>
@@ -1582,14 +1725,15 @@ export function Layout() {
                 className="rounded-full"
                 className="rounded-full"
                 onClick={handleLock}
                 onClick={handleLock}
                 title="Lock"
                 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>
               </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="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 className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
                 </span>
                 </span>
               </Link>
               </Link>
@@ -1602,7 +1746,7 @@ export function Layout() {
                   className="rounded-full"
                   className="rounded-full"
                   aria-label="Open menu"
                   aria-label="Open menu"
                 >
                 >
-                  <span className="material-icons-outlined">menu</span>
+                  <span aria-hidden="true" className="material-icons-outlined">menu</span>
                 </Button>
                 </Button>
               </PopoverTrigger>
               </PopoverTrigger>
               <PopoverContent align="end" className="w-56 p-2">
               <PopoverContent align="end" className="w-56 p-2">
@@ -1611,7 +1755,7 @@ export function Layout() {
                     onClick={() => setIsDark(!isDark)}
                     onClick={() => setIsDark(!isDark)}
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
                   >
                   >
-                    <span className="material-icons-outlined text-xl">
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">
                       {isDark ? 'light_mode' : 'dark_mode'}
                       {isDark ? 'light_mode' : 'dark_mode'}
                     </span>
                     </span>
                     {isDark ? 'Light Mode' : 'Dark Mode'}
                     {isDark ? 'Light Mode' : 'Dark Mode'}
@@ -1620,7 +1764,7 @@ export function Layout() {
                     onClick={handleToggleLogs}
                     onClick={handleToggleLogs}
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
                   >
                   >
-                    <span className="material-icons-outlined text-xl">article</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-xl">article</span>
                     View Logs
                     View Logs
                   </button>
                   </button>
                   <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"
                     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
                     WiFi Setup
                   </button>
                   </button>
                   <Separator className="my-1" />
                   <Separator className="my-1" />
@@ -1638,14 +1782,14 @@ export function Layout() {
                     onClick={handleRestart}
                     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"
                     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
                     Restart
                   </button>
                   </button>
                   <button
                   <button
                     onClick={handleShutdown}
                     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"
                     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
                     Shutdown
                   </button>
                   </button>
                 </div>
                 </div>
@@ -1662,14 +1806,15 @@ export function Layout() {
                 className="rounded-full"
                 className="rounded-full"
                 onClick={handleLock}
                 onClick={handleLock}
                 title="Lock"
                 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>
               </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="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 className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
                 </span>
                 </span>
               </Link>
               </Link>
@@ -1682,7 +1827,7 @@ export function Layout() {
                   className="rounded-full"
                   className="rounded-full"
                   aria-label="Open menu"
                   aria-label="Open menu"
                 >
                 >
-                  <span className="material-icons-outlined">
+                  <span aria-hidden="true" className="material-icons-outlined">
                     {isMobileMenuOpen ? 'close' : 'menu'}
                     {isMobileMenuOpen ? 'close' : 'menu'}
                   </span>
                   </span>
                 </Button>
                 </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"
                     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'}
                       {isDark ? 'light_mode' : 'dark_mode'}
                     </span>
                     </span>
                     {isDark ? 'Light Mode' : 'Dark Mode'}
                     {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"
                     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
                     View Logs
                   </button>
                   </button>
                   <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"
                     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
                     WiFi Setup
                   </button>
                   </button>
                   <Separator className="my-1" />
                   <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"
                     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
                     Restart
                   </button>
                   </button>
                   <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"
                     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
                     Shutdown
                   </button>
                   </button>
                 </div>
                 </div>
@@ -1792,7 +1937,7 @@ export function Layout() {
           <>
           <>
             {/* Resize Handle */}
             {/* Resize Handle */}
             <div
             <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}
               onMouseDown={handleResizeStart}
               onTouchStart={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"
                   className="text-xs bg-background border rounded px-2 py-1 w-28 sm:w-40"
                 />
                 />
                 {logSearchQuery && (
                 {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>
                   </Button>
                 )}
                 )}
                 <span className="text-xs text-muted-foreground">
                 <span className="text-xs text-muted-foreground">
@@ -1839,8 +1984,9 @@ export function Layout() {
                   onClick={handleCopyLogs}
                   onClick={handleCopyLogs}
                   className="rounded-full"
                   className="rounded-full"
                   title="Copy logs"
                   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>
                 <Button
                 <Button
                   variant="ghost"
                   variant="ghost"
@@ -1848,8 +1994,9 @@ export function Layout() {
                   onClick={handleDownloadLogs}
                   onClick={handleDownloadLogs}
                   className="rounded-full"
                   className="rounded-full"
                   title="Download logs"
                   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>
                 <Button
                 <Button
                   variant="ghost"
                   variant="ghost"
@@ -1857,8 +2004,9 @@ export function Layout() {
                   onClick={() => setIsLogsOpen(false)}
                   onClick={() => setIsLogsOpen(false)}
                   className="rounded-full"
                   className="rounded-full"
                   title="Close"
                   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>
                 </Button>
               </div>
               </div>
             </div>
             </div>
@@ -1871,7 +2019,7 @@ export function Layout() {
               {/* Loading indicator for older logs */}
               {/* Loading indicator for older logs */}
               {isLoadingMoreLogs && (
               {isLoadingMoreLogs && (
                 <div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
                 <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>
                   <span>Loading older logs...</span>
                 </div>
                 </div>
               )}
               )}
@@ -1907,7 +2055,8 @@ export function Layout() {
       </div>
       </div>
 
 
       {/* Floating Now Playing Button - draggable, snaps to left/center/right */}
       {/* 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
         <button
           ref={buttonRef}
           ref={buttonRef}
           onClick={() => {
           onClick={() => {
@@ -1940,7 +2089,7 @@ export function Layout() {
           style={getButtonPositionStyle()}
           style={getButtonPositionStyle()}
           aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
           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'}
             {isCurrentlyPlaying ? 'play_circle' : 'stop_circle'}
           </span>
           </span>
           <span className="text-sm font-medium">
           <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">
       <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))` }}>
         <div className={`max-w-5xl mx-auto grid h-16`} style={{ gridTemplateColumns: `repeat(${visibleNavItems.length + (isPlayOnlyActive ? 1 : 0)}, minmax(0, 1fr))` }}>
           {visibleNavItems.map((item) => {
           {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 (
             return (
               <Link
               <Link
                 key={item.path}
                 key={item.path}
@@ -1968,7 +2120,7 @@ export function Layout() {
                 {isActive && (
                 {isActive && (
                   <span className="absolute -top-0.5 w-8 h-1 rounded-full bg-primary" />
                   <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}
                   {item.icon}
                 </span>
                 </span>
                 <span className="text-xs font-medium">{item.label}</span>
                 <span className="text-xs font-medium">{item.label}</span>
@@ -1981,7 +2133,7 @@ export function Layout() {
               onClick={() => { setShowPasswordDialog(true); setPasswordInput(''); setPasswordError(false) }}
               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"
               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>
               <span className="text-xs font-medium">Settings</span>
             </button>
             </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"] {
   [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 {
 .animate-slide-in-right {
   animation: slideInRight 0.3s ease-out forwards;
   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',
         'Content-Type': 'application/json',
         ...headers,
         ...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,
       signal,
     }
     }
 
 

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

@@ -32,6 +32,12 @@ const MAX_MEMORY_CACHE_SIZE = 100
 export async function initPreviewCacheDB(): Promise<IDBDatabase> {
 export async function initPreviewCacheDB(): Promise<IDBDatabase> {
   if (previewCacheDB) return previewCacheDB
   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) => {
   return new Promise((resolve, reject) => {
     const request = indexedDB.open(PREVIEW_CACHE_DB_NAME, PREVIEW_CACHE_DB_VERSION)
     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)
     entries.sort((a, b) => a.lastAccessed - b.lastAccessed)
 
 
     let freedSpace = 0
     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) {
     for (const entry of entries) {
       if (freedSpace >= targetSpace) break
       if (freedSpace >= targetSpace) break

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

@@ -10,6 +10,9 @@ import {
 import { fuzzyMatch } from '@/lib/utils'
 import { fuzzyMatch } from '@/lib/utils'
 import { apiClient } from '@/lib/apiClient'
 import { apiClient } from '@/lib/apiClient'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
 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 { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
@@ -59,6 +62,9 @@ interface PreviewContextType {
 
 
 const PreviewContext = createContext<PreviewContextType | null>(null)
 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() {
 export function BrowsePage() {
   const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
   const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
 
 
@@ -73,6 +79,10 @@ export function BrowsePage() {
   const [sortBy, setSortBy] = useState<SortOption>('name')
   const [sortBy, setSortBy] = useState<SortOption>('name')
   const [sortAsc, setSortAsc] = useState(true)
   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
   // Selection and panel state
   const [selectedPattern, setSelectedPattern] = useState<PatternMetadata | null>(null)
   const [selectedPattern, setSelectedPattern] = useState<PatternMetadata | null>(null)
   const [isPanelOpen, setIsPanelOpen] = useState(false)
   const [isPanelOpen, setIsPanelOpen] = useState(false)
@@ -81,6 +91,11 @@ export function BrowsePage() {
     return (cached as PreExecution) || 'adaptive'
     return (cached as PreExecution) || 'adaptive'
   })
   })
   const [isRunning, setIsRunning] = useState(false)
   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
   // Animated preview modal state
   const [isAnimatedPreviewOpen, setIsAnimatedPreviewOpen] = useState(false)
   const [isAnimatedPreviewOpen, setIsAnimatedPreviewOpen] = useState(false)
@@ -113,6 +128,13 @@ export function BrowsePage() {
   const pendingPreviewsRef = useRef<Set<string>>(new Set())
   const pendingPreviewsRef = useRef<Set<string>>(new Set())
   const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const abortControllerRef = useRef<AbortController | 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
   // Cache all previews state
   const [isCaching, setIsCaching] = useState(false)
   const [isCaching, setIsCaching] = useState(false)
@@ -430,10 +452,52 @@ export function BrowsePage() {
     return result
     return result
   }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc, favorites, allPatternHistories])
   }, [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
   // Batched preview loading - collects requests and fetches in batches
   const requestPreview = useCallback((path: string) => {
   const requestPreview = useCallback((path: string) => {
     // Skip if already loaded or pending
     // 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)
     pendingPreviewsRef.current.add(path)
 
 
@@ -449,7 +513,7 @@ export function BrowsePage() {
         fetchPreviewsBatch(pathsToFetch)
         fetchPreviewsBatch(pathsToFetch)
       }
       }
     }, 50) // Batch requests within 50ms window
     }, 50) // Batch requests within 50ms window
-  }, [previews])
+  }, [])
 
 
   // Canvas drawing functions
   // Canvas drawing functions
   const polarToCartesian = useCallback((theta: number, rho: number, size: number) => {
   const polarToCartesian = useCallback((theta: number, rho: number, size: number) => {
@@ -642,7 +706,6 @@ export function BrowsePage() {
   const handlePatternClick = async (pattern: PatternMetadata) => {
   const handlePatternClick = async (pattern: PatternMetadata) => {
     setSelectedPattern(pattern)
     setSelectedPattern(pattern)
     setIsPanelOpen(true)
     setIsPanelOpen(true)
-    setPreExecution('adaptive')
     setPatternHistory(null) // Reset while loading
     setPatternHistory(null) // Reset while loading
 
 
     // Fetch pattern execution history
     // Fetch pattern execution history
@@ -740,7 +803,7 @@ export function BrowsePage() {
     }
     }
   }
   }
 
 
-  const handleDeletePattern = async () => {
+  const handleDeletePattern = () => {
     if (!selectedPattern) return
     if (!selectedPattern) return
 
 
     if (!selectedPattern.path.startsWith('custom_patterns/')) {
     if (!selectedPattern.path.startsWith('custom_patterns/')) {
@@ -748,18 +811,22 @@ export function BrowsePage() {
       return
       return
     }
     }
 
 
-    if (!confirm(`Delete "${selectedPattern.name}"? This cannot be undone.`)) {
-      return
-    }
+    setPatternToDelete(selectedPattern)
+  }
+
+  const confirmDeletePattern = async () => {
+    if (!patternToDelete) return
 
 
     try {
     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)
       setIsPanelOpen(false)
       setSelectedPattern(null)
       setSelectedPattern(null)
       fetchPatterns()
       fetchPatterns()
     } catch {
     } catch {
       toast.error('Failed to delete pattern')
       toast.error('Failed to delete pattern')
+    } finally {
+      setPatternToDelete(null)
     }
     }
   }
   }
 
 
@@ -787,9 +854,12 @@ export function BrowsePage() {
     return preview?.image_data || null
     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/')
   const canDelete = selectedPattern?.path.startsWith('custom_patterns/')
@@ -846,16 +916,18 @@ export function BrowsePage() {
       } catch (error) {
       } catch (error) {
         console.error(`Upload error for ${file.name}:`, error)
         console.error(`Upload error for ${file.name}:`, error)
         failCount++
         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) {
     if (successCount > 0) {
-      toast.success(
-        successCount === 1
-          ? `Pattern "${files[0].name}" uploaded successfully`
-          : `${successCount} pattern${successCount > 1 ? 's' : ''} uploaded successfully`
-      )
       await fetchPatterns()
       await fetchPatterns()
     }
     }
 
 
@@ -869,7 +941,7 @@ export function BrowsePage() {
   if (isLoading) {
   if (isLoading) {
     return (
     return (
       <div className="flex items-center justify-center min-h-[60vh]">
       <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
           sync
         </span>
         </span>
       </div>
       </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"
             className="gap-2 shrink-0 h-9 w-9 sm:h-11 sm:w-auto rounded-full px-0 sm:px-4 justify-center bg-card border border-border shadow-sm hover:bg-accent"
           >
           >
             {isUploading ? (
             {isUploading ? (
-              <span className="material-icons-outlined animate-spin text-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>
             <span className="hidden sm:inline">Add Pattern</span>
           </Button>
           </Button>
         )}
         )}
       </div>
       </div>
 
 
+      <ConnectionBanner />
+
       {/* Filter Bar */}
       {/* Filter Bar */}
       <div
       <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"
         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">
         <div className="flex items-center gap-2 sm:gap-3">
           {/* Search - Pill shaped, white background */}
           {/* Search - Pill shaped, white background */}
           <div className="relative flex-1 min-w-0">
           <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
               search
             </span>
             </span>
             <Input
             <Input
@@ -937,9 +1011,10 @@ export function BrowsePage() {
                 variant="ghost"
                 variant="ghost"
                 size="icon"
                 size="icon"
                 onClick={() => setSearchQuery('')}
                 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"
                 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>
               </Button>
             )}
             )}
           </div>
           </div>
@@ -947,7 +1022,7 @@ export function BrowsePage() {
           {/* Category - Icon on mobile, text on desktop */}
           {/* Category - Icon on mobile, text on desktop */}
           <Select value={selectedCategory} onValueChange={setSelectedCategory}>
           <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">
             <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" />
               <SelectValue placeholder="All" />
             </SelectTrigger>
             </SelectTrigger>
             <SelectContent>
             <SelectContent>
@@ -967,7 +1042,7 @@ export function BrowsePage() {
             setSortAsc(option !== 'plays' && option !== 'last_played')
             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">
             <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" />
               <SelectValue placeholder="Sort" />
             </SelectTrigger>
             </SelectTrigger>
             <SelectContent>
             <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"
             className="shrink-0 h-9 w-9 sm:h-11 sm:w-11 rounded-full bg-card shadow-sm"
             title={sortAsc ? 'Ascending' : 'Descending'}
             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'}
               {sortAsc ? 'arrow_upward' : 'arrow_downward'}
             </span>
             </span>
           </Button>
           </Button>
@@ -1007,12 +1082,12 @@ export function BrowsePage() {
             >
             >
               {isCaching ? (
               {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="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>
                   <span className="hidden sm:inline text-sm">Cache</span>
                 </>
                 </>
               )}
               )}
@@ -1028,10 +1103,32 @@ export function BrowsePage() {
       )}
       )}
 
 
       {/* Pattern Grid */}
       {/* 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="flex flex-col items-center justify-center min-h-[40vh] gap-4 text-center">
           <div className="p-4 rounded-full bg-muted">
           <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
               search_off
             </span>
             </span>
           </div>
           </div>
@@ -1054,7 +1151,7 @@ export function BrowsePage() {
       ) : (
       ) : (
         <PreviewContext.Provider value={{ requestPreview, previews }}>
         <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">
           <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
               <PatternCard
                 key={pattern.path}
                 key={pattern.path}
                 pattern={pattern}
                 pattern={pattern}
@@ -1067,6 +1164,15 @@ export function BrowsePage() {
               />
               />
             ))}
             ))}
           </div>
           </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>
         </PreviewContext.Provider>
       )}
       )}
 
 
@@ -1097,7 +1203,7 @@ export function BrowsePage() {
                   }}
                   }}
                   title={favorites.has(selectedPattern.path) ? 'Remove from favorites' : 'Add to favorites'}
                   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'}
                     {favorites.has(selectedPattern.path) ? 'favorite' : 'favorite_border'}
                   </span>
                   </span>
                 </span>
                 </span>
@@ -1110,8 +1216,9 @@ export function BrowsePage() {
             <div className="p-6 overflow-y-auto flex-1">
             <div className="p-6 overflow-y-auto flex-1">
               {/* Clickable Round Preview Image */}
               {/* Clickable Round Preview Image */}
               <div className="mb-6">
               <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}
                   onClick={handleOpenAnimatedPreview}
                 >
                 >
                   {getPreviewUrl(selectedPattern.path) ? (
                   {getPreviewUrl(selectedPattern.path) ? (
@@ -1122,57 +1229,62 @@ export function BrowsePage() {
                     />
                     />
                   ) : (
                   ) : (
                     <div className="w-full h-full flex items-center justify-center">
                     <div className="w-full h-full flex items-center justify-center">
-                      <span className="material-icons-outlined text-4xl text-muted-foreground">
+                      <span aria-hidden="true" className="material-icons-outlined text-4xl text-muted-foreground">
                         image
                         image
                       </span>
                       </span>
                     </div>
                     </div>
                   )}
                   )}
                   {/* Play badge - always visible */}
                   {/* 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">
                   <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>
                   </div>
                   {/* Hover overlay */}
                   {/* 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 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>
                 <p className="text-xs text-muted-foreground text-center mt-2">Tap to preview animation</p>
               </div>
               </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 */}
               {/* Play History Info */}
               {(() => {
               {(() => {
                 const historyKey = selectedPattern.path.split('/').pop() || ''
                 const historyKey = selectedPattern.path.split('/').pop() || ''
                 const playCount = allPatternHistories[historyKey]?.play_count ?? 0
                 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 (
                 return (
                   <>
                   <>
-                    {(patternHistory?.actual_time_formatted || playCount > 0) && (
+                    {(patternHistory?.actual_time_formatted || estMinutes !== null || playCount > 0) && (
                       <div className="mb-4 flex justify-between text-sm">
                       <div className="mb-4 flex justify-between text-sm">
-                        {patternHistory?.actual_time_formatted && (
+                        {patternHistory?.actual_time_formatted ? (
                           <div className="flex items-center gap-2">
                           <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="text-muted-foreground">Last run:</span>
                             <span className="font-semibold">{patternHistory.actual_time_formatted}</span>
                             <span className="font-semibold">{patternHistory.actual_time_formatted}</span>
                           </div>
                           </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 && (
                         {playCount > 0 && (
                           <div className="flex items-center gap-2">
                           <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="text-muted-foreground">Plays:</span>
                             <span className="font-semibold">{playCount}</span>
                             <span className="font-semibold">{playCount}</span>
                           </div>
                           </div>
@@ -1219,14 +1331,15 @@ export function BrowsePage() {
                 <div className="flex gap-2">
                 <div className="flex gap-2">
                   <Button
                   <Button
                     onClick={handleRunPattern}
                     onClick={handleRunPattern}
-                    disabled={isRunning}
+                    disabled={isRunning || !tableConnected}
+                    title={!tableConnected ? 'Table not connected' : undefined}
                     className="flex-1 gap-2"
                     className="flex-1 gap-2"
                     size="lg"
                     size="lg"
                   >
                   >
                     {isRunning ? (
                     {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
                     Play
                   </Button>
                   </Button>
@@ -1235,10 +1348,11 @@ export function BrowsePage() {
                     <Button
                     <Button
                       variant="outline"
                       variant="outline"
                       onClick={handleDeletePattern}
                       onClick={handleDeletePattern}
+                      aria-label="Delete pattern"
                       className="text-destructive hover:bg-destructive/10 hover:border-destructive px-3"
                       className="text-destructive hover:bg-destructive/10 hover:border-destructive px-3"
                       size="lg"
                       size="lg"
                     >
                     >
-                      <span className="material-icons text-lg">delete</span>
+                      <span aria-hidden="true" className="material-icons text-lg">delete</span>
                     </Button>
                     </Button>
                   )}
                   )}
                 </div>
                 </div>
@@ -1250,8 +1364,10 @@ export function BrowsePage() {
                     size="sm"
                     size="sm"
                     className="flex-1 gap-1.5"
                     className="flex-1 gap-1.5"
                     onClick={() => handleAddToQueue('next')}
                     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
                     Play Next
                   </Button>
                   </Button>
                   <Button
                   <Button
@@ -1259,8 +1375,10 @@ export function BrowsePage() {
                     size="sm"
                     size="sm"
                     className="flex-1 gap-1.5"
                     className="flex-1 gap-1.5"
                     onClick={() => handleAddToQueue('end')}
                     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
                     Add to Queue
                   </Button>
                   </Button>
                 </div>
                 </div>
@@ -1270,6 +1388,19 @@ export function BrowsePage() {
         </SheetContent>
         </SheetContent>
       </Sheet>
       </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 */}
       {/* Animated Preview Modal */}
       {isAnimatedPreviewOpen && (
       {isAnimatedPreviewOpen && (
         <div
         <div
@@ -1289,9 +1420,10 @@ export function BrowsePage() {
                 variant="ghost"
                 variant="ghost"
                 size="icon"
                 size="icon"
                 onClick={handleCloseAnimatedPreview}
                 onClick={handleCloseAnimatedPreview}
+                aria-label="Close preview"
                 className="rounded-full"
                 className="rounded-full"
               >
               >
-                <span className="material-icons text-2xl">close</span>
+                <span aria-hidden="true" className="material-icons text-2xl">close</span>
               </Button>
               </Button>
             </div>
             </div>
 
 
@@ -1299,7 +1431,7 @@ export function BrowsePage() {
             <div className="p-6 overflow-y-auto flex-1 flex justify-center items-center">
             <div className="p-6 overflow-y-auto flex-1 flex justify-center items-center">
               {isLoadingCoordinates ? (
               {isLoadingCoordinates ? (
                 <div className="w-full max-w-[400px] aspect-square flex items-center justify-center rounded-full bg-muted">
                 <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
                     sync
                   </span>
                   </span>
                 </div>
                 </div>
@@ -1309,6 +1441,8 @@ export function BrowsePage() {
                     ref={canvasRef}
                     ref={canvasRef}
                     width={400}
                     width={400}
                     height={400}
                     height={400}
+                    role="img"
+                    aria-label={`Animated preview of ${selectedPattern?.name || 'pattern'}`}
                     className="rounded-full w-full h-full"
                     className="rounded-full w-full h-full"
                   />
                   />
                   {/* Play/Pause overlay */}
                   {/* Play/Pause overlay */}
@@ -1317,7 +1451,7 @@ export function BrowsePage() {
                     onClick={handlePlayPause}
                     onClick={handlePlayPause}
                   >
                   >
                     <div className="bg-background rounded-full w-16 h-16 flex items-center justify-center shadow-lg">
                     <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'}
                         {isPlaying ? 'pause' : 'play_arrow'}
                       </span>
                       </span>
                     </div>
                     </div>
@@ -1363,13 +1497,13 @@ export function BrowsePage() {
               {/* Control Buttons */}
               {/* Control Buttons */}
               <div className="flex items-center justify-center gap-4">
               <div className="flex items-center justify-center gap-4">
                 <Button onClick={handlePlayPause} className="gap-2">
                 <Button onClick={handlePlayPause} className="gap-2">
-                  <span className="material-icons">
+                  <span aria-hidden="true" className="material-icons">
                     {isPlaying ? 'pause' : 'play_arrow'}
                     {isPlaying ? 'pause' : 'play_arrow'}
                   </span>
                   </span>
                   {isPlaying ? 'Pause' : 'Play'}
                   {isPlaying ? 'Pause' : 'Play'}
                 </Button>
                 </Button>
                 <Button variant="secondary" onClick={handleReset} className="gap-2">
                 <Button variant="secondary" onClick={handleReset} className="gap-2">
-                  <span className="material-icons">replay</span>
+                  <span aria-hidden="true" className="material-icons">replay</span>
                   Reset
                   Reset
                 </Button>
                 </Button>
               </div>
               </div>
@@ -1435,7 +1569,7 @@ function PatternCard({ pattern, isSelected, isFavorite, playTime, playCount, onT
             <>
             <>
               {!imageLoaded && (
               {!imageLoaded && (
                 <div className="absolute inset-0 flex items-center justify-center">
                 <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
                     sync
                   </span>
                   </span>
                 </div>
                 </div>
@@ -1453,7 +1587,7 @@ function PatternCard({ pattern, isSelected, isFavorite, playTime, playCount, onT
             </>
             </>
           ) : (
           ) : (
             <div className="w-full h-full flex items-center justify-center">
             <div className="w-full h-full flex items-center justify-center">
-              <span className="material-icons-outlined text-2xl text-muted-foreground">
+              <span aria-hidden="true" className="material-icons-outlined text-2xl text-muted-foreground">
                 {imageError ? 'broken_image' : 'image'}
                 {imageError ? 'broken_image' : 'image'}
               </span>
               </span>
             </div>
             </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">
         <div className="flex items-center w-full px-0.5 -mb-1 justify-between">
           {playCount > 0 && (
           {playCount > 0 && (
             <span className="flex items-center gap-0.5 text-xs text-muted-foreground" title={`Played ${playCount} time${playCount !== 1 ? 's' : ''}`}>
             <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
               {playCount}x
             </span>
             </span>
           )}
           )}
           {playTime && (
           {playTime && (
             <span className="flex items-center gap-0.5 text-xs text-muted-foreground ml-auto" title={`Last run: ${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+)$/)
                 const colonMatch = playTime.match(/^(?:(\d+):)?(\d+):(\d+)$/)
                 if (colonMatch) {
                 if (colonMatch) {
@@ -1520,7 +1654,7 @@ function PatternCard({ pattern, isSelected, isFavorite, playTime, playCount, onT
           }}
           }}
           title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
           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'}
             {isFavorite ? 'favorite' : 'favorite_border'}
           </span>
           </span>
         </span>
         </span>

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

@@ -23,6 +23,7 @@ import {
 } from '@/components/ui/select'
 } from '@/components/ui/select'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { ColorPicker } from '@/components/ui/color-picker'
 import { ColorPicker } from '@/components/ui/color-picker'
+import { ConnectionBanner } from '@/components/ConnectionBanner'
 
 
 // Types
 // Types
 interface LedConfig {
 interface LedConfig {
@@ -77,6 +78,8 @@ export function LEDPage() {
 
 
   // Ref for debouncing color picker API calls
   // Ref for debouncing color picker API calls
   const colorDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   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
   // LED control mode
   const [controlMode, setControlMode] = useState<'manual' | 'automated'>('automated')
   const [controlMode, setControlMode] = useState<'manual' | 'automated'>('automated')
@@ -88,6 +91,27 @@ export function LEDPage() {
   const [idleTimeoutMinutes, setIdleTimeoutMinutes] = useState(30)
   const [idleTimeoutMinutes, setIdleTimeoutMinutes] = useState(30)
   const [idleTimeoutInput, setIdleTimeoutInput] = 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
   // Fetch LED configuration
   useEffect(() => {
   useEffect(() => {
     const fetchConfig = async () => {
     const fetchConfig = async () => {
@@ -190,11 +214,13 @@ export function LEDPage() {
   }
   }
 
 
   const handleControlModeChange = async (mode: 'manual' | 'automated') => {
   const handleControlModeChange = async (mode: 'manual' | 'automated') => {
+    const previousMode = controlMode
     setControlMode(mode)
     setControlMode(mode)
     try {
     try {
       await apiClient.patch('/api/settings', { led: { control_mode: mode } })
       await apiClient.patch('/api/settings', { led: { control_mode: mode } })
-      toast.success(mode === 'manual' ? 'Manual / HA mode' : 'DW Automated mode')
     } catch {
     } catch {
+      // Roll back the optimistic update so the UI doesn't desync from the server
+      setControlMode(previousMode)
       toast.error('Failed to update control mode')
       toast.error('Failed to update control mode')
     }
     }
   }
   }
@@ -203,7 +229,6 @@ export function LEDPage() {
     try {
     try {
       const data = await apiClient.post<{ connected?: boolean; power_on?: boolean; error?: string }>('/api/dw_leds/power', { state: 2 })
       const data = await apiClient.post<{ connected?: boolean; power_on?: boolean; error?: string }>('/api/dw_leds/power', { state: 2 })
       if (data.connected) {
       if (data.connected) {
-        toast.success(`Power ${data.power_on ? 'ON' : 'OFF'}`)
         await fetchDWLedsStatus()
         await fetchDWLedsStatus()
       } else {
       } else {
         toast.error(data.error || 'Failed to toggle power')
         toast.error(data.error || 'Failed to toggle power')
@@ -219,10 +244,7 @@ export function LEDPage() {
 
 
   const handleBrightnessCommit = async (value: number[]) => {
   const handleBrightnessCommit = async (value: number[]) => {
     try {
     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 {
     } catch {
       toast.error('Failed to set brightness')
       toast.error('Failed to set brightness')
     }
     }
@@ -236,7 +258,6 @@ export function LEDPage() {
   const handleSpeedCommit = async (value: number[]) => {
   const handleSpeedCommit = async (value: number[]) => {
     try {
     try {
       await apiClient.post('/api/dw_leds/speed', { speed: value[0] })
       await apiClient.post('/api/dw_leds/speed', { speed: value[0] })
-      toast.success(`Speed: ${value[0]}`)
     } catch {
     } catch {
       toast.error('Failed to set speed')
       toast.error('Failed to set speed')
     }
     }
@@ -250,7 +271,6 @@ export function LEDPage() {
   const handleIntensityCommit = async (value: number[]) => {
   const handleIntensityCommit = async (value: number[]) => {
     try {
     try {
       await apiClient.post('/api/dw_leds/intensity', { intensity: value[0] })
       await apiClient.post('/api/dw_leds/intensity', { intensity: value[0] })
-      toast.success(`Intensity: ${value[0]}`)
     } catch {
     } catch {
       toast.error('Failed to set intensity')
       toast.error('Failed to set intensity')
     }
     }
@@ -261,7 +281,6 @@ export function LEDPage() {
     try {
     try {
       const data = await apiClient.post<{ connected?: boolean; power_on?: boolean }>('/api/dw_leds/effect', { effect_id: parseInt(value) })
       const data = await apiClient.post<{ connected?: boolean; power_on?: boolean }>('/api/dw_leds/effect', { effect_id: parseInt(value) })
       if (data.connected) {
       if (data.connected) {
-        toast.success('Effect changed')
         if (data.power_on !== undefined) {
         if (data.power_on !== undefined) {
           const powerOn = data.power_on
           const powerOn = data.power_on
           setDwStatus((prev) => prev ? { ...prev, power_on: powerOn } : null)
           setDwStatus((prev) => prev ? { ...prev, power_on: powerOn } : null)
@@ -275,10 +294,7 @@ export function LEDPage() {
   const handlePaletteChange = async (value: string) => {
   const handlePaletteChange = async (value: string) => {
     setSelectedPalette(value)
     setSelectedPalette(value)
     try {
     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 {
     } catch {
       toast.error('Failed to set palette')
       toast.error('Failed to set palette')
     }
     }
@@ -311,6 +327,12 @@ export function LEDPage() {
         await apiClient.post('/api/dw_leds/colors', payload)
         await apiClient.post('/api/dw_leds/colors', payload)
       } catch (error) {
       } catch (error) {
         console.error('Failed to set color:', 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)
     }, 300)
   }
   }
@@ -373,7 +395,7 @@ export function LEDPage() {
   if (isLoading) {
   if (isLoading) {
     return (
     return (
       <div className="flex items-center justify-center min-h-[60vh]">
       <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
           sync
         </span>
         </span>
       </div>
       </div>
@@ -385,7 +407,7 @@ export function LEDPage() {
     return (
     return (
       <div className="flex flex-col items-center justify-center min-h-[60vh] gap-6 text-center px-4">
       <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">
         <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
             lightbulb
           </span>
           </span>
         </div>
         </div>
@@ -397,7 +419,7 @@ export function LEDPage() {
         </div>
         </div>
         <Button asChild className="gap-2">
         <Button asChild className="gap-2">
           <Link to="/settings?section=led">
           <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
             Go to Settings
           </Link>
           </Link>
         </Button>
         </Button>
@@ -410,7 +432,7 @@ export function LEDPage() {
     <Card>
     <Card>
       <CardHeader className="pb-3">
       <CardHeader className="pb-3">
         <CardTitle className="text-lg flex items-center gap-2">
         <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
           LED Control Mode
         </CardTitle>
         </CardTitle>
         <CardDescription>
         <CardDescription>
@@ -428,11 +450,11 @@ export function LEDPage() {
             }`}
             }`}
           >
           >
             <div className="flex items-center gap-2 mb-1">
             <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>
               <span className="font-medium text-sm">Manual / Home Assistant</span>
             </div>
             </div>
             <p className="text-xs text-muted-foreground">
             <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>
             </p>
           </button>
           </button>
           <button
           <button
@@ -444,11 +466,11 @@ export function LEDPage() {
             }`}
             }`}
           >
           >
             <div className="flex items-center gap-2 mb-1">
             <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>
               <span className="font-medium text-sm">DW Automated</span>
             </div>
             </div>
             <p className="text-xs text-muted-foreground">
             <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>
             </p>
           </button>
           </button>
         </div>
         </div>
@@ -458,16 +480,41 @@ export function LEDPage() {
 
 
   // WLED iframe view
   // WLED iframe view
   if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
   if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
+    const wledIp = ledConfig.wled_ip
     return (
     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">
       <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}
         {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>
       </div>
     )
     )
   }
   }
@@ -481,6 +528,8 @@ export function LEDPage() {
         <p className="text-xs text-muted-foreground">DW LEDs - GPIO controlled LED strip</p>
         <p className="text-xs text-muted-foreground">DW LEDs - GPIO controlled LED strip</p>
       </div>
       </div>
 
 
+      <ConnectionBanner />
+
       <Separator />
       <Separator />
 
 
       {modeSelector}
       {modeSelector}
@@ -503,7 +552,7 @@ export function LEDPage() {
                         : 'bg-muted hover:bg-muted/80'
                         : '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
                       power_settings_new
                     </span>
                     </span>
                   </button>
                   </button>
@@ -516,7 +565,7 @@ export function LEDPage() {
                 <div className="flex-1 w-full space-y-4">
                 <div className="flex-1 w-full space-y-4">
                   {/* Connection Status */}
                   {/* Connection Status */}
                   <div className={`flex items-center gap-2 text-sm ${dwStatus?.connected ? 'text-green-600' : 'text-destructive'}`}>
                   <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'}
                       {dwStatus?.connected ? 'check_circle' : 'error'}
                     </span>
                     </span>
                     {dwStatus?.connected
                     {dwStatus?.connected
@@ -528,12 +577,13 @@ export function LEDPage() {
                   <div className="space-y-2">
                   <div className="space-y-2">
                     <div className="flex justify-between items-center">
                     <div className="flex justify-between items-center">
                       <Label>
                       <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
                         Brightness
                       </Label>
                       </Label>
                       <span className="text-sm font-medium">{brightness}%</span>
                       <span className="text-sm font-medium">{brightness}%</span>
                     </div>
                     </div>
                     <Slider
                     <Slider
+                      aria-label="Brightness"
                       value={[brightness]}
                       value={[brightness]}
                       onValueChange={handleBrightnessChange}
                       onValueChange={handleBrightnessChange}
                       onValueCommit={handleBrightnessCommit}
                       onValueCommit={handleBrightnessCommit}
@@ -550,7 +600,7 @@ export function LEDPage() {
           <Card>
           <Card>
             <CardHeader className="pb-3">
             <CardHeader className="pb-3">
               <CardTitle className="text-lg flex items-center gap-2">
               <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
                 Effects & Palettes
               </CardTitle>
               </CardTitle>
             </CardHeader>
             </CardHeader>
@@ -558,9 +608,9 @@ export function LEDPage() {
               {/* Effect & Palette Selects */}
               {/* Effect & Palette Selects */}
               <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
               <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
                 <div className="space-y-2">
                 <div className="space-y-2">
-                  <Label>Effect</Label>
+                  <Label htmlFor="led-effect-select">Effect</Label>
                   <Select value={selectedEffect} onValueChange={handleEffectChange}>
                   <Select value={selectedEffect} onValueChange={handleEffectChange}>
-                    <SelectTrigger>
+                    <SelectTrigger id="led-effect-select">
                       <SelectValue placeholder="Select effect..." />
                       <SelectValue placeholder="Select effect..." />
                     </SelectTrigger>
                     </SelectTrigger>
                     <SelectContent>
                     <SelectContent>
@@ -573,9 +623,9 @@ export function LEDPage() {
                   </Select>
                   </Select>
                 </div>
                 </div>
                 <div className="space-y-2">
                 <div className="space-y-2">
-                  <Label>Palette</Label>
+                  <Label htmlFor="led-palette-select">Palette</Label>
                   <Select value={selectedPalette} onValueChange={handlePaletteChange}>
                   <Select value={selectedPalette} onValueChange={handlePaletteChange}>
-                    <SelectTrigger>
+                    <SelectTrigger id="led-palette-select">
                       <SelectValue placeholder="Select palette..." />
                       <SelectValue placeholder="Select palette..." />
                     </SelectTrigger>
                     </SelectTrigger>
                     <SelectContent>
                     <SelectContent>
@@ -593,11 +643,12 @@ export function LEDPage() {
               <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
               <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
                 <div className="p-4 rounded-lg border space-y-3">
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex justify-between items-center">
                   <div className="flex justify-between items-center">
-                    <Label>
-                      <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
                       Speed
                     </Label>
                     </Label>
                     <Input
                     <Input
+                      id="led-speed-input"
                       type="text"
                       type="text"
                       inputMode="numeric"
                       inputMode="numeric"
                       value={speedInput}
                       value={speedInput}
@@ -623,6 +674,7 @@ export function LEDPage() {
                     />
                     />
                   </div>
                   </div>
                   <Slider
                   <Slider
+                    aria-label="Speed"
                     value={[speed]}
                     value={[speed]}
                     onValueChange={handleSpeedChange}
                     onValueChange={handleSpeedChange}
                     onValueCommit={handleSpeedCommit}
                     onValueCommit={handleSpeedCommit}
@@ -632,11 +684,12 @@ export function LEDPage() {
                 </div>
                 </div>
                 <div className="p-4 rounded-lg border space-y-3">
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex justify-between items-center">
                   <div className="flex justify-between items-center">
-                    <Label>
-                      <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
                       Intensity
                     </Label>
                     </Label>
                     <Input
                     <Input
+                      id="led-intensity-input"
                       type="text"
                       type="text"
                       inputMode="numeric"
                       inputMode="numeric"
                       value={intensityInput}
                       value={intensityInput}
@@ -662,6 +715,7 @@ export function LEDPage() {
                     />
                     />
                   </div>
                   </div>
                   <Slider
                   <Slider
+                    aria-label="Intensity"
                     value={[intensity]}
                     value={[intensity]}
                     onValueChange={handleIntensityChange}
                     onValueChange={handleIntensityChange}
                     onValueCommit={handleIntensityCommit}
                     onValueCommit={handleIntensityCommit}
@@ -680,7 +734,7 @@ export function LEDPage() {
           <Card className="flex-1 flex flex-col">
           <Card className="flex-1 flex flex-col">
             <CardHeader className="pb-3">
             <CardHeader className="pb-3">
               <CardTitle className="text-lg flex items-center gap-2">
               <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
                 Colors
               </CardTitle>
               </CardTitle>
             </CardHeader>
             </CardHeader>
@@ -716,7 +770,7 @@ export function LEDPage() {
           <Card className="flex-1 flex flex-col">
           <Card className="flex-1 flex flex-col">
             <CardHeader className="pb-3">
             <CardHeader className="pb-3">
               <CardTitle className="text-lg flex items-center gap-2">
               <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
                 Auto Turn Off
               </CardTitle>
               </CardTitle>
             </CardHeader>
             </CardHeader>
@@ -724,6 +778,7 @@ export function LEDPage() {
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <span className="text-sm text-muted-foreground">Enable timeout</span>
                 <span className="text-sm text-muted-foreground">Enable timeout</span>
                 <Switch
                 <Switch
+                  aria-label="Enable idle timeout"
                   checked={idleTimeoutEnabled}
                   checked={idleTimeoutEnabled}
                   onCheckedChange={handleIdleTimeoutToggle}
                   onCheckedChange={handleIdleTimeoutToggle}
                 />
                 />
@@ -731,6 +786,7 @@ export function LEDPage() {
               {idleTimeoutEnabled && (
               {idleTimeoutEnabled && (
                 <div className="flex items-center gap-2">
                 <div className="flex items-center gap-2">
                   <Input
                   <Input
+                    aria-label="Idle timeout in minutes"
                     type="text"
                     type="text"
                     inputMode="numeric"
                     inputMode="numeric"
                     value={idleTimeoutInput}
                     value={idleTimeoutInput}
@@ -742,6 +798,9 @@ export function LEDPage() {
                       const num = Math.min(1440, Math.max(1, parseInt(idleTimeoutInput) || 30))
                       const num = Math.min(1440, Math.max(1, parseInt(idleTimeoutInput) || 30))
                       setIdleTimeoutMinutes(num)
                       setIdleTimeoutMinutes(num)
                       setIdleTimeoutInput(String(num))
                       setIdleTimeoutInput(String(num))
+                      if (num !== idleTimeoutMinutes) {
+                        saveIdleTimeout(idleTimeoutEnabled, num)
+                      }
                     }}
                     }}
                     onKeyDown={(e) => {
                     onKeyDown={(e) => {
                       if (e.key === 'Enter') {
                       if (e.key === 'Enter') {
@@ -770,7 +829,7 @@ export function LEDPage() {
       <Card>
       <Card>
         <CardHeader className="pb-3">
         <CardHeader className="pb-3">
           <CardTitle className="text-lg flex items-center gap-2">
           <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
             Effect Automation
           </CardTitle>
           </CardTitle>
           <CardDescription>
           <CardDescription>
@@ -783,7 +842,7 @@ export function LEDPage() {
             <div className="p-4 rounded-lg border space-y-3">
             <div className="p-4 rounded-lg border space-y-3">
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
                 <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>
                   <span className="font-medium">While Playing</span>
                 </div>
                 </div>
               </div>
               </div>
@@ -796,7 +855,7 @@ export function LEDPage() {
                   onClick={() => saveCurrentEffectSettings('playing')}
                   onClick={() => saveCurrentEffectSettings('playing')}
                   className="flex-1 gap-1"
                   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
                   Save Current
                 </Button>
                 </Button>
                 <Button
                 <Button
@@ -813,7 +872,7 @@ export function LEDPage() {
             <div className="p-4 rounded-lg border space-y-3">
             <div className="p-4 rounded-lg border space-y-3">
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
                 <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>
                   <span className="font-medium">When Idle</span>
                 </div>
                 </div>
               </div>
               </div>
@@ -826,7 +885,7 @@ export function LEDPage() {
                   onClick={() => saveCurrentEffectSettings('idle')}
                   onClick={() => saveCurrentEffectSettings('idle')}
                   className="flex-1 gap-1"
                   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
                   Save Current
                 </Button>
                 </Button>
                 <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 type { PatternMetadata, PreviewData, SortOption, PreExecution, RunMode } from '@/lib/types'
 import { preExecutionOptions } from '@/lib/types'
 import { preExecutionOptions } from '@/lib/types'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
 import { Separator } from '@/components/ui/separator'
 import { Separator } from '@/components/ui/separator'
@@ -30,6 +31,23 @@ import {
   DialogTitle,
   DialogTitle,
   DialogFooter,
   DialogFooter,
 } from '@/components/ui/dialog'
 } 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() {
 export function PlaylistsPage() {
   const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
   const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
@@ -53,6 +71,9 @@ export function PlaylistsPage() {
   // Pattern picker modal state
   // Pattern picker modal state
   const [isPickerOpen, setIsPickerOpen] = useState(false)
   const [isPickerOpen, setIsPickerOpen] = useState(false)
   const [selectedPatternPaths, setSelectedPatternPaths] = useState<Set<string>>(new Set())
   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 [searchQuery, setSearchQuery] = useState('')
   const [selectedCategory, setSelectedCategory] = useState<string>('all')
   const [selectedCategory, setSelectedCategory] = useState<string>('all')
   const [sortBy, setSortBy] = useState<SortOption>('name')
   const [sortBy, setSortBy] = useState<SortOption>('name')
@@ -67,6 +88,12 @@ export function PlaylistsPage() {
   const [newPlaylistName, setNewPlaylistName] = useState('')
   const [newPlaylistName, setNewPlaylistName] = useState('')
   const [playlistToRename, setPlaylistToRename] = useState<string | null>(null)
   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
   // Mobile view state - show content panel when a playlist is selected
   const [mobileShowContent, setMobileShowContent] = useState(false)
   const [mobileShowContent, setMobileShowContent] = useState(false)
 
 
@@ -187,6 +214,12 @@ export function PlaylistsPage() {
   // Preview loading
   // Preview loading
   const pendingPreviewsRef = useRef<Set<string>>(new Set())
   const pendingPreviewsRef = useRef<Set<string>>(new Set())
   const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   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)
   const abortControllerRef = useRef<AbortController | null>(null)
 
 
   // Initialize and fetch data
   // Initialize and fetch data
@@ -323,7 +356,7 @@ export function PlaylistsPage() {
   }
   }
 
 
   const requestPreview = useCallback((path: string) => {
   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)
     pendingPreviewsRef.current.add(path)
 
 
@@ -338,7 +371,7 @@ export function PlaylistsPage() {
         loadPreviewsForPaths(pathsToFetch)
         loadPreviewsForPaths(pathsToFetch)
       }
       }
     }, 100)
     }, 100)
-  }, [previews])
+  }, [])
 
 
   // Playlist CRUD operations
   // Playlist CRUD operations
   const handleSelectPlaylist = (name: string) => {
   const handleSelectPlaylist = (name: string) => {
@@ -353,12 +386,14 @@ export function PlaylistsPage() {
   }
   }
 
 
   const handleCreatePlaylist = async () => {
   const handleCreatePlaylist = async () => {
+    if (isSubmitting) return
     if (!newPlaylistName.trim()) {
     if (!newPlaylistName.trim()) {
       toast.error('Please enter a playlist name')
       toast.error('Please enter a playlist name')
       return
       return
     }
     }
 
 
     const name = newPlaylistName.trim()
     const name = newPlaylistName.trim()
+    setIsSubmitting(true)
     try {
     try {
       await apiClient.post('/create_playlist', { playlist_name: name, files: [] })
       await apiClient.post('/create_playlist', { playlist_name: name, files: [] })
       toast.success('Playlist created')
       toast.success('Playlist created')
@@ -369,12 +404,16 @@ export function PlaylistsPage() {
     } catch (error) {
     } catch (error) {
       console.error('Create playlist error:', error)
       console.error('Create playlist error:', error)
       toast.error(error instanceof Error ? error.message : 'Failed to create playlist')
       toast.error(error instanceof Error ? error.message : 'Failed to create playlist')
+    } finally {
+      setIsSubmitting(false)
     }
     }
   }
   }
 
 
   const handleRenamePlaylist = async () => {
   const handleRenamePlaylist = async () => {
+    if (isSubmitting) return
     if (!playlistToRename || !newPlaylistName.trim()) return
     if (!playlistToRename || !newPlaylistName.trim()) return
 
 
+    setIsSubmitting(true)
     try {
     try {
       await apiClient.post('/rename_playlist', { old_name: playlistToRename, new_name: newPlaylistName.trim() })
       await apiClient.post('/rename_playlist', { old_name: playlistToRename, new_name: newPlaylistName.trim() })
       toast.success('Playlist renamed')
       toast.success('Playlist renamed')
@@ -387,12 +426,12 @@ export function PlaylistsPage() {
       }
       }
     } catch (error) {
     } catch (error) {
       toast.error('Failed to rename playlist')
       toast.error('Failed to rename playlist')
+    } finally {
+      setIsSubmitting(false)
     }
     }
   }
   }
 
 
   const handleDeletePlaylist = async (name: string) => {
   const handleDeletePlaylist = async (name: string) => {
-    if (!confirm(`Delete playlist "${name}"?`)) return
-
     try {
     try {
       await apiClient.delete('/delete_playlist', { playlist_name: name })
       await apiClient.delete('/delete_playlist', { playlist_name: name })
       toast.success('Playlist deleted')
       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
   // Pattern picker modal
   const openPatternPicker = () => {
   const openPatternPicker = () => {
     setSelectedPatternPaths(new Set(playlistPatterns))
     setSelectedPatternPaths(new Set(playlistPatterns))
+    initialPickerSelectionRef.current = new Set(playlistPatterns)
     setSearchQuery('')
     setSearchQuery('')
     setIsPickerOpen(true)
     setIsPickerOpen(true)
     // Previews are lazy-loaded via IntersectionObserver in LazyPatternPreview
     // 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 () => {
   const handleSavePatterns = async () => {
+    if (isSubmitting) return
     if (!selectedPlaylist) 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 {
     try {
       await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
       await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
       setPlaylistPatterns(newPatterns)
       setPlaylistPatterns(newPatterns)
@@ -439,6 +538,8 @@ export function PlaylistsPage() {
       // Previews are lazy-loaded via IntersectionObserver
       // Previews are lazy-loaded via IntersectionObserver
     } catch (error) {
     } catch (error) {
       toast.error('Failed to update playlist')
       toast.error('Failed to update playlist')
+    } finally {
+      setIsSubmitting(false)
     }
     }
   }
   }
 
 
@@ -557,6 +658,26 @@ export function PlaylistsPage() {
     return preview?.image_data || null
     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 (
   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))' }}>
     <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 */}
       {/* Page Header */}
@@ -585,12 +706,13 @@ export function PlaylistsPage() {
                 variant="ghost"
                 variant="ghost"
                 size="icon"
                 size="icon"
                 className="h-8 w-8"
                 className="h-8 w-8"
+                aria-label="Create playlist"
                 onClick={() => {
                 onClick={() => {
                   setNewPlaylistName('')
                   setNewPlaylistName('')
                   setIsCreateModalOpen(true)
                   setIsCreateModalOpen(true)
                 }}
                 }}
               >
               >
-                <span className="material-icons-outlined text-xl">add</span>
+                <span aria-hidden="true" className="material-icons-outlined text-xl">add</span>
               </Button>
               </Button>
             )}
             )}
           </div>
           </div>
@@ -602,30 +724,34 @@ export function PlaylistsPage() {
             </div>
             </div>
           ) : playlists.length === 0 ? (
           ) : playlists.length === 0 ? (
             <div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
             <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>
               <span className="text-sm">No playlists yet</span>
             </div>
             </div>
           ) : (
           ) : (
             playlists.map(name => (
             playlists.map(name => (
               <div
               <div
                 key={name}
                 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
                   selectedPlaylist === name
                     ? 'bg-accent text-accent-foreground'
                     ? 'bg-accent text-accent-foreground'
                     : 'hover:bg-muted text-muted-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>
                   <span className="truncate text-sm font-medium">{name}</span>
-                </div>
+                </button>
                 {!isPlayOnlyActive && (
                 {!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
                     <Button
                       variant="ghost"
                       variant="ghost"
                       size="icon-sm"
                       size="icon-sm"
                       className="h-7 w-7"
                       className="h-7 w-7"
+                      aria-label={`Rename playlist ${name}`}
                       onClick={(e) => {
                       onClick={(e) => {
                         e.stopPropagation()
                         e.stopPropagation()
                         setPlaylistToRename(name)
                         setPlaylistToRename(name)
@@ -633,15 +759,16 @@ export function PlaylistsPage() {
                         setIsRenameModalOpen(true)
                         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>
                     <Button
                     <Button
                       variant="ghost"
                       variant="ghost"
                       size="icon-sm"
                       size="icon-sm"
                       className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/20"
                       className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/20"
+                      aria-label={`Delete playlist ${name}`}
                       onClick={(e) => {
                       onClick={(e) => {
                         e.stopPropagation()
                         e.stopPropagation()
-                        handleDeletePlaylist(name)
+                        setPlaylistToDelete(name)
                       }}
                       }}
                     >
                     >
                       <Trash2 className="h-4 w-4" />
                       <Trash2 className="h-4 w-4" />
@@ -670,9 +797,10 @@ export function PlaylistsPage() {
                 variant="ghost"
                 variant="ghost"
                 size="icon"
                 size="icon"
                 className="h-8 w-8 lg:hidden shrink-0"
                 className="h-8 w-8 lg:hidden shrink-0"
+                aria-label="Back to playlists"
                 onClick={handleMobileBack}
                 onClick={handleMobileBack}
               >
               >
-                <span className="material-icons-outlined">arrow_back</span>
+                <span aria-hidden="true" className="material-icons-outlined">arrow_back</span>
               </Button>
               </Button>
               <div className="min-w-0">
               <div className="min-w-0">
                 <h2 className="text-lg font-semibold truncate">
                 <h2 className="text-lg font-semibold truncate">
@@ -692,7 +820,7 @@ export function PlaylistsPage() {
                 size="sm"
                 size="sm"
                 className="gap-2"
                 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>
                 <span className="hidden sm:inline">Add Patterns</span>
               </Button>
               </Button>
             )}
             )}
@@ -703,7 +831,7 @@ export function PlaylistsPage() {
             {!selectedPlaylist ? (
             {!selectedPlaylist ? (
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
                 <div className="p-4 rounded-full bg-muted">
                 <div className="p-4 rounded-full bg-muted">
-                  <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>
                 <div className="text-center">
                 <div className="text-center">
                   <p className="font-medium">No playlist selected</p>
                   <p className="font-medium">No playlist selected</p>
@@ -713,7 +841,7 @@ export function PlaylistsPage() {
             ) : playlistPatterns.length === 0 ? (
             ) : playlistPatterns.length === 0 ? (
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
                 <div className="p-4 rounded-full bg-muted">
                 <div className="p-4 rounded-full bg-muted">
-                  <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>
                 <div className="text-center">
                 <div className="text-center">
                   <p className="font-medium">Empty playlist</p>
                   <p className="font-medium">Empty playlist</p>
@@ -721,41 +849,37 @@ export function PlaylistsPage() {
                 </div>
                 </div>
                 {!isPlayOnlyActive && (
                 {!isPlayOnlyActive && (
                   <Button variant="secondary" className="mt-2 gap-2" onClick={openPatternPicker}>
                   <Button variant="secondary" className="mt-2 gap-2" onClick={openPatternPicker}>
-                    <span className="material-icons-outlined text-base">add</span>
+                    <span aria-hidden="true" className="material-icons-outlined text-base">add</span>
                     Add Patterns
                     Add Patterns
                   </Button>
                   </Button>
                 )}
                 )}
               </div>
               </div>
             ) : (
             ) : (
-              <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
-                {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>
-                ))}
-              </div>
+                </SortableContext>
+              </DndContext>
             )}
             )}
           </div>
           </div>
 
 
@@ -779,8 +903,10 @@ export function PlaylistsPage() {
                           : 'text-muted-foreground hover:bg-muted'
                           : 'text-muted-foreground hover:bg-muted'
                       }`}
                       }`}
                       title="Shuffle"
                       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>
                     <button
                     <button
                       onClick={() => setRunMode(runMode === 'indefinite' ? 'single' : 'indefinite')}
                       onClick={() => setRunMode(runMode === 'indefinite' ? 'single' : 'indefinite')}
@@ -790,8 +916,10 @@ export function PlaylistsPage() {
                           : 'text-muted-foreground hover:bg-muted'
                           : 'text-muted-foreground hover:bg-muted'
                       }`}
                       }`}
                       title={runMode === 'indefinite' ? 'Loop mode' : 'Play once mode'}
                       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>
                     </button>
                   </div>
                   </div>
 
 
@@ -803,6 +931,7 @@ export function PlaylistsPage() {
                         variant="secondary"
                         variant="secondary"
                         size="icon"
                         size="icon"
                         className="w-7 h-7 sm:w-8 sm:h-8"
                         className="w-7 h-7 sm:w-8 sm:h-8"
+                        aria-label="Decrease pause time"
                         onClick={() => {
                         onClick={() => {
                           const step = pauseUnit === 'hr' ? 0.5 : 1
                           const step = pauseUnit === 'hr' ? 0.5 : 1
                           // 'per_day' is plays-per-day, so it must stay >= 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))
                           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>
                       <button
                       <button
                         onClick={() => {
                         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"
                         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'}
                         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'
                         {pauseUnit === 'per_day'
                           ? `${pauseTime}/day`
                           ? `${pauseTime}/day`
                           : `${pauseTime}${pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}`}
                           : `${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>
                       <Button
                       <Button
                         variant="secondary"
                         variant="secondary"
                         size="icon"
                         size="icon"
                         className="w-7 h-7 sm:w-8 sm:h-8"
                         className="w-7 h-7 sm:w-8 sm:h-8"
+                        aria-label="Increase pause time"
                         onClick={() => {
                         onClick={() => {
                           const step = pauseUnit === 'hr' ? 0.5 : 1
                           const step = pauseUnit === 'hr' ? 0.5 : 1
                           // Cap per_day at 24 (one start every hour) — beyond that the cadence
                           // 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))
                           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>
                       </Button>
                     </div>
                     </div>
                   </div>
                   </div>
@@ -851,19 +982,22 @@ export function PlaylistsPage() {
                   {/* Clear Pattern Dropdown */}
                   {/* Clear Pattern Dropdown */}
                   <div className="flex items-center px-1 sm:px-2">
                   <div className="flex items-center px-1 sm:px-2">
                     <Select value={clearPattern} onValueChange={(v) => setClearPattern(v as PreExecution)}>
                     <Select value={clearPattern} onValueChange={(v) => setClearPattern(v as PreExecution)}>
-                      <SelectTrigger className={`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'
                         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'
                           clearPattern !== 'none' ? 'text-primary' : 'text-muted-foreground'
                         }`}>cleaning_services</span>
                         }`}>cleaning_services</span>
                       </SelectTrigger>
                       </SelectTrigger>
-                      <SelectContent>
+                      <SelectContent className="max-w-[min(22rem,calc(100vw-2.5rem))]">
                         {preExecutionOptions.map(opt => (
                         {preExecutionOptions.map(opt => (
                           <SelectItem key={opt.value} value={opt.value}>
                           <SelectItem key={opt.value} value={opt.value}>
                             <div>
                             <div>
                               <div>{opt.label}</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>
                             </div>
                           </SelectItem>
                           </SelectItem>
                         ))}
                         ))}
@@ -878,11 +1012,12 @@ export function PlaylistsPage() {
                   disabled={isRunning || playlistPatterns.length === 0}
                   disabled={isRunning || playlistPatterns.length === 0}
                   className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground text-primary-foreground shadow-lg shadow-primary/30 hover:shadow-primary/50 hover:scale-105 disabled:shadow-none disabled:hover:scale-100 transition-all duration-200 flex items-center justify-center"
                   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"
                   title="Run Playlist"
+                  aria-label="Run playlist"
                 >
                 >
                   {isRunning ? (
                   {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>
                 </button>
               </div>
               </div>
@@ -896,7 +1031,7 @@ export function PlaylistsPage() {
         <DialogContent className="sm:max-w-md">
         <DialogContent className="sm:max-w-md">
           <DialogHeader>
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
             <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
               Create New Playlist
             </DialogTitle>
             </DialogTitle>
           </DialogHeader>
           </DialogHeader>
@@ -917,8 +1052,8 @@ export function PlaylistsPage() {
             <Button variant="secondary" onClick={() => setIsCreateModalOpen(false)}>
             <Button variant="secondary" onClick={() => setIsCreateModalOpen(false)}>
               Cancel
               Cancel
             </Button>
             </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
               Create Playlist
             </Button>
             </Button>
           </DialogFooter>
           </DialogFooter>
@@ -930,7 +1065,7 @@ export function PlaylistsPage() {
         <DialogContent className="sm:max-w-md">
         <DialogContent className="sm:max-w-md">
           <DialogHeader>
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
             <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
               Rename Playlist
             </DialogTitle>
             </DialogTitle>
           </DialogHeader>
           </DialogHeader>
@@ -951,8 +1086,8 @@ export function PlaylistsPage() {
             <Button variant="secondary" onClick={() => setIsRenameModalOpen(false)}>
             <Button variant="secondary" onClick={() => setIsRenameModalOpen(false)}>
               Cancel
               Cancel
             </Button>
             </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
               Save Name
             </Button>
             </Button>
           </DialogFooter>
           </DialogFooter>
@@ -960,11 +1095,11 @@ export function PlaylistsPage() {
       </Dialog>
       </Dialog>
 
 
       {/* Pattern Picker Modal */}
       {/* Pattern Picker Modal */}
-      <Dialog open={isPickerOpen} onOpenChange={setIsPickerOpen}>
+      <Dialog open={isPickerOpen} onOpenChange={handlePickerOpenChange}>
         <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
         <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
           <DialogHeader>
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
             <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}
               Add Patterns to {selectedPlaylist}
             </DialogTitle>
             </DialogTitle>
           </DialogHeader>
           </DialogHeader>
@@ -972,7 +1107,7 @@ export function PlaylistsPage() {
           {/* Search and Filters */}
           {/* Search and Filters */}
           <div className="space-y-3 py-2">
           <div className="space-y-3 py-2">
             <div className="relative">
             <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
                 search
               </span>
               </span>
               <Input
               <Input
@@ -984,9 +1119,10 @@ export function PlaylistsPage() {
               {searchQuery && (
               {searchQuery && (
                 <button
                 <button
                   className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
                   className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                  aria-label="Clear search"
                   onClick={() => setSearchQuery('')}
                   onClick={() => setSearchQuery('')}
                 >
                 >
-                  <span className="material-icons-outlined text-lg">close</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-lg">close</span>
                 </button>
                 </button>
               )}
               )}
             </div>
             </div>
@@ -995,7 +1131,7 @@ export function PlaylistsPage() {
               {/* Folder dropdown - icon only on mobile, with text on sm+ */}
               {/* Folder dropdown - icon only on mobile, with text on sm+ */}
               <Select value={selectedCategory} onValueChange={setSelectedCategory}>
               <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">
                 <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 />
                   <SelectValue />
                 </SelectTrigger>
                 </SelectTrigger>
                 <SelectContent>
                 <SelectContent>
@@ -1010,7 +1146,7 @@ export function PlaylistsPage() {
               {/* Sort dropdown - icon only on mobile, with text on sm+ */}
               {/* Sort dropdown - icon only on mobile, with text on sm+ */}
               <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
               <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">
                 <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 />
                   <SelectValue />
                 </SelectTrigger>
                 </SelectTrigger>
                 <SelectContent>
                 <SelectContent>
@@ -1031,7 +1167,7 @@ export function PlaylistsPage() {
                 onClick={() => setSortAsc(!sortAsc)}
                 onClick={() => setSortAsc(!sortAsc)}
                 title={sortAsc ? 'Ascending' : 'Descending'}
                 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'}
                   {sortAsc ? 'arrow_upward' : 'arrow_downward'}
                 </span>
                 </span>
               </Button>
               </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'}
                   {filteredPatterns.length > 0 && filteredPatterns.every(p => selectedPatternPaths.has(p.path)) ? 'deselect' : 'select_all'}
                 </span>
                 </span>
                 <span className="hidden sm:inline">
                 <span className="hidden sm:inline">
@@ -1067,7 +1203,7 @@ export function PlaylistsPage() {
 
 
               {/* Selection count - compact on mobile */}
               {/* 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">
               <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="font-medium">{selectedPatternPaths.size}</span>
                 <span className="hidden sm:inline text-muted-foreground">selected</span>
                 <span className="hidden sm:inline text-muted-foreground">selected</span>
               </div>
               </div>
@@ -1079,7 +1215,7 @@ export function PlaylistsPage() {
             {filteredPatterns.length === 0 ? (
             {filteredPatterns.length === 0 ? (
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
               <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
                 <div className="p-4 rounded-full bg-muted">
                 <div className="p-4 rounded-full bg-muted">
-                  <span className="material-icons-outlined text-5xl">search_off</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-5xl">search_off</span>
                 </div>
                 </div>
                 <span className="text-sm">No patterns found</span>
                 <span className="text-sm">No patterns found</span>
               </div>
               </div>
@@ -1090,8 +1226,17 @@ export function PlaylistsPage() {
                   return (
                   return (
                     <div
                     <div
                       key={pattern.path}
                       key={pattern.path}
+                      role="checkbox"
+                      aria-checked={isSelected}
+                      tabIndex={0}
                       className="flex flex-col items-center gap-2 cursor-pointer"
                       className="flex flex-col items-center gap-2 cursor-pointer"
                       onClick={() => togglePatternSelection(pattern.path)}
                       onClick={() => togglePatternSelection(pattern.path)}
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter' || e.key === ' ') {
+                          e.preventDefault()
+                          togglePatternSelection(pattern.path)
+                        }
+                      }}
                     >
                     >
                       <div
                       <div
                         className={`relative w-full aspect-square rounded-full overflow-hidden border-2 bg-muted transition-all ${
                         className={`relative w-full aspect-square rounded-full overflow-hidden border-2 bg-muted transition-all ${
@@ -1108,7 +1253,7 @@ export function PlaylistsPage() {
                         />
                         />
                         {isSelected && (
                         {isSelected && (
                           <div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-primary flex items-center justify-center shadow-md">
                           <div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-primary flex items-center justify-center shadow-md">
-                            <span className="material-icons text-primary-foreground" style={{ fontSize: '14px' }}>
+                            <span aria-hidden="true" className="material-icons text-primary-foreground" style={{ fontSize: '14px' }}>
                               check
                               check
                             </span>
                             </span>
                           </div>
                           </div>
@@ -1125,16 +1270,122 @@ export function PlaylistsPage() {
           </div>
           </div>
 
 
           <DialogFooter className="gap-2 sm:gap-0">
           <DialogFooter className="gap-2 sm:gap-0">
-            <Button variant="secondary" onClick={() => setIsPickerOpen(false)}>
+            <Button variant="secondary" onClick={() => handlePickerOpenChange(false)}>
               Cancel
               Cancel
             </Button>
             </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
               Save Selection
             </Button>
             </Button>
           </DialogFooter>
           </DialogFooter>
         </DialogContent>
         </DialogContent>
       </Dialog>
       </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>
     </div>
   )
   )
 }
 }
@@ -1183,7 +1434,7 @@ function LazyPatternPreview({ path, previewUrl, requestPreview, alt, className =
           className="w-full h-full object-cover pattern-preview"
           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
           image
         </span>
         </span>
       )}
       )}

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

@@ -27,6 +27,7 @@ import {
 import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 import { SearchableSelect } from '@/components/ui/searchable-select'
 import { SearchableSelect } from '@/components/ui/searchable-select'
 import { UpdateDialog } from '@/components/UpdateDialog'
 import { UpdateDialog } from '@/components/UpdateDialog'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
 
 
 // Types
 // Types
 
 
@@ -182,9 +183,14 @@ export function SettingsPage() {
 
 
   // Security state
   // Security state
   const [securityMode, setSecurityMode] = useState<'off' | 'lockdown' | 'play_only'>('off')
   const [securityMode, setSecurityMode] = useState<'off' | 'lockdown' | 'play_only'>('off')
+  const [savedSecurityMode, setSavedSecurityMode] = useState<'off' | 'lockdown' | 'play_only'>('off')
   const [securityPassword, setSecurityPassword] = useState('')
   const [securityPassword, setSecurityPassword] = useState('')
   const [securityPasswordConfirm, setSecurityPasswordConfirm] = useState('')
   const [securityPasswordConfirm, setSecurityPasswordConfirm] = useState('')
   const [hasExistingPassword, setHasExistingPassword] = useState(false)
   const [hasExistingPassword, setHasExistingPassword] = useState(false)
+  const [securityOffConfirmOpen, setSecurityOffConfirmOpen] = useState(false)
+
+  // Logo removal confirmation
+  const [logoConfirmOpen, setLogoConfirmOpen] = useState(false)
 
 
   // Version state
   // Version state
   const [versionInfo, setVersionInfo] = useState<{
   const [versionInfo, setVersionInfo] = useState<{
@@ -192,6 +198,7 @@ export function SettingsPage() {
     latest: string
     latest: string
     update_available: boolean
     update_available: boolean
   } | null>(null)
   } | null>(null)
+  const [versionError, setVersionError] = useState(false)
   const [updateDialogOpen, setUpdateDialogOpen] = useState(false)
   const [updateDialogOpen, setUpdateDialogOpen] = useState(false)
 
 
   // Helper to scroll to element with header offset
   // Helper to scroll to element with header offset
@@ -273,11 +280,13 @@ export function SettingsPage() {
   }
   }
 
 
   const fetchVersionInfo = async () => {
   const fetchVersionInfo = async () => {
+    setVersionError(false)
     try {
     try {
       const data = await apiClient.get<{ current: string; latest: string; update_available: boolean }>('/api/version')
       const data = await apiClient.get<{ current: string; latest: string; update_available: boolean }>('/api/version')
       setVersionInfo(data)
       setVersionInfo(data)
     } catch (error) {
     } catch (error) {
       console.error('Failed to fetch version info:', 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
   // Refetch when backend reconnects
   useOnBackendConnected(() => {
   useOnBackendConnected(() => {
     fetchPorts()
     fetchPorts()
@@ -413,6 +417,7 @@ export function SettingsPage() {
       // Set security settings
       // Set security settings
       if (data.security) {
       if (data.security) {
         setSecurityMode(data.security.mode || 'off')
         setSecurityMode(data.security.mode || 'off')
+        setSavedSecurityMode(data.security.mode || 'off')
         setHasExistingPassword(data.security.has_password || false)
         setHasExistingPassword(data.security.has_password || false)
       }
       }
       // Set MQTT config from the same response
       // Set MQTT config from the same response
@@ -572,8 +577,6 @@ export function SettingsPage() {
   }
   }
 
 
   const handleDeleteLogo = async () => {
   const handleDeleteLogo = async () => {
-    if (!confirm('Remove custom logo and revert to default?')) return
-
     setIsLoading('logo')
     setIsLoading('logo')
     try {
     try {
       await apiClient.delete('/api/custom-logo')
       await apiClient.delete('/api/custom-logo')
@@ -732,6 +735,20 @@ export function SettingsPage() {
   }
   }
 
 
   const handleSaveStillSandsSettings = async () => {
   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')
     setIsLoading('stillsands')
     try {
     try {
       await apiClient.patch('/api/settings', {
       await apiClient.patch('/api/settings', {
@@ -745,20 +762,49 @@ export function SettingsPage() {
     }
     }
   }
   }
 
 
-  const handleSaveRebootSettings = async () => {
-    setIsLoading('reboot')
+  const doSaveSecuritySettings = async () => {
+    setIsLoading('security')
     try {
     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 {
     } finally {
       setIsLoading(null)
       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 = () => {
   const addTimeSlot = () => {
     setStillSandsSettings({
     setStillSandsSettings({
       ...stillSandsSettings,
       ...stillSandsSettings,
@@ -794,6 +840,23 @@ export function SettingsPage() {
 
 
       <Separator />
       <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
       <Accordion
         type="multiple"
         type="multiple"
         value={openSections}
         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">
         <AccordionItem value="connection" id="section-connection" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 usb
                 usb
               </span>
               </span>
               <div className="text-left">
               <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 justify-between p-4 rounded-lg border">
               <div className="flex items-center gap-3">
               <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'}`}>
                 <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'}
                     {isConnected ? 'usb' : 'usb_off'}
                   </span>
                   </span>
                 </div>
                 </div>
@@ -871,9 +934,9 @@ export function SettingsPage() {
                   className="gap-2"
                   className="gap-2"
                 >
                 >
                   {isLoading === 'connect' ? (
                   {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
                   Connect
                 </Button>
                 </Button>
@@ -919,9 +982,9 @@ export function SettingsPage() {
                   className="gap-2"
                   className="gap-2"
                 >
                 >
                   {isLoading === 'preferredPort' ? (
                   {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
                   Save
                 </Button>
                 </Button>
@@ -936,7 +999,7 @@ export function SettingsPage() {
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div>
                 <div>
                   <p className="font-medium flex items-center gap-2">
                   <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
                     Home on Connect
                   </p>
                   </p>
                   <p className="text-xs text-muted-foreground mt-1">
                   <p className="text-xs text-muted-foreground mt-1">
@@ -944,6 +1007,8 @@ export function SettingsPage() {
                   </p>
                   </p>
                 </div>
                 </div>
                 <Switch
                 <Switch
+                  id="home-on-connect"
+                  aria-label="Home on Connect"
                   checked={settings.home_on_connect !== false}
                   checked={settings.home_on_connect !== false}
                   onCheckedChange={async (checked) => {
                   onCheckedChange={async (checked) => {
                     setSettings({ ...settings, home_on_connect: 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">
         <AccordionItem value="machine" id="section-machine" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 precision_manufacturing
                 precision_manufacturing
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -1026,9 +1091,9 @@ export function SettingsPage() {
                   className="gap-2"
                   className="gap-2"
                 >
                 >
                   {isLoading === 'machine' ? (
                   {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
                   Save
                 </Button>
                 </Button>
@@ -1062,9 +1127,9 @@ export function SettingsPage() {
                   className="gap-2"
                   className="gap-2"
                 >
                 >
                   {isLoading === 'machine' ? (
                   {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
                   Save
                 </Button>
                 </Button>
@@ -1075,28 +1140,12 @@ export function SettingsPage() {
             </div>
             </div>
 
 
             <Alert className="flex items-start">
             <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>
               <AlertDescription>
                 Table type is normally detected automatically from GRBL settings. Use override if auto-detection is incorrect for your hardware.
                 Table type is normally detected automatically from GRBL settings. Use override if auto-detection is incorrect for your hardware.
               </AlertDescription>
               </AlertDescription>
             </Alert>
             </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>
           </AccordionContent>
         </AccordionItem>
         </AccordionItem>
 
 
@@ -1104,7 +1153,7 @@ export function SettingsPage() {
         <AccordionItem value="homing" id="section-homing" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="homing" id="section-homing" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 home
                 home
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -1181,7 +1230,7 @@ export function SettingsPage() {
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div>
                 <div>
                   <p className="font-medium flex items-center gap-2">
                   <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
                     Auto-Home During Playlists
                   </p>
                   </p>
                   <p className="text-xs text-muted-foreground mt-1">
                   <p className="text-xs text-muted-foreground mt-1">
@@ -1224,7 +1273,7 @@ export function SettingsPage() {
               <div className="flex items-center justify-between">
               <div className="flex items-center justify-between">
                 <div>
                 <div>
                   <p className="font-medium flex items-center gap-2">
                   <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
                     Reset Machine on Theta Normalization
                   </p>
                   </p>
                   <p className="text-xs text-muted-foreground mt-1">
                   <p className="text-xs text-muted-foreground mt-1">
@@ -1250,9 +1299,9 @@ export function SettingsPage() {
               className="gap-2"
               className="gap-2"
             >
             >
               {isLoading === 'homing' ? (
               {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
               Save Homing Configuration
             </Button>
             </Button>
@@ -1263,7 +1312,7 @@ export function SettingsPage() {
         <AccordionItem value="application" id="section-application" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="application" id="section-application" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 tune
                 tune
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -1313,9 +1362,9 @@ export function SettingsPage() {
                     onClick={() => document.getElementById('logo-upload')?.click()}
                     onClick={() => document.getElementById('logo-upload')?.click()}
                   >
                   >
                     {isLoading === 'logo' ? (
                     {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
                     Upload
                   </Button>
                   </Button>
@@ -1325,9 +1374,9 @@ export function SettingsPage() {
                       size="sm"
                       size="sm"
                       className="gap-2 text-destructive hover:text-destructive"
                       className="gap-2 text-destructive hover:text-destructive"
                       disabled={isLoading === 'logo'}
                       disabled={isLoading === 'logo'}
-                      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>
                     </Button>
                   )}
                   )}
                 </div>
                 </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"
                     className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
                     onClick={() => setSettings({ ...settings, app_name: 'Dune Weaver' })}
                     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>
                   </Button>
                 </div>
                 </div>
                 <Button
                 <Button
@@ -1374,9 +1423,9 @@ export function SettingsPage() {
                   className="gap-2"
                   className="gap-2"
                 >
                 >
                   {isLoading === 'appName' ? (
                   {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
                   Save
                 </Button>
                 </Button>
@@ -1392,7 +1441,7 @@ export function SettingsPage() {
         <AccordionItem value="clearing" id="section-clearing" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="clearing" id="section-clearing" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 cleaning_services
                 cleaning_services
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -1490,9 +1539,9 @@ export function SettingsPage() {
               className="gap-2"
               className="gap-2"
             >
             >
               {isLoading === 'clearing' ? (
               {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
               Save Clearing Settings
             </Button>
             </Button>
@@ -1503,7 +1552,7 @@ export function SettingsPage() {
         <AccordionItem value="led" id="section-led" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="led" id="section-led" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 lightbulb
                 lightbulb
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -1562,7 +1611,7 @@ export function SettingsPage() {
             {ledConfig.provider === 'dw_leds' && (
             {ledConfig.provider === 'dw_leds' && (
               <div className="space-y-3 p-4 rounded-lg border">
               <div className="space-y-3 p-4 rounded-lg border">
                 <Alert className="flex items-start">
                 <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>
                   <AlertDescription>
                     Supports WS2812, WS2812B, SK6812 and other WS281x LED strips
                     Supports WS2812, WS2812B, SK6812 and other WS281x LED strips
                   </AlertDescription>
                   </AlertDescription>
@@ -1653,9 +1702,9 @@ export function SettingsPage() {
               className="gap-2"
               className="gap-2"
             >
             >
               {isLoading === 'led' ? (
               {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
               Save LED Configuration
             </Button>
             </Button>
@@ -1666,7 +1715,7 @@ export function SettingsPage() {
         <AccordionItem value="mqtt" id="section-mqtt" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="mqtt" id="section-mqtt" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 home
                 home
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -1687,10 +1736,21 @@ export function SettingsPage() {
                 </p>
                 </p>
               </div>
               </div>
               <Switch
               <Switch
+                id="mqtt-enabled"
+                aria-label="Enable MQTT"
                 checked={mqttConfig.enabled}
                 checked={mqttConfig.enabled}
-                onCheckedChange={(checked) =>
+                onCheckedChange={async (checked) => {
                   setMqttConfig({ ...mqttConfig, enabled: 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>
             </div>
 
 
@@ -1779,7 +1839,7 @@ export function SettingsPage() {
                 </div>
                 </div>
 
 
                 <Alert className="flex items-start">
                 <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>
                   <AlertDescription>
                     MQTT configuration changes require a restart to take effect.
                     MQTT configuration changes require a restart to take effect.
                   </AlertDescription>
                   </AlertDescription>
@@ -1794,9 +1854,9 @@ export function SettingsPage() {
                 className="gap-2"
                 className="gap-2"
               >
               >
                 {isLoading === 'mqtt' ? (
                 {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
                 Save MQTT Configuration
               </Button>
               </Button>
@@ -1808,9 +1868,9 @@ export function SettingsPage() {
                   className="gap-2"
                   className="gap-2"
                 >
                 >
                   {isLoading === 'mqttTest' ? (
                   {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
                   Test Connection
                 </Button>
                 </Button>
@@ -1823,7 +1883,7 @@ export function SettingsPage() {
         <AccordionItem value="autoplay" id="section-autoplay" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="autoplay" id="section-autoplay" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 play_circle
                 play_circle
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -1843,10 +1903,21 @@ export function SettingsPage() {
                 </p>
                 </p>
               </div>
               </div>
               <Switch
               <Switch
+                id="autoplay-enabled"
+                aria-label="Enable Auto-play"
                 checked={autoPlaySettings.enabled}
                 checked={autoPlaySettings.enabled}
-                onCheckedChange={(checked) =>
+                onCheckedChange={async (checked) => {
                   setAutoPlaySettings({ ...autoPlaySettings, enabled: 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>
             </div>
 
 
@@ -1904,9 +1975,10 @@ export function SettingsPage() {
                     </Select>
                     </Select>
                   </div>
                   </div>
                   <div className="space-y-3">
                   <div className="space-y-3">
-                    <Label>Pause Between Patterns</Label>
+                    <Label htmlFor="autoplay-pause">Pause Between Patterns</Label>
                     <div className="flex gap-2">
                     <div className="flex gap-2">
                       <Input
                       <Input
+                        id="autoplay-pause"
                         type="text"
                         type="text"
                         inputMode="numeric"
                         inputMode="numeric"
                         value={autoPlayPauseInput}
                         value={autoPlayPauseInput}
@@ -1995,9 +2067,9 @@ export function SettingsPage() {
               className="gap-2"
               className="gap-2"
             >
             >
               {isLoading === 'autoplay' ? (
               {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
               Save Auto-play Settings
             </Button>
             </Button>
@@ -2008,7 +2080,7 @@ export function SettingsPage() {
         <AccordionItem value="stillsands" id="section-stillsands" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="stillsands" id="section-stillsands" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 bedtime
                 bedtime
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -2028,10 +2100,21 @@ export function SettingsPage() {
                 </p>
                 </p>
               </div>
               </div>
               <Switch
               <Switch
+                id="stillsands-enabled"
+                aria-label="Enable Still Sands"
                 checked={stillSandsSettings.enabled}
                 checked={stillSandsSettings.enabled}
-                onCheckedChange={(checked) =>
+                onCheckedChange={async (checked) => {
                   setStillSandsSettings({ ...stillSandsSettings, enabled: 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>
             </div>
 
 
@@ -2041,7 +2124,7 @@ export function SettingsPage() {
                 <div className="p-4 rounded-lg border space-y-3">
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex items-center justify-between">
                   <div className="flex items-center justify-between">
                     <div className="flex items-center gap-2">
                     <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
                         hourglass_bottom
                       </span>
                       </span>
                       <div>
                       <div>
@@ -2063,7 +2146,7 @@ export function SettingsPage() {
 
 
                   <div className="flex items-center justify-between">
                   <div className="flex items-center justify-between">
                     <div className="flex items-center gap-2">
                     <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
                         lightbulb
                       </span>
                       </span>
                       <div>
                       <div>
@@ -2084,7 +2167,7 @@ export function SettingsPage() {
                   {/* Timezone */}
                   {/* Timezone */}
                   <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 pt-3 border-t">
                   <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">
                     <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
                         schedule
                       </span>
                       </span>
                       <div>
                       <div>
@@ -2150,7 +2233,7 @@ export function SettingsPage() {
                   <div className="flex items-center justify-between">
                   <div className="flex items-center justify-between">
                     <h4 className="font-medium">Still Periods</h4>
                     <h4 className="font-medium">Still Periods</h4>
                     <Button onClick={addTimeSlot} size="sm" variant="secondary" className="gap-1">
                     <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
                       Add Period
                     </Button>
                     </Button>
                   </div>
                   </div>
@@ -2161,7 +2244,7 @@ export function SettingsPage() {
 
 
                   {stillSandsSettings.time_slots.length === 0 ? (
                   {stillSandsSettings.time_slots.length === 0 ? (
                     <div className="text-center py-6 text-muted-foreground">
                     <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-sm">No still periods configured</p>
                       <p className="text-xs">Click "Add Period" to create one</p>
                       <p className="text-xs">Click "Add Period" to create one</p>
                     </div>
                     </div>
@@ -2180,14 +2263,15 @@ export function SettingsPage() {
                               onClick={() => removeTimeSlot(index)}
                               onClick={() => removeTimeSlot(index)}
                               className="h-7 w-7 text-destructive hover:text-destructive"
                               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>
                             </Button>
                           </div>
                           </div>
 
 
                           <div className="grid grid-cols-[1fr_1fr] gap-2">
                           <div className="grid grid-cols-[1fr_1fr] gap-2">
                             <div className="space-y-1.5 min-w-0 overflow-hidden">
                             <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
                               <Input
+                                id={`slot-start-${index}`}
                                 type="time"
                                 type="time"
                                 value={slot.start_time}
                                 value={slot.start_time}
                                 onChange={(e) =>
                                 onChange={(e) =>
@@ -2197,8 +2281,9 @@ export function SettingsPage() {
                               />
                               />
                             </div>
                             </div>
                             <div className="space-y-1.5 min-w-0 overflow-hidden">
                             <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
                               <Input
+                                id={`slot-end-${index}`}
                                 type="time"
                                 type="time"
                                 value={slot.end_time}
                                 value={slot.end_time}
                                 onChange={(e) =>
                                 onChange={(e) =>
@@ -2277,7 +2362,7 @@ export function SettingsPage() {
                 </div>
                 </div>
 
 
                 <Alert className="flex items-start">
                 <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>
                   <AlertDescription>
                     Times are based on the timezone selected above (or system default). Still
                     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
                     periods that span midnight (e.g., 22:00 to 06:00) are supported. Patterns
@@ -2293,9 +2378,9 @@ export function SettingsPage() {
               className="gap-2"
               className="gap-2"
             >
             >
               {isLoading === 'stillsands' ? (
               {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
               Save Still Sands Settings
             </Button>
             </Button>
@@ -2306,7 +2391,7 @@ export function SettingsPage() {
         <AccordionItem value="reboot" id="section-reboot" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="reboot" id="section-reboot" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 restart_alt
                 restart_alt
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -2326,10 +2411,21 @@ export function SettingsPage() {
                 </p>
                 </p>
               </div>
               </div>
               <Switch
               <Switch
+                id="reboot-enabled"
+                aria-label="Enable Scheduled Reboot"
                 checked={rebootSettings.enabled}
                 checked={rebootSettings.enabled}
-                onCheckedChange={(checked) =>
+                onCheckedChange={async (checked) => {
                   setRebootSettings({ ...rebootSettings, enabled: 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>
             </div>
 
 
@@ -2338,7 +2434,7 @@ export function SettingsPage() {
                 <div className="p-4 rounded-lg border">
                 <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 flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
                     <div className="flex items-center 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
                         schedule
                       </span>
                       </span>
                       <div>
                       <div>
@@ -2349,18 +2445,33 @@ export function SettingsPage() {
                       </div>
                       </div>
                     </div>
                     </div>
                     <Input
                     <Input
+                      id="reboot-time"
+                      aria-label="Reboot Time"
                       type="time"
                       type="time"
                       value={rebootSettings.time}
                       value={rebootSettings.time}
                       onChange={(e) =>
                       onChange={(e) =>
                         setRebootSettings({ ...rebootSettings, time: e.target.value })
                         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]"
                       className="w-full sm:w-[140px]"
                     />
                     />
                   </div>
                   </div>
                 </div>
                 </div>
 
 
                 <Alert className="flex items-start">
                 <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>
                   <AlertDescription>
                     If a pattern is drawing at the scheduled time, the reboot waits until the
                     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.
                     table is idle. The table reconnects and homes automatically after rebooting.
@@ -2369,18 +2480,6 @@ export function SettingsPage() {
               </div>
               </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>
           </AccordionContent>
         </AccordionItem>
         </AccordionItem>
 
 
@@ -2388,7 +2487,7 @@ export function SettingsPage() {
         <AccordionItem value="security" id="section-security" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="security" id="section-security" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 lock
                 lock
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -2411,9 +2510,6 @@ export function SettingsPage() {
                 value={securityMode}
                 value={securityMode}
                 onValueChange={(value) => {
                 onValueChange={(value) => {
                   const newMode = value as 'off' | 'lockdown' | 'play_only'
                   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)
                   setSecurityMode(newMode)
                   // Clear password fields when switching modes
                   // Clear password fields when switching modes
                   setSecurityPassword('')
                   setSecurityPassword('')
@@ -2484,39 +2580,7 @@ export function SettingsPage() {
 
 
             {/* Save button */}
             {/* Save button */}
             <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={
               disabled={
                 isLoading === 'security' ||
                 isLoading === 'security' ||
                 (securityMode !== 'off' && securityPassword !== '' && securityPassword !== securityPasswordConfirm) ||
                 (securityMode !== 'off' && securityPassword !== '' && securityPassword !== securityPasswordConfirm) ||
@@ -2525,9 +2589,9 @@ export function SettingsPage() {
               className="w-full gap-2"
               className="w-full gap-2"
             >
             >
               {isLoading === 'security' ? (
               {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
               Save Security Settings
             </Button>
             </Button>
@@ -2538,7 +2602,7 @@ export function SettingsPage() {
         <AccordionItem value="wifi" id="section-wifi" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="wifi" id="section-wifi" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 wifi
                 wifi
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -2558,7 +2622,7 @@ export function SettingsPage() {
               className="w-full gap-2"
               className="w-full gap-2"
               onClick={() => navigate('/wifi-setup')}
               onClick={() => navigate('/wifi-setup')}
             >
             >
-              <span className="material-icons-outlined">settings</span>
+              <span aria-hidden="true" className="material-icons-outlined">settings</span>
               Open WiFi Setup
               Open WiFi Setup
             </Button>
             </Button>
           </AccordionContent>
           </AccordionContent>
@@ -2568,7 +2632,7 @@ export function SettingsPage() {
         <AccordionItem value="version" id="section-version" className="border rounded-lg px-4 overflow-visible bg-card">
         <AccordionItem value="version" id="section-version" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center 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">
                 info
                 info
               </span>
               </span>
               <div className="text-left">
               <div className="text-left">
@@ -2582,19 +2646,19 @@ export function SettingsPage() {
           <AccordionContent className="pt-4 pb-6 space-y-3">
           <AccordionContent className="pt-4 pb-6 space-y-3">
             <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
             <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
               <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
               <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
-                <span className="material-icons text-muted-foreground">terminal</span>
+                <span aria-hidden="true" className="material-icons text-muted-foreground">terminal</span>
               </div>
               </div>
               <div className="flex-1">
               <div className="flex-1">
                 <p className="font-medium">Current Version</p>
                 <p className="font-medium">Current Version</p>
                 <p className="text-sm text-muted-foreground">
                 <p className="text-sm text-muted-foreground">
-                  {versionInfo?.current ? `v${versionInfo.current}` : 'Loading...'}
+                  {versionInfo?.current ? `v${versionInfo.current}` : versionError ? '—' : 'Loading...'}
                 </p>
                 </p>
               </div>
               </div>
             </div>
             </div>
 
 
             <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
             <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
               <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
               <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
-                <span className="material-icons text-muted-foreground">system_update</span>
+                <span aria-hidden="true" className="material-icons text-muted-foreground">system_update</span>
               </div>
               </div>
               <div className="flex-1">
               <div className="flex-1">
                 <p className="font-medium">Latest Version</p>
                 <p className="font-medium">Latest Version</p>
@@ -2608,6 +2672,18 @@ export function SettingsPage() {
                     >
                     >
                       v{versionInfo.latest}
                       v{versionInfo.latest}
                     </a>
                     </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...'}
                   ) : 'Checking...'}
                   {versionInfo?.update_available && ' (Update available!)'}
                   {versionInfo?.update_available && ' (Update available!)'}
                 </p>
                 </p>
@@ -2616,7 +2692,7 @@ export function SettingsPage() {
 
 
             {versionInfo?.update_available && (
             {versionInfo?.update_available && (
               <Button onClick={() => setUpdateDialogOpen(true)} className="w-full">
               <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
                 Update Now
               </Button>
               </Button>
             )}
             )}
@@ -2630,6 +2706,26 @@ export function SettingsPage() {
           </AccordionContent>
           </AccordionContent>
         </AccordionItem>
         </AccordionItem>
       </Accordion>
       </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>
     </div>
   )
   )
 }
 }

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

@@ -111,7 +111,8 @@ function CalibrationWizard() {
     async (axis: 'x' | 'y') => {
     async (axis: 'x' | 'y') => {
       setWizard((w) => ({ ...w, fixing: true }))
       setWizard((w) => ({ ...w, fixing: true }))
       try {
       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', {
         await apiClient.patch('/api/fluidnc/config', {
           axes: { [axis]: { direction_inverted: true } },
           axes: { [axis]: { direction_inverted: true } },
         })
         })

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

@@ -36,15 +36,20 @@ import {
 } from '@/components/ui/select'
 } from '@/components/ui/select'
 import { apiClient } from '@/lib/apiClient'
 import { apiClient } from '@/lib/apiClient'
 import { useStatusStore } from '@/stores/useStatusStore'
 import { useStatusStore } from '@/stores/useStatusStore'
+import { ConnectionBanner, useTableConnected } from '@/components/ConnectionBanner'
 
 
 export function TableControlPage() {
 export function TableControlPage() {
   const [speedInput, setSpeedInput] = useState('')
   const [speedInput, setSpeedInput] = useState('')
   const [currentSpeed, setCurrentSpeed] = useState<number | null>(null)
   const [currentSpeed, setCurrentSpeed] = useState<number | null>(null)
   const [currentTheta, setCurrentTheta] = useState(0)
   const [currentTheta, setCurrentTheta] = useState(0)
   const [isLoading, setIsLoading] = useState<string | null>(null)
   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
   // Subscribe to shared status WebSocket via store
   const speed = useStatusStore((s) => s.status?.speed ?? null)
   const speed = useStatusStore((s) => s.status?.speed ?? null)
+  const hardwareTheta = useStatusStore((s) => s.status?.current_theta ?? null)
   const isPatternRunning = useStatusStore((s) =>
   const isPatternRunning = useStatusStore((s) =>
     (s.status?.is_running || s.status?.is_paused) ?? false
     (s.status?.is_running || s.status?.is_paused) ?? false
   )
   )
@@ -54,6 +59,13 @@ export function TableControlPage() {
     if (speed !== null) setCurrentSpeed(speed)
     if (speed !== null) setCurrentSpeed(speed)
   }, [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
   // Serial terminal state
   const [serialPorts, setSerialPorts] = useState<string[]>([])
   const [serialPorts, setSerialPorts] = useState<string[]>([])
   const [selectedSerialPort, setSelectedSerialPort] = useState('')
   const [selectedSerialPort, setSelectedSerialPort] = useState('')
@@ -153,10 +165,14 @@ export function TableControlPage() {
 
 
   const handleSetSpeed = async () => {
   const handleSetSpeed = async () => {
     const speed = parseFloat(speedInput)
     const speed = parseFloat(speedInput)
-    if (isNaN(speed) || speed <= 0) {
+    if (isNaN(speed)) {
       toast.error('Please enter a valid speed value')
       toast.error('Please enter a valid speed value')
       return
       return
     }
     }
+    if (speed < 10) {
+      toast.error('Speed must be at least 10 mm/s')
+      return
+    }
     try {
     try {
       await handleAction('speed', '/set_speed', { speed })
       await handleAction('speed', '/set_speed', { speed })
       setCurrentSpeed(speed)
       setCurrentSpeed(speed)
@@ -348,6 +364,8 @@ export function TableControlPage() {
           </p>
           </p>
         </div>
         </div>
 
 
+        <ConnectionBanner />
+
         <Separator />
         <Separator />
 
 
         {/* Main Controls Grid - 2x2 */}
         {/* Main Controls Grid - 2x2 */}
@@ -364,14 +382,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                   <TooltipTrigger asChild>
                     <Button
                     <Button
                       onClick={handleHome}
                       onClick={handleHome}
-                      disabled={isLoading === 'home'}
+                      disabled={isLoading === 'home' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="primary"
                       variant="primary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'home' ? (
                       {isLoading === 'home' ? (
-                        <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>
                       <span className="text-xs">Home</span>
                     </Button>
                     </Button>
@@ -388,9 +407,9 @@ export function TableControlPage() {
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'stop' ? (
                       {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>
                       <span className="text-xs">Stop</span>
                     </Button>
                     </Button>
@@ -403,14 +422,15 @@ export function TableControlPage() {
                     <TooltipTrigger asChild>
                     <TooltipTrigger asChild>
                       <DialogTrigger asChild>
                       <DialogTrigger asChild>
                         <Button
                         <Button
-                          disabled={isLoading === 'reset'}
+                          disabled={isLoading === 'reset' || !tableConnected}
+                          title={disconnectedTitle}
                           variant="secondary"
                           variant="secondary"
                           className="h-16 gap-1 flex-col items-center justify-center"
                           className="h-16 gap-1 flex-col items-center justify-center"
                         >
                         >
                           {isLoading === 'reset' ? (
                           {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>
                           <span className="text-xs">Reset</span>
                         </Button>
                         </Button>
@@ -426,7 +446,7 @@ export function TableControlPage() {
                       </DialogDescription>
                       </DialogDescription>
                     </DialogHeader>
                     </DialogHeader>
                     <Alert className="flex items-center border-amber-500/50">
                     <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">
                       <AlertDescription className="text-amber-600 dark:text-amber-400">
                         Homing is required after resetting. The table will lose its position reference.
                         Homing is required after resetting. The table will lose its position reference.
                       </AlertDescription>
                       </AlertDescription>
@@ -467,24 +487,27 @@ export function TableControlPage() {
                   value={speedInput}
                   value={speedInput}
                   onChange={(e) => setSpeedInput(e.target.value)}
                   onChange={(e) => setSpeedInput(e.target.value)}
                   placeholder="mm/s"
                   placeholder="mm/s"
-                  min="1"
+                  min="10"
+                  max="2000"
                   step="1"
                   step="1"
                   className="flex-1"
                   className="flex-1"
                   onKeyDown={(e) => e.key === 'Enter' && handleSetSpeed()}
                   onKeyDown={(e) => e.key === 'Enter' && handleSetSpeed()}
                 />
                 />
                 <Button
                 <Button
                   onClick={handleSetSpeed}
                   onClick={handleSetSpeed}
-                  disabled={isLoading === 'speed' || !speedInput}
+                  disabled={isLoading === 'speed' || !speedInput || !tableConnected}
+                  title={disconnectedTitle}
                   className="gap-2"
                   className="gap-2"
                 >
                 >
                   {isLoading === 'speed' ? (
                   {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
                   Set
                 </Button>
                 </Button>
               </div>
               </div>
+              <p className="mt-2 text-xs text-muted-foreground">10–2000 mm/s</p>
             </CardContent>
             </CardContent>
           </Card>
           </Card>
 
 
@@ -500,14 +523,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                   <TooltipTrigger asChild>
                     <Button
                     <Button
                       onClick={handleMoveToCenter}
                       onClick={handleMoveToCenter}
-                      disabled={isLoading === 'center'}
+                      disabled={isLoading === 'center' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="secondary"
                       variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'center' ? (
                       {isLoading === 'center' ? (
-                        <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>
                       <span className="text-xs">Center</span>
                     </Button>
                     </Button>
@@ -519,14 +543,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                   <TooltipTrigger asChild>
                     <Button
                     <Button
                       onClick={handleMoveToPerimeter}
                       onClick={handleMoveToPerimeter}
-                      disabled={isLoading === 'perimeter'}
+                      disabled={isLoading === 'perimeter' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="secondary"
                       variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'perimeter' ? (
                       {isLoading === 'perimeter' ? (
-                        <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>
                       <span className="text-xs">Perimeter</span>
                     </Button>
                     </Button>
@@ -540,9 +565,11 @@ export function TableControlPage() {
                       <DialogTrigger asChild>
                       <DialogTrigger asChild>
                         <Button
                         <Button
                           variant="secondary"
                           variant="secondary"
+                          disabled={!tableConnected}
+                          title={disconnectedTitle}
                           className="h-16 gap-1 flex-col items-center justify-center"
                           className="h-16 gap-1 flex-col items-center justify-center"
                         >
                         >
-                          <span className="material-icons-outlined text-2xl">screen_rotation</span>
+                          <span aria-hidden="true" className="material-icons-outlined text-2xl">screen_rotation</span>
                           <span className="text-xs">Align</span>
                           <span className="text-xs">Align</span>
                         </Button>
                         </Button>
                       </DialogTrigger>
                       </DialogTrigger>
@@ -579,7 +606,7 @@ export function TableControlPage() {
                     <Separator />
                     <Separator />
 
 
                     <Alert className="flex items-start border-amber-500/50">
                     <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
                         warning
                       </span>
                       </span>
                       <AlertDescription className="text-amber-600 dark:text-amber-400">
                       <AlertDescription className="text-amber-600 dark:text-amber-400">
@@ -593,18 +620,20 @@ export function TableControlPage() {
                         <Button
                         <Button
                           variant="secondary"
                           variant="secondary"
                           onClick={() => handleRotate(-10)}
                           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°
                           CCW 10°
                         </Button>
                         </Button>
                         <Button
                         <Button
                           variant="secondary"
                           variant="secondary"
                           onClick={() => handleRotate(10)}
                           onClick={() => handleRotate(10)}
-                          disabled={isLoading === 'rotate'}
+                          disabled={isLoading === 'rotate' || !tableConnected}
+                          title={disconnectedTitle}
                         >
                         >
                           CW 10°
                           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>
                         </Button>
                       </div>
                       </div>
                       <p className="text-xs text-muted-foreground text-center">
                       <p className="text-xs text-muted-foreground text-center">
@@ -635,14 +664,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                   <TooltipTrigger asChild>
                     <Button
                     <Button
                       onClick={() => handleClearPattern('clear_from_in.thr', 'clear from center')}
                       onClick={() => handleClearPattern('clear_from_in.thr', 'clear from center')}
-                      disabled={isLoading === 'clear_from_in.thr'}
+                      disabled={isLoading === 'clear_from_in.thr' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="secondary"
                       variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'clear_from_in.thr' ? (
                       {isLoading === 'clear_from_in.thr' ? (
-                        <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>
                       <span className="text-xs">Clear Center</span>
                     </Button>
                     </Button>
@@ -654,14 +684,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                   <TooltipTrigger asChild>
                     <Button
                     <Button
                       onClick={() => handleClearPattern('clear_from_out.thr', 'clear from perimeter')}
                       onClick={() => handleClearPattern('clear_from_out.thr', 'clear from perimeter')}
-                      disabled={isLoading === 'clear_from_out.thr'}
+                      disabled={isLoading === 'clear_from_out.thr' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="secondary"
                       variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'clear_from_out.thr' ? (
                       {isLoading === 'clear_from_out.thr' ? (
-                        <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>
                       <span className="text-xs">Clear Edge</span>
                     </Button>
                     </Button>
@@ -673,14 +704,15 @@ export function TableControlPage() {
                   <TooltipTrigger asChild>
                   <TooltipTrigger asChild>
                     <Button
                     <Button
                       onClick={() => handleClearPattern('clear_sideway.thr', 'clear sideways')}
                       onClick={() => handleClearPattern('clear_sideway.thr', 'clear sideways')}
-                      disabled={isLoading === 'clear_sideway.thr'}
+                      disabled={isLoading === 'clear_sideway.thr' || !tableConnected}
+                      title={disconnectedTitle}
                       variant="secondary"
                       variant="secondary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                     >
                       {isLoading === 'clear_sideway.thr' ? (
                       {isLoading === 'clear_sideway.thr' ? (
-                        <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>
                       <span className="text-xs">Clear Sideways</span>
                     </Button>
                     </Button>
@@ -698,13 +730,13 @@ export function TableControlPage() {
             <div className="flex items-start justify-between gap-2">
             <div className="flex items-start justify-between gap-2">
               <div className="min-w-0 space-y-2">
               <div className="min-w-0 space-y-2">
                 <CardTitle className="text-lg flex items-center gap-2">
                 <CardTitle className="text-lg flex items-center gap-2">
-                  <span className="material-icons-outlined text-xl">terminal</span>
+                  <span aria-hidden="true" className="material-icons-outlined text-xl">terminal</span>
                   Serial Terminal
                   Serial Terminal
                 </CardTitle>
                 </CardTitle>
                 <CardDescription className="hidden sm:block">Send raw commands to the table controller</CardDescription>
                 <CardDescription className="hidden sm:block">Send raw commands to the table controller</CardDescription>
                 {/* Warning about pattern interference */}
                 {/* Warning about pattern interference */}
                 <Alert className="flex items-center border-amber-500/50 py-2">
                 <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">
                   <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.
                     Do not use while a pattern is running. This will interfere with the main connection.
                   </AlertDescription>
                   </AlertDescription>
@@ -718,8 +750,9 @@ export function TableControlPage() {
                     size="icon"
                     size="icon"
                     onClick={() => setSerialHistory([])}
                     onClick={() => setSerialHistory([])}
                     title="Clear history"
                     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>
                   </Button>
                 )}
                 )}
               </div>
               </div>
@@ -750,9 +783,9 @@ export function TableControlPage() {
                   title="Connect"
                   title="Connect"
                 >
                 >
                   {serialLoading ? (
                   {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>
                   <span className="hidden sm:inline">Connect</span>
                 </Button>
                 </Button>
@@ -765,7 +798,7 @@ export function TableControlPage() {
                     disabled={serialLoading}
                     disabled={serialLoading}
                     title="Disconnect"
                     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>
                     <span className="hidden sm:inline">Disconnect</span>
                   </Button>
                   </Button>
                   <Button
                   <Button
@@ -775,7 +808,7 @@ export function TableControlPage() {
                     disabled={serialLoading}
                     disabled={serialLoading}
                     title="Send soft reset to controller"
                     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>
                     <span className="hidden sm:inline">Reset</span>
                   </Button>
                   </Button>
                 </>
                 </>
@@ -788,8 +821,9 @@ export function TableControlPage() {
                   className="sm:hidden"
                   className="sm:hidden"
                   onClick={() => setSerialHistory([])}
                   onClick={() => setSerialHistory([])}
                   title="Clear history"
                   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>
                 </Button>
               )}
               )}
             </div>
             </div>
@@ -841,12 +875,13 @@ export function TableControlPage() {
               <Button
               <Button
                 onClick={handleSerialSend}
                 onClick={handleSerialSend}
                 disabled={!serialConnected || !serialCommand.trim() || serialLoading}
                 disabled={!serialConnected || !serialCommand.trim() || serialLoading}
+                aria-label="Send command"
                 className="h-11 px-6"
                 className="h-11 px-6"
               >
               >
                 {serialLoading ? (
                 {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>
               </Button>
             </div>
             </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 { Label } from '@/components/ui/label'
 import { Card, CardContent } from '@/components/ui/card'
 import { Card, CardContent } from '@/components/ui/card'
 import { Alert, AlertDescription } from '@/components/ui/alert'
 import { Alert, AlertDescription } from '@/components/ui/alert'
+import { ConfirmDialog } from '@/components/ConfirmDialog'
 import { Badge } from '@/components/ui/badge'
 import { Badge } from '@/components/ui/badge'
 import { Separator } from '@/components/ui/separator'
 import { Separator } from '@/components/ui/separator'
 import {
 import {
@@ -43,7 +44,7 @@ function SignalIcon({ signal }: { signal: number }) {
                signal >= 25 ? 'network_wifi_2_bar' :
                signal >= 25 ? 'network_wifi_2_bar' :
                'network_wifi_1_bar'
                'network_wifi_1_bar'
   const color = signal >= 50 ? 'text-green-500' : signal >= 25 ? 'text-yellow-500' : 'text-red-500'
   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() {
 export function WiFiSetupPage() {
@@ -63,6 +64,8 @@ export function WiFiSetupPage() {
   const [apPasswordInput, setApPasswordInput] = useState('')
   const [apPasswordInput, setApPasswordInput] = useState('')
   const [showApPassword, setShowApPassword] = useState(false)
   const [showApPassword, setShowApPassword] = useState(false)
   const [isSavingApPassword, setIsSavingApPassword] = 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 () => {
   const fetchStatus = useCallback(async () => {
     try {
     try {
@@ -111,10 +114,37 @@ export function WiFiSetupPage() {
     fetchApPassword()
     fetchApPassword()
   }, [fetchStatus, scanNetworks, fetchSaved, 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 &&
   const needsPassword = isManualEntry || (selectedNetwork &&
     selectedNetwork.security !== 'Open' &&
     selectedNetwork.security !== 'Open' &&
     !selectedNetwork.saved)
     !selectedNetwork.saved)
 
 
+  const isHotspotMode = status?.mode === 'hotspot'
+
   const handleConnect = async () => {
   const handleConnect = async () => {
     const ssid = isManualEntry ? manualSsid.trim() : selectedNetwork?.ssid
     const ssid = isManualEntry ? manualSsid.trim() : selectedNetwork?.ssid
     if (!ssid) return
     if (!ssid) return
@@ -132,10 +162,19 @@ export function WiFiSetupPage() {
         fetchStatus()
         fetchStatus()
         fetchSaved()
         fetchSaved()
         scanNetworks()
         scanNetworks()
+      } else {
+        toast.error(result.message || 'Failed to connect to network')
       }
       }
     } catch (err) {
     } 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 {
     } finally {
       setIsConnecting(false)
       setIsConnecting(false)
     }
     }
@@ -155,6 +194,8 @@ export function WiFiSetupPage() {
         toast.success(result.message)
         toast.success(result.message)
         closeDialog()
         closeDialog()
         fetchSaved()
         fetchSaved()
+      } else {
+        toast.error(result.message || 'Failed to save network')
       }
       }
     } catch (err) {
     } catch (err) {
       const message = err instanceof Error ? err.message : 'Failed to save network'
       const message = err instanceof Error ? err.message : 'Failed to save network'
@@ -191,6 +232,8 @@ export function WiFiSetupPage() {
       if (result.success) {
       if (result.success) {
         setApPassword(apPasswordInput)
         setApPassword(apPasswordInput)
         toast.success(result.message)
         toast.success(result.message)
+      } else {
+        toast.error(result.message || 'Failed to update password')
       }
       }
     } catch (err) {
     } catch (err) {
       const message = err instanceof Error ? err.message : 'Failed to update password'
       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 })
     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 (
   return (
     <div className="container max-w-lg mx-auto px-3 py-4 space-y-3">
     <div className="container max-w-lg mx-auto px-3 py-4 space-y-3">
       {/* Hotspot Welcome Banner */}
       {/* Hotspot Welcome Banner */}
       {isHotspotMode && (
       {isHotspotMode && (
         <Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950/30 dark:border-blue-800">
         <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>
           <AlertDescription>
             <strong>Welcome to Dune Weaver!</strong>
             <strong>Welcome to Dune Weaver!</strong>
             <br />
             <br />
@@ -244,7 +305,7 @@ export function WiFiSetupPage() {
       <Card>
       <Card>
         <CardContent className="pt-4 pb-3 px-4">
         <CardContent className="pt-4 pb-3 px-4">
           <div className="flex items-center gap-2 mb-2">
           <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>
             <span className="font-semibold text-sm">WiFi Status</span>
           </div>
           </div>
           {status ? (
           {status ? (
@@ -292,7 +353,7 @@ export function WiFiSetupPage() {
         <Card>
         <Card>
           <CardContent className="pt-4 pb-2 px-4">
           <CardContent className="pt-4 pb-2 px-4">
             <div className="flex items-center gap-2 mb-2">
             <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>
               <span className="font-semibold text-sm">Saved Networks</span>
             </div>
             </div>
             <div className="space-y-0.5">
             <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"
                   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">
                   <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>
                     <span className="font-medium text-sm">{con.ssid}</span>
                   </div>
                   </div>
                   <Button
                   <Button
@@ -310,8 +371,9 @@ export function WiFiSetupPage() {
                     size="sm"
                     size="sm"
                     className="h-7 w-7 p-0"
                     className="h-7 w-7 p-0"
                     onClick={() => setForgetSsid(con.ssid)}
                     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>
                   </Button>
                 </div>
                 </div>
               ))}
               ))}
@@ -324,7 +386,7 @@ export function WiFiSetupPage() {
       <Card>
       <Card>
         <CardContent className="pt-4 pb-3 px-4">
         <CardContent className="pt-4 pb-3 px-4">
           <div className="flex items-center gap-2 mb-2">
           <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>
             <span className="font-semibold text-sm">Hotspot Password</span>
             {!apPassword && (
             {!apPassword && (
               <Badge variant="secondary" className="text-xs px-1.5 py-0">Open</Badge>
               <Badge variant="secondary" className="text-xs px-1.5 py-0">Open</Badge>
@@ -340,6 +402,7 @@ export function WiFiSetupPage() {
                 value={apPasswordInput}
                 value={apPasswordInput}
                 onChange={(e) => setApPasswordInput(e.target.value)}
                 onChange={(e) => setApPasswordInput(e.target.value)}
                 placeholder="No password (open)"
                 placeholder="No password (open)"
+                aria-label="Hotspot password"
                 className="pr-8 h-8 text-sm"
                 className="pr-8 h-8 text-sm"
                 onKeyDown={(e) => {
                 onKeyDown={(e) => {
                   if (e.key === 'Enter' && apPasswordInput !== apPassword) handleSaveApPassword()
                   if (e.key === 'Enter' && apPasswordInput !== apPassword) handleSaveApPassword()
@@ -349,8 +412,9 @@ export function WiFiSetupPage() {
                 type="button"
                 type="button"
                 onClick={() => setShowApPassword(!showApPassword)}
                 onClick={() => setShowApPassword(!showApPassword)}
                 className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
                 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'}
                   {showApPassword ? 'visibility_off' : 'visibility'}
                 </span>
                 </span>
               </button>
               </button>
@@ -362,7 +426,7 @@ export function WiFiSetupPage() {
               disabled={isSavingApPassword || apPasswordInput === apPassword || (apPasswordInput.length > 0 && apPasswordInput.length < 8)}
               disabled={isSavingApPassword || apPasswordInput === apPassword || (apPasswordInput.length > 0 && apPasswordInput.length < 8)}
             >
             >
               {isSavingApPassword ? (
               {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'
                 'Save'
               )}
               )}
@@ -393,7 +457,7 @@ export function WiFiSetupPage() {
       <Card>
       <Card>
         <CardContent className="pt-4 pb-3 px-4">
         <CardContent className="pt-4 pb-3 px-4">
           <div className="flex items-center gap-2 mb-2">
           <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>
             <span className="font-semibold text-sm">System</span>
           </div>
           </div>
           <div className="flex gap-2">
           <div className="flex gap-2">
@@ -401,30 +465,18 @@ export function WiFiSetupPage() {
               variant="outline"
               variant="outline"
               size="sm"
               size="sm"
               className="flex-1 h-8"
               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
               Restart
             </Button>
             </Button>
             <Button
             <Button
               variant="outline"
               variant="outline"
               size="sm"
               size="sm"
               className="flex-1 h-8 text-destructive hover:text-destructive"
               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
               Shutdown
             </Button>
             </Button>
           </div>
           </div>
@@ -436,7 +488,7 @@ export function WiFiSetupPage() {
         <CardContent className="pt-4 pb-2 px-4">
         <CardContent className="pt-4 pb-2 px-4">
           <div className="flex items-center justify-between mb-2">
           <div className="flex items-center justify-between mb-2">
             <div className="flex items-center gap-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="font-semibold text-sm">Networks</span>
               <span className="text-xs text-muted-foreground">
               <span className="text-xs text-muted-foreground">
                 {networks.length > 0
                 {networks.length > 0
@@ -451,8 +503,9 @@ export function WiFiSetupPage() {
                 className="h-7 w-7 p-0"
                 className="h-7 w-7 p-0"
                 onClick={openManualEntry}
                 onClick={openManualEntry}
                 title="Add network manually"
                 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>
               <Button
               <Button
                 variant="ghost"
                 variant="ghost"
@@ -460,8 +513,9 @@ export function WiFiSetupPage() {
                 className="h-7 w-7 p-0"
                 className="h-7 w-7 p-0"
                 onClick={scanNetworks}
                 onClick={scanNetworks}
                 disabled={isScanning}
                 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
                   refresh
                 </span>
                 </span>
               </Button>
               </Button>
@@ -480,7 +534,7 @@ export function WiFiSetupPage() {
             )}
             )}
             {networks.map((network) => (
             {networks.map((network) => (
               <button
               <button
-                key={network.ssid}
+                key={`${network.ssid}-${network.security}-${network.signal}`}
                 onClick={() => openConnectDialog(network)}
                 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
                 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' : ''}`}
                   ${network.active ? 'bg-green-50 dark:bg-green-950/20' : ''}`}
@@ -496,7 +550,7 @@ export function WiFiSetupPage() {
                 </div>
                 </div>
                 <span className="text-xs text-muted-foreground">{network.signal}%</span>
                 <span className="text-xs text-muted-foreground">{network.signal}%</span>
                 {network.security !== 'Open' && (
                 {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>
               </button>
             ))}
             ))}
@@ -543,7 +597,7 @@ export function WiFiSetupPage() {
             <DialogTitle className="flex items-center gap-2">
             <DialogTitle className="flex items-center gap-2">
               {isManualEntry ? (
               {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
                   Add Network
                 </>
                 </>
               ) : (
               ) : (
@@ -565,6 +619,16 @@ export function WiFiSetupPage() {
           </DialogHeader>
           </DialogHeader>
 
 
           <div className="space-y-4">
           <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 && (
             {isManualEntry && (
               <div className="space-y-2">
               <div className="space-y-2">
                 <Label htmlFor="wifi-ssid">Network Name (SSID)</Label>
                 <Label htmlFor="wifi-ssid">Network Name (SSID)</Label>
@@ -598,8 +662,9 @@ export function WiFiSetupPage() {
                     type="button"
                     type="button"
                     onClick={() => setShowPassword(!showPassword)}
                     onClick={() => setShowPassword(!showPassword)}
                     className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
                     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'}
                       {showPassword ? 'visibility_off' : 'visibility'}
                     </span>
                     </span>
                   </button>
                   </button>
@@ -617,12 +682,12 @@ export function WiFiSetupPage() {
                 >
                 >
                   {isSaving ? (
                   {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...
                       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
                       Save
                     </>
                     </>
                   )}
                   )}
@@ -634,12 +699,12 @@ export function WiFiSetupPage() {
                 >
                 >
                   {isConnecting ? (
                   {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...
                       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
                       Connect
                     </>
                     </>
                   )}
                   )}
@@ -653,12 +718,12 @@ export function WiFiSetupPage() {
               >
               >
                 {isConnecting ? (
                 {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...
                     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
                     Connect
                   </>
                   </>
                 )}
                 )}
@@ -667,6 +732,56 @@ export function WiFiSetupPage() {
           </div>
           </div>
         </DialogContent>
         </DialogContent>
       </Dialog>
       </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>
     </div>
   )
   )
 }
 }

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

@@ -71,8 +71,11 @@ function connectWebSocket() {
   const socket = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
   const socket = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
   ws = socket
   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 = () => {
   socket.onopen = () => {
-    if (isStopped) {
+    if (isStopped || ws !== socket) {
       socket.close()
       socket.close()
       return
       return
     }
     }
@@ -81,7 +84,7 @@ function connectWebSocket() {
   }
   }
 
 
   socket.onmessage = (event) => {
   socket.onmessage = (event) => {
-    if (isStopped) return
+    if (isStopped || ws !== socket) return
     try {
     try {
       const data = JSON.parse(event.data)
       const data = JSON.parse(event.data)
       if (data.type === 'status_update' && data.data) {
       if (data.type === 'status_update' && data.data) {
@@ -93,17 +96,22 @@ function connectWebSocket() {
   }
   }
 
 
   socket.onclose = () => {
   socket.onclose = () => {
-    if (isStopped) return
+    if (isStopped || ws !== socket) return
     ws = null
     ws = null
-    useStatusStore.setState((prev) => ({
+    const attempts = useStatusStore.getState().connectionAttempts + 1
+    useStatusStore.setState({
       isBackendConnected: false,
       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 = () => {
   socket.onerror = () => {
-    if (isStopped) return
+    if (isStopped || ws !== socket) return
     useStatusStore.setState({ isBackendConnected: false })
     useStatusStore.setState({ isBackendConnected: false })
   }
   }
 }
 }
@@ -115,10 +123,13 @@ apiClient.onBaseUrlChange(() => {
     clearTimeout(reconnectTimeout)
     clearTimeout(reconnectTimeout)
     reconnectTimeout = null
     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) {
-    if (ws.readyState === WebSocket.OPEN) ws.close()
+    const oldSocket = ws
     ws = null
     ws = null
+    oldSocket.close()
   }
   }
   connectWebSocket()
   connectWebSocket()
 })
 })
@@ -158,6 +169,7 @@ apiClient.onBaseUrlChange(() => {
 export function _stopStatusWebSocket() {
 export function _stopStatusWebSocket() {
   isStopped = true
   isStopped = true
   if (reconnectTimeout) clearTimeout(reconnectTimeout)
   if (reconnectTimeout) clearTimeout(reconnectTimeout)
-  if (ws && ws.readyState === WebSocket.OPEN) ws.close()
+  // close() is safe in any readyState, including CONNECTING
+  ws?.close()
   ws = null
   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 { setupMockWebSocket, cleanupMockWebSocket } from './mocks/websocket'
 import { server } from './mocks/server'
 import { server } from './mocks/server'
 import { resetMockData, resetApiCallLog } from './mocks/handlers'
 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)
 // Setup browser mocks FIRST (before MSW starts)
 // This ensures WebSocket mock is in place before MSW tries to intercept
 // This ensures WebSocket mock is in place before MSW tries to intercept
 beforeAll(() => {
 beforeAll(() => {
   setupBrowserMocks()
   setupBrowserMocks()
-  setupMockWebSocket()
   // Use 'warn' instead of 'error' to avoid failing on WebSocket requests
   // Use 'warn' instead of 'error' to avoid failing on WebSocket requests
   // that are handled by our mock WebSocket, not MSW
   // that are handled by our mock WebSocket, not MSW
   server.listen({ onUnhandledRequest: 'warn' })
   server.listen({ onUnhandledRequest: 'warn' })
@@ -20,6 +27,14 @@ beforeAll(() => {
 beforeEach(() => {
 beforeEach(() => {
   resetMockData()
   resetMockData()
   resetApiCallLog()
   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
 // Cleanup after each test

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

@@ -1,8 +1,13 @@
 import { render } from '@testing-library/react'
 import { render } from '@testing-library/react'
 import type { RenderOptions } 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 { ReactElement, ReactNode } from 'react'
 import type { PatternMetadata, PreviewData } from '@/lib/types'
 import type { PatternMetadata, PreviewData } from '@/lib/types'
+import { useStatusStore } from '@/stores/useStatusStore'
+import type { StatusData } from '@/stores/useStatusStore'
 import { TableProvider } from '@/contexts/TableContext'
 import { TableProvider } from '@/contexts/TableContext'
 import { Layout } from '@/components/layout/Layout'
 import { Layout } from '@/components/layout/Layout'
 import { BrowsePage } from '@/pages/BrowsePage'
 import { BrowsePage } from '@/pages/BrowsePage'
@@ -87,6 +92,35 @@ export function createMockPlaylists(): string[] {
   return ['default', 'favorites', 'geometric', 'relaxing']
   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
 // Render full app for integration tests
 export function renderApp(options?: {
 export function renderApp(options?: {
   initialRoute?: string
   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: [
   plugins: [
     react(),
     react(),
     VitePWA({
     VitePWA({
-      registerType: 'autoUpdate',
+      registerType: 'prompt',
       includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'android-chrome-192x192.png', 'android-chrome-512x512.png'],
       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
       manifest: false, // We use our own manifest at /static/site.webmanifest
       workbox: {
       workbox: {
@@ -171,5 +171,22 @@ export default defineConfig({
   build: {
   build: {
     outDir: '../static/dist',
     outDir: '../static/dist',
     emptyOutDir: true,
     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
+        },
+      },
+    },
   },
   },
 })
 })

Разница между файлами не показана из-за своего большого размера
+ 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};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/LEDPage-oDN2VKcW.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/PlaylistsPage-CplC-pMF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/SettingsPage-BQ-dtYJf.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/SetupPage-C5AL1838.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/TableControlPage-DUmfP8p3.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/WiFiSetupPage-TJa-Ecum.js


Разница между файлами не показана из-за своего большого размера
+ 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};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/dnd-Bgdma8TD.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/dist/assets/index-B3c97KGp.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/index-Bg3BUsME.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/index-D9WwIcnt.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/index-MuBercVK.js


Разница между файлами не показана из-за своего большого размера
+ 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};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/motion-8nKk6_RD.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/react-DnK_pEU5.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/select-CmzQQBWZ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/assets/slider-DrXXF40r.js


Разница между файлами не показана из-за своего большого размера
+ 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};

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/dist/assets/workbox-window.prod.es5-BIl4cyR9.js


+ 6 - 3
static/dist/index.html

@@ -58,9 +58,12 @@
           .catch(function() {});
           .catch(function() {});
       })();
       })();
     </script>
     </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>
   <body>
     <div id="root"></div>
     <div id="root"></div>
   </body>
   </body>

+ 0 - 1
static/dist/registerSW.js

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

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/sw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/dist/workbox-4424bb96.js


Некоторые файлы не были показаны из-за большого количества измененных файлов