فهرست منبع

release: Dune Weaver 5.0.0

Finish the 5.0 release work on the FluidNC HTTP backend branch:

- Touch app rework: themed component set (ChoiceChip, DwSlider, DwSwitch,
  Icon, SectionLabel, SettingsCard), bundled fonts, supersampled sand-hued
  pattern previews with local-catalog rendering, page-level redesign
- Frontend: Table Wi-Fi management card, crash-homing orientation aligner
  dialog, firmware LED ring preview, sand-tinted pattern previews (previews
  now match the mobile/touch golden sand hue instead of reading white)
- Backend: firmware OTA updater module, board Wi-Fi/password/log endpoints,
  reconnect watchdog and mDNS relocate refinements, test updates
- Bump VERSION to 5.0.0; ignore the harvested board_log.txt runtime artifact

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tuanchris 5 روز پیش
والد
کامیت
d0fae4a477
76فایلهای تغییر یافته به همراه9865 افزوده شده و 6171 حذف شده
  1. 3 0
      .gitignore
  2. 1 1
      VERSION
  3. 45 23
      dune-weaver-touch/README.md
  4. 325 24
      dune-weaver-touch/backend.py
  5. 184 36
      dune-weaver-touch/firmware_client.py
  6. 202 0
      dune-weaver-touch/fonts/LICENSE-MaterialIcons.txt
  7. BIN
      dune-weaver-touch/fonts/MaterialIconsRound-Regular.otf
  8. 93 0
      dune-weaver-touch/fonts/OFL-Outfit.txt
  9. BIN
      dune-weaver-touch/fonts/Outfit-Bold.ttf
  10. BIN
      dune-weaver-touch/fonts/Outfit-Medium.ttf
  11. BIN
      dune-weaver-touch/fonts/Outfit-Regular.ttf
  12. BIN
      dune-weaver-touch/fonts/Outfit-SemiBold.ttf
  13. 15 1
      dune-weaver-touch/main.py
  14. 83 7
      dune-weaver-touch/models/pattern_model.py
  15. 51 108
      dune-weaver-touch/qml/components/BottomNavTab.qml
  16. 71 77
      dune-weaver-touch/qml/components/BottomNavigation.qml
  17. 40 0
      dune-weaver-touch/qml/components/ChoiceChip.qml
  18. 0 161
      dune-weaver-touch/qml/components/ConnectionSplash.qml
  19. 174 54
      dune-weaver-touch/qml/components/ConnectionStatus.qml
  20. 36 0
      dune-weaver-touch/qml/components/DwSlider.qml
  21. 40 0
      dune-weaver-touch/qml/components/DwSwitch.qml
  22. 50 0
      dune-weaver-touch/qml/components/Icon.qml
  23. 0 66
      dune-weaver-touch/qml/components/KeyboardHelper.qml
  24. 70 108
      dune-weaver-touch/qml/components/ModernControlButton.qml
  25. 103 156
      dune-weaver-touch/qml/components/ModernPatternCard.qml
  26. 0 53
      dune-weaver-touch/qml/components/PatternCard.qml
  27. 12 0
      dune-weaver-touch/qml/components/SectionLabel.qml
  28. 15 0
      dune-weaver-touch/qml/components/SettingsCard.qml
  29. 116 76
      dune-weaver-touch/qml/components/ThemeManager.qml
  30. 0 53
      dune-weaver-touch/qml/components/VirtualKeyboardLoader.qml
  31. 34 53
      dune-weaver-touch/qml/main.qml
  32. 437 462
      dune-weaver-touch/qml/pages/ExecutionPage.qml
  33. 622 379
      dune-weaver-touch/qml/pages/LedControlPage.qml
  34. 365 356
      dune-weaver-touch/qml/pages/ModernPatternListPage.qml
  35. 914 1426
      dune-weaver-touch/qml/pages/ModernPlaylistPage.qml
  36. 392 504
      dune-weaver-touch/qml/pages/PatternDetailPage.qml
  37. 0 71
      dune-weaver-touch/qml/pages/PatternListPage.qml
  38. 375 401
      dune-weaver-touch/qml/pages/PatternSelectorPage.qml
  39. 0 113
      dune-weaver-touch/qml/pages/PlaylistPage.qml
  40. 677 646
      dune-weaver-touch/qml/pages/TableControlPage.qml
  41. 159 36
      dune-weaver-touch/thr_preview.py
  42. 189 0
      frontend/src/components/AlignOrientationDialog.tsx
  43. 736 0
      frontend/src/components/LedRingPreview.tsx
  44. 32 18
      frontend/src/components/NowPlayingBar.tsx
  45. 265 4
      frontend/src/components/TableSelector.tsx
  46. 253 0
      frontend/src/components/TableWifiCard.tsx
  47. 290 107
      frontend/src/components/layout/Layout.tsx
  48. 7 5
      frontend/src/index.css
  49. 522 379
      frontend/src/pages/LEDPage.tsx
  50. 381 89
      frontend/src/pages/SettingsPage.tsx
  51. 141 16
      frontend/src/pages/TableControlPage.tsx
  52. 11 0
      frontend/src/stores/useStatusStore.ts
  53. 17 0
      frontend/src/test/mocks/handlers.ts
  54. 331 25
      main.py
  55. 26 4
      modules/connection/connection_manager.py
  56. 161 4
      modules/connection/fluidnc_client.py
  57. 138 23
      modules/core/board_settings.py
  58. 43 12
      modules/core/cache_manager.py
  59. 241 7
      modules/core/execution.py
  60. 83 1
      modules/core/mdns_discovery.py
  61. 72 6
      modules/core/pattern_manager.py
  62. 24 6
      modules/core/state.py
  63. 16 1
      modules/led/board_led_controller.py
  64. 78 0
      modules/update/firmware_updater.py
  65. 0 0
      static/dist/assets/index-BCKILkcX.js
  66. 0 0
      static/dist/assets/index-BQCSo6mO.css
  67. 0 0
      static/dist/assets/index-BbPVIrry.js
  68. 0 0
      static/dist/assets/index-CEWL2r_b.css
  69. 2 2
      static/dist/index.html
  70. 0 0
      static/dist/sw.js
  71. 1 3
      tests/conftest.py
  72. 1 3
      tests/unit/conftest.py
  73. 2 2
      tests/unit/test_api_status.py
  74. 2 1
      tests/unit/test_board_settings.py
  75. 42 2
      tests/unit/test_connection_manager.py
  76. 54 0
      tests/unit/test_pattern_manager.py

+ 3 - 0
.gitignore

@@ -36,3 +36,6 @@ static/custom/*
 frontend/coverage/
 frontend/coverage/
 frontend/playwright-report/
 frontend/playwright-report/
 frontend/test-results/
 frontend/test-results/
+
+# Runtime artifacts
+board_log.txt

+ 1 - 1
VERSION

@@ -1 +1 @@
-4.1.3
+5.0.0

+ 45 - 23
dune-weaver-touch/README.md

@@ -21,8 +21,15 @@ The chosen table is remembered in `touch_settings.json`.
 - **Status** is polled from `GET /sand_status` (~1 Hz) instead of a WebSocket.
 - **Status** is polled from `GET /sand_status` (~1 Hz) instead of a WebSocket.
 - **Actions** go out as `$...` commands via `/command` and the `/sand_*` routes
 - **Actions** go out as `$...` commands via `/command` and the `/sand_*` routes
   (run/stop/pause/resume/home/goto/feed/LED).
   (run/stop/pause/resume/home/goto/feed/LED).
-- **Patterns** come from `GET /sand_patterns`; previews are rendered locally from the
-  raw `.thr` files (`/sd/patterns/...`) and cached under `preview_cache/`.
+- **Patterns** come from `GET /sand_patterns`; previews are rendered locally and cached
+  under `preview_cache/`. The `.thr` source is read from the co-located backend catalog
+  (`../patterns/`, matched by path suffix / unique basename) when available — fetching
+  from the board's SD (`/sd/patterns/...`) is the slow fallback for board-only patterns.
+- **Password-protected boards** are supported: the `$Sand/Password` key is stored
+  (base64) in `touch_settings.json` and sent as `X-Sand-Key` on every request; set it
+  under Control → Table Connection. `503 busy: low memory` responses are retried with
+  backoff, and the clock sync pushes epoch + POSIX timezone (quiet-hours/autostart
+  schedules run on board-local time).
 - **Playlists** are `.txt` files on the SD card — listed via `GET /sand_playlists`,
 - **Playlists** are `.txt` files on the SD card — listed via `GET /sand_playlists`,
   read from `/sd/playlists/...`, and created/edited by uploading/deleting files.
   read from `/sd/playlists/...`, and created/edited by uploading/deleting files.
 - **LEDs** use the firmware's named effect/palette catalogue (`$LED/*` / `/sand_led`).
 - **LEDs** use the firmware's named effect/palette catalogue (`$LED/*` / `/sand_led`).
@@ -32,13 +39,16 @@ See the firmware's `API.md` for the full contract.
 
 
 ## Features
 ## Features
 
 
-- **Modern SwipeView Navigation**: Swipe between Patterns, Playlists, and Control pages
-- **Pattern Browsing**: Beautiful grid view with search and thumbnail previews
-- **Pattern Execution**: Touch-optimized controls with pre-execution options
-- **Table Control**: Dedicated control page with status monitoring and quick actions
-- **Real-time Status**: WebSocket integration for live progress updates
-- **Modern UI**: Material Design inspired interface with animations and shadows
-- **Touch Optimized**: Large buttons, smooth animations, and intuitive gestures
+- **Five pages**: Browse, Playlists, Control, Light (LED ring), and Now Playing
+- **Pattern Browsing**: grid of circular sand-dish previews with search
+- **Now Playing**: progress drawn as an ember arc around the live pattern disc,
+  with the ball as the moving endpoint — plus transport and speed controls
+- **Table Control**: mDNS table discovery, movement, auto-play, screen settings
+- **"Table at night" design system**: warm basalt/bone night palette (default)
+  and a sand day palette, one amber accent, all defined in
+  `qml/components/ThemeManager.qml`; bundled Outfit + Material Icons Round
+  fonts in `fonts/` (registered in `main.py`, no system fonts needed)
+- **Touch Optimized**: 48px+ targets, pill controls, linuxfb-safe (no effects layers)
 
 
 ## Architecture
 ## Architecture
 
 
@@ -114,20 +124,30 @@ Choose from multiple setup options including systemd service, desktop autostart,
 
 
 ```
 ```
 dune-weaver-touch/
 dune-weaver-touch/
-├── main.py                     # Application entry point
-├── backend.py                  # Backend controller with API/WebSocket integration
+├── main.py                     # Application entry point (fonts, QML engine)
+├── backend.py                  # Backend controller (status poll, actions, LEDs)
+├── fonts/                      # Bundled Outfit + Material Icons Round fonts
 ├── models/
 ├── models/
-│   ├── pattern_model.py        # Pattern list model with file system access
-│   └── playlist_model.py       # Playlist model reading from JSON
+│   ├── pattern_model.py        # Pattern list model (firmware-backed)
+│   └── playlist_model.py       # Playlist model (firmware-backed)
 ├── qml/
 ├── qml/
-│   ├── main.qml               # Main window with StackView navigation
+│   ├── main.qml                    # Main window, tab navigation, error dialog
 │   ├── pages/
 │   ├── pages/
-│   │   ├── PatternListPage.qml    # Grid view of patterns with search
-│   │   ├── PatternDetailPage.qml  # Pattern details with execution controls
-│   │   ├── PlaylistPage.qml       # Playlist selection and execution
-│   │   └── ExecutionPage.qml      # Current execution status display
+│   │   ├── ModernPatternListPage.qml  # Browse: grid + search
+│   │   ├── PatternDetailPage.qml      # Pattern detail: clear mode + play
+│   │   ├── ModernPlaylistPage.qml     # Playlists: list, detail, settings
+│   │   ├── PatternSelectorPage.qml    # Add-to-playlist pattern picker
+│   │   ├── TableControlPage.qml       # Connection, movement, system settings
+│   │   ├── LedControlPage.qml         # Light: power, effects, ball tracker
+│   │   └── ExecutionPage.qml          # Now Playing: progress ring + transport
 │   └── components/
 │   └── components/
-│       └── PatternCard.qml        # Pattern thumbnail card
+│       ├── ThemeManager.qml        # Design tokens (palettes, type, spacing)
+│       ├── Icon.qml                # Material icon glyph by name
+│       ├── ModernControlButton.qml # Pill button (filled / outlined)
+│       ├── ChoiceChip.qml          # Selectable option chip
+│       ├── DwSlider.qml / DwSwitch.qml / SectionLabel.qml / SettingsCard.qml
+│       ├── ModernPatternCard.qml   # Pattern card with circular preview
+│       └── BottomNavigation.qml / BottomNavTab.qml / ConnectionStatus.qml
 ├── requirements.txt
 ├── requirements.txt
 └── README.md
 └── README.md
 ```
 ```
@@ -135,10 +155,12 @@ dune-weaver-touch/
 ## Usage
 ## Usage
 
 
 ### Navigation
 ### Navigation
-- **Swipe left/right** to navigate between the three main pages:
-  - **Patterns**: Browse and search through all available patterns
-  - **Playlists**: View and manage pattern playlists
-  - **Control**: Monitor table status and quick control actions
+- **Bottom tabs** switch between the five pages:
+  - **Browse**: search and pick patterns
+  - **Playlists**: create and run playlists
+  - **Control**: connection, movement, and device settings
+  - **Light**: the table's LED ring
+  - **Now Playing**: live progress ring and transport controls
 
 
 ### Pattern Management
 ### Pattern Management
 1. **Browse Patterns**: Swipe to Patterns page to see grid view with thumbnail previews
 1. **Browse Patterns**: Swipe to Patterns page to see grid view with thumbnail previews

+ 325 - 24
dune-weaver-touch/backend.py

@@ -1,6 +1,7 @@
 from PySide6.QtCore import QObject, Signal, Property, Slot, QTimer
 from PySide6.QtCore import QObject, Signal, Property, Slot, QTimer
 from PySide6.QtQml import QmlElement
 from PySide6.QtQml import QmlElement
 import asyncio
 import asyncio
+import base64
 import json
 import json
 import logging
 import logging
 import subprocess
 import subprocess
@@ -9,7 +10,8 @@ import time
 from pathlib import Path
 from pathlib import Path
 import os
 import os
 
 
-from firmware_client import FirmwareClient, LED_EFFECTS, LED_PALETTES
+from firmware_client import (FirmwareClient, LED_EFFECTS, LED_PALETTES,
+                             friendly_error, posix_tz)
 import discovery
 import discovery
 
 
 # Configure logging
 # Configure logging
@@ -39,6 +41,14 @@ def _run(coro):
         logger.warning("No running event loop to schedule task")
         logger.warning("No running event loop to schedule task")
 
 
 
 
+def _clamp_int(value, default, lo, hi):
+    """Coerce ``value`` to an int within [lo, hi], falling back to ``default``."""
+    try:
+        return max(lo, min(hi, int(value)))
+    except (TypeError, ValueError):
+        return default
+
+
 @QmlElement
 @QmlElement
 class Backend(QObject):
 class Backend(QObject):
     """Backend controller: drives a headless FluidNC sand table over HTTP."""
     """Backend controller: drives a headless FluidNC sand table over HTTP."""
@@ -47,6 +57,12 @@ class Backend(QObject):
     SETTINGS_FILE = "touch_settings.json"
     SETTINGS_FILE = "touch_settings.json"
     DEFAULT_SCREEN_TIMEOUT = 300  # 5 minutes in seconds
     DEFAULT_SCREEN_TIMEOUT = 300  # 5 minutes in seconds
     STATUS_POLL_MS = 1000         # /sand_status poll interval
     STATUS_POLL_MS = 1000         # /sand_status poll interval
+    # Consecutive poll failures before the UI flips to "disconnected". The
+    # board's single-threaded web server stalls status reads for seconds while
+    # it serves file transfers, so one slow/failed poll means "busy", not
+    # "gone". A hard-down board fails fast (connection refused), so real
+    # disconnects are still detected within a few seconds.
+    STATUS_FAIL_THRESHOLD = 3
 
 
     # Predefined timeout options (in seconds)
     # Predefined timeout options (in seconds)
     TIMEOUT_OPTIONS = {
     TIMEOUT_OPTIONS = {
@@ -79,9 +95,11 @@ class Backend(QObject):
     progressChanged = Signal()
     progressChanged = Signal()
     connectionChanged = Signal()
     connectionChanged = Signal()
     executionStarted = Signal(str, str)  # patternName, patternPreview
     executionStarted = Signal(str, str)  # patternName, patternPreview
+    patternPreviewReady = Signal(str, str)  # patternName, preview rendered late
     executionStopped = Signal()
     executionStopped = Signal()
     errorOccurred = Signal(str)
     errorOccurred = Signal(str)
     serialPortsUpdated = Signal(list)          # now: list of discovered table URLs
     serialPortsUpdated = Signal(list)          # now: list of discovered table URLs
+    discoveredTablesUpdated = Signal(list)     # [{name, url}] from mDNS discovery
     serialConnectionChanged = Signal(bool)     # now: table reachable
     serialConnectionChanged = Signal(bool)     # now: table reachable
     currentPortChanged = Signal(str)           # now: current table URL
     currentPortChanged = Signal(str)           # now: current table URL
     speedChanged = Signal(int)
     speedChanged = Signal(int)
@@ -155,6 +173,17 @@ class Backend(QObject):
         self._led_color = "#ffffff"
         self._led_color = "#ffffff"
         self._led_last_effect = 2         # remembered on power-off (default rainbow)
         self._led_last_effect = 2         # remembered on power-off (default rainbow)
 
 
+        # 'ball' tracker state (firmware-native effect id 38; the blob follows
+        # the sand ball). Mirrors the board's NVS; written live via /sand_led.
+        self._led_color2 = "#000000"      # background colour when bg == "static"
+        self._led_ball_fgbright = 255     # blob brightness (0..255)
+        self._led_ball_bgbright = 255     # background brightness (0..255)
+        self._led_ball_size = 3           # glow size in LEDs (1..30 in the UI)
+        self._led_ball_bg = "static"      # "static" | "off" | any effect name
+        self._led_ball_direction = "cw"   # "cw" | "ccw"
+        self._led_ball_align = 0          # rotate the blob onto the ball (0..359)
+        self._led_last_non_ball_effect = 2  # restored when the ball toggle is off
+
         # Screen management
         # Screen management
         self._screen_on = True
         self._screen_on = True
         self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
         self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
@@ -169,6 +198,9 @@ class Backend(QObject):
         self._load_local_settings()
         self._load_local_settings()
         self._detect_backlight()
         self._detect_backlight()
 
 
+        # Apply the saved table password ($Sand/Password key) to the client.
+        self.client.set_api_key(self._decode_password(self._table_password_b64))
+
         # Point the shared client at the saved / env-configured table.
         # Point the shared client at the saved / env-configured table.
         env_url = os.environ.get("DUNE_WEAVER_URL", "")
         env_url = os.environ.get("DUNE_WEAVER_URL", "")
         initial_url = env_url or self._saved_table_url
         initial_url = env_url or self._saved_table_url
@@ -180,6 +212,23 @@ class Backend(QObject):
         self._status_timer = QTimer()
         self._status_timer = QTimer()
         self._status_timer.timeout.connect(self._tick_status)
         self._status_timer.timeout.connect(self._tick_status)
         self._time_synced = False
         self._time_synced = False
+        self._poll_inflight = False   # never stack polls on the busy board
+        self._poll_failures = 0       # consecutive failures (see threshold)
+        self._discovered_tables = []  # [{name, url}] from the last mDNS browse
+        self._table_name = ""         # firmware hostname from /sand_status
+        self._table_password_b64 = "" # $Sand/Password, base64 like the backend
+
+        # Playlist run state from /sand_status's playlist object (firmware
+        # sequences playlists; this is read-only telemetry, like the
+        # backend's translate_status).
+        self._playlist_active = False
+        self._playlist_index = 0      # 0-based position in the playlist
+        self._playlist_total = 0
+        self._playlist_name = ""
+        self._next_pattern = ""       # next pattern (display name, no path/.thr)
+        self._playlist_clearing = False
+        self._pause_remaining = -1    # seconds until next pattern; -1 = not pausing
+        self._pause_total = -1
 
 
         # Kick everything off once the event loop is running.
         # Kick everything off once the event loop is running.
         QTimer.singleShot(200, self._start)
         QTimer.singleShot(200, self._start)
@@ -189,9 +238,15 @@ class Backend(QObject):
         self._status_timer.start(self.STATUS_POLL_MS)
         self._status_timer.start(self.STATUS_POLL_MS)
         if not self.client.base_url:
         if not self.client.base_url:
             # No configured table -> try to discover one automatically.
             # No configured table -> try to discover one automatically.
-            _run(self._auto_discover())
+            _run(self._discover(auto_connect=True))
+
+    async def _discover(self, auto_connect=False):
+        """mDNS-browse for tables; auto_connect picks one on startup.
 
 
-    async def _auto_discover(self):
+        Preference order: the last-connected table (saved URL) if it's on the
+        network, otherwise the first table found — the touch panel should
+        come up connected without being asked.
+        """
         self._set_reconnect_status("Searching for tables (mDNS)...")
         self._set_reconnect_status("Searching for tables (mDNS)...")
         try:
         try:
             tables = await discovery.discover_tables(timeout=3.0)
             tables = await discovery.discover_tables(timeout=3.0)
@@ -199,14 +254,19 @@ class Backend(QObject):
             logger.warning(f"Discovery failed: {exc}")
             logger.warning(f"Discovery failed: {exc}")
             tables = []
             tables = []
         self._serial_ports = [t.base_url for t in tables]
         self._serial_ports = [t.base_url for t in tables]
+        self._discovered_tables = [{"name": t.name, "url": t.base_url} for t in tables]
         self.serialPortsUpdated.emit(self._serial_ports)
         self.serialPortsUpdated.emit(self._serial_ports)
-        if len(tables) == 1:
-            logger.info(f"Auto-connecting to the only discovered table: {tables[0].base_url}")
-            self._connect_to(tables[0].base_url)
-        elif not tables:
+        self.discoveredTablesUpdated.emit(self._discovered_tables)
+        if not auto_connect:
+            return
+        if not tables:
             self._set_reconnect_status("No table found. Enter the table address.")
             self._set_reconnect_status("No table found. Enter the table address.")
-        else:
-            self._set_reconnect_status(f"{len(tables)} tables found. Pick one.")
+            return
+        target = next((t for t in tables if t.base_url == self._saved_table_url),
+                      tables[0])
+        which = "last-connected" if target.base_url == self._saved_table_url else "first discovered"
+        logger.info(f"Auto-connecting to the {which} table: {target.base_url}")
+        self._connect_to(target.base_url)
 
 
     # ==================== Status polling ====================
     # ==================== Status polling ====================
     @Slot()
     @Slot()
@@ -214,13 +274,29 @@ class Backend(QObject):
         _run(self._poll_status())
         _run(self._poll_status())
 
 
     async def _poll_status(self):
     async def _poll_status(self):
-        if not self.client.base_url:
+        if not self.client.base_url or self._poll_inflight:
             return
             return
+        self._poll_inflight = True
         try:
         try:
             data = await self.client.status()
             data = await self.client.status()
         except Exception as exc:
         except Exception as exc:
-            self._on_unreachable(str(exc))
+            self._poll_failures += 1
+            reason = str(exc) or type(exc).__name__
+            # 401 = board is password-locked; deterministic, so say so
+            # instead of the generic "retrying" message.
+            if getattr(exc, "status", None) == 401:
+                self._on_unreachable(reason)
+                self._set_reconnect_status(
+                    "Table requires a password — set it below.")
+            elif self._poll_failures >= self.STATUS_FAIL_THRESHOLD:
+                self._on_unreachable(reason)
+            else:
+                logger.debug(f"Status poll failed ({self._poll_failures}/"
+                             f"{self.STATUS_FAIL_THRESHOLD}): {reason}")
             return
             return
+        finally:
+            self._poll_inflight = False
+        self._poll_failures = 0
         self._on_status(data)
         self._on_status(data)
 
 
     def _on_status(self, status):
     def _on_status(self, status):
@@ -241,7 +317,10 @@ class Backend(QObject):
             self.loadLedConfig()
             self.loadLedConfig()
             _run(self._sync_time_once())
             _run(self._sync_time_once())
 
 
-        state = status.get("state", "")
+        # GRBL states can carry a substate suffix ("Hold:0") — strip it, like
+        # the backend's execution._state() does, or pause is never detected.
+        state = (status.get("state") or "").split(":", 1)[0]
+        self._table_name = status.get("hostname", "") or self._table_name
 
 
         # Current pattern / execution start detection
         # Current pattern / execution start detection
         raw_file = status.get("file", "") or ""
         raw_file = status.get("file", "") or ""
@@ -252,11 +331,29 @@ class Backend(QObject):
                 break
                 break
         if new_file and new_file != self._current_file:
         if new_file and new_file != self._current_file:
             logger.info(f"Pattern changed to '{new_file}'")
             logger.info(f"Pattern changed to '{new_file}'")
-            self.executionStarted.emit(new_file, self._preview_path(new_file))
+            preview = self._preview_path(new_file)
+            self.executionStarted.emit(new_file, preview)
+            if not preview:
+                _run(self._render_preview_late(new_file))
         self._current_file = new_file
         self._current_file = new_file
 
 
         self._is_running = bool(status.get("running", False))
         self._is_running = bool(status.get("running", False))
 
 
+        # Playlist telemetry (between-patterns pause countdown + position)
+        pl = status.get("playlist") or {}
+        self._playlist_active = bool(pl.get("active"))
+        self._playlist_index = int(pl.get("index", 0) or 0)
+        self._playlist_total = int(pl.get("total", 0) or 0)
+        self._playlist_name = str(pl.get("name") or "")
+        nxt = str(pl.get("next") or "").rsplit("/", 1)[-1]
+        self._next_pattern = nxt[:-4] if nxt.endswith(".thr") else nxt
+        self._playlist_clearing = bool(pl.get("clearing"))
+
+        def _secs(v):
+            return int(v) if isinstance(v, (int, float)) and v >= 0 else -1
+        self._pause_remaining = _secs(pl.get("pause_remaining", -1))
+        self._pause_total = _secs(pl.get("pause_total", -1))
+
         new_paused = (state == "Hold")
         new_paused = (state == "Hold")
         if new_paused != self._is_paused:
         if new_paused != self._is_paused:
             self._is_paused = new_paused
             self._is_paused = new_paused
@@ -300,11 +397,39 @@ class Backend(QObject):
         if self._time_synced:
         if self._time_synced:
             return
             return
         try:
         try:
-            await self.client.sync_time(int(time.time()))
+            # epoch + POSIX tz, like the backend — board-side quiet-hours and
+            # autostart schedules run on the board's local time.
+            await self.client.sync_time(int(time.time()), tz=posix_tz())
             self._time_synced = True
             self._time_synced = True
         except Exception as exc:
         except Exception as exc:
             logger.debug(f"time sync failed: {exc}")
             logger.debug(f"time sync failed: {exc}")
 
 
+    # ==================== Table password ($Sand/Password) ====================
+    @staticmethod
+    def _decode_password(b64: str):
+        if not b64:
+            return None
+        try:
+            return base64.b64decode(b64).decode("utf-8")
+        except Exception:
+            return None
+
+    @Slot(str)
+    def setTablePassword(self, password):
+        """Store the table's API password (empty string clears it)."""
+        password = (password or "").strip()
+        self._table_password_b64 = (
+            base64.b64encode(password.encode("utf-8")).decode("ascii")
+            if password else "")
+        self.client.set_api_key(password or None)
+        self._save_local_settings()
+        logger.info("Table password %s", "set" if password else "cleared")
+        _run(self._poll_status())
+
+    @Property(bool, notify=settingsLoaded)
+    def hasTablePassword(self):
+        return bool(self._table_password_b64)
+
     def _preview_path(self, rel_name):
     def _preview_path(self, rel_name):
         """Best-effort cached preview path for a pattern (may be empty)."""
         """Best-effort cached preview path for a pattern (may be empty)."""
         try:
         try:
@@ -313,6 +438,21 @@ class Backend(QObject):
         except Exception:
         except Exception:
             return ""
             return ""
 
 
+    async def _render_preview_late(self, rel_name):
+        """Render an uncached preview for the executing pattern, then notify.
+
+        The Execution page otherwise only ever sees whatever was cached at
+        the moment the pattern started."""
+        try:
+            import thr_preview
+            path = await thr_preview.render_preview(
+                self.client, self.client.base_url, rel_name)
+        except Exception as exc:
+            logger.debug(f"late preview render failed for {rel_name}: {exc}")
+            return
+        if path:
+            self.patternPreviewReady.emit(rel_name, path)
+
     # ==================== Properties ====================
     # ==================== Properties ====================
     @Property(str, notify=statusChanged)
     @Property(str, notify=statusChanged)
     def currentFile(self):
     def currentFile(self):
@@ -334,6 +474,46 @@ class Backend(QObject):
     def isConnected(self):
     def isConnected(self):
         return self._is_connected
         return self._is_connected
 
 
+    @Property(str, notify=statusChanged)
+    def tableName(self):
+        return self._table_name
+
+    @Property(bool, notify=statusChanged)
+    def playlistActive(self):
+        return self._playlist_active
+
+    @Property(int, notify=statusChanged)
+    def playlistIndex(self):
+        return self._playlist_index
+
+    @Property(int, notify=statusChanged)
+    def playlistTotal(self):
+        return self._playlist_total
+
+    @Property(str, notify=statusChanged)
+    def playlistName(self):
+        return self._playlist_name
+
+    @Property(str, notify=statusChanged)
+    def nextPattern(self):
+        return self._next_pattern
+
+    @Property(bool, notify=statusChanged)
+    def playlistClearing(self):
+        return self._playlist_clearing
+
+    @Property(int, notify=statusChanged)
+    def pauseRemaining(self):
+        return self._pause_remaining
+
+    @Property(int, notify=statusChanged)
+    def pauseTotal(self):
+        return self._pause_total
+
+    @Property(list, notify=discoveredTablesUpdated)
+    def discoveredTables(self):
+        return self._discovered_tables
+
     @Property(list, notify=serialPortsUpdated)
     @Property(list, notify=serialPortsUpdated)
     def serialPorts(self):
     def serialPorts(self):
         return self._serial_ports
         return self._serial_ports
@@ -369,13 +549,14 @@ class Backend(QObject):
         if self.client.base_url:
         if self.client.base_url:
             _run(self._poll_status())
             _run(self._poll_status())
         else:
         else:
-            _run(self._auto_discover())
+            _run(self._discover(auto_connect=True))
 
 
     @Slot()
     @Slot()
     def refreshSerialPorts(self):
     def refreshSerialPorts(self):
-        """Re-run mDNS discovery to populate the table picker."""
+        """Re-run mDNS discovery to populate the table picker (list only —
+        never switches tables; connecting is an explicit user tap)."""
         logger.info("Discovering tables...")
         logger.info("Discovering tables...")
-        _run(self._auto_discover())
+        _run(self._discover())
 
 
     @Slot(str)
     @Slot(str)
     def connectSerial(self, port):
     def connectSerial(self, port):
@@ -388,6 +569,7 @@ class Backend(QObject):
         self._current_port = self.client.base_url
         self._current_port = self.client.base_url
         self._saved_table_url = self.client.base_url
         self._saved_table_url = self.client.base_url
         self._time_synced = False
         self._time_synced = False
+        self._poll_failures = 0  # fresh table, fresh streak
         self._save_local_settings()
         self._save_local_settings()
         self.currentPortChanged.emit(self._current_port)
         self.currentPortChanged.emit(self._current_port)
         self._set_reconnect_status(f"Connecting to {self._current_port}...")
         self._set_reconnect_status(f"Connecting to {self._current_port}...")
@@ -398,6 +580,7 @@ class Backend(QObject):
         logger.info("Disconnecting from table...")
         logger.info("Disconnecting from table...")
         self.client.set_base_url("")
         self.client.set_base_url("")
         self._current_port = ""
         self._current_port = ""
+        self._table_name = ""
         self._serial_connected = False
         self._serial_connected = False
         self._backend_connected = False
         self._backend_connected = False
         self._is_connected = False
         self._is_connected = False
@@ -415,10 +598,13 @@ class Backend(QObject):
     async def _execute_pattern(self, fileName, preExecution):
     async def _execute_pattern(self, fileName, preExecution):
         try:
         try:
             await self.client.run_pattern(fileName, preExecution)
             await self.client.run_pattern(fileName, preExecution)
-            self.executionStarted.emit(fileName, self._preview_path(fileName))
+            preview = self._preview_path(fileName)
+            self.executionStarted.emit(fileName, preview)
+            if not preview:
+                _run(self._render_preview_late(fileName))
         except Exception as exc:
         except Exception as exc:
             logger.error(f"executePattern failed: {exc}")
             logger.error(f"executePattern failed: {exc}")
-            self.errorOccurred.emit(str(exc))
+            self.errorOccurred.emit("Couldn't start the pattern. " + friendly_error(exc))
 
 
     @Slot()
     @Slot()
     def stopExecution(self):
     def stopExecution(self):
@@ -447,7 +633,7 @@ class Backend(QObject):
                 on_ok()
                 on_ok()
         except Exception as exc:
         except Exception as exc:
             logger.error(f"{label} failed: {exc}")
             logger.error(f"{label} failed: {exc}")
-            self.errorOccurred.emit(f"{label} failed: {exc}")
+            self.errorOccurred.emit(f"Couldn't {label}. " + friendly_error(exc))
 
 
     @Slot(str, float, str, str, bool)
     @Slot(str, float, str, str, bool)
     def executePlaylist(self, playlistName, pauseTime=0.0, clearPattern="adaptive",
     def executePlaylist(self, playlistName, pauseTime=0.0, clearPattern="adaptive",
@@ -463,7 +649,7 @@ class Backend(QObject):
                 run_mode=runMode, shuffle=shuffle)
                 run_mode=runMode, shuffle=shuffle)
         except Exception as exc:
         except Exception as exc:
             logger.error(f"executePlaylist failed: {exc}")
             logger.error(f"executePlaylist failed: {exc}")
-            self.errorOccurred.emit(str(exc))
+            self.errorOccurred.emit("Couldn't start the playlist. " + friendly_error(exc))
 
 
     # ==================== Hardware movement ====================
     # ==================== Hardware movement ====================
     @Slot()
     @Slot()
@@ -494,7 +680,7 @@ class Backend(QObject):
             self.speedChanged.emit(speed)
             self.speedChanged.emit(speed)
         except Exception as exc:
         except Exception as exc:
             logger.error(f"set speed failed: {exc}")
             logger.error(f"set speed failed: {exc}")
-            self.errorOccurred.emit(str(exc))
+            self.errorOccurred.emit("Couldn't change the speed. " + friendly_error(exc))
 
 
     @Slot(result='QStringList')
     @Slot(result='QStringList')
     def getSpeedOptions(self):
     def getSpeedOptions(self):
@@ -719,6 +905,7 @@ class Backend(QObject):
                 self._playlist_run_mode = settings.get('playlist_run_mode', "loop")
                 self._playlist_run_mode = settings.get('playlist_run_mode', "loop")
                 self._playlist_clear_pattern = settings.get('playlist_clear_pattern', "adaptive")
                 self._playlist_clear_pattern = settings.get('playlist_clear_pattern', "adaptive")
                 self._saved_table_url = settings.get('table_url', "")
                 self._saved_table_url = settings.get('table_url', "")
+                self._table_password_b64 = settings.get('table_password', "")
             else:
             else:
                 self._save_local_settings()
                 self._save_local_settings()
         except Exception as e:
         except Exception as e:
@@ -737,6 +924,7 @@ class Backend(QObject):
                 'playlist_run_mode': self._playlist_run_mode,
                 'playlist_run_mode': self._playlist_run_mode,
                 'playlist_clear_pattern': self._playlist_clear_pattern,
                 'playlist_clear_pattern': self._playlist_clear_pattern,
                 'table_url': self._saved_table_url,
                 'table_url': self._saved_table_url,
+                'table_password': self._table_password_b64,
                 'version': '2.0'
                 'version': '2.0'
             }
             }
             with open(self.SETTINGS_FILE, 'w') as f:
             with open(self.SETTINGS_FILE, 'w') as f:
@@ -865,6 +1053,35 @@ class Backend(QObject):
     def ledColor(self):
     def ledColor(self):
         return self._led_color
         return self._led_color
 
 
+    # -- 'ball' tracker properties (firmware effect id 38) -----------------
+    @Property(str, notify=ledStatusChanged)
+    def ledColor2(self):
+        return self._led_color2
+
+    @Property(int, notify=ledStatusChanged)
+    def ledBallFgBright(self):
+        return self._led_ball_fgbright
+
+    @Property(int, notify=ledStatusChanged)
+    def ledBallBgBright(self):
+        return self._led_ball_bgbright
+
+    @Property(int, notify=ledStatusChanged)
+    def ledBallSize(self):
+        return self._led_ball_size
+
+    @Property(str, notify=ledStatusChanged)
+    def ledBallBg(self):
+        return self._led_ball_bg
+
+    @Property(str, notify=ledStatusChanged)
+    def ledBallDirection(self):
+        return self._led_ball_direction
+
+    @Property(int, notify=ledStatusChanged)
+    def ledBallAlign(self):
+        return self._led_ball_align
+
     @Slot()
     @Slot()
     def loadLedConfig(self):
     def loadLedConfig(self):
         logger.debug("Loading LED configuration...")
         logger.debug("Loading LED configuration...")
@@ -904,6 +1121,19 @@ class Backend(QObject):
             color = settings.get("LED/Color", "ffffff")
             color = settings.get("LED/Color", "ffffff")
             self._led_color = f"#{color.lstrip('#')}"
             self._led_color = f"#{color.lstrip('#')}"
 
 
+            # 'ball' tracker params (read NVS names, written live via /sand_led).
+            color2 = settings.get("LED/Color2", "000000")
+            self._led_color2 = f"#{color2.lstrip('#')}"
+            self._led_ball_fgbright = _clamp_int(settings.get("LED/BallBright"), 255, 0, 255)
+            self._led_ball_bgbright = _clamp_int(settings.get("LED/BallBgBright"), 255, 0, 255)
+            self._led_ball_size = _clamp_int(settings.get("LED/BallSize"), 3, 1, 200)
+            self._led_ball_align = _clamp_int(settings.get("LED/Align"), 0, 0, 359)
+            self._led_ball_bg = (settings.get("LED/BallBg") or "static").lower()
+            direction = (settings.get("LED/Direction") or "cw").lower()
+            self._led_ball_direction = direction if direction in ("cw", "ccw") else "cw"
+            if effect != "ball":
+                self._led_last_non_ball_effect = self._led_current_effect
+
         self.ledStatusChanged.emit()
         self.ledStatusChanged.emit()
 
 
     def _ingest_led_status(self, led):
     def _ingest_led_status(self, led):
@@ -921,6 +1151,8 @@ class Backend(QObject):
                 changed = True
                 changed = True
             if power:
             if power:
                 self._led_last_effect = idx
                 self._led_last_effect = idx
+            if effect not in ("ball", "off"):
+                self._led_last_non_ball_effect = idx
         b = led.get("brightness")
         b = led.get("brightness")
         if b is not None:
         if b is not None:
             scaled = round(int(b) * 100 / 255)
             scaled = round(int(b) * 100 / 255)
@@ -961,7 +1193,7 @@ class Backend(QObject):
             await self.client.set_led(**kwargs)
             await self.client.set_led(**kwargs)
         except Exception as exc:
         except Exception as exc:
             logger.error(f"LED update failed: {exc}")
             logger.error(f"LED update failed: {exc}")
-            self.errorOccurred.emit(str(exc))
+            self.errorOccurred.emit("Couldn't update the light. " + friendly_error(exc))
 
 
     @Slot(int)
     @Slot(int)
     def setLedBrightness(self, value):
     def setLedBrightness(self, value):
@@ -994,6 +1226,8 @@ class Backend(QObject):
             self._led_power_on = (name != "off")
             self._led_power_on = (name != "off")
             if self._led_power_on:
             if self._led_power_on:
                 self._led_last_effect = effectId
                 self._led_last_effect = effectId
+            if name not in ("ball", "off"):
+                self._led_last_non_ball_effect = effectId
             _run(self._apply_led(effect=name))
             _run(self._apply_led(effect=name))
             self.ledStatusChanged.emit()
             self.ledStatusChanged.emit()
 
 
@@ -1005,6 +1239,73 @@ class Backend(QObject):
             _run(self._apply_led(palette=LED_PALETTES[paletteId]))
             _run(self._apply_led(palette=LED_PALETTES[paletteId]))
             self.ledStatusChanged.emit()
             self.ledStatusChanged.emit()
 
 
+    # -- 'ball' tracker control (firmware-native effect id 38) -------------
+    BALL_EFFECT_ID = LED_EFFECTS.index("ball")
+
+    @Slot(bool)
+    def setBallTracker(self, on):
+        """Enable/disable the ball tracker by toggling the 'ball' effect.
+        Mirrors the mobile app's ball switch: remembers the previous non-ball
+        effect and restores it when turned off."""
+        if on:
+            if self._led_current_effect != self.BALL_EFFECT_ID:
+                self._led_last_non_ball_effect = self._led_current_effect
+            self.setLedEffect(self.BALL_EFFECT_ID)
+        else:
+            restore = self._led_last_non_ball_effect or LED_EFFECTS.index("rainbow")
+            self.setLedEffect(restore)
+
+    @Slot(str)
+    def setLedColor2Hex(self, hexColor):
+        """Background colour for the ball tracker (used when bg == 'static')."""
+        hexColor = hexColor.lstrip('#')
+        if len(hexColor) != 6:
+            logger.warning(f"Invalid hex color2: {hexColor}")
+            return
+        self._led_color2 = f"#{hexColor}"
+        _run(self._apply_led(color2=hexColor))
+        self.ledStatusChanged.emit()
+
+    @Slot(int)
+    def setLedBallFgBright(self, value):
+        self._led_ball_fgbright = _clamp_int(value, 255, 0, 255)
+        _run(self._apply_led(fgbright=self._led_ball_fgbright))
+        self.ledStatusChanged.emit()
+
+    @Slot(int)
+    def setLedBallBgBright(self, value):
+        self._led_ball_bgbright = _clamp_int(value, 255, 0, 255)
+        _run(self._apply_led(bgbright=self._led_ball_bgbright))
+        self.ledStatusChanged.emit()
+
+    @Slot(int)
+    def setLedBallSize(self, value):
+        self._led_ball_size = _clamp_int(value, 3, 1, 200)
+        _run(self._apply_led(size=self._led_ball_size))
+        self.ledStatusChanged.emit()
+
+    @Slot(int)
+    def setLedBallAlign(self, value):
+        self._led_ball_align = _clamp_int(value, 0, 0, 359)
+        _run(self._apply_led(align=self._led_ball_align))
+        self.ledStatusChanged.emit()
+
+    @Slot(str)
+    def setLedBallDirection(self, direction):
+        if direction not in ("cw", "ccw"):
+            return
+        self._led_ball_direction = direction
+        _run(self._apply_led(direction=direction))
+        self.ledStatusChanged.emit()
+
+    @Slot(str)
+    def setLedBallBg(self, bg):
+        if not bg:
+            return
+        self._led_ball_bg = str(bg)
+        _run(self._apply_led(bg=self._led_ball_bg))
+        self.ledStatusChanged.emit()
+
     # ==================== LCD brightness ====================
     # ==================== LCD brightness ====================
     def _detect_backlight(self):
     def _detect_backlight(self):
         if not self._lcd_brightness_path:
         if not self._lcd_brightness_path:
@@ -1088,7 +1389,7 @@ class Backend(QObject):
             subprocess.run(['sudo', 'shutdown', '-h', 'now'], check=False, timeout=5)
             subprocess.run(['sudo', 'shutdown', '-h', 'now'], check=False, timeout=5)
         except Exception as e:
         except Exception as e:
             logger.error(f"Shutdown failed: {e}")
             logger.error(f"Shutdown failed: {e}")
-            self.errorOccurred.emit(str(e))
+            self.errorOccurred.emit("Couldn't shut down the Pi. " + friendly_error(e))
 
 
     # ==================== Playlist management ====================
     # ==================== Playlist management ====================
     @Slot(str)
     @Slot(str)

+ 184 - 36
dune-weaver-touch/firmware_client.py

@@ -16,6 +16,8 @@ from __future__ import annotations
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import random
+import socket
 from typing import Optional
 from typing import Optional
 from urllib.parse import quote
 from urllib.parse import quote
 
 
@@ -53,7 +55,32 @@ CLEAR_MODE_MAP = {
 }
 }
 
 
 DEFAULT_HTTP_TIMEOUT = 6      # seconds, for normal requests
 DEFAULT_HTTP_TIMEOUT = 6      # seconds, for normal requests
-STATUS_TIMEOUT = 3           # seconds, for the ~1 Hz status poll
+# 503 low-memory load-shedding retry, mirroring the backend's FluidNCClient.
+_RETRY_503_ATTEMPTS = 3       # total tries (1 initial + 2 retries)
+_RETRY_503_BASE = 0.3         # seconds; base for exponential backoff + jitter
+# The board's web server serializes requests, so a request behind a big file
+# transfer can legitimately time out once and succeed a moment later.
+_TRANSIENT_RETRY_DELAY = 0.5  # seconds between transient-error retries
+# Status poll budget. The board's web server serializes requests, so a status
+# read legitimately waits several seconds behind a file transfer — a tight
+# timeout here makes a busy board look dead.
+STATUS_TIMEOUT = 5           # seconds, for the ~1 Hz status poll
+
+
+def friendly_error(exc: BaseException) -> str:
+    """Human-readable message for a request failure.
+
+    ``str(asyncio.TimeoutError())`` is an EMPTY string — surfacing raw
+    exceptions produced blank error dialogs. Every user-facing error goes
+    through here instead.
+    """
+    if isinstance(exc, asyncio.TimeoutError):
+        return "The table didn't respond in time — it may be busy. Try again."
+    if isinstance(exc, aiohttp.ClientResponseError) and exc.status == 401:
+        return "The table rejected the password. Set it under Table connection."
+    if isinstance(exc, (aiohttp.ClientConnectionError, aiohttp.ClientError)):
+        return "Can't reach the table. Check that it's powered on and on your network."
+    return str(exc) or type(exc).__name__
 
 
 
 
 def _raise_file_error(status: int, body) -> None:
 def _raise_file_error(status: int, body) -> None:
@@ -69,6 +96,29 @@ def _raise_file_error(status: int, body) -> None:
     raise RuntimeError(detail or f"HTTP {status}")
     raise RuntimeError(detail or f"HTTP {status}")
 
 
 
 
+def posix_tz() -> Optional[str]:
+    """POSIX TZ rule for the system zone (from the TZif v2+ footer line).
+
+    Same derivation as the backend's board_settings.posix_tz(): modern TZif
+    files end with a footer holding exactly the rule string the firmware's
+    $Time/Zone wants (e.g. 'EST5EDT,M3.2.0,M11.1.0'). None if unreadable.
+    """
+    try:
+        with open("/etc/localtime", "rb") as f:
+            data = f.read()
+        if not data.startswith(b"TZif"):
+            return None
+        end = data.rfind(b"\n")
+        if end <= 0:
+            return None
+        begin = data.rfind(b"\n", 0, end)
+        footer = data[begin + 1:end].decode("ascii").strip()
+        return footer or None
+    except Exception as exc:
+        logger.debug(f"Could not derive POSIX tz: {exc}")
+        return None
+
+
 def normalize_base_url(value: str) -> str:
 def normalize_base_url(value: str) -> str:
     """Turn a user/mDNS supplied host into a ``http://host[:port]`` base URL."""
     """Turn a user/mDNS supplied host into a ``http://host[:port]`` base URL."""
     value = (value or "").strip().rstrip("/")
     value = (value or "").strip().rstrip("/")
@@ -100,6 +150,18 @@ class FirmwareClient(QObject):
         self._base_url = ""
         self._base_url = ""
         self._session: Optional[aiohttp.ClientSession] = None
         self._session: Optional[aiohttp.ClientSession] = None
         self._reachable = False
         self._reachable = False
+        # $Sand/Password key (fw >= v0.1.11), sent as X-Sand-Key on every
+        # request — same contract as the backend's FluidNCClient.
+        self._api_key: Optional[str] = None
+        # True when the board rejected us with 401 (locked, wrong/missing key).
+        self.locked = False
+
+    def set_api_key(self, key: Optional[str]) -> None:
+        self._api_key = key or None
+        self.locked = False
+
+    def _headers(self) -> dict:
+        return {"X-Sand-Key": self._api_key} if self._api_key else {}
 
 
     # ------------------------------------------------------------------ target
     # ------------------------------------------------------------------ target
     @property
     @property
@@ -127,7 +189,12 @@ class FirmwareClient(QObject):
     # ----------------------------------------------------------------- session
     # ----------------------------------------------------------------- session
     async def _ensure_session(self) -> aiohttp.ClientSession:
     async def _ensure_session(self) -> aiohttp.ClientSession:
         if self._session is None or self._session.closed:
         if self._session is None or self._session.closed:
-            connector = aiohttp.TCPConnector(ssl=False, limit=8)
+            # IPv4 only: the boards publish no AAAA record over mDNS, and a
+            # dual-stack getaddrinfo stalls ~5s waiting for it (longer than the
+            # 3s status timeout, so every poll would die in name resolution).
+            connector = aiohttp.TCPConnector(
+                ssl=False, limit=8, family=socket.AF_INET, ttl_dns_cache=300
+            )
             self._session = aiohttp.ClientSession(connector=connector)
             self._session = aiohttp.ClientSession(connector=connector)
         return self._session
         return self._session
 
 
@@ -136,35 +203,69 @@ class FirmwareClient(QObject):
             await self._session.close()
             await self._session.close()
 
 
     # ------------------------------------------------------------- HTTP helpers
     # ------------------------------------------------------------- HTTP helpers
-    async def _get(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT):
-        """GET ``path`` (leading-slash relative). Returns the aiohttp response
-        inside a context manager caller. Raises on transport error."""
+    async def _fetch(self, path: str, parse: str, *, timeout: float,
+                     transient_retries: int = 1):
+        """GET ``path`` and return the parsed body ("json" | "text" | "bytes").
+
+        Retries the firmware's ``503 busy: low memory`` load-shedding with
+        exponential backoff, like the backend's FluidNCClient (the board sheds
+        every route except the status/stop/pause/resume lifeline when free
+        heap drops below ~10 KB; these resolve in a few seconds). Tracks 401s
+        in ``self.locked`` so the UI can prompt for the table password.
+
+        ``transient_retries`` additionally retries timeouts and connection
+        errors (the board's serialized web server queues requests behind file
+        transfers, so a one-off timeout is normal). Callers whose requests are
+        NOT safe to re-send (``$...`` commands) pass 0.
+        """
         if not self._base_url:
         if not self._base_url:
             raise RuntimeError("No table selected")
             raise RuntimeError("No table selected")
         session = await self._ensure_session()
         session = await self._ensure_session()
         client_timeout = aiohttp.ClientTimeout(total=timeout)
         client_timeout = aiohttp.ClientTimeout(total=timeout)
-        return session.get(f"{self._base_url}{path}", timeout=client_timeout)
-
-    async def get_json(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT):
-        async with await self._get(path, timeout=timeout) as resp:
-            resp.raise_for_status()
-            data = await resp.json(content_type=None)
-            self._set_reachable(True)
-            return data
-
-    async def get_text(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT) -> str:
-        async with await self._get(path, timeout=timeout) as resp:
-            resp.raise_for_status()
-            text = await resp.text()
-            self._set_reachable(True)
-            return text
-
-    async def get_bytes(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT) -> bytes:
-        async with await self._get(path, timeout=timeout) as resp:
-            resp.raise_for_status()
-            data = await resp.read()
-            self._set_reachable(True)
-            return data
+        url = f"{self._base_url}{path}"
+        attempt_503 = 0
+        transient_left = transient_retries
+        while True:
+            try:
+                async with session.get(url, timeout=client_timeout,
+                                       headers=self._headers()) as resp:
+                    if resp.status == 503 and attempt_503 < _RETRY_503_ATTEMPTS - 1:
+                        attempt_503 += 1
+                        delay = _RETRY_503_BASE * (2 ** attempt_503) + random.uniform(0, _RETRY_503_BASE)
+                        logger.debug(f"Board 503 (low memory) on {path}; retrying in {delay:.2f}s")
+                        await asyncio.sleep(delay)
+                        continue
+                    self.locked = resp.status == 401
+                    resp.raise_for_status()
+                    if parse == "json":
+                        data = await resp.json(content_type=None)
+                    elif parse == "text":
+                        data = await resp.text()
+                    else:
+                        data = await resp.read()
+                    self._set_reachable(True)
+                    return data
+            except (asyncio.TimeoutError, aiohttp.ClientConnectionError) as exc:
+                if transient_left <= 0:
+                    raise
+                transient_left -= 1
+                logger.debug(f"Transient failure on {path} ({exc!r}); retrying")
+                await asyncio.sleep(_TRANSIENT_RETRY_DELAY)
+
+    async def get_json(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT,
+                       transient_retries: int = 1):
+        return await self._fetch(path, "json", timeout=timeout,
+                                 transient_retries=transient_retries)
+
+    async def get_text(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT,
+                       transient_retries: int = 1) -> str:
+        return await self._fetch(path, "text", timeout=timeout,
+                                 transient_retries=transient_retries)
+
+    async def get_bytes(self, path: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT,
+                        transient_retries: int = 1) -> bytes:
+        return await self._fetch(path, "bytes", timeout=timeout,
+                                 transient_retries=transient_retries)
 
 
     # -------------------------------------------------------------- status/read
     # -------------------------------------------------------------- status/read
     async def status(self) -> dict:
     async def status(self) -> dict:
@@ -186,10 +287,15 @@ class FirmwareClient(QObject):
         data = await self.get_json("/sand_settings")
         data = await self.get_json("/sand_settings")
         return data if isinstance(data, dict) else {}
         return data if isinstance(data, dict) else {}
 
 
-    async def fetch_sd_file(self, sd_path: str) -> bytes:
-        """Fetch a file from the SD card, e.g. ``/patterns/star.thr``."""
+    async def fetch_sd_file(self, sd_path: str, *, timeout: float = 45) -> bytes:
+        """Fetch a file from the SD card, e.g. ``/patterns/star.thr``.
+
+        Default timeout is generous: the board serves large .thr files slowly
+        (measured up to ~26s for 500KB when it is busy), and the default 6s
+        budget made every big pattern's preview fetch fail.
+        """
         sd_path = "/" + sd_path.lstrip("/")
         sd_path = "/" + sd_path.lstrip("/")
-        return await self.get_bytes(f"/sd{sd_path}")
+        return await self.get_bytes(f"/sd{sd_path}", timeout=timeout)
 
 
     # ------------------------------------------------------------------ actions
     # ------------------------------------------------------------------ actions
     async def command(self, cmd: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT) -> str:
     async def command(self, cmd: str, *, timeout: float = DEFAULT_HTTP_TIMEOUT) -> str:
@@ -197,8 +303,12 @@ class FirmwareClient(QObject):
 
 
         Output routing over ``/command`` is racy for anything but ``$/`` reads,
         Output routing over ``/command`` is racy for anything but ``$/`` reads,
         so callers that need a value should poll a ``/sand_*`` route instead.
         so callers that need a value should poll a ``/sand_*`` route instead.
+        No transient retry: a timed-out command may still execute on the board
+        once its queue drains, and re-sending e.g. ``$Playlist/Run`` or ``$Bye``
+        would double-fire it.
         """
         """
-        return await self.get_text(f"/command?plain={quote(cmd)}", timeout=timeout)
+        return await self.get_text(f"/command?plain={quote(cmd)}",
+                                   timeout=timeout, transient_retries=0)
 
 
     async def run_pattern(self, rel_path: str, clear: str = "none") -> None:
     async def run_pattern(self, rel_path: str, clear: str = "none") -> None:
         """Run ``/patterns/<rel_path>`` with an optional pre-execution clear."""
         """Run ``/patterns/<rel_path>`` with an optional pre-execution clear."""
@@ -209,9 +319,37 @@ class FirmwareClient(QObject):
         else:
         else:
             await self.command(f"$Sand/Run={path} clear={mode}")
             await self.command(f"$Sand/Run={path} clear={mode}")
 
 
+    async def _wait_for_idle(self, timeout_s: float) -> bool:
+        loop = asyncio.get_event_loop()
+        deadline = loop.time() + timeout_s
+        while loop.time() < deadline:
+            try:
+                st = await self.status()
+                state = (st.get("state") or "").split(":", 1)[0]
+                if state == "Idle" and not st.get("running"):
+                    return True
+            except Exception as exc:
+                logger.debug(f"Idle wait poll failed: {exc}")
+            await asyncio.sleep(0.5)
+        return False
+
     async def run_playlist(self, name: str, *, pause_time=None, clear_pattern=None,
     async def run_playlist(self, name: str, *, pause_time=None, clear_pattern=None,
                            run_mode=None, shuffle=None) -> None:
                            run_mode=None, shuffle=None) -> None:
-        """Apply the run parameters (NVS) then start the playlist."""
+        """Apply the run parameters (NVS) then start the playlist.
+
+        NVS writes are idle-gated on the firmware (rejected mid-motion), so a
+        run-while-running stops the board first — same as the backend's
+        execution.start_playlist.
+        """
+        try:
+            st = await self.status()
+        except Exception:
+            st = None
+        state = ((st or {}).get("state") or "").split(":", 1)[0]
+        if st and (st.get("running") or state not in ("Idle", "Alarm")):
+            await self.stop()
+            if not await self._wait_for_idle(15.0):
+                raise RuntimeError("Table is busy and did not stop in time")
         if run_mode in ("single", "loop"):
         if run_mode in ("single", "loop"):
             await self.command(f"$Playlist/Mode={run_mode}")
             await self.command(f"$Playlist/Mode={run_mode}")
         if shuffle is not None:
         if shuffle is not None:
@@ -267,8 +405,16 @@ class FirmwareClient(QObject):
     async def reboot(self) -> None:
     async def reboot(self) -> None:
         await self.command("$Bye")
         await self.command("$Bye")
 
 
-    async def sync_time(self, epoch: int) -> None:
-        await self.get_text(f"/sand_time?epoch={int(epoch)}")
+    async def sync_time(self, epoch: int, tz: Optional[str] = None) -> None:
+        """Push the wall clock and (optionally) a POSIX timezone rule.
+
+        The tz matters: board-side quiet-hours/autostart schedules run on the
+        board's local time. The backend always sends both; so do we.
+        """
+        query = f"epoch={int(epoch)}"
+        if tz:
+            query += f"&tz={quote(tz)}"
+        await self.get_text(f"/sand_time?{query}")
 
 
     # --------------------------------------------------------------- file ops
     # --------------------------------------------------------------- file ops
     async def upload_file(self, name: str, data: bytes, path: str = "/patterns") -> dict:
     async def upload_file(self, name: str, data: bytes, path: str = "/patterns") -> dict:
@@ -282,7 +428,8 @@ class FirmwareClient(QObject):
                        content_type="application/octet-stream")
                        content_type="application/octet-stream")
         url = f"{self._base_url}/upload?path={quote(path)}"
         url = f"{self._base_url}/upload?path={quote(path)}"
         timeout = aiohttp.ClientTimeout(total=60)
         timeout = aiohttp.ClientTimeout(total=60)
-        async with session.post(url, data=form, timeout=timeout) as resp:
+        async with session.post(url, data=form, timeout=timeout,
+                                headers=self._headers()) as resp:
             body = await resp.json(content_type=None)
             body = await resp.json(content_type=None)
             self._set_reachable(True)
             self._set_reachable(True)
             _raise_file_error(resp.status, body)
             _raise_file_error(resp.status, body)
@@ -297,7 +444,8 @@ class FirmwareClient(QObject):
         query = "&".join(f"{k}={quote(str(v))}" for k, v in params.items())
         query = "&".join(f"{k}={quote(str(v))}" for k, v in params.items())
         url = f"{self._base_url}/upload?{query}"
         url = f"{self._base_url}/upload?{query}"
         timeout = aiohttp.ClientTimeout(total=DEFAULT_HTTP_TIMEOUT)
         timeout = aiohttp.ClientTimeout(total=DEFAULT_HTTP_TIMEOUT)
-        async with session.get(url, timeout=timeout) as resp:
+        async with session.get(url, timeout=timeout,
+                               headers=self._headers()) as resp:
             body = await resp.json(content_type=None)
             body = await resp.json(content_type=None)
             self._set_reachable(True)
             self._set_reachable(True)
             _raise_file_error(resp.status, body)
             _raise_file_error(resp.status, body)

+ 202 - 0
dune-weaver-touch/fonts/LICENSE-MaterialIcons.txt

@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

BIN
dune-weaver-touch/fonts/MaterialIconsRound-Regular.otf


+ 93 - 0
dune-weaver-touch/fonts/OFL-Outfit.txt

@@ -0,0 +1,93 @@
+Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
dune-weaver-touch/fonts/Outfit-Bold.ttf


BIN
dune-weaver-touch/fonts/Outfit-Medium.ttf


BIN
dune-weaver-touch/fonts/Outfit-Regular.ttf


BIN
dune-weaver-touch/fonts/Outfit-SemiBold.ttf


+ 15 - 1
dune-weaver-touch/main.py

@@ -6,8 +6,9 @@ import time
 import signal
 import signal
 from pathlib import Path
 from pathlib import Path
 from PySide6.QtCore import QUrl, QTimer, QObject, QEvent
 from PySide6.QtCore import QUrl, QTimer, QObject, QEvent
-from PySide6.QtGui import QGuiApplication
+from PySide6.QtGui import QGuiApplication, QFont, QFontDatabase
 from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType
 from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType
+from PySide6.QtQuickControls2 import QQuickStyle
 from qasync import QEventLoop
 from qasync import QEventLoop
 from dotenv import load_dotenv
 from dotenv import load_dotenv
 
 
@@ -102,6 +103,19 @@ def main():
 
 
     app = QGuiApplication(sys.argv)
     app = QGuiApplication(sys.argv)
 
 
+    # Basic style everywhere: the custom control styling (DwSlider, DwSwitch,
+    # TextField pills) is ignored by native styles (e.g. macOS in dev runs).
+    QQuickStyle.setStyle("Basic")
+
+    # Bundled fonts: Outfit (UI text) + Material Icons Round (icon glyphs).
+    # The Pi image has no reliable emoji/symbol fonts, so all icons in QML go
+    # through components/Icon.qml against this icon font.
+    fonts_dir = Path(__file__).parent / "fonts"
+    for font_file in sorted(fonts_dir.glob("*.ttf")) + sorted(fonts_dir.glob("*.otf")):
+        if QFontDatabase.addApplicationFont(str(font_file)) == -1:
+            logger.warning(f"Failed to load font {font_file.name}")
+    app.setFont(QFont("Outfit", 10))
+
     # Install first-touch filter to ignore wake-up touches
     # Install first-touch filter to ignore wake-up touches
     # Ignores the first touch after 2 seconds of inactivity
     # Ignores the first touch after 2 seconds of inactivity
     first_touch_filter = FirstTouchFilter(idle_threshold_seconds=2.0)
     first_touch_filter = FirstTouchFilter(idle_threshold_seconds=2.0)

+ 83 - 7
dune-weaver-touch/models/pattern_model.py

@@ -33,8 +33,10 @@ class PatternModel(QAbstractListModel):
         self._patterns = []           # all patterns [{name, path}]
         self._patterns = []           # all patterns [{name, path}]
         self._filtered_patterns = []  # current view
         self._filtered_patterns = []  # current view
         self._search_text = ""
         self._search_text = ""
-        self._previews = {}           # rel_path -> cached png path ("" = pending)
+        self._previews = {}           # rel_path -> cached png path ("" = none)
         self._rendering = set()       # rel_paths with an in-flight render
         self._rendering = set()       # rel_paths with an in-flight render
+        self._render_attempts = {}    # rel_path -> transient-failure count
+        self._warm_task = None        # background cache-warmer task
 
 
         self._client = FirmwareClient.instance()
         self._client = FirmwareClient.instance()
         self._client.baseUrlChanged.connect(self._on_table_changed)
         self._client.baseUrlChanged.connect(self._on_table_changed)
@@ -67,15 +69,15 @@ class PatternModel(QAbstractListModel):
 
 
     # ------------------------------------------------------------- previews
     # ------------------------------------------------------------- previews
     def _preview_for(self, rel_path):
     def _preview_for(self, rel_path):
-        """Return a cached preview path, kicking off a render if needed."""
+        """Return a cached preview path, kicking off a render if needed.
+
+        Runs on the GUI thread for every row the grid materializes, so it
+        must not touch the disk: the on-disk cache is folded into
+        ``self._previews`` in one scan per refresh (see ``_fetch_patterns``).
+        """
         cached = self._previews.get(rel_path)
         cached = self._previews.get(rel_path)
         if cached is not None:
         if cached is not None:
             return cached
             return cached
-        # Fast synchronous cache lookup on disk.
-        on_disk = thr_preview.cached_preview(self._client.base_url, rel_path)
-        if on_disk:
-            self._previews[rel_path] = on_disk
-            return on_disk
         # Not cached yet - render asynchronously and update the row later.
         # Not cached yet - render asynchronously and update the row later.
         self._schedule_render(rel_path)
         self._schedule_render(rel_path)
         return ""
         return ""
@@ -89,6 +91,13 @@ class PatternModel(QAbstractListModel):
         except RuntimeError:
         except RuntimeError:
             self._rendering.discard(rel_path)
             self._rendering.discard(rel_path)
 
 
+    # Transient fetch failures (timeouts, board busy) are retried with a
+    # delay; only a real result — a PNG path or a definitive "" (pattern has
+    # nothing to render) — is cached. Caching "" on a timeout used to leave
+    # tiles on "No Preview" forever.
+    _MAX_RENDER_ATTEMPTS = 3
+    _RETRY_DELAY_S = 10
+
     async def _render(self, rel_path):
     async def _render(self, rel_path):
         base_url = self._client.base_url
         base_url = self._client.base_url
         try:
         try:
@@ -97,6 +106,18 @@ class PatternModel(QAbstractListModel):
             self._rendering.discard(rel_path)
             self._rendering.discard(rel_path)
         if base_url != self._client.base_url:
         if base_url != self._client.base_url:
             return  # table changed under us; drop stale result
             return  # table changed under us; drop stale result
+        if path is None:
+            attempts = self._render_attempts.get(rel_path, 0) + 1
+            self._render_attempts[rel_path] = attempts
+            if attempts < self._MAX_RENDER_ATTEMPTS:
+                await asyncio.sleep(self._RETRY_DELAY_S)
+                if base_url == self._client.base_url:
+                    self._schedule_render(rel_path)
+            # else: leave uncached — scrolling back to the tile retries fresh
+            else:
+                self._render_attempts.pop(rel_path, None)
+            return
+        self._render_attempts.pop(rel_path, None)
         self._previews[rel_path] = path
         self._previews[rel_path] = path
         self._emit_preview_changed(rel_path)
         self._emit_preview_changed(rel_path)
 
 
@@ -109,8 +130,12 @@ class PatternModel(QAbstractListModel):
 
 
     # -------------------------------------------------------------- fetching
     # -------------------------------------------------------------- fetching
     def _on_table_changed(self, _base_url):
     def _on_table_changed(self, _base_url):
+        if self._warm_task is not None:
+            self._warm_task.cancel()
+            self._warm_task = None
         self._previews.clear()
         self._previews.clear()
         self._rendering.clear()
         self._rendering.clear()
+        self._render_attempts.clear()
         self.refresh()
         self.refresh()
 
 
     @Slot()
     @Slot()
@@ -137,7 +162,58 @@ class PatternModel(QAbstractListModel):
                 rel = rel[len("patterns/"):]
                 rel = rel[len("patterns/"):]
             patterns.append({"name": rel, "path": rel})
             patterns.append({"name": rel, "path": rel})
         patterns.sort(key=lambda x: x["name"].lower())
         patterns.sort(key=lambda x: x["name"].lower())
+
+        # Fold the on-disk preview cache into _previews with a single
+        # directory scan, off the GUI thread — data() must never hit the disk.
+        base_url = self._client.base_url
+        index = await asyncio.to_thread(thr_preview.preview_index, base_url)
+        if base_url != self._client.base_url:
+            return  # table changed under us; drop stale result
+        for pattern in patterns:
+            rel = pattern["name"]
+            if rel not in self._previews:
+                on_disk = index.get(thr_preview.safe_name(rel))
+                if on_disk:
+                    self._previews[rel] = on_disk
+
         self._apply_patterns(patterns)
         self._apply_patterns(patterns)
+        self._start_warmer()
+
+    # ------------------------------------------------------------ cache warm
+    def _start_warmer(self):
+        """Render the still-missing previews in the background, one at a time.
+
+        Without this, a fresh install shows placeholder dishes for the whole
+        first pass over the library. Only patterns with a local .thr are
+        warmed (no board I/O); board-only patterns stay lazy. Visible tiles
+        still render on demand and win the CPU cap's other slot.
+        """
+        if self._warm_task is not None:
+            self._warm_task.cancel()
+            self._warm_task = None
+        try:
+            self._warm_task = asyncio.get_event_loop().create_task(self._warm_previews())
+        except RuntimeError:
+            pass
+
+    async def _warm_previews(self):
+        base_url = self._client.base_url
+        warmed = 0
+        for pattern in list(self._patterns):
+            if base_url != self._client.base_url:
+                return  # table changed; the new fetch starts a fresh warmer
+            rel = pattern["name"]
+            if rel in self._previews or rel in self._rendering:
+                continue
+            if not await asyncio.to_thread(thr_preview.has_local_source, rel):
+                continue
+            self._rendering.add(rel)
+            await self._render(rel)
+            warmed += 1
+            # Breathe between renders so the warmer never monopolizes the pool.
+            await asyncio.sleep(0.1)
+        if warmed:
+            logger.info(f"Preview cache warmed: {warmed} patterns rendered")
 
 
     def _apply_patterns(self, patterns):
     def _apply_patterns(self, patterns):
         self.beginResetModel()
         self.beginResetModel()

+ 51 - 108
dune-weaver-touch/qml/components/BottomNavTab.qml

@@ -1,108 +1,51 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import "." as Components
-
-Rectangle {
-    property string icon: ""
-    property string text: ""
-    property bool active: false
-
-    signal clicked()
-
-    color: "transparent"
-
-    // Active indicator (blue bottom border)
-    Rectangle {
-        anchors.bottom: parent.bottom
-        width: parent.width
-        height: 3
-        color: active ? Components.ThemeManager.navIconActive : "transparent"
-
-        Behavior on color {
-            ColorAnimation { duration: 200 }
-        }
-    }
-
-    Column {
-        anchors.centerIn: parent
-        spacing: 4
-
-        // Icon (using emoji for cross-platform compatibility)
-        Text {
-            property string iconValue: parent.parent.icon
-            text: {
-                // Debug log the icon value
-
-                // Map icon names to Unicode symbols that work on Raspberry Pi
-                switch(iconValue) {
-                    case "search": return "⌕"      // U+2315 - Works better than magnifying glass
-                    case "list_alt": return "☰"    // U+2630 - Hamburger menu, widely supported
-                    case "table_chart": return "⚙"  // U+2699 - Gear without variant selector
-                    case "play_arrow": return "▶"   // U+25B6 - Play without variant selector
-                    case "lightbulb": return "☀"   // U+2600 - Sun symbol for LED
-                    default: {
-                        return "□"  // U+25A1 - Simple box, universally supported
-                    }
-                }
-            }
-            font.pixelSize: 20
-            font.family: "sans-serif"  // Use system sans-serif font
-            color: parent.parent.active ? Components.ThemeManager.navIconActive : Components.ThemeManager.navIconInactive
-            anchors.horizontalCenter: parent.horizontalCenter
-
-            Behavior on color {
-                ColorAnimation { duration: 200 }
-            }
-        }
-
-        // Label
-        Label {
-            text: parent.parent.text
-            font.pixelSize: 11
-            font.weight: Font.Medium
-            color: parent.parent.active ? Components.ThemeManager.navTextActive : Components.ThemeManager.navTextInactive
-            anchors.horizontalCenter: parent.horizontalCenter
-
-            Behavior on color {
-                ColorAnimation { duration: 200 }
-            }
-        }
-    }
-
-    // Touch feedback
-    Rectangle {
-        id: touchFeedback
-        anchors.fill: parent
-        color: Components.ThemeManager.darkMode ? "#404040" : "#f3f4f6"
-        opacity: 0
-        radius: 0
-        
-        NumberAnimation {
-            id: touchAnimation
-            target: touchFeedback
-            property: "opacity"
-            from: 0.3
-            to: 0
-            duration: 200
-            easing.type: Easing.OutQuad
-        }
-    }
-    
-    MouseArea {
-        anchors.fill: parent
-        onClicked: parent.clicked()
-        
-        onPressed: {
-            touchAnimation.stop()
-            touchFeedback.opacity = 0.3
-        }
-        
-        onReleased: {
-            touchAnimation.start()
-        }
-        
-        onCanceled: {
-            touchAnimation.start()
-        }
-    }
-}
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import "." as Components
+
+Rectangle {
+    id: tab
+
+    property string icon: ""
+    property string text: ""
+    property bool active: false
+
+    signal clicked()
+
+    color: touchArea.pressed ? Components.ThemeManager.pressedColor : "transparent"
+
+    Column {
+        anchors.centerIn: parent
+        spacing: 3
+
+        Components.Icon {
+            name: tab.icon
+            size: 22
+            color: tab.active ? Components.ThemeManager.navIconActive
+                              : Components.ThemeManager.navIconInactive
+            anchors.horizontalCenter: parent.horizontalCenter
+
+            Behavior on color {
+                ColorAnimation { duration: 200 }
+            }
+        }
+
+        Label {
+            text: tab.text
+            font.family: Components.ThemeManager.fontMedium
+            font.pixelSize: 11
+            color: tab.active ? Components.ThemeManager.navTextActive
+                              : Components.ThemeManager.navTextInactive
+            anchors.horizontalCenter: parent.horizontalCenter
+
+            Behavior on color {
+                ColorAnimation { duration: 200 }
+            }
+        }
+    }
+
+    MouseArea {
+        id: touchArea
+        anchors.fill: parent
+        onClicked: tab.clicked()
+    }
+}

+ 71 - 77
dune-weaver-touch/qml/components/BottomNavigation.qml

@@ -1,77 +1,71 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-import "." as Components
-
-Rectangle {
-    id: bottomNav
-
-    property int currentIndex: 0
-    signal tabClicked(int index)
-
-    height: 55
-    color: Components.ThemeManager.navBackground
-
-    // Top border to match web UI
-    Rectangle {
-        anchors.top: parent.top
-        width: parent.width
-        height: 1
-        color: Components.ThemeManager.navBorder
-    }
-    
-    RowLayout {
-        anchors.fill: parent
-        spacing: 0
-        
-        // Browse Tab
-        BottomNavTab {
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            icon: "search"
-            text: "Browse"
-            active: bottomNav.currentIndex === 0
-            onClicked: bottomNav.tabClicked(0)
-        }
-        
-        // Playlists Tab
-        BottomNavTab {
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            icon: "list_alt"
-            text: "Playlists"
-            active: bottomNav.currentIndex === 1
-            onClicked: bottomNav.tabClicked(1)
-        }
-        
-        // Table Control Tab
-        BottomNavTab {
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            icon: "table_chart"
-            text: "Control"
-            active: bottomNav.currentIndex === 2
-            onClicked: bottomNav.tabClicked(2)
-        }
-
-        // LED Control Tab (index 3)
-        BottomNavTab {
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            icon: "lightbulb"
-            text: "LED"
-            active: bottomNav.currentIndex === 3
-            onClicked: bottomNav.tabClicked(3)
-        }
-
-        // Execution Tab (index 4)
-        BottomNavTab {
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            icon: "play_arrow"
-            text: "Execution"
-            active: bottomNav.currentIndex === 4
-            onClicked: bottomNav.tabClicked(4)
-        }
-    }
-}
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import "." as Components
+
+Rectangle {
+    id: bottomNav
+
+    property int currentIndex: 0
+    signal tabClicked(int index)
+
+    height: Components.ThemeManager.navHeight
+    color: Components.ThemeManager.navBackground
+
+    Rectangle {
+        anchors.top: parent.top
+        width: parent.width
+        height: 1
+        color: Components.ThemeManager.navBorder
+    }
+
+    RowLayout {
+        anchors.fill: parent
+        spacing: 0
+
+        BottomNavTab {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            icon: "search"
+            text: "Browse"
+            active: bottomNav.currentIndex === 0
+            onClicked: bottomNav.tabClicked(0)
+        }
+
+        BottomNavTab {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            icon: "playlist_play"
+            text: "Playlists"
+            active: bottomNav.currentIndex === 1
+            onClicked: bottomNav.tabClicked(1)
+        }
+
+        BottomNavTab {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            icon: "tune"
+            text: "Control"
+            active: bottomNav.currentIndex === 2
+            onClicked: bottomNav.tabClicked(2)
+        }
+
+        BottomNavTab {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            icon: "light_mode"
+            text: "Light"
+            active: bottomNav.currentIndex === 3
+            onClicked: bottomNav.tabClicked(3)
+        }
+
+        BottomNavTab {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            icon: "play_circle"
+            text: "Now Playing"
+            active: bottomNav.currentIndex === 4
+            onClicked: bottomNav.tabClicked(4)
+        }
+    }
+}

+ 40 - 0
dune-weaver-touch/qml/components/ChoiceChip.qml

@@ -0,0 +1,40 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import "." as Components
+
+// Selectable pill chip — the one way any option is picked in this UI
+// (clear modes, LED effects, timeouts, pause lengths, ...).
+Rectangle {
+    id: chip
+
+    property string label: ""
+    property bool selected: false
+
+    signal clicked()
+
+    implicitHeight: 44
+    radius: height / 2
+    color: selected ? Components.ThemeManager.accentSoft
+                    : (chipArea.pressed ? Components.ThemeManager.pressedColor : "transparent")
+    border.width: 1
+    border.color: selected ? Components.ThemeManager.accent
+                           : Components.ThemeManager.borderColor
+
+    Label {
+        anchors.centerIn: parent
+        width: parent.width - Components.ThemeManager.spaceMd
+        text: chip.label
+        font.family: Components.ThemeManager.fontMedium
+        font.pixelSize: 13
+        color: chip.selected ? Components.ThemeManager.accent
+                             : Components.ThemeManager.textSecondary
+        elide: Text.ElideRight
+        horizontalAlignment: Text.AlignHCenter
+    }
+
+    MouseArea {
+        id: chipArea
+        anchors.fill: parent
+        onClicked: chip.clicked()
+    }
+}

+ 0 - 161
dune-weaver-touch/qml/components/ConnectionSplash.qml

@@ -1,161 +0,0 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-import "." as Components
-
-Rectangle {
-    id: root
-    anchors.fill: parent
-    color: Components.ThemeManager.backgroundColor
-    
-    property string statusText: "Connecting to backend..."
-    property bool showRetryButton: false
-    
-    signal retryConnection()
-    
-    ColumnLayout {
-        anchors.centerIn: parent
-        spacing: 30
-        width: Math.min(parent.width * 0.8, 400)
-        
-        // Logo/Title Area
-        Rectangle {
-            Layout.alignment: Qt.AlignHCenter
-            width: 120
-            height: 120
-            radius: 60
-            color: Components.ThemeManager.cardColor
-            border.color: "#4a90e2"
-            border.width: 3
-
-            Text {
-                anchors.centerIn: parent
-                text: "DW"
-                font.pixelSize: 36
-                font.bold: true
-                color: "#4a90e2"
-            }
-        }
-
-        Text {
-            Layout.alignment: Qt.AlignHCenter
-            text: "Dune Weaver Touch"
-            font.pixelSize: 32
-            font.bold: true
-            color: Components.ThemeManager.textPrimary
-        }
-
-        // Status Area
-        Rectangle {
-            Layout.alignment: Qt.AlignHCenter
-            Layout.preferredWidth: parent.width
-            Layout.preferredHeight: 80
-            color: Components.ThemeManager.cardColor
-            radius: 10
-            border.color: Components.ThemeManager.borderColor
-            border.width: 1
-            
-            RowLayout {
-                anchors.fill: parent
-                anchors.margins: 20
-                spacing: 15
-                
-                // Spinning loader
-                Rectangle {
-                    width: 40
-                    height: 40
-                    radius: 20
-                    color: "transparent"
-                    border.color: "#4a90e2"
-                    border.width: 3
-                    
-                    Rectangle {
-                        width: 8
-                        height: 8
-                        radius: 4
-                        color: "#4a90e2"
-                        anchors.top: parent.top
-                        anchors.horizontalCenter: parent.horizontalCenter
-                        anchors.topMargin: 2
-                        
-                        visible: !root.showRetryButton
-                    }
-                    
-                    RotationAnimation on rotation {
-                        running: !root.showRetryButton
-                        loops: Animation.Infinite
-                        from: 0
-                        to: 360
-                        duration: 2000
-                    }
-                }
-                
-                Text {
-                    Layout.fillWidth: true
-                    text: root.statusText
-                    font.pixelSize: 16
-                    color: Components.ThemeManager.textSecondary
-                    wrapMode: Text.WordWrap
-                    verticalAlignment: Text.AlignVCenter
-                }
-            }
-        }
-
-        // Retry Button (only show when connection fails)
-        Button {
-            Layout.alignment: Qt.AlignHCenter
-            visible: root.showRetryButton
-            text: "Retry Connection"
-            font.pixelSize: 16
-
-            background: Rectangle {
-                color: parent.pressed ? "#3a7bc8" : "#4a90e2"
-                radius: 8
-                border.color: "#5a9ff2"
-                border.width: 1
-
-                Behavior on color {
-                    ColorAnimation { duration: 150 }
-                }
-            }
-
-            contentItem: Text {
-                text: parent.text
-                font: parent.font
-                color: "white"
-                horizontalAlignment: Text.AlignHCenter
-                verticalAlignment: Text.AlignVCenter
-            }
-
-            onClicked: {
-                root.showRetryButton = false
-                root.retryConnection()
-            }
-        }
-
-        // Connection Help Text
-        Text {
-            Layout.alignment: Qt.AlignHCenter
-            Layout.preferredWidth: parent.width
-            text: "Waiting for backend connection... Make sure the Dune Weaver backend is running on this device."
-            font.pixelSize: 14
-            color: Components.ThemeManager.textTertiary
-            horizontalAlignment: Text.AlignHCenter
-            wrapMode: Text.WordWrap
-        }
-    }
-    
-    // Background animation - subtle pulse
-    Rectangle {
-        anchors.fill: parent
-        color: "#4a90e2"
-        opacity: 0.05
-        
-        SequentialAnimation on opacity {
-            running: !root.showRetryButton
-            loops: Animation.Infinite
-            NumberAnimation { to: 0.1; duration: 2000 }
-            NumberAnimation { to: 0.05; duration: 2000 }
-        }
-    }
-}

+ 174 - 54
dune-weaver-touch/qml/components/ConnectionStatus.qml

@@ -1,54 +1,174 @@
-import QtQuick 2.15
-
-Rectangle {
-    id: connectionDot
-    
-    property var backend: null
-    
-    width: 12
-    height: 12
-    radius: 6
-    
-    // Direct property binding to backend.serialConnected
-    color: {
-        if (!backend) {
-            return "#FF5722"  // Red if no backend
-        }
-        
-        var connected = backend.serialConnected
-        
-        if (connected === true) {
-            return "#4CAF50"  // Green if connected
-        } else {
-            return "#FF5722"  // Red if not connected
-        }
-    }
-    
-    // Listen for changes to trigger color update
-    Connections {
-        target: backend
-        
-        function onSerialConnectionChanged(connected) {
-            // The color binding will automatically update
-        }
-    }
-    
-    // Debug logging
-    Component.onCompleted: {
-        if (backend) {
-        }
-    }
-    
-    onBackendChanged: {
-        if (backend) {
-        }
-    }
-    
-    // Animate color changes
-    Behavior on color {
-        ColorAnimation {
-            duration: 300
-            easing.type: Easing.OutQuart
-        }
-    }
-}
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import "." as Components
+
+// Connection dot + the connected table's name (firmware hostname), shown in
+// every page header. Tapping it opens the table switcher: a dropdown of the
+// tables discovered on the network, one tap to jump between them.
+Rectangle {
+    id: connectionStatus
+
+    property var backend: null
+
+    readonly property bool connected: backend && backend.serialConnected
+    readonly property string tableName: connected ? (backend.tableName || "Table") : "No table"
+
+    implicitHeight: 40
+    implicitWidth: statusRow.implicitWidth + 2 * Components.ThemeManager.spaceMd
+    radius: height / 2
+    color: switchArea.pressed || tablePopup.opened
+           ? Components.ThemeManager.pressedColor : "transparent"
+
+    Row {
+        id: statusRow
+        anchors.centerIn: parent
+        spacing: 6
+
+        Rectangle {
+            width: 9
+            height: 9
+            radius: 4.5
+            anchors.verticalCenter: parent.verticalCenter
+            color: connectionStatus.connected ? Components.ThemeManager.ok
+                                              : Components.ThemeManager.danger
+
+            Behavior on color {
+                ColorAnimation {
+                    duration: 300
+                    easing.type: Easing.OutQuart
+                }
+            }
+        }
+
+        Text {
+            anchors.verticalCenter: parent.verticalCenter
+            text: connectionStatus.tableName
+            font.family: Components.ThemeManager.fontMedium
+            font.pixelSize: Components.ThemeManager.fontSizeCaption
+            color: Components.ThemeManager.textSecondary
+        }
+
+        Components.Icon {
+            anchors.verticalCenter: parent.verticalCenter
+            name: "expand_more"
+            size: 16
+            color: Components.ThemeManager.textTertiary
+        }
+    }
+
+    MouseArea {
+        id: switchArea
+        anchors.fill: parent
+        onClicked: {
+            if (!backend)
+                return
+            backend.refreshSerialPorts()  // fresh mDNS browse while the list shows
+            tablePopup.open()
+        }
+    }
+
+    Popup {
+        id: tablePopup
+        y: parent.height + Components.ThemeManager.spaceSm
+        x: 0
+        width: 300
+        padding: Components.ThemeManager.spaceMd
+        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+        background: Rectangle {
+            color: Components.ThemeManager.surfaceColor
+            radius: Components.ThemeManager.radiusMd
+            border.color: Components.ThemeManager.borderColor
+            border.width: 1
+        }
+
+        contentItem: ColumnLayout {
+            spacing: Components.ThemeManager.spaceSm
+
+            SectionLabel {
+                text: "Switch table"
+                Layout.leftMargin: Components.ThemeManager.spaceXs
+            }
+
+            Repeater {
+                model: backend ? backend.discoveredTables : []
+
+                delegate: Rectangle {
+                    required property var modelData
+                    readonly property bool isCurrent:
+                        connectionStatus.connected && modelData.url === backend.currentPort
+
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: 56
+                    radius: Components.ThemeManager.radiusSm
+                    color: isCurrent ? Components.ThemeManager.accentSoft
+                                     : (rowArea.pressed ? Components.ThemeManager.pressedColor
+                                                        : Components.ThemeManager.cardColor)
+                    border.width: 1
+                    border.color: isCurrent ? Components.ThemeManager.accent
+                                            : Components.ThemeManager.borderLight
+
+                    RowLayout {
+                        anchors.fill: parent
+                        anchors.leftMargin: Components.ThemeManager.spaceMd
+                        anchors.rightMargin: Components.ThemeManager.spaceMd
+                        spacing: Components.ThemeManager.spaceSm
+
+                        Column {
+                            Layout.fillWidth: true
+                            spacing: 1
+
+                            Label {
+                                text: modelData.name || modelData.url
+                                font.family: Components.ThemeManager.fontMedium
+                                font.pixelSize: Components.ThemeManager.fontSizeBody
+                                color: Components.ThemeManager.textPrimary
+                                elide: Text.ElideRight
+                                width: parent.width
+                            }
+
+                            Label {
+                                text: modelData.url
+                                font.family: Components.ThemeManager.fontBody
+                                font.pixelSize: 11
+                                color: Components.ThemeManager.textTertiary
+                                elide: Text.ElideRight
+                                width: parent.width
+                            }
+                        }
+
+                        Components.Icon {
+                            visible: parent.parent.isCurrent
+                            name: "check"
+                            size: 18
+                            color: Components.ThemeManager.accent
+                        }
+                    }
+
+                    MouseArea {
+                        id: rowArea
+                        anchors.fill: parent
+                        enabled: !parent.isCurrent
+                        onClicked: {
+                            if (backend)
+                                backend.connectSerial(parent.modelData.url)
+                            tablePopup.close()
+                        }
+                    }
+                }
+            }
+
+            Label {
+                visible: !backend || backend.discoveredTables.length === 0
+                text: "Searching your network for tables..."
+                font.family: Components.ThemeManager.fontBody
+                font.pixelSize: Components.ThemeManager.fontSizeCaption
+                color: Components.ThemeManager.textTertiary
+                Layout.fillWidth: true
+                Layout.margins: Components.ThemeManager.spaceXs
+                wrapMode: Text.WordWrap
+            }
+        }
+    }
+}

+ 36 - 0
dune-weaver-touch/qml/components/DwSlider.qml

@@ -0,0 +1,36 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import "." as Components
+
+// Slider with the ember handle — a 28px handle is grabbable with a fingertip.
+Slider {
+    id: control
+
+    implicitHeight: Components.ThemeManager.touchTarget
+
+    background: Rectangle {
+        x: control.leftPadding
+        y: control.topPadding + control.availableHeight / 2 - height / 2
+        width: control.availableWidth
+        height: 6
+        radius: 3
+        color: Components.ThemeManager.pressedColor
+
+        Rectangle {
+            width: control.visualPosition * parent.width
+            height: parent.height
+            radius: 3
+            color: Components.ThemeManager.accent
+        }
+    }
+
+    handle: Rectangle {
+        x: control.leftPadding + control.visualPosition * (control.availableWidth - width)
+        y: control.topPadding + control.availableHeight / 2 - height / 2
+        width: 28
+        height: 28
+        radius: 14
+        color: control.pressed ? Components.ThemeManager.accentPressed
+                               : Components.ThemeManager.accent
+    }
+}

+ 40 - 0
dune-weaver-touch/qml/components/DwSwitch.qml

@@ -0,0 +1,40 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import "." as Components
+
+// Toggle styled to the token system: ember when on, quiet when off.
+Switch {
+    id: control
+
+    implicitWidth: 56
+    implicitHeight: 32
+
+    indicator: Rectangle {
+        anchors.verticalCenter: parent.verticalCenter
+        width: 56
+        height: 32
+        radius: 16
+        color: control.checked ? Components.ThemeManager.accent
+                               : Components.ThemeManager.pressedColor
+        border.width: 1
+        border.color: control.checked ? Components.ThemeManager.accentPressed
+                                      : Components.ThemeManager.borderColor
+
+        Behavior on color {
+            ColorAnimation { duration: 150 }
+        }
+
+        Rectangle {
+            x: control.checked ? parent.width - width - 3 : 3
+            anchors.verticalCenter: parent.verticalCenter
+            width: 26
+            height: 26
+            radius: 13
+            color: control.checked ? Components.ThemeManager.onAccent : Components.ThemeManager.textSecondary
+
+            Behavior on x {
+                NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
+            }
+        }
+    }
+}

+ 50 - 0
dune-weaver-touch/qml/components/Icon.qml

@@ -0,0 +1,50 @@
+import QtQuick 2.15
+import "." as Components
+
+// Material Icons Round glyph by name (font bundled in fonts/, loaded in
+// main.py). Codepoints from MaterialIconsRound-Regular.codepoints - add
+// entries here as needed; names match the Material set.
+Text {
+    id: icon
+
+    property string name: ""
+    property real size: 22
+
+    readonly property var _glyphs: ({
+        "add": "\ue145",
+        "adjust": "\ue39e",
+        "arrow_back": "\ue2ea",
+        "brightness": "\ue3ab",
+        "check": "\ue5ca",
+        "circle": "\uef4a",
+        "close": "\ue5cd",
+        "delete": "\ue872",
+        "expand_more": "\ue5cf",
+        "home": "\ue88a",
+        "light_mode": "\ue518",
+        "lightbulb": "\ue0f0",
+        "music_note": "\ue405",
+        "pause": "\ue034",
+        "play_arrow": "\ue037",
+        "play_circle": "\ue1c4",
+        "playlist_play": "\ue05f",
+        "power": "\ue8ac",
+        "queue_music": "\ue03d",
+        "radio_unchecked": "\ue836",
+        "refresh": "\ue5d5",
+        "restart": "\uf053",
+        "search": "\ue8b6",
+        "shuffle": "\ue043",
+        "skip_next": "\ue044",
+        "stop": "\ue047",
+        "tune": "\ue429",
+        "wifi": "\ue63e"
+    })
+
+    text: _glyphs[name] || ""
+    font.family: Components.ThemeManager.fontIcon
+    font.pixelSize: size
+    color: Components.ThemeManager.textPrimary
+    verticalAlignment: Text.AlignVCenter
+    horizontalAlignment: Text.AlignHCenter
+}

+ 0 - 66
dune-weaver-touch/qml/components/KeyboardHelper.qml

@@ -1,66 +0,0 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-
-// Helper component to ensure virtual keyboard works properly
-Item {
-    id: keyboardHelper
-    
-    // Force show keyboard for a specific TextField
-    function showKeyboardFor(textField) {
-        textField.forceActiveFocus()
-        Qt.inputMethod.show()
-    }
-    
-    // Hide keyboard
-    function hideKeyboard() {
-        Qt.inputMethod.hide()
-    }
-    
-    // Enhanced TextField with proper keyboard support
-    component EnhancedTextField: TextField {
-        activeFocusOnPress: true
-        selectByMouse: true
-        
-        // Default input hints
-        inputMethodHints: Qt.ImhNone
-        
-        MouseArea {
-            anchors.fill: parent
-            onPressed: {
-                parent.forceActiveFocus()
-                Qt.inputMethod.show()
-                mouse.accepted = false
-            }
-        }
-        
-        onActiveFocusChanged: {
-            if (activeFocus) {
-                Qt.inputMethod.show()
-            }
-        }
-        
-        Keys.onReturnPressed: {
-            Qt.inputMethod.hide()
-            focus = false
-        }
-        
-        Keys.onEscapePressed: {
-            Qt.inputMethod.hide()
-            focus = false
-        }
-    }
-    
-    // Numeric-only TextField
-    component NumericTextField: EnhancedTextField {
-        inputMethodHints: Qt.ImhDigitsOnly | Qt.ImhNoPredictiveText
-        validator: IntValidator { bottom: 0; top: 9999 }
-        
-        // Only allow numeric input
-        onTextChanged: {
-            var numeric = text.replace(/[^0-9]/g, '')
-            if (text !== numeric) {
-                text = numeric
-            }
-        }
-    }
-}

+ 70 - 108
dune-weaver-touch/qml/components/ModernControlButton.qml

@@ -1,108 +1,70 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-// import QtQuick.Effects  // Disabled - causes issues on linuxfb
-
-Rectangle {
-    property alias text: buttonLabel.text
-    property string icon: ""
-    property color buttonColor: "#2196F3"
-    property bool enabled: true
-    property int fontSize: 16
-    property int iconSize: -1  // -1 means use fontSize + 2
-
-    signal clicked()
-
-    radius: 12
-    color: enabled ? buttonColor : "#E0E0E0"
-    opacity: enabled ? 1.0 : 0.6
-    border.width: 1
-    border.color: Qt.darker(buttonColor, 1.2)
-
-    // Gradient effect
-    gradient: Gradient {
-        GradientStop { position: 0; color: Qt.lighter(buttonColor, 1.1) }
-        GradientStop { position: 1; color: buttonColor }
-    }
-
-    // Press animation
-    scale: mouseArea.pressed ? 0.95 : (mouseArea.containsMouse ? 1.02 : 1.0)
-
-    Behavior on scale {
-        NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
-    }
-
-    Behavior on color {
-        ColorAnimation { duration: 200 }
-    }
-
-    // Shadow effect - disabled on linuxfb (causes rendering issues)
-    // Using border instead for visual separation
-    // layer.enabled: true
-    // layer.effect: MultiEffect {
-    //     shadowEnabled: true
-    //     shadowColor: "#25000000"
-    //     shadowBlur: 0.8
-    //     shadowVerticalOffset: 2
-    // }
-    
-    RowLayout {
-        anchors.centerIn: parent
-        spacing: 8
-        
-        Text {
-            text: parent.parent.icon
-            font.pixelSize: parent.parent.iconSize > 0 ? parent.parent.iconSize : parent.parent.fontSize + 2
-            color: "white"
-            visible: parent.parent.icon !== ""
-        }
-        
-        Label {
-            id: buttonLabel
-            color: "white"
-            font.pixelSize: parent.parent.fontSize
-            font.bold: true
-        }
-    }
-    
-    MouseArea {
-        id: mouseArea
-        anchors.fill: parent
-        hoverEnabled: true
-        enabled: parent.enabled
-        onClicked: parent.clicked()
-        
-        // Ripple effect
-        Rectangle {
-            id: ripple
-            width: 0
-            height: 0
-            radius: width / 2
-            color: "#40FFFFFF"
-            anchors.centerIn: parent
-            
-            NumberAnimation {
-                id: rippleAnimation
-                target: ripple
-                property: "width"
-                from: 0
-                to: parent.width * 1.2
-                duration: 400
-                easing.type: Easing.OutQuad
-                
-                onFinished: {
-                    ripple.width = 0
-                    ripple.height = 0
-                }
-            }
-            
-            Connections {
-                target: mouseArea
-                function onPressed() {
-                    ripple.height = ripple.width
-                    rippleAnimation.start()
-                }
-            }
-        }
-    }
-}
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import "." as Components
+
+// Pill button — flat fill, darker while pressed, no hover states (touch-only
+// device) and no effects layers (linuxfb). `icon` takes a Material icon name
+// resolved by Icon.qml. `outlined` renders the quiet variant: transparent
+// fill, buttonColor ring + label.
+Rectangle {
+    id: root
+
+    property alias text: buttonLabel.text
+    property string icon: ""
+    property color buttonColor: Components.ThemeManager.accent
+    // Fill variant picks label contrast from the fill's lightness, so amber
+    // gets ink text and rust/sage get bone automatically.
+    property color textColor: outlined
+                              ? buttonColor
+                              : (buttonColor.hslLightness > 0.55 ? "#241a0c" : "#fdf8ee")
+    property bool outlined: false
+    property bool enabled: true
+    property int fontSize: Components.ThemeManager.fontSizeBody
+    property int iconSize: -1  // -1 means fontSize + 4
+
+    signal clicked()
+
+    implicitHeight: Components.ThemeManager.touchTarget
+    radius: height / 2
+    color: {
+        if (outlined)
+            return mouseArea.pressed ? Components.ThemeManager.pressedColor : "transparent"
+        return mouseArea.pressed ? Qt.darker(buttonColor, 1.15) : buttonColor
+    }
+    opacity: enabled ? 1.0 : 0.45
+    border.width: outlined ? 1 : 0
+    border.color: buttonColor
+
+    scale: mouseArea.pressed ? 0.97 : 1.0
+    Behavior on scale {
+        NumberAnimation { duration: 100; easing.type: Easing.OutQuad }
+    }
+
+    RowLayout {
+        anchors.centerIn: parent
+        spacing: Components.ThemeManager.spaceSm
+
+        Components.Icon {
+            name: root.icon
+            size: root.iconSize > 0 ? root.iconSize : root.fontSize + 4
+            color: root.textColor
+            visible: root.icon !== ""
+        }
+
+        Label {
+            id: buttonLabel
+            color: root.textColor
+            font.family: Components.ThemeManager.fontDisplay
+            font.pixelSize: root.fontSize
+            visible: text !== ""
+        }
+    }
+
+    MouseArea {
+        id: mouseArea
+        anchors.fill: parent
+        enabled: root.enabled
+        onClicked: root.clicked()
+    }
+}

+ 103 - 156
dune-weaver-touch/qml/components/ModernPatternCard.qml

@@ -1,156 +1,103 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-// import QtQuick.Effects  // Disabled - MultiEffect causes issues on linuxfb
-import "." as Components
-
-Rectangle {
-    property string name: ""
-    property alias preview: previewImage.source
-
-    // Clean up the pattern name for display
-    property string cleanName: {
-        var cleanedName = name
-        // Remove path (get everything after the last slash)
-        var parts = cleanedName.split('/')
-        cleanedName = parts[parts.length - 1]
-        // Remove .thr extension
-        cleanedName = cleanedName.replace('.thr', '')
-        return cleanedName
-    }
-
-    signal clicked()
-
-    color: Components.ThemeManager.surfaceColor
-    radius: 12
-    border.width: 1
-    border.color: Components.ThemeManager.borderColor
-
-    // Drop shadow effect - disabled on linuxfb (causes rendering issues)
-    // Using simple border instead for compatibility
-    // layer.enabled: true
-    // layer.effect: MultiEffect {
-    //     shadowEnabled: true
-    //     shadowColor: "#20000000"
-    //     shadowBlur: 0.8
-    //     shadowVerticalOffset: 2
-    //     shadowHorizontalOffset: 0
-    // }
-    
-    // Hover/press animation
-    scale: mouseArea.pressed ? 0.95 : (mouseArea.containsMouse ? 1.02 : 1.0)
-    
-    Behavior on scale {
-        NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
-    }
-    
-    Column {
-        anchors.fill: parent
-        anchors.margins: 8
-        spacing: 6
-        
-        // Preview image container
-        Rectangle {
-            width: parent.width
-            height: parent.height - nameLabel.height - 12
-            radius: 8
-            color: Components.ThemeManager.previewBackground
-            clip: true
-            
-            Image {
-                id: previewImage
-                anchors.fill: parent
-                fillMode: Image.PreserveAspectFit
-                source: preview ? "file:///" + preview : ""
-                smooth: true
-                
-                // Loading animation
-                opacity: status === Image.Ready ? 1 : 0
-                Behavior on opacity {
-                    NumberAnimation { duration: 200 }
-                }
-            }
-            
-            // Placeholder when no preview
-            Rectangle {
-                anchors.fill: parent
-                color: Components.ThemeManager.placeholderBackground
-                visible: previewImage.status === Image.Error || previewImage.source == ""
-                radius: 8
-
-                Column {
-                    anchors.centerIn: parent
-                    spacing: 8
-
-                    Text {
-                        text: "◻"
-                        font.pixelSize: 32
-                        anchors.horizontalCenter: parent.horizontalCenter
-                        color: Components.ThemeManager.placeholderText
-                    }
-
-                    Text {
-                        text: "No Preview"
-                        anchors.horizontalCenter: parent.horizontalCenter
-                        color: Components.ThemeManager.textTertiary
-                        font.pixelSize: 12
-                    }
-                }
-            }
-        }
-
-        // Pattern name
-        Label {
-            id: nameLabel
-            text: cleanName
-            width: parent.width
-            elide: Label.ElideRight
-            horizontalAlignment: Label.AlignHCenter
-            font.pixelSize: 13
-            font.weight: Font.Medium
-            color: Components.ThemeManager.textPrimary
-            wrapMode: Text.Wrap
-            maximumLineCount: 2
-        }
-    }
-    
-    // Click area
-    MouseArea {
-        id: mouseArea
-        anchors.fill: parent
-        hoverEnabled: true
-        onClicked: parent.clicked()
-        
-        // Ripple effect on click
-        Rectangle {
-            id: ripple
-            width: 0
-            height: 0
-            radius: width / 2
-            color: "#20000000"
-            anchors.centerIn: parent
-            
-            NumberAnimation {
-                id: rippleAnimation
-                target: ripple
-                property: "width"
-                from: 0
-                to: mouseArea.width * 1.5
-                duration: 300
-                easing.type: Easing.OutQuad
-                
-                onFinished: {
-                    ripple.width = 0
-                    ripple.height = 0
-                }
-            }
-            
-            Connections {
-                target: mouseArea
-                function onPressed() {
-                    ripple.height = ripple.width
-                    rippleAnimation.start()
-                }
-            }
-        }
-    }
-}
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import "." as Components
+
+// Pattern card: the preview PNG is a circular sand dish (thr_preview.py
+// renders the disc with transparent corners), so it sits directly on the
+// card surface — no boxed frame around round art.
+Rectangle {
+    id: card
+
+    property string name: ""
+    property alias preview: previewImage.source
+
+    // Clean up the pattern name for display
+    property string cleanName: {
+        var cleanedName = name
+        // Remove path (get everything after the last slash)
+        var parts = cleanedName.split('/')
+        cleanedName = parts[parts.length - 1]
+        // Remove .thr extension
+        cleanedName = cleanedName.replace('.thr', '')
+        return cleanedName
+    }
+
+    signal clicked()
+
+    color: mouseArea.pressed ? Components.ThemeManager.pressedColor
+                             : Components.ThemeManager.surfaceColor
+    radius: Components.ThemeManager.radiusMd
+    border.width: 1
+    border.color: Components.ThemeManager.borderColor
+
+    scale: mouseArea.pressed ? 0.97 : 1.0
+    Behavior on scale {
+        NumberAnimation { duration: 100; easing.type: Easing.OutQuad }
+    }
+
+    Column {
+        anchors.fill: parent
+        anchors.margins: Components.ThemeManager.spaceSm
+        spacing: Components.ThemeManager.spaceXs
+
+        Item {
+            width: parent.width
+            height: parent.height - nameLabel.height - Components.ThemeManager.spaceXs
+
+            Image {
+                id: previewImage
+                anchors.fill: parent
+                fillMode: Image.PreserveAspectFit
+                source: preview ? "file:///" + preview : ""
+                smooth: true
+                // Decode off the GUI thread, at grid-cell resolution — a
+                // full 512px decode per tile is what makes flicking stutter.
+                asynchronous: true
+                sourceSize.width: 200
+                sourceSize.height: 200
+
+                opacity: status === Image.Ready ? 1 : 0
+                Behavior on opacity {
+                    NumberAnimation { duration: 200 }
+                }
+            }
+
+            // Placeholder dish until the preview is decoded (or on failure)
+            Rectangle {
+                anchors.centerIn: parent
+                width: Math.min(parent.width, parent.height)
+                height: width
+                radius: width / 2
+                color: Components.ThemeManager.cardColor
+                border.width: 1
+                border.color: Components.ThemeManager.borderColor
+                visible: previewImage.status !== Image.Ready
+
+                Components.Icon {
+                    anchors.centerIn: parent
+                    name: "radio_unchecked"
+                    size: 28
+                    color: Components.ThemeManager.placeholderText
+                }
+            }
+        }
+
+        Label {
+            id: nameLabel
+            text: cleanName
+            width: parent.width
+            elide: Label.ElideRight
+            horizontalAlignment: Label.AlignHCenter
+            font.family: Components.ThemeManager.fontMedium
+            font.pixelSize: 13
+            color: Components.ThemeManager.textPrimary
+            maximumLineCount: 1
+        }
+    }
+
+    MouseArea {
+        id: mouseArea
+        anchors.fill: parent
+        onClicked: card.clicked()
+    }
+}

+ 0 - 53
dune-weaver-touch/qml/components/PatternCard.qml

@@ -1,53 +0,0 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-
-Rectangle {
-    property alias name: nameLabel.text
-    property alias preview: previewImage.source
-    
-    signal clicked()
-    
-    color: mouseArea.pressed ? "#e0e0e0" : "#f5f5f5"
-    radius: 8
-    border.color: "#d0d0d0"
-    
-    Column {
-        anchors.fill: parent
-        anchors.margins: 10
-        spacing: 10
-        
-        Image {
-            id: previewImage
-            width: parent.width
-            height: parent.height - nameLabel.height - 10
-            fillMode: Image.PreserveAspectFit
-            source: preview ? "file:///" + preview : ""
-            
-            Rectangle {
-                anchors.fill: parent
-                color: "#f0f0f0"
-                visible: previewImage.status === Image.Error || previewImage.source == ""
-                
-                Text {
-                    anchors.centerIn: parent
-                    text: "No Preview"
-                    color: "#999"
-                }
-            }
-        }
-        
-        Label {
-            id: nameLabel
-            width: parent.width
-            elide: Label.ElideRight
-            horizontalAlignment: Label.AlignHCenter
-            font.pixelSize: 12
-        }
-    }
-    
-    MouseArea {
-        id: mouseArea
-        anchors.fill: parent
-        onClicked: parent.clicked()
-    }
-}

+ 12 - 0
dune-weaver-touch/qml/components/SectionLabel.qml

@@ -0,0 +1,12 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import "." as Components
+
+// Uppercase eyebrow used as a section title on settings-style pages.
+Label {
+    font.family: Components.ThemeManager.fontDisplay
+    font.pixelSize: 11
+    font.letterSpacing: 1.4
+    font.capitalization: Font.AllUppercase
+    color: Components.ThemeManager.textTertiary
+}

+ 15 - 0
dune-weaver-touch/qml/components/SettingsCard.qml

@@ -0,0 +1,15 @@
+import QtQuick 2.15
+import QtQuick.Layouts 1.15
+import "." as Components
+
+// Surface card for one settings section; meant to sit in a ColumnLayout.
+Rectangle {
+    Layout.fillWidth: true
+    Layout.leftMargin: Components.ThemeManager.spaceLg
+    Layout.rightMargin: Components.ThemeManager.spaceLg
+    Layout.topMargin: Components.ThemeManager.spaceMd
+    radius: Components.ThemeManager.radiusMd
+    color: Components.ThemeManager.surfaceColor
+    border.width: 1
+    border.color: Components.ThemeManager.borderLight
+}

+ 116 - 76
dune-weaver-touch/qml/components/ThemeManager.qml

@@ -1,76 +1,116 @@
-pragma Singleton
-import QtQuick 2.15
-import Qt.labs.settings 1.0
-
-QtObject {
-    id: themeManager
-
-    // Theme state - loaded from settings
-    property bool darkMode: settings.darkMode
-
-    // Background colors
-    property color backgroundColor: darkMode ? "#1a1a1a" : "#f5f5f5"
-    property color surfaceColor: darkMode ? "#2d2d2d" : "#ffffff"
-    property color cardColor: darkMode ? "#3d3d3d" : "#f8f9fa"
-
-    // Text colors
-    property color textPrimary: darkMode ? "#ffffff" : "#333333"
-    property color textSecondary: darkMode ? "#b0b0b0" : "#666666"
-    property color textTertiary: darkMode ? "#808080" : "#999999"
-
-    // Border colors
-    property color borderColor: darkMode ? "#4d4d4d" : "#e5e7eb"
-    property color borderLight: darkMode ? "#3d3d3d" : "#f0f0f0"
-
-    // Accent colors (consistent in both themes)
-    property color accentBlue: "#2563eb"
-    property color accentBlueHover: "#1e40af"
-    property color accentRed: "#dc2626"
-    property color accentRedHover: "#b91c1c"
-    property color accentGray: "#6b7280"
-    property color accentGrayHover: "#525252"
-    property color accentGrayDisabled: "#9ca3af"
-
-    // Control colors
-    property color buttonBackground: darkMode ? "#3d3d3d" : "#f0f0f0"
-    property color buttonBackgroundHover: darkMode ? "#4d4d4d" : "#e0e0e0"
-    property color buttonBorder: darkMode ? "#5d5d5d" : "#cccccc"
-
-    // Selected/Active colors
-    property color selectedBackground: "#2196F3"
-    property color selectedBorder: "#1976D2"
-
-    // Placeholder colors
-    property color placeholderBackground: darkMode ? "#2d2d2d" : "#f0f0f0"
-    property color placeholderText: darkMode ? "#9a9a9a" : "#999999"
-
-    // Preview background - lighter in dark mode for better pattern visibility
-    property color previewBackground: darkMode ? "#707070" : "#f8f9fa"
-
-    // Shadow colors
-    property color shadowColor: darkMode ? "#000000" : "#00000020"
-
-    // Navigation colors
-    property color navBackground: darkMode ? "#1f1f1f" : "#ffffff"
-    property color navBorder: darkMode ? "#3d3d3d" : "#e5e7eb"
-    property color navIconActive: "#2196F3"
-    property color navIconInactive: darkMode ? "#808080" : "#9ca3af"
-    property color navTextActive: darkMode ? "#ffffff" : "#333333"
-    property color navTextInactive: darkMode ? "#808080" : "#666666"
-
-    // Persistent settings
-    property Settings settings: Settings {
-        category: "Appearance"
-        property bool darkMode: false  // Default to light mode
-    }
-
-    onDarkModeChanged: {
-        // Save preference
-        settings.darkMode = darkMode
-    }
-
-    // Helper function to get contrast color
-    function getContrastColor(baseColor) {
-        return darkMode ? Qt.lighter(baseColor, 1.2) : Qt.darker(baseColor, 1.1)
-    }
-}
+pragma Singleton
+import QtQuick 2.15
+import Qt.labs.settings 1.0
+
+// Design tokens for the touch UI — "the table at night".
+// Night: warm basalt ground, bone text, one amber accent (the LED glow).
+// Day: warm sand ground instead of inverted gray. Same ember accent family.
+// Every color, size, and spacing in the QML goes through this singleton.
+QtObject {
+    id: themeManager
+
+    // Theme state - loaded from settings
+    property bool darkMode: settings.darkMode
+
+    // ---- Typography (bundled in fonts/, registered in main.py) ----
+    // Static instances carry legacy family names, hence three families.
+    readonly property string fontBody: "Outfit"
+    readonly property string fontMedium: "Outfit Medium"
+    readonly property string fontDisplay: "Outfit SemiBold"
+    readonly property string fontIcon: "Material Icons Round"
+
+    // Type scale — the panel is read at arm's length, so nothing under 12.
+    readonly property int fontSizeCaption: 12   // eyebrows, meta, counts
+    readonly property int fontSizeBody: 14      // controls, list rows
+    readonly property int fontSizeTitle: 17     // page titles, card titles
+    readonly property int fontSizeDisplay: 24   // the one big thing per page
+
+    // ---- Layout ----
+    readonly property int spaceXs: 4
+    readonly property int spaceSm: 8
+    readonly property int spaceMd: 12
+    readonly property int spaceLg: 16
+    readonly property int spaceXl: 24
+    readonly property int radiusSm: 10
+    readonly property int radiusMd: 14
+    readonly property int radiusPill: 999       // circles/pills: the table's geometry
+    readonly property int touchTarget: 48       // minimum hit size, fingertips + sand
+    readonly property int controlHeight: 56     // primary transport controls
+    readonly property int headerHeight: 60
+    readonly property int navHeight: 64
+
+    // ---- Surfaces ----
+    property color backgroundColor: darkMode ? "#171310" : "#ece5d6"   // basalt / dune
+    property color surfaceColor: darkMode ? "#201b16" : "#f5f0e5"
+    property color cardColor: darkMode ? "#2a241d" : "#e4dcca"
+    property color pressedColor: darkMode ? "#332c24" : "#d9cfba"
+
+    // ---- Text ----
+    property color textPrimary: darkMode ? "#ece4d3" : "#332c22"       // bone / ink
+    property color textSecondary: darkMode ? "#a39885" : "#7c7161"
+    property color textTertiary: darkMode ? "#6e6455" : "#a89d8a"
+
+    // ---- Borders ----
+    property color borderColor: darkMode ? "#362f26" : "#d6ccb7"
+    property color borderLight: darkMode ? "#2b2620" : "#e0d8c6"
+
+    // ---- Accent: ember (the LED ring's glow) ----
+    property color accent: darkMode ? "#e2a860" : "#b0791f"
+    property color accentPressed: darkMode ? "#c98f49" : "#8f6014"
+    property color onAccent: darkMode ? "#241a0c" : "#fdf8ee"
+    // Subtle amber-tinted fill for selected chips/rows
+    property color accentSoft: darkMode ? "#3a2f1e" : "#eadfc2"
+
+    // ---- Semantic ----
+    property color ok: darkMode ? "#9db07f" : "#5f7a3f"                 // connected, running
+    property color okSoft: darkMode ? "#28301f" : "#e2e6d2"
+    property color danger: darkMode ? "#c65a33" : "#b0431d"             // stop, delete, alarm
+    property color dangerPressed: darkMode ? "#a84a28" : "#8f3517"
+
+    // ---- Legacy aliases (older call sites; prefer the tokens above) ----
+    property color accentBlue: accent
+    property color accentBlueHover: accentPressed
+    property color accentRed: danger
+    property color accentRedHover: dangerPressed
+    property color accentGray: textSecondary
+    property color accentGrayHover: textPrimary
+    property color accentGrayDisabled: textTertiary
+    property color buttonBackground: cardColor
+    property color buttonBackgroundHover: pressedColor
+    property color buttonBorder: borderColor
+    property color selectedBackground: accent
+    property color selectedBorder: accentPressed
+
+    // Placeholder / preview
+    property color placeholderBackground: cardColor
+    property color placeholderText: textTertiary
+    // Previews carry their own dark circular dish (thr_preview.py) and sit
+    // directly on the page surface — no boxed backdrop.
+    property color previewBackground: backgroundColor
+
+    property color shadowColor: darkMode ? "#000000" : "#00000020"
+
+    // Navigation
+    property color navBackground: surfaceColor
+    property color navBorder: borderColor
+    property color navIconActive: accent
+    property color navIconInactive: textSecondary
+    property color navTextActive: accent
+    property color navTextInactive: textSecondary
+
+    // Persistent settings
+    property Settings settings: Settings {
+        category: "Appearance"
+        property bool darkMode: true  // night is the default: this screen lives on furniture
+    }
+
+    onDarkModeChanged: {
+        // Save preference
+        settings.darkMode = darkMode
+    }
+
+    // Helper function to get contrast color
+    function getContrastColor(baseColor) {
+        return darkMode ? Qt.lighter(baseColor, 1.2) : Qt.darker(baseColor, 1.1)
+    }
+}

+ 0 - 53
dune-weaver-touch/qml/components/VirtualKeyboardLoader.qml

@@ -1,53 +0,0 @@
-import QtQuick 2.15
-import QtQuick.VirtualKeyboard 2.15
-import QtQuick.VirtualKeyboard.Settings 2.15
-
-Item {
-    id: keyboardLoader
-    
-    // Configure keyboard settings
-    Component.onCompleted: {
-        // Set keyboard style (can be "default", "retro", etc.)
-        VirtualKeyboardSettings.styleName = "default"
-        
-        // Set available locales (languages)
-        VirtualKeyboardSettings.activeLocales = ["en_US"]
-        
-        // Enable word candidate list
-        VirtualKeyboardSettings.wordCandidateList.enabled = true
-        
-        // Set keyboard height (as percentage of screen)
-        VirtualKeyboardSettings.fullScreenMode = false
-    }
-    
-    InputPanel {
-        id: inputPanel
-        z: 99999
-        y: window.height
-        anchors.left: parent.left
-        anchors.right: parent.right
-        
-        states: State {
-            name: "visible"
-            when: inputPanel.active
-            PropertyChanges {
-                target: inputPanel
-                y: window.height - inputPanel.height
-            }
-        }
-        
-        transitions: Transition {
-            from: ""
-            to: "visible"
-            reversible: true
-            ParallelAnimation {
-                NumberAnimation {
-                    target: inputPanel
-                    property: "y"
-                    duration: 250
-                    easing.type: Easing.InOutQuad
-                }
-            }
-        }
-    }
-}

+ 34 - 53
dune-weaver-touch/qml/main.qml

@@ -1,7 +1,6 @@
 import QtQuick 2.15
 import QtQuick 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Layouts 1.15
 import QtQuick.Layouts 1.15
-import QtQuick.Dialogs
 import QtQuick.VirtualKeyboard 2.15
 import QtQuick.VirtualKeyboard 2.15
 import DuneWeaver 1.0
 import DuneWeaver 1.0
 import "components"
 import "components"
@@ -51,27 +50,23 @@ ApplicationWindow {
             // Navigate to Execution tab (index 3) instead of pushing page
             // Navigate to Execution tab (index 3) instead of pushing page
             shouldNavigateToExecution = true
             shouldNavigateToExecution = true
         }
         }
-        
-        onErrorOccurred: function(error) {
-            // Use custom dialog on Pi 5 for proper rotation
-            if (typeof rotateDisplay !== 'undefined' && rotateDisplay) {
-                customErrorDialog.errorText = error
-                customErrorDialog.open()
-            } else {
-                errorDialog.text = error
-                errorDialog.open()
+
+        // Preview rendered after the pattern started (it wasn't cached yet)
+        onPatternPreviewReady: function(patternName, preview) {
+            if (patternName === window.currentPatternName) {
+                window.currentPatternPreview = preview
             }
             }
         }
         }
         
         
-        onScreenStateChanged: function(isOn) {
+        onErrorOccurred: function(error) {
+            // Always the in-scene themed dialog: the native MessageDialog
+            // renders as an unreadable empty box on some platforms and can't
+            // follow the Pi 5's 180° rotation.
+            customErrorDialog.errorText = error
+            customErrorDialog.open()
         }
         }
         
         
-        onBackendConnectionChanged: function(connected) {
-            if (connected && stackView.currentItem.toString().indexOf("ConnectionSplash") !== -1) {
-                stackView.replace(mainSwipeView)
-            } else if (!connected && stackView.currentItem.toString().indexOf("ConnectionSplash") === -1) {
-                stackView.replace(connectionSplash)
-            }
+        onScreenStateChanged: function(isOn) {
         }
         }
     }
     }
     
     
@@ -109,21 +104,11 @@ ApplicationWindow {
     StackView {
     StackView {
         id: stackView
         id: stackView
         anchors.fill: parent
         anchors.fill: parent
-        initialItem: backend.backendConnected ? mainSwipeView : connectionSplash
-        
-        Component {
-            id: connectionSplash
-            
-            ConnectionSplash {
-                statusText: backend.reconnectStatus
-                showRetryButton: backend.reconnectStatus === "Cannot connect to backend"
-                
-                onRetryConnection: {
-                    backend.retryConnection()
-                }
-            }
-        }
-        
+        // The UI is always usable regardless of table reachability; the
+        // per-page ConnectionStatus dot (green/red) reflects the link state
+        // and Table Control handles picking/reconnecting a table.
+        initialItem: mainSwipeView
+
         Component {
         Component {
             id: mainSwipeView
             id: mainSwipeView
             
             
@@ -241,30 +226,22 @@ ApplicationWindow {
         }
         }
     }
     }
 
 
-    // Error dialog - note: MessageDialog is a system dialog, rotation may not work
-    // If rotation doesn't work, we'll need to replace with a custom Dialog
-    MessageDialog {
-        id: errorDialog
-        title: "Error"
-        buttons: MessageDialog.Ok
-    }
-
-    // Custom error dialog as fallback for Pi 5 rotation
+    // Error dialog (in-scene so it themes and rotates with the UI)
     Popup {
     Popup {
         id: customErrorDialog
         id: customErrorDialog
         modal: true
         modal: true
         x: (window.width - width) / 2
         x: (window.width - width) / 2
         y: (window.height - height) / 2
         y: (window.height - height) / 2
-        width: 320
-        height: 180
+        width: 380
+        height: Math.max(190, errorColumn.implicitHeight + 50)
         closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
         closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
 
 
         property string errorText: ""
         property string errorText: ""
 
 
         background: Rectangle {
         background: Rectangle {
-            color: "#2d2d2d"
-            radius: 12
-            border.color: "#404040"
+            color: Components.ThemeManager.surfaceColor
+            radius: Components.ThemeManager.radiusMd
+            border.color: Components.ThemeManager.borderColor
             border.width: 1
             border.width: 1
 
 
             // Rotate the entire dialog content for Pi 5
             // Rotate the entire dialog content for Pi 5
@@ -277,15 +254,16 @@ ApplicationWindow {
             transformOrigin: Item.Center
             transformOrigin: Item.Center
 
 
             Column {
             Column {
+                id: errorColumn
                 anchors.fill: parent
                 anchors.fill: parent
                 anchors.margins: 20
                 anchors.margins: 20
                 spacing: 15
                 spacing: 15
 
 
                 Label {
                 Label {
-                    text: "Error"
-                    font.pixelSize: 18
-                    font.bold: true
-                    color: "#ff6b6b"
+                    text: "Something went wrong"
+                    font.family: Components.ThemeManager.fontDisplay
+                    font.pixelSize: Components.ThemeManager.fontSizeTitle
+                    color: Components.ThemeManager.danger
                     anchors.horizontalCenter: parent.horizontalCenter
                     anchors.horizontalCenter: parent.horizontalCenter
                 }
                 }
 
 
@@ -294,12 +272,15 @@ ApplicationWindow {
                     wrapMode: Text.WordWrap
                     wrapMode: Text.WordWrap
                     width: parent.width
                     width: parent.width
                     horizontalAlignment: Text.AlignHCenter
                     horizontalAlignment: Text.AlignHCenter
-                    color: "#ffffff"
-                    font.pixelSize: 14
+                    color: Components.ThemeManager.textPrimary
+                    font.family: Components.ThemeManager.fontBody
+                    font.pixelSize: Components.ThemeManager.fontSizeBody
                 }
                 }
 
 
-                Button {
+                Components.ModernControlButton {
                     text: "OK"
                     text: "OK"
+                    width: 120
+                    height: Components.ThemeManager.touchTarget
                     anchors.horizontalCenter: parent.horizontalCenter
                     anchors.horizontalCenter: parent.horizontalCenter
                     onClicked: customErrorDialog.close()
                     onClicked: customErrorDialog.close()
                 }
                 }

+ 437 - 462
dune-weaver-touch/qml/pages/ExecutionPage.qml

@@ -1,462 +1,437 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-import Qt.labs.folderlistmodel 2.15
-import "../components"
-import "../components" as Components
-
-Page {
-    id: page
-    property var backend: null
-    property var stackView: null
-    property string patternName: ""
-    property string patternPreview: ""  // Backend provides this via executionStarted signal
-    
-    // Debug backend connection
-    onBackendChanged: {
-        if (backend) {
-        }
-    }
-    
-    Component.onCompleted: {
-        if (backend) {
-        }
-    }
-    
-    // Direct connection to backend signals
-    Connections {
-        target: backend
-
-        function onSerialConnectionChanged(connected) {
-        }
-
-        function onConnectionChanged() {
-            if (backend) {
-            }
-        }
-
-        function onExecutionStarted(fileName, preview) {
-            // Update preview directly from backend signal
-            patternName = fileName
-            patternPreview = preview
-        }
-    }
-    
-
-    Rectangle {
-        anchors.fill: parent
-        color: Components.ThemeManager.backgroundColor
-    }
-
-    ColumnLayout {
-        anchors.fill: parent
-        spacing: 0
-
-        // Header (consistent with other pages)
-        Rectangle {
-            Layout.fillWidth: true
-            Layout.preferredHeight: 50
-            color: Components.ThemeManager.surfaceColor
-
-            // Bottom border
-            Rectangle {
-                anchors.bottom: parent.bottom
-                width: parent.width
-                height: 1
-                color: Components.ThemeManager.borderColor
-            }
-
-            RowLayout {
-                anchors.fill: parent
-                anchors.leftMargin: 15
-                anchors.rightMargin: 10
-
-                ConnectionStatus {
-                    backend: page.backend
-                    Layout.rightMargin: 8
-                }
-
-                Label {
-                    text: "Pattern Execution"
-                    font.pixelSize: 18
-                    font.bold: true
-                    color: Components.ThemeManager.textPrimary
-                }
-
-                Item {
-                    Layout.fillWidth: true
-                }
-            }
-        }
-        
-        // Content - Side by side layout
-        Item {
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            
-            Row {
-                anchors.fill: parent
-                spacing: 0
-                
-                // Left side - Pattern Preview (60% of width)
-                Rectangle {
-                    width: parent.width * 0.6
-                    height: parent.height
-                    color: Components.ThemeManager.previewBackground
-                    
-                    Image {
-                        anchors.fill: parent
-                        anchors.margins: 10
-                        source: {
-                            var finalSource = ""
-
-                            // Trust the backend's preview path - it already has recursive search
-                            if (patternPreview) {
-                                // Backend returns absolute path, just add file:// prefix
-                                finalSource = "file://" + patternPreview
-                            } else {
-                            }
-
-                            return finalSource
-                        }
-                        fillMode: Image.PreserveAspectFit
-
-                        onStatusChanged: {
-                            if (status === Image.Error) {
-                            } else if (status === Image.Ready) {
-                            } else if (status === Image.Loading) {
-                            }
-                        }
-
-                        onSourceChanged: {
-                        }
-                        
-                        Rectangle {
-                            anchors.fill: parent
-                            color: Components.ThemeManager.placeholderBackground
-                            visible: parent.status === Image.Error || parent.source == ""
-
-                            Column {
-                                anchors.centerIn: parent
-                                spacing: 10
-
-                                Text {
-                                    text: "⚙"
-                                    font.pixelSize: 48
-                                    color: Components.ThemeManager.placeholderText
-                                    anchors.horizontalCenter: parent.horizontalCenter
-                                }
-
-                                Text {
-                                    text: "Pattern Preview"
-                                    color: Components.ThemeManager.textTertiary
-                                    font.pixelSize: 14
-                                    anchors.horizontalCenter: parent.horizontalCenter
-                                }
-                            }
-                        }
-                    }
-                }
-                
-                // Divider
-                Rectangle {
-                    width: 1
-                    height: parent.height
-                    color: Components.ThemeManager.borderColor
-                }
-
-                // Right side - Controls (40% of width)
-                Rectangle {
-                    width: parent.width * 0.4 - 1
-                    height: parent.height
-                    color: Components.ThemeManager.surfaceColor
-                    
-                    ScrollView {
-                        anchors.fill: parent
-                        anchors.margins: 10
-                        clip: true
-                        contentWidth: availableWidth
-                        
-                        Column {
-                            width: parent.width
-                            spacing: 8
-                        
-                        // Pattern Name
-                        Rectangle {
-                            width: parent.width
-                            height: 50
-                            radius: 8
-                            color: Components.ThemeManager.cardColor
-                            border.color: Components.ThemeManager.borderColor
-                            border.width: 1
-
-                            Column {
-                                anchors.centerIn: parent
-                                spacing: 4
-
-                                Label {
-                                    text: "Current Pattern"
-                                    font.pixelSize: 10
-                                    color: Components.ThemeManager.textSecondary
-                                    anchors.horizontalCenter: parent.horizontalCenter
-                                }
-
-                                Label {
-                                    text: {
-                                        // Use WebSocket current pattern first, then fallback to passed parameter
-                                        var displayName = ""
-                                        if (backend && backend.currentFile) displayName = backend.currentFile
-                                        else if (patternName) displayName = patternName
-                                        else return "No pattern running"
-
-                                        // Clean up the name for display
-                                        var parts = displayName.split('/')
-                                        displayName = parts[parts.length - 1]
-                                        displayName = displayName.replace('.thr', '')
-                                        return displayName
-                                    }
-                                    font.pixelSize: 12
-                                    font.bold: true
-                                    color: Components.ThemeManager.textPrimary
-                                    anchors.horizontalCenter: parent.horizontalCenter
-                                    width: parent.parent.width - 20
-                                    elide: Text.ElideMiddle
-                                    horizontalAlignment: Text.AlignHCenter
-                                }
-                            }
-                        }
-                        
-                        // Progress
-                        Rectangle {
-                            width: parent.width
-                            height: 70
-                            radius: 8
-                            color: Components.ThemeManager.cardColor
-                            border.color: Components.ThemeManager.borderColor
-                            border.width: 1
-
-                            Column {
-                                anchors.fill: parent
-                                anchors.margins: 10
-                                spacing: 8
-
-                                Label {
-                                    text: "Progress"
-                                    font.pixelSize: 12
-                                    font.bold: true
-                                    color: Components.ThemeManager.textPrimary
-                                }
-                                
-                                ProgressBar {
-                                    width: parent.width
-                                    height: 8
-                                    value: backend ? backend.progress / 100 : 0
-                                }
-                                
-                                Label {
-                                    text: backend ? Math.round(backend.progress) + "%" : "0%"
-                                    anchors.horizontalCenter: parent.horizontalCenter
-                                    font.pixelSize: 14
-                                    font.bold: true
-                                    color: Components.ThemeManager.textPrimary
-                                }
-                            }
-                        }
-
-                        // Control Buttons
-                        Rectangle {
-                            width: parent.width
-                            height: 90
-                            radius: 8
-                            color: Components.ThemeManager.cardColor
-                            border.color: Components.ThemeManager.borderColor
-                            border.width: 1
-
-                            Column {
-                                anchors.fill: parent
-                                anchors.margins: 10
-                                spacing: 10
-
-                                Label {
-                                    text: "Controls"
-                                    font.pixelSize: 12
-                                    font.bold: true
-                                    color: Components.ThemeManager.textPrimary
-                                }
-                                
-                                // Control buttons row
-                                Row {
-                                    width: parent.width
-                                    height: 35
-                                    spacing: 8
-                                    
-                                    // Pause/Resume button
-                                    Rectangle {
-                                        width: (parent.width - 16) / 3  // Divide width evenly with spacing
-                                        height: parent.height
-                                        radius: 6
-                                        color: pauseMouseArea.pressed ? "#1e40af" : (backend && backend.currentFile !== "" ? "#2563eb" : "#9ca3af")
-
-                                        Text {
-                                            anchors.centerIn: parent
-                                            // Show pause icon when running and not paused, play icon when paused
-                                            text: (backend && backend.isRunning && !backend.isPaused) ? "||" : "▶"
-                                            color: "white"
-                                            font.pixelSize: 14
-                                            font.bold: true
-                                        }
-
-                                        MouseArea {
-                                            id: pauseMouseArea
-                                            anchors.fill: parent
-                                            enabled: backend && backend.currentFile !== ""
-                                            onClicked: {
-                                                if (backend) {
-                                                    // If paused, resume; otherwise pause
-                                                    if (backend.isPaused) {
-                                                        backend.resumeExecution()
-                                                    } else {
-                                                        backend.pauseExecution()
-                                                    }
-                                                }
-                                            }
-                                        }
-                                    }
-                                    
-                                    // Stop button
-                                    Rectangle {
-                                        width: (parent.width - 16) / 3
-                                        height: parent.height
-                                        radius: 6
-                                        color: stopMouseArea.pressed ? "#b91c1c" : (backend && backend.currentFile !== "" ? "#dc2626" : "#9ca3af")
-                                        
-                                        Text {
-                                            anchors.centerIn: parent
-                                            text: "■"
-                                            color: "white"
-                                            font.pixelSize: 14
-                                            font.bold: true
-                                        }
-                                        
-                                        MouseArea {
-                                            id: stopMouseArea
-                                            anchors.fill: parent
-                                            enabled: backend
-                                            onClicked: {
-                                                if (backend) {
-                                                    backend.stopExecution()
-                                                }
-                                            }
-                                        }
-                                    }
-                                    
-                                    // Skip button
-                                    Rectangle {
-                                        width: (parent.width - 16) / 3
-                                        height: parent.height
-                                        radius: 6
-                                        color: skipMouseArea.pressed ? "#525252" : (backend && backend.currentFile !== "" ? "#6b7280" : "#9ca3af")
-                                        
-                                        Text {
-                                            anchors.centerIn: parent
-                                            text: "▶▶"
-                                            color: "white"
-                                            font.pixelSize: 14
-                                            font.bold: true
-                                        }
-                                        
-                                        MouseArea {
-                                            id: skipMouseArea
-                                            anchors.fill: parent
-                                            enabled: backend
-                                            onClicked: {
-                                                if (backend) {
-                                                    backend.skipPattern()
-                                                }
-                                            }
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                        
-                        // Speed Control Section
-                        Rectangle {
-                            width: parent.width
-                            height: 120
-                            radius: 8
-                            color: Components.ThemeManager.cardColor
-                            border.color: Components.ThemeManager.borderColor
-                            border.width: 1
-
-                            Column {
-                                anchors.fill: parent
-                                anchors.margins: 10
-                                spacing: 10
-
-                                Label {
-                                    text: "Speed"
-                                    font.pixelSize: 12
-                                    font.bold: true
-                                    color: Components.ThemeManager.textPrimary
-                                }
-                                
-                                // Touch-friendly button row for speed options
-                                Row {
-                                    id: speedControlRow
-                                    width: parent.width
-                                    spacing: 8
-                                    
-                                    property string currentSelection: backend ? backend.getCurrentSpeedOption() : "200"
-                                    
-                                    // Speed buttons
-                                    Repeater {
-                                        model: ["50", "100", "150", "200", "300", "500"]
-
-                                        Rectangle {
-                                            width: (speedControlRow.width - 40) / 6  // Distribute evenly with spacing
-                                            height: 50
-                                            color: speedControlRow.currentSelection === modelData ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                            border.color: speedControlRow.currentSelection === modelData ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                            border.width: 2
-                                            radius: 8
-
-                                            Label {
-                                                anchors.centerIn: parent
-                                                text: modelData
-                                                font.pixelSize: 12
-                                                font.bold: true
-                                                color: speedControlRow.currentSelection === modelData ? "white" : Components.ThemeManager.textPrimary
-                                            }
-                                            
-                                            MouseArea {
-                                                anchors.fill: parent
-                                                onClicked: {
-                                                    if (backend) {
-                                                        backend.setSpeedByOption(modelData)
-                                                        speedControlRow.currentSelection = modelData
-                                                    }
-                                                }
-                                            }
-                                        }
-                                    }
-                                    
-                                    // Update selection when backend changes
-                                    Connections {
-                                        target: backend
-                                        function onSpeedChanged(speed) {
-                                            if (backend) {
-                                                speedControlRow.currentSelection = backend.getCurrentSpeedOption()
-                                            }
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-            }
-        }
-    }
-}
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import "../components"
+import "../components" as Components
+
+// Now Playing — the signature screen. Progress is not a percent bar: it is
+// an ember arc traced around the live pattern disc, with the ball as the
+// moving endpoint — the interface shows progress the way the machine makes
+// it. Plain Canvas drawing only (no effects layers; linuxfb-safe).
+Page {
+    id: page
+    property var backend: null
+    property var stackView: null
+    property string patternName: ""
+    property string patternPreview: ""  // Backend provides this via executionStarted signal
+
+    readonly property bool hasPattern: (backend && backend.currentFile !== "") || patternName !== ""
+    readonly property real progressRatio: backend ? backend.progress / 100 : 0
+    readonly property bool inPause: backend && backend.pauseRemaining >= 0
+
+    property string displayName: {
+        var name = ""
+        if (backend && backend.currentFile) name = backend.currentFile
+        else if (patternName) name = patternName
+        if (!name) return ""
+        var parts = name.split('/')
+        return parts[parts.length - 1].replace('.thr', '')
+    }
+
+    function formatDuration(s) {
+        if (s < 0) return ""
+        var h = Math.floor(s / 3600)
+        var m = Math.floor((s % 3600) / 60)
+        var sec = s % 60
+        function pad(n) { return (n < 10 ? "0" : "") + n }
+        return h > 0 ? h + ":" + pad(m) + ":" + pad(sec) : m + ":" + pad(sec)
+    }
+
+    // Direct connection to backend signals
+    Connections {
+        target: backend
+
+        function onExecutionStarted(fileName, preview) {
+            patternName = fileName
+            patternPreview = preview
+        }
+    }
+
+    Rectangle {
+        anchors.fill: parent
+        color: Components.ThemeManager.backgroundColor
+    }
+
+    ColumnLayout {
+        anchors.fill: parent
+        spacing: 0
+
+        // Header
+        Rectangle {
+            Layout.fillWidth: true
+            Layout.preferredHeight: Components.ThemeManager.headerHeight
+            color: Components.ThemeManager.surfaceColor
+
+            Rectangle {
+                anchors.bottom: parent.bottom
+                width: parent.width
+                height: 1
+                color: Components.ThemeManager.borderColor
+            }
+
+            RowLayout {
+                anchors.fill: parent
+                anchors.leftMargin: Components.ThemeManager.spaceLg
+                anchors.rightMargin: Components.ThemeManager.spaceLg
+
+                ConnectionStatus {
+                    backend: page.backend
+                    Layout.rightMargin: Components.ThemeManager.spaceSm
+                }
+
+                Label {
+                    text: "Now Playing"
+                    font.family: Components.ThemeManager.fontDisplay
+                    font.pixelSize: Components.ThemeManager.fontSizeTitle
+                    color: Components.ThemeManager.textPrimary
+                }
+
+                Item {
+                    Layout.fillWidth: true
+                }
+            }
+        }
+
+        // Content
+        RowLayout {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            spacing: 0
+
+            // ---- Left: the disc and its progress ring ----
+            Item {
+                id: stage
+                Layout.fillHeight: true
+                Layout.preferredWidth: page.width * 0.55
+
+                Item {
+                    id: ringWrap
+                    anchors.centerIn: parent
+                    width: Math.min(stage.width, stage.height) - 2 * Components.ThemeManager.spaceXl
+                    height: width
+
+                    // Track + progress arc, redrawn only when progress moves
+                    Canvas {
+                        id: arcCanvas
+                        anchors.fill: parent
+
+                        // While weaving: fraction of the pattern drawn.
+                        // Between patterns: the countdown to the next one.
+                        property real ratio: {
+                            if (inPause)
+                                return backend && backend.pauseTotal > 0
+                                       ? 1 - backend.pauseRemaining / backend.pauseTotal : 0
+                            return hasPattern ? progressRatio : 0
+                        }
+                        onRatioChanged: requestPaint()
+                        Connections {
+                            target: Components.ThemeManager
+                            function onDarkModeChanged() { arcCanvas.requestPaint() }
+                        }
+
+                        onPaint: {
+                            var ctx = getContext("2d")
+                            var w = width, h = height
+                            ctx.clearRect(0, 0, w, h)
+                            var cx = w / 2, cy = h / 2
+                            var lineW = 5
+                            var r = Math.min(w, h) / 2 - lineW / 2
+                            if (r <= 0)
+                                return  // layout not settled yet
+
+                            ctx.lineWidth = lineW
+                            ctx.lineCap = "round"
+
+                            // Track
+                            ctx.beginPath()
+                            ctx.strokeStyle = String(Components.ThemeManager.cardColor)
+                            ctx.arc(cx, cy, r, 0, 2 * Math.PI)
+                            ctx.stroke()
+
+                            // Progress, from 12 o'clock
+                            if (ratio > 0) {
+                                ctx.beginPath()
+                                ctx.strokeStyle = String(Components.ThemeManager.accent)
+                                ctx.arc(cx, cy, r, -Math.PI / 2,
+                                        -Math.PI / 2 + ratio * 2 * Math.PI)
+                                ctx.stroke()
+                            }
+                        }
+                    }
+
+                    // The ball: endpoint of the arc (two stacked dots stand in
+                    // for a glow — no shadow effects on linuxfb)
+                    Item {
+                        anchors.fill: parent
+                        rotation: arcCanvas.ratio * 360
+                        visible: (hasPattern || inPause) && arcCanvas.ratio > 0
+
+                        Rectangle {
+                            anchors.horizontalCenter: parent.horizontalCenter
+                            y: -7
+                            width: 19
+                            height: 19
+                            radius: 9.5
+                            color: Components.ThemeManager.accent
+                            opacity: 0.3
+                        }
+                        Rectangle {
+                            anchors.horizontalCenter: parent.horizontalCenter
+                            y: -3
+                            width: 11
+                            height: 11
+                            radius: 5.5
+                            color: Components.ThemeManager.accent
+                        }
+                    }
+
+                    // Live pattern disc (the preview PNG is itself a round dish)
+                    Image {
+                        anchors.fill: parent
+                        anchors.margins: 14
+                        source: patternPreview ? "file://" + patternPreview : ""
+                        fillMode: Image.PreserveAspectFit
+                        asynchronous: true
+                        visible: hasPattern && status === Image.Ready
+                    }
+
+                    // Resting dish when idle (or while the preview renders)
+                    Rectangle {
+                        anchors.fill: parent
+                        anchors.margins: 14
+                        radius: width / 2
+                        color: Components.ThemeManager.surfaceColor
+                        border.width: 1
+                        border.color: Components.ThemeManager.borderColor
+                        visible: !hasPattern || patternPreview === ""
+
+                        Column {
+                            anchors.centerIn: parent
+                            spacing: Components.ThemeManager.spaceSm
+
+                            Components.Icon {
+                                name: "radio_unchecked"
+                                size: 34
+                                color: Components.ThemeManager.textTertiary
+                                anchors.horizontalCenter: parent.horizontalCenter
+                            }
+
+                            Label {
+                                text: {
+                                    if (inPause) return "Resting between patterns"
+                                    return hasPattern ? "Rendering preview" : "The table is resting"
+                                }
+                                font.family: Components.ThemeManager.fontMedium
+                                font.pixelSize: Components.ThemeManager.fontSizeBody
+                                color: Components.ThemeManager.textSecondary
+                                anchors.horizontalCenter: parent.horizontalCenter
+                            }
+                        }
+                    }
+                }
+            }
+
+            // ---- Right: name, state, transport, speed ----
+            Rectangle {
+                Layout.fillHeight: true
+                Layout.fillWidth: true
+                color: Components.ThemeManager.surfaceColor
+
+                Rectangle {
+                    anchors.left: parent.left
+                    width: 1
+                    height: parent.height
+                    color: Components.ThemeManager.borderColor
+                }
+
+                ColumnLayout {
+                    anchors.fill: parent
+                    anchors.margins: Components.ThemeManager.spaceXl
+                    spacing: 0
+
+                    Label {
+                        text: inPause ? "Up next" : "Now weaving"
+                        font.family: Components.ThemeManager.fontDisplay
+                        font.pixelSize: 11
+                        font.letterSpacing: 1.6
+                        font.capitalization: Font.AllUppercase
+                        color: Components.ThemeManager.accent
+                    }
+
+                    Label {
+                        Layout.fillWidth: true
+                        Layout.topMargin: Components.ThemeManager.spaceSm
+                        text: {
+                            if (inPause && backend && backend.nextPattern)
+                                return backend.nextPattern
+                            return displayName || "Nothing playing"
+                        }
+                        font.family: Components.ThemeManager.fontDisplay
+                        font.pixelSize: Components.ThemeManager.fontSizeDisplay
+                        color: hasPattern || inPause ? Components.ThemeManager.textPrimary
+                                                     : Components.ThemeManager.textTertiary
+                        elide: Text.ElideRight
+                        maximumLineCount: 2
+                        wrapMode: Text.Wrap
+                    }
+
+                    // Playlist position
+                    Label {
+                        Layout.fillWidth: true
+                        Layout.topMargin: Components.ThemeManager.spaceXs
+                        visible: backend && backend.playlistActive && backend.playlistTotal > 0
+                        text: {
+                            if (!backend) return ""
+                            var s = (backend.playlistName ? backend.playlistName + " · " : "")
+                                    + (backend.playlistIndex + 1) + " of " + backend.playlistTotal
+                            if (backend.playlistClearing) s += " · clearing"
+                            return s
+                        }
+                        font.family: Components.ThemeManager.fontBody
+                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                        color: Components.ThemeManager.textSecondary
+                        elide: Text.ElideMiddle
+                    }
+
+                    // Progress / pause countdown line
+                    Label {
+                        Layout.fillWidth: true
+                        Layout.topMargin: Components.ThemeManager.spaceLg
+                        visible: hasPattern || inPause
+                        textFormat: Text.StyledText
+                        text: {
+                            if (!backend) return ""
+                            if (inPause)
+                                return "<b>" + formatDuration(backend.pauseRemaining)
+                                       + "</b> until the next pattern"
+                            var pct = Math.round(backend.progress)
+                            var s = "<b>" + pct + "%</b> woven"
+                            if (backend.isPaused) s += " · paused"
+                            return s
+                        }
+                        font.family: Components.ThemeManager.fontBody
+                        font.pixelSize: Components.ThemeManager.fontSizeBody
+                        color: Components.ThemeManager.textSecondary
+                    }
+
+                    Item { Layout.fillHeight: true }
+
+                    // Transport
+                    RowLayout {
+                        Layout.fillWidth: true
+                        spacing: Components.ThemeManager.spaceSm
+
+                        ModernControlButton {
+                            Layout.fillWidth: true
+                            Layout.preferredWidth: 3
+                            Layout.preferredHeight: Components.ThemeManager.controlHeight
+                            icon: (backend && backend.isRunning && !backend.isPaused) ? "pause" : "play_arrow"
+                            text: (backend && backend.isRunning && !backend.isPaused) ? "Pause" : "Resume"
+                            buttonColor: Components.ThemeManager.accent
+                            enabled: backend && backend.currentFile !== ""
+                            onClicked: {
+                                if (!backend) return
+                                if (backend.isPaused) backend.resumeExecution()
+                                else backend.pauseExecution()
+                            }
+                        }
+
+                        ModernControlButton {
+                            Layout.fillWidth: true
+                            Layout.preferredWidth: 2
+                            Layout.preferredHeight: Components.ThemeManager.controlHeight
+                            icon: "stop"
+                            text: "Stop"
+                            outlined: true
+                            buttonColor: Components.ThemeManager.danger
+                            enabled: backend !== null
+                            onClicked: if (backend) backend.stopExecution()
+                        }
+
+                        ModernControlButton {
+                            Layout.fillWidth: true
+                            Layout.preferredWidth: 2
+                            Layout.preferredHeight: Components.ThemeManager.controlHeight
+                            icon: "skip_next"
+                            text: "Skip"
+                            buttonColor: Components.ThemeManager.cardColor
+                            enabled: backend !== null
+                            onClicked: if (backend) backend.skipPattern()
+                        }
+                    }
+
+                    // Speed
+                    Label {
+                        Layout.topMargin: Components.ThemeManager.spaceLg
+                        text: "Speed · mm/s"
+                        font.family: Components.ThemeManager.fontDisplay
+                        font.pixelSize: 11
+                        font.letterSpacing: 1.4
+                        font.capitalization: Font.AllUppercase
+                        color: Components.ThemeManager.textTertiary
+                    }
+
+                    // Segmented control
+                    Rectangle {
+                        id: speedSeg
+                        Layout.fillWidth: true
+                        Layout.topMargin: Components.ThemeManager.spaceSm
+                        Layout.preferredHeight: 48
+                        radius: height / 2
+                        color: Components.ThemeManager.cardColor
+
+                        property string currentSelection: backend ? backend.getCurrentSpeedOption() : "200"
+
+                        Connections {
+                            target: backend
+                            function onSpeedChanged(speed) {
+                                if (backend)
+                                    speedSeg.currentSelection = backend.getCurrentSpeedOption()
+                            }
+                        }
+
+                        Row {
+                            anchors.fill: parent
+                            anchors.margins: 4
+                            spacing: 2
+
+                            Repeater {
+                                model: ["50", "100", "150", "200", "300", "500"]
+
+                                Rectangle {
+                                    property bool selected: speedSeg.currentSelection === modelData
+
+                                    width: (parent.width - 10) / 6
+                                    height: parent.height
+                                    radius: height / 2
+                                    color: selected ? Components.ThemeManager.backgroundColor : "transparent"
+                                    border.width: selected ? 1 : 0
+                                    border.color: Components.ThemeManager.borderColor
+
+                                    Label {
+                                        anchors.centerIn: parent
+                                        text: modelData
+                                        font.family: Components.ThemeManager.fontMedium
+                                        font.pixelSize: 13
+                                        color: parent.selected ? Components.ThemeManager.accent
+                                                               : Components.ThemeManager.textSecondary
+                                    }
+
+                                    MouseArea {
+                                        anchors.fill: parent
+                                        onClicked: {
+                                            if (backend) {
+                                                backend.setSpeedByOption(modelData)
+                                                speedSeg.currentSelection = modelData
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 622 - 379
dune-weaver-touch/qml/pages/LedControlPage.qml

@@ -16,10 +16,51 @@ Page {
     property bool ledConnected: false
     property bool ledConnected: false
     property int currentEffectIndex: 0
     property int currentEffectIndex: 0
     property int currentPaletteIndex: 0
     property int currentPaletteIndex: 0
+    property string ledColor: "#ffffff"
     property var effectsList: []
     property var effectsList: []
     property var palettesList: []
     property var palettesList: []
 
 
-    // Predefined colors for quick selection (muted tones to fit dark UI)
+    readonly property bool hasRing: ledProvider === "dw_leds"
+
+    // 'ball' tracker state (firmware-native effect that follows the sand ball)
+    property string ballColor2: "#000000"
+    property int ballFgBright: 255
+    property int ballBgBright: 255
+    property int ballSize: 3
+    property string ballBg: "static"
+    property string ballDirection: "cw"
+    property int ballAlign: 0
+
+    // Resolve the 'ball' effect id from the catalogue (id 38 today).
+    property int ballEffectId: {
+        for (var i = 0; i < effectsList.length; i++)
+            if (effectsList[i].name === "ball")
+                return effectsList[i].id
+        return 38
+    }
+    property bool ballActive: currentEffectIndex === ballEffectId
+
+    // The ball tracker has its own card (matching the web UI), so it is not
+    // offered as a plain effect chip.
+    property var selectableEffects: effectsList.filter(function(e) {
+        return e.name !== "ball"
+    })
+
+    // Background options for the ball: solid colour, off, or any other effect.
+    property var ballBgOptions: {
+        var opts = [
+            {"label": "Solid", "value": "static"},
+            {"label": "Off", "value": "off"}
+        ]
+        for (var i = 0; i < effectsList.length; i++) {
+            var n = effectsList[i].name
+            if (n !== "ball" && n !== "off" && n !== "static")
+                opts.push({"label": n.charAt(0).toUpperCase() + n.slice(1), "value": n})
+        }
+        return opts
+    }
+
+    // Predefined colors for quick selection (muted tones to fit the dark UI)
     property var presetColors: [
     property var presetColors: [
         {"name": "White", "color": "#e8e8e8", "sendColor": "#ffffff"},
         {"name": "White", "color": "#e8e8e8", "sendColor": "#ffffff"},
         {"name": "Warm", "color": "#d4a574", "sendColor": "#ffaa55"},
         {"name": "Warm", "color": "#d4a574", "sendColor": "#ffaa55"},
@@ -45,6 +86,14 @@ Page {
                 ledConnected = backend.ledConnected
                 ledConnected = backend.ledConnected
                 currentEffectIndex = backend.ledCurrentEffect
                 currentEffectIndex = backend.ledCurrentEffect
                 currentPaletteIndex = backend.ledCurrentPalette
                 currentPaletteIndex = backend.ledCurrentPalette
+                ledColor = backend.ledColor
+                ballColor2 = backend.ledColor2
+                ballFgBright = backend.ledBallFgBright
+                ballBgBright = backend.ledBallBgBright
+                ballSize = backend.ledBallSize
+                ballBg = backend.ledBallBg
+                ballDirection = backend.ledBallDirection
+                ballAlign = backend.ledBallAlign
             }
             }
         }
         }
 
 
@@ -76,7 +125,7 @@ Page {
         // Header
         // Header
         Rectangle {
         Rectangle {
             Layout.fillWidth: true
             Layout.fillWidth: true
-            Layout.preferredHeight: 50
+            Layout.preferredHeight: Components.ThemeManager.headerHeight
             color: Components.ThemeManager.surfaceColor
             color: Components.ThemeManager.surfaceColor
 
 
             Rectangle {
             Rectangle {
@@ -88,18 +137,18 @@ Page {
 
 
             RowLayout {
             RowLayout {
                 anchors.fill: parent
                 anchors.fill: parent
-                anchors.leftMargin: 15
-                anchors.rightMargin: 10
+                anchors.leftMargin: Components.ThemeManager.spaceLg
+                anchors.rightMargin: Components.ThemeManager.spaceLg
 
 
                 ConnectionStatus {
                 ConnectionStatus {
                     backend: page.backend
                     backend: page.backend
-                    Layout.rightMargin: 8
+                    Layout.rightMargin: Components.ThemeManager.spaceSm
                 }
                 }
 
 
                 Label {
                 Label {
-                    text: "LED Control"
-                    font.pixelSize: 18
-                    font.bold: true
+                    text: "Light"
+                    font.family: Components.ThemeManager.fontDisplay
+                    font.pixelSize: Components.ThemeManager.fontSizeTitle
                     color: Components.ThemeManager.textPrimary
                     color: Components.ThemeManager.textPrimary
                 }
                 }
 
 
@@ -109,306 +158,260 @@ Page {
             }
             }
         }
         }
 
 
-        // Main Content
-        ScrollView {
+        // Content — the light's state stays pinned on the left (power and
+        // brightness never scroll away); the right column scrolls through
+        // every effect, the ball tracker, every palette, and quick colors.
+        RowLayout {
             Layout.fillWidth: true
             Layout.fillWidth: true
             Layout.fillHeight: true
             Layout.fillHeight: true
-            contentWidth: availableWidth
-
-            ColumnLayout {
-                width: parent.width
-                anchors.margins: 5
-                spacing: 2
-
-                // Screen Brightness Section (always visible, controls Pi LCD backlight)
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: 60
-                    Layout.margins: 5
-                    radius: 8
-                    color: Components.ThemeManager.surfaceColor
-                    visible: backend && backend.lcdMaxBrightness > 0
+            spacing: 0
+
+            // ---- Left: state panel (fits without scrolling in most
+            // setups; scrolls independently when the backlight card is
+            // present on the Pi) ----
+            ScrollView {
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+                Layout.maximumWidth: hasRing ? Math.round(page.width * 0.42) : page.width
+                contentWidth: availableWidth
+
+                ColumnLayout {
+                    width: parent.width
+                    spacing: 0
+
+                // Table light: power + brightness (or provider notices)
+                SettingsCard {
+                    Layout.rightMargin: hasRing ? Components.ThemeManager.spaceSm
+                                                : Components.ThemeManager.spaceLg
+                    Layout.preferredHeight: providerColumn.implicitHeight + 2 * Components.ThemeManager.spaceLg
 
 
-                    RowLayout {
+                    ColumnLayout {
+                        id: providerColumn
                         anchors.fill: parent
                         anchors.fill: parent
-                        anchors.leftMargin: 15
-                        anchors.rightMargin: 15
-                        anchors.topMargin: 10
-                        anchors.bottomMargin: 10
-                        spacing: 10
-
-                        Label {
-                            text: "\u2600"
-                            font.pixelSize: 20
-                            color: Components.ThemeManager.textSecondary
-                        }
+                        anchors.margins: Components.ThemeManager.spaceLg
+                        spacing: Components.ThemeManager.spaceMd
 
 
-                        Slider {
-                            id: lcdBrightnessSlider
+                        RowLayout {
                             Layout.fillWidth: true
                             Layout.fillWidth: true
-                            from: 0
-                            to: backend ? backend.lcdMaxBrightness : 255
-                            stepSize: 1
-                            value: backend ? backend.lcdBrightness : 255
-
-                            onMoved: {
-                                if (backend) {
-                                    backend.setLcdBrightness(Math.round(value))
-                                }
-                            }
-                        }
+                            spacing: Components.ThemeManager.spaceSm
 
 
-                        Label {
-                            text: {
-                                var max = backend ? backend.lcdMaxBrightness : 255
-                                if (max <= 0) return "0%"
-                                return Math.round(lcdBrightnessSlider.value / max * 100) + "%"
+                            SectionLabel {
+                                text: "Table light"
+                                Layout.fillWidth: true
                             }
                             }
-                            font.pixelSize: 12
-                            font.bold: true
-                            color: Components.ThemeManager.textPrimary
-                            Layout.preferredWidth: 35
-                            horizontalAlignment: Text.AlignRight
-                        }
-                    }
-                }
 
 
-                // Provider Info & Power/Brightness Section
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: ledProvider === "none" ? 100 : (ledProvider === "wled" ? 90 : 110)
-                    Layout.margins: 5
-                    radius: 8
-                    color: Components.ThemeManager.surfaceColor
+                            Rectangle {
+                                visible: hasRing
+                                width: 8
+                                height: 8
+                                radius: 4
+                                color: ledConnected ? Components.ThemeManager.ok
+                                                    : Components.ThemeManager.danger
+                            }
 
 
-                    ColumnLayout {
-                        anchors.fill: parent
-                        anchors.margins: 15
-                        spacing: 10
+                            Label {
+                                visible: hasRing
+                                text: ledConnected ? "Connected" : "Disconnected"
+                                font.family: Components.ThemeManager.fontBody
+                                font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                color: Components.ThemeManager.textTertiary
+                            }
+                        }
 
 
                         // Not configured message
                         // Not configured message
-                        ColumnLayout {
+                        Label {
                             visible: ledProvider === "none"
                             visible: ledProvider === "none"
+                            text: "No light ring is set up for this table. Configure one in the Dune Weaver web interface."
+                            font.family: Components.ThemeManager.fontBody
+                            font.pixelSize: Components.ThemeManager.fontSizeBody
+                            color: Components.ThemeManager.textSecondary
+                            wrapMode: Text.WordWrap
                             Layout.fillWidth: true
                             Layout.fillWidth: true
-                            spacing: 8
-
-                            Label {
-                                text: "LED Not Configured"
-                                font.pixelSize: 14
-                                font.bold: true
-                                color: Components.ThemeManager.textPrimary
-                            }
+                        }
 
 
-                            Label {
-                                text: "Configure LED settings in the main Dune Weaver web interface"
-                                font.pixelSize: 12
-                                color: Components.ThemeManager.textSecondary
-                                wrapMode: Text.WordWrap
-                                Layout.fillWidth: true
-                            }
+                        // WLED notice
+                        Label {
+                            visible: ledProvider === "wled"
+                            text: "This table's light is driven by WLED — use the Dune Weaver web interface to control it."
+                            font.family: Components.ThemeManager.fontBody
+                            font.pixelSize: Components.ThemeManager.fontSizeBody
+                            color: Components.ThemeManager.textSecondary
+                            wrapMode: Text.WordWrap
+                            Layout.fillWidth: true
                         }
                         }
 
 
-                        // DW LEDs Controls - Power and Brightness in same section
-                        ColumnLayout {
-                            visible: ledProvider === "dw_leds"
+                        // Power row
+                        RowLayout {
+                            visible: hasRing
                             Layout.fillWidth: true
                             Layout.fillWidth: true
-                            spacing: 8
+                            spacing: Components.ThemeManager.spaceMd
 
 
-                            // Power row with status and toggle
-                            RowLayout {
+                            Label {
+                                text: ledPowerOn ? "On" : "Off"
+                                font.family: Components.ThemeManager.fontDisplay
+                                font.pixelSize: Components.ThemeManager.fontSizeBody
+                                color: Components.ThemeManager.textPrimary
                                 Layout.fillWidth: true
                                 Layout.fillWidth: true
-                                spacing: 12
-
-                                // Status indicator with label
-                                RowLayout {
-                                    spacing: 6
-
-                                    Rectangle {
-                                        width: 12
-                                        height: 12
-                                        radius: 6
-                                        color: ledPowerOn ? "#4CAF50" : "#6b7280"
-                                    }
+                            }
 
 
-                                    Label {
-                                        text: ledPowerOn ? "On" : "Off"
-                                        font.pixelSize: 13
-                                        font.bold: true
-                                        color: Components.ThemeManager.textPrimary
+                            DwSwitch {
+                                id: powerSwitch
+                                checked: ledPowerOn
+                                onToggled: {
+                                    if (backend) {
+                                        backend.toggleLedPower()
                                     }
                                     }
                                 }
                                 }
 
 
-                                // Toggle button
-                                ModernControlButton {
-                                    Layout.preferredWidth: 100
-                                    Layout.preferredHeight: 36
-                                    text: ledPowerOn ? "Turn Off" : "Turn On"
-                                    icon: ""
-                                    buttonColor: ledPowerOn ? "#6b7280" : "#4CAF50"
-                                    fontSize: 11
-
-                                    onClicked: {
-                                        if (backend) {
-                                            backend.toggleLedPower()
-                                        }
-                                    }
+                                // A user toggle breaks the declarative binding;
+                                // this keeps the switch following backend state.
+                                Binding {
+                                    target: powerSwitch
+                                    property: "checked"
+                                    value: ledPowerOn
                                 }
                                 }
+                            }
+                        }
 
 
-                                Item { Layout.fillWidth: true }
-
-                                // Connection status (smaller, secondary)
-                                RowLayout {
-                                    spacing: 4
+                        // Brightness row
+                        RowLayout {
+                            visible: hasRing
+                            Layout.fillWidth: true
+                            spacing: Components.ThemeManager.spaceMd
 
 
-                                    Rectangle {
-                                        width: 8
-                                        height: 8
-                                        radius: 4
-                                        color: ledConnected ? "#4CAF50" : "#ef4444"
-                                    }
+                            Label {
+                                text: "Brightness"
+                                font.family: Components.ThemeManager.fontBody
+                                font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                color: Components.ThemeManager.textSecondary
+                                Layout.preferredWidth: 76
+                            }
 
 
-                                    Label {
-                                        text: ledConnected ? "Connected" : "Disconnected"
-                                        font.pixelSize: 10
-                                        color: Components.ThemeManager.textTertiary
+                            DwSlider {
+                                id: brightnessSlider
+                                Layout.fillWidth: true
+                                from: 0
+                                to: 100
+                                stepSize: 5
+                                value: ledBrightness
+
+                                onMoved: {
+                                    if (backend) {
+                                        backend.setLedBrightness(Math.round(value))
                                     }
                                     }
                                 }
                                 }
                             }
                             }
 
 
-                            // Brightness row
-                            RowLayout {
-                                Layout.fillWidth: true
-                                spacing: 10
+                            Label {
+                                text: Math.round(brightnessSlider.value) + "%"
+                                font.family: Components.ThemeManager.fontMedium
+                                font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                color: Components.ThemeManager.textPrimary
+                                Layout.preferredWidth: 36
+                                horizontalAlignment: Text.AlignRight
+                            }
+                        }
+                    }
+                }
 
 
-                                Label {
-                                    text: "Brightness"
-                                    font.pixelSize: 12
-                                    color: Components.ThemeManager.textSecondary
-                                }
+                // Screen brightness (always visible, controls Pi LCD backlight)
+                SettingsCard {
+                    Layout.rightMargin: hasRing ? Components.ThemeManager.spaceSm
+                                                : Components.ThemeManager.spaceLg
+                    Layout.preferredHeight: lcdColumn.implicitHeight + 2 * Components.ThemeManager.spaceLg
+                    visible: backend && backend.lcdMaxBrightness > 0
 
 
-                                Slider {
-                                    id: brightnessSlider
-                                    Layout.fillWidth: true
-                                    from: 0
-                                    to: 100
-                                    stepSize: 5
-                                    value: ledBrightness
-
-                                    onMoved: {
-                                        if (backend) {
-                                            backend.setLedBrightness(Math.round(value))
-                                        }
-                                    }
-                                }
+                    ColumnLayout {
+                        id: lcdColumn
+                        anchors.fill: parent
+                        anchors.margins: Components.ThemeManager.spaceLg
+                        spacing: Components.ThemeManager.spaceSm
 
 
-                                Label {
-                                    text: Math.round(brightnessSlider.value) + "%"
-                                    font.pixelSize: 12
-                                    font.bold: true
-                                    color: Components.ThemeManager.textPrimary
-                                    Layout.preferredWidth: 35
-                                    horizontalAlignment: Text.AlignRight
-                                }
-                            }
+                        SectionLabel {
+                            text: "Screen brightness"
                         }
                         }
 
 
-                        // WLED Info
-                        ColumnLayout {
-                            visible: ledProvider === "wled"
+                        RowLayout {
                             Layout.fillWidth: true
                             Layout.fillWidth: true
-                            spacing: 8
+                            spacing: Components.ThemeManager.spaceMd
 
 
-                            Label {
-                                text: "WLED Mode"
-                                font.pixelSize: 14
-                                font.bold: true
-                                color: Components.ThemeManager.textPrimary
+                            Components.Icon {
+                                name: "brightness"
+                                size: 20
+                                color: Components.ThemeManager.textSecondary
                             }
                             }
 
 
-                            Label {
-                                text: "Use the main Dune Weaver web interface for WLED controls"
-                                font.pixelSize: 12
-                                color: Components.ThemeManager.textSecondary
-                                wrapMode: Text.WordWrap
+                            DwSlider {
+                                id: lcdBrightnessSlider
                                 Layout.fillWidth: true
                                 Layout.fillWidth: true
+                                from: 0
+                                to: backend ? backend.lcdMaxBrightness : 255
+                                stepSize: 1
+                                value: backend ? backend.lcdBrightness : 255
+
+                                onMoved: {
+                                    if (backend) {
+                                        backend.setLcdBrightness(Math.round(value))
+                                    }
+                                }
+                            }
+
+                            Label {
+                                text: {
+                                    var max = backend ? backend.lcdMaxBrightness : 255
+                                    if (max <= 0) return "0%"
+                                    return Math.round(lcdBrightnessSlider.value / max * 100) + "%"
+                                }
+                                font.family: Components.ThemeManager.fontMedium
+                                font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                color: Components.ThemeManager.textPrimary
+                                Layout.preferredWidth: 36
+                                horizontalAlignment: Text.AlignRight
                             }
                             }
                         }
                         }
                     }
                     }
                 }
                 }
 
 
-                // Effects Section (only for dw_leds)
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: effectsList.length > 0 ? 180 : 80
-                    Layout.margins: 5
-                    radius: 8
-                    color: Components.ThemeManager.surfaceColor
-                    visible: ledProvider === "dw_leds"
+                // Quick colors — set the whole ring to one plain colour
+                SettingsCard {
+                    Layout.rightMargin: Components.ThemeManager.spaceSm
+                    Layout.preferredHeight: quickColumn.implicitHeight + 2 * Components.ThemeManager.spaceLg
+                    visible: hasRing
 
 
                     ColumnLayout {
                     ColumnLayout {
+                        id: quickColumn
                         anchors.fill: parent
                         anchors.fill: parent
-                        anchors.margins: 15
-                        spacing: 10
+                        anchors.margins: Components.ThemeManager.spaceLg
+                        spacing: Components.ThemeManager.spaceMd
 
 
-                        Label {
-                            text: "Effects"
-                            font.pixelSize: 14
-                            font.bold: true
-                            color: Components.ThemeManager.textPrimary
+                        SectionLabel {
+                            text: "Quick colors"
                         }
                         }
 
 
-                        // Show loading or no effects message
-                        Label {
-                            visible: effectsList.length === 0
-                            text: "No effects available"
-                            font.pixelSize: 12
-                            color: Components.ThemeManager.textSecondary
-                        }
-
-                        // Effects grid
                         GridLayout {
                         GridLayout {
                             Layout.fillWidth: true
                             Layout.fillWidth: true
-                            columns: 4
-                            rowSpacing: 6
-                            columnSpacing: 6
-                            visible: effectsList.length > 0
+                            columns: 5
+                            rowSpacing: Components.ThemeManager.spaceSm
+                            columnSpacing: Components.ThemeManager.spaceSm
 
 
                             Repeater {
                             Repeater {
-                                model: effectsList.slice(0, 12) // Show first 12 effects
+                                model: presetColors
 
 
                                 Rectangle {
                                 Rectangle {
-                                    property int effectId: modelData.id !== undefined ? modelData.id : index
-                                    property bool isSelected: effectId === currentEffectIndex
-
+                                    property bool isSel: ledColor.toLowerCase() === modelData.sendColor.toLowerCase()
                                     Layout.fillWidth: true
                                     Layout.fillWidth: true
-                                    Layout.preferredHeight: 35
-                                    radius: 6
-                                    color: isSelected ?
-                                           Components.ThemeManager.selectedBackground :
-                                           Components.ThemeManager.buttonBackground
-                                    border.color: isSelected ?
-                                                  Components.ThemeManager.selectedBorder :
-                                                  Components.ThemeManager.buttonBorder
-                                    border.width: 1
-
-                                    Label {
-                                        anchors.centerIn: parent
-                                        anchors.leftMargin: 4
-                                        anchors.rightMargin: 4
-                                        width: parent.width - 8
-                                        text: modelData.name || ("Effect " + effectId)
-                                        font.pixelSize: 10
-                                        color: isSelected ? "white" : Components.ThemeManager.textPrimary
-                                        elide: Text.ElideRight
-                                        horizontalAlignment: Text.AlignHCenter
-                                    }
+                                    Layout.preferredHeight: 40
+                                    radius: 20
+                                    color: modelData.color
+                                    border.color: isSel ? Components.ThemeManager.textPrimary
+                                                        : Qt.darker(modelData.color, 1.2)
+                                    border.width: isSel ? 2 : 1
 
 
                                     MouseArea {
                                     MouseArea {
                                         anchors.fill: parent
                                         anchors.fill: parent
                                         onClicked: {
                                         onClicked: {
                                             if (backend) {
                                             if (backend) {
-                                                backend.setLedEffect(effectId)
-                                                currentEffectIndex = effectId
+                                                backend.setLedColorHex(modelData.sendColor)
                                             }
                                             }
                                         }
                                         }
                                     }
                                     }
@@ -418,79 +421,65 @@ Page {
                     }
                     }
                 }
                 }
 
 
-                // Palettes Section (only for dw_leds)
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: palettesList.length > 0 ? 140 : 80
-                    Layout.margins: 5
-                    radius: 8
-                    color: Components.ThemeManager.surfaceColor
-                    visible: ledProvider === "dw_leds"
+                }
+            }
 
 
-                    ColumnLayout {
-                        anchors.fill: parent
-                        anchors.margins: 15
-                        spacing: 10
+            // ---- Right: everything the ring can do (scrolls) ----
+            ScrollView {
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+                contentWidth: availableWidth
+                visible: hasRing
 
 
-                        Label {
-                            text: "Palettes"
-                            font.pixelSize: 14
-                            font.bold: true
-                            color: Components.ThemeManager.textPrimary
-                        }
+                ColumnLayout {
+                    width: parent.width
+                    spacing: 0
 
 
-                        // Show loading or no palettes message
-                        Label {
-                            visible: palettesList.length === 0
-                            text: "No palettes available"
-                            font.pixelSize: 12
-                            color: Components.ThemeManager.textSecondary
-                        }
+                    // Effects — the full firmware catalogue
+                    SettingsCard {
+                        Layout.leftMargin: Components.ThemeManager.spaceSm
+                        Layout.preferredHeight: effectsColumn.implicitHeight + 2 * Components.ThemeManager.spaceLg
 
 
-                        // Palettes grid
-                        GridLayout {
-                            Layout.fillWidth: true
-                            columns: 4
-                            rowSpacing: 6
-                            columnSpacing: 6
-                            visible: palettesList.length > 0
+                        ColumnLayout {
+                            id: effectsColumn
+                            anchors.fill: parent
+                            anchors.margins: Components.ThemeManager.spaceLg
+                            spacing: Components.ThemeManager.spaceMd
 
 
-                            Repeater {
-                                model: palettesList.slice(0, 8) // Show first 8 palettes
+                            SectionLabel {
+                                text: "Effect"
+                            }
 
 
-                                Rectangle {
-                                    property int paletteId: modelData.id !== undefined ? modelData.id : index
-                                    property bool isSelected: paletteId === currentPaletteIndex
+                            Label {
+                                visible: selectableEffects.length === 0
+                                text: "No effects available"
+                                font.family: Components.ThemeManager.fontBody
+                                font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                color: Components.ThemeManager.textSecondary
+                            }
 
 
-                                    Layout.fillWidth: true
-                                    Layout.preferredHeight: 35
-                                    radius: 6
-                                    color: isSelected ?
-                                           Components.ThemeManager.selectedBackground :
-                                           Components.ThemeManager.buttonBackground
-                                    border.color: isSelected ?
-                                                  Components.ThemeManager.selectedBorder :
-                                                  Components.ThemeManager.buttonBorder
-                                    border.width: 1
+                            GridLayout {
+                                Layout.fillWidth: true
+                                columns: 3
+                                rowSpacing: Components.ThemeManager.spaceSm
+                                columnSpacing: Components.ThemeManager.spaceSm
+                                visible: selectableEffects.length > 0
 
 
-                                    Label {
-                                        anchors.centerIn: parent
-                                        anchors.leftMargin: 4
-                                        anchors.rightMargin: 4
-                                        width: parent.width - 8
-                                        text: modelData.name || ("Palette " + paletteId)
-                                        font.pixelSize: 10
-                                        color: isSelected ? "white" : Components.ThemeManager.textPrimary
-                                        elide: Text.ElideRight
-                                        horizontalAlignment: Text.AlignHCenter
-                                    }
+                                Repeater {
+                                    model: selectableEffects
+
+                                    ChoiceChip {
+                                        property int effectId: modelData.id !== undefined ? modelData.id : index
+                                        property string effectName: modelData.name || ("Effect " + effectId)
+
+                                        Layout.fillWidth: true
+                                        label: effectName.charAt(0).toUpperCase() + effectName.slice(1)
+                                        selected: effectId === currentEffectIndex
 
 
-                                    MouseArea {
-                                        anchors.fill: parent
                                         onClicked: {
                                         onClicked: {
                                             if (backend) {
                                             if (backend) {
-                                                backend.setLedPalette(paletteId)
-                                                currentPaletteIndex = paletteId
+                                                backend.setLedEffect(effectId)
+                                                currentEffectIndex = effectId
                                             }
                                             }
                                         }
                                         }
                                     }
                                     }
@@ -498,104 +487,358 @@ Page {
                             }
                             }
                         }
                         }
                     }
                     }
-                }
 
 
-                // Quick Colors Section - MOVED TO BOTTOM (only for dw_leds)
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: 160
-                    Layout.margins: 5
-                    radius: 8
-                    color: Components.ThemeManager.surfaceColor
-                    visible: ledProvider === "dw_leds"
+                    // Ball Tracker — firmware-native effect (id 38) that
+                    // follows the sand ball, so it lives with the effects.
+                    SettingsCard {
+                        Layout.leftMargin: Components.ThemeManager.spaceSm
+                        Layout.preferredHeight: ballCol.implicitHeight + 2 * Components.ThemeManager.spaceLg
 
 
-                    ColumnLayout {
-                        anchors.fill: parent
-                        anchors.margins: 15
-                        spacing: 10
+                        ColumnLayout {
+                            id: ballCol
+                            anchors.fill: parent
+                            anchors.margins: Components.ThemeManager.spaceLg
+                            spacing: Components.ThemeManager.spaceMd
 
 
-                        Label {
-                            text: "Quick Colors"
-                            font.pixelSize: 14
-                            font.bold: true
-                            color: Components.ThemeManager.textPrimary
-                        }
+                            // Header row: title + enable toggle
+                            RowLayout {
+                                Layout.fillWidth: true
+                                spacing: Components.ThemeManager.spaceSm
 
 
-                        GridLayout {
-                            Layout.fillWidth: true
-                            Layout.fillHeight: true
-                            columns: 5
-                            rowSpacing: 8
-                            columnSpacing: 8
+                                ColumnLayout {
+                                    Layout.fillWidth: true
+                                    spacing: 2
+                                    SectionLabel {
+                                        text: "Ball tracker"
+                                    }
+                                    Label {
+                                        text: ballActive
+                                              ? "Following the sand ball — replaces the effect above."
+                                              : "A glowing dot that follows the sand ball."
+                                        font.family: Components.ThemeManager.fontBody
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textSecondary
+                                        wrapMode: Text.WordWrap
+                                        Layout.fillWidth: true
+                                    }
+                                }
 
 
-                            Repeater {
-                                model: presetColors
+                                DwSwitch {
+                                    id: ballSwitch
+                                    checked: ballActive
+                                    onToggled: {
+                                        if (backend)
+                                            backend.setBallTracker(!ballActive)
+                                    }
 
 
-                                Rectangle {
+                                    Binding {
+                                        target: ballSwitch
+                                        property: "checked"
+                                        value: ballActive
+                                    }
+                                }
+                            }
+
+                            // Controls (only meaningful while the ball effect is on)
+                            ColumnLayout {
+                                Layout.fillWidth: true
+                                visible: ballActive
+                                spacing: Components.ThemeManager.spaceMd
+
+                                // ---- Blob ----
+                                Label {
+                                    text: "The dot"
+                                    font.family: Components.ThemeManager.fontMedium
+                                    font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                    color: Components.ThemeManager.textSecondary
+                                }
+
+                                // Blob colour swatches
+                                GridLayout {
                                     Layout.fillWidth: true
                                     Layout.fillWidth: true
-                                    Layout.fillHeight: true
-                                    Layout.minimumHeight: 50
-                                    radius: 6
-                                    color: Components.ThemeManager.buttonBackground
-                                    border.color: Components.ThemeManager.buttonBorder
-                                    border.width: 1
-
-                                    RowLayout {
-                                        anchors.centerIn: parent
-                                        spacing: 6
-
-                                        // Color indicator circle
+                                    columns: 10
+                                    rowSpacing: Components.ThemeManager.spaceSm
+                                    columnSpacing: Components.ThemeManager.spaceSm
+                                    Repeater {
+                                        model: presetColors
                                         Rectangle {
                                         Rectangle {
-                                            width: 14
-                                            height: 14
-                                            radius: 7
+                                            property bool isSel: ledColor.toLowerCase() === modelData.sendColor.toLowerCase()
+                                            Layout.fillWidth: true
+                                            Layout.preferredHeight: 32
+                                            radius: 16
                                             color: modelData.color
                                             color: modelData.color
-                                            border.color: Qt.darker(modelData.color, 1.2)
-                                            border.width: 1
+                                            border.color: isSel ? Components.ThemeManager.textPrimary
+                                                                : Qt.darker(modelData.color, 1.2)
+                                            border.width: isSel ? 2 : 1
+                                            MouseArea {
+                                                anchors.fill: parent
+                                                onClicked: { if (backend) backend.setLedColorHex(modelData.sendColor) }
+                                            }
                                         }
                                         }
+                                    }
+                                }
+
+                                // Blob brightness
+                                RowLayout {
+                                    Layout.fillWidth: true
+                                    spacing: Components.ThemeManager.spaceMd
+                                    Label {
+                                        text: "Brightness"
+                                        font.family: Components.ThemeManager.fontBody
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textSecondary
+                                        Layout.preferredWidth: 84
+                                    }
+                                    DwSlider {
+                                        id: ballFgSlider
+                                        Layout.fillWidth: true
+                                        from: 0; to: 255; stepSize: 1
+                                        value: ballFgBright
+                                        onMoved: { if (backend) backend.setLedBallFgBright(Math.round(value)) }
+                                    }
+                                    Label {
+                                        text: Math.round(ballFgSlider.value)
+                                        font.family: Components.ThemeManager.fontMedium
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textPrimary
+                                        Layout.preferredWidth: 36
+                                        horizontalAlignment: Text.AlignRight
+                                    }
+                                }
 
 
-                                        Label {
-                                            text: modelData.name
-                                            font.pixelSize: 11
-                                            color: Components.ThemeManager.textPrimary
+                                // Direction
+                                RowLayout {
+                                    Layout.fillWidth: true
+                                    spacing: Components.ThemeManager.spaceMd
+                                    Label {
+                                        text: "Direction"
+                                        font.family: Components.ThemeManager.fontBody
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textSecondary
+                                        Layout.preferredWidth: 84
+                                    }
+                                    Repeater {
+                                        model: [
+                                            {"label": "Clockwise", "value": "cw"},
+                                            {"label": "Counter-CW", "value": "ccw"}
+                                        ]
+                                        ChoiceChip {
+                                            Layout.fillWidth: true
+                                            label: modelData.label
+                                            selected: ballDirection === modelData.value
+                                            onClicked: { if (backend) backend.setLedBallDirection(modelData.value) }
                                         }
                                         }
                                     }
                                     }
+                                }
 
 
-                                    MouseArea {
-                                        anchors.fill: parent
-                                        onClicked: {
-                                            if (backend) {
-                                                backend.setLedColorHex(modelData.sendColor)
-                                            }
+                                // Glow size
+                                RowLayout {
+                                    Layout.fillWidth: true
+                                    spacing: Components.ThemeManager.spaceMd
+                                    Label {
+                                        text: "Glow size"
+                                        font.family: Components.ThemeManager.fontBody
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textSecondary
+                                        Layout.preferredWidth: 84
+                                    }
+                                    DwSlider {
+                                        id: ballSizeSlider
+                                        Layout.fillWidth: true
+                                        from: 1; to: 30; stepSize: 1
+                                        value: ballSize
+                                        onMoved: { if (backend) backend.setLedBallSize(Math.round(value)) }
+                                    }
+                                    Label {
+                                        text: Math.round(ballSizeSlider.value)
+                                        font.family: Components.ThemeManager.fontMedium
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textPrimary
+                                        Layout.preferredWidth: 36
+                                        horizontalAlignment: Text.AlignRight
+                                    }
+                                }
+
+                                // Alignment
+                                RowLayout {
+                                    Layout.fillWidth: true
+                                    spacing: Components.ThemeManager.spaceMd
+                                    Label {
+                                        text: "Alignment"
+                                        font.family: Components.ThemeManager.fontBody
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textSecondary
+                                        Layout.preferredWidth: 84
+                                    }
+                                    DwSlider {
+                                        id: ballAlignSlider
+                                        Layout.fillWidth: true
+                                        from: 0; to: 359; stepSize: 1
+                                        value: ballAlign
+                                        onMoved: { if (backend) backend.setLedBallAlign(Math.round(value)) }
+                                    }
+                                    Label {
+                                        text: Math.round(ballAlignSlider.value) + "°"
+                                        font.family: Components.ThemeManager.fontMedium
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textPrimary
+                                        Layout.preferredWidth: 36
+                                        horizontalAlignment: Text.AlignRight
+                                    }
+                                }
+
+                                // ---- Background ----
+                                Rectangle {
+                                    Layout.fillWidth: true
+                                    Layout.preferredHeight: 1
+                                    Layout.topMargin: Components.ThemeManager.spaceXs
+                                    color: Components.ThemeManager.borderColor
+                                }
+                                Label {
+                                    text: "Behind the dot"
+                                    font.family: Components.ThemeManager.fontMedium
+                                    font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                    color: Components.ThemeManager.textSecondary
+                                }
+
+                                // Background selector (Solid / Off / any effect)
+                                GridLayout {
+                                    Layout.fillWidth: true
+                                    columns: 3
+                                    rowSpacing: Components.ThemeManager.spaceSm
+                                    columnSpacing: Components.ThemeManager.spaceSm
+                                    Repeater {
+                                        model: ballBgOptions
+                                        ChoiceChip {
+                                            Layout.fillWidth: true
+                                            label: modelData.label
+                                            selected: ballBg === modelData.value
+                                            onClicked: { if (backend) backend.setLedBallBg(modelData.value) }
                                         }
                                         }
                                     }
                                     }
+                                }
 
 
-                                    // Touch feedback
-                                    Rectangle {
-                                        id: colorTouchFeedback
-                                        anchors.fill: parent
-                                        color: Components.ThemeManager.darkMode ? "#ffffff" : "#000000"
-                                        opacity: 0
-                                        radius: 6
-
-                                        NumberAnimation {
-                                            id: colorTouchAnimation
-                                            target: colorTouchFeedback
-                                            property: "opacity"
-                                            from: 0.15
-                                            to: 0
-                                            duration: 200
+                                // Background colour (only for the solid background)
+                                ColumnLayout {
+                                    Layout.fillWidth: true
+                                    visible: ballBg === "static"
+                                    spacing: Components.ThemeManager.spaceSm
+                                    Label {
+                                        text: "Background colour"
+                                        font.family: Components.ThemeManager.fontBody
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textSecondary
+                                    }
+                                    GridLayout {
+                                        Layout.fillWidth: true
+                                        columns: 10
+                                        rowSpacing: Components.ThemeManager.spaceSm
+                                        columnSpacing: Components.ThemeManager.spaceSm
+                                        Repeater {
+                                            model: presetColors
+                                            Rectangle {
+                                                property bool isSel: ballColor2.toLowerCase() === modelData.sendColor.toLowerCase()
+                                                Layout.fillWidth: true
+                                                Layout.preferredHeight: 32
+                                                radius: 16
+                                                color: modelData.color
+                                                border.color: isSel ? Components.ThemeManager.textPrimary
+                                                                    : Qt.darker(modelData.color, 1.2)
+                                                border.width: isSel ? 2 : 1
+                                                MouseArea {
+                                                    anchors.fill: parent
+                                                    onClicked: { if (backend) backend.setLedColor2Hex(modelData.sendColor) }
+                                                }
+                                            }
                                         }
                                         }
                                     }
                                     }
                                 }
                                 }
+
+                                // Background brightness (hidden when background is off)
+                                RowLayout {
+                                    Layout.fillWidth: true
+                                    visible: ballBg !== "off"
+                                    spacing: Components.ThemeManager.spaceMd
+                                    Label {
+                                        text: "Bg brightness"
+                                        font.family: Components.ThemeManager.fontBody
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textSecondary
+                                        Layout.preferredWidth: 84
+                                    }
+                                    DwSlider {
+                                        id: ballBgSlider
+                                        Layout.fillWidth: true
+                                        from: 0; to: 255; stepSize: 1
+                                        value: ballBgBright
+                                        onMoved: { if (backend) backend.setLedBallBgBright(Math.round(value)) }
+                                    }
+                                    Label {
+                                        text: Math.round(ballBgSlider.value)
+                                        font.family: Components.ThemeManager.fontMedium
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textPrimary
+                                        Layout.preferredWidth: 36
+                                        horizontalAlignment: Text.AlignRight
+                                    }
+                                }
                             }
                             }
                         }
                         }
                     }
                     }
-                }
 
 
-                // Add some bottom spacing for better scrolling
-                Item {
-                    Layout.preferredHeight: 20
+                    // Palettes — the full firmware catalogue
+                    SettingsCard {
+                        Layout.leftMargin: Components.ThemeManager.spaceSm
+                        Layout.bottomMargin: Components.ThemeManager.spaceLg
+                        Layout.preferredHeight: palettesColumn.implicitHeight + 2 * Components.ThemeManager.spaceLg
+
+                        ColumnLayout {
+                            id: palettesColumn
+                            anchors.fill: parent
+                            anchors.margins: Components.ThemeManager.spaceLg
+                            spacing: Components.ThemeManager.spaceMd
+
+                            SectionLabel {
+                                text: "Palette"
+                            }
+
+                            Label {
+                                visible: palettesList.length === 0
+                                text: "No palettes available"
+                                font.family: Components.ThemeManager.fontBody
+                                font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                color: Components.ThemeManager.textSecondary
+                            }
+
+                            GridLayout {
+                                Layout.fillWidth: true
+                                columns: 3
+                                rowSpacing: Components.ThemeManager.spaceSm
+                                columnSpacing: Components.ThemeManager.spaceSm
+                                visible: palettesList.length > 0
+
+                                Repeater {
+                                    model: palettesList
+
+                                    ChoiceChip {
+                                        property int paletteId: modelData.id !== undefined ? modelData.id : index
+                                        property string paletteName: modelData.name || ("Palette " + paletteId)
+
+                                        Layout.fillWidth: true
+                                        label: paletteName.charAt(0).toUpperCase() + paletteName.slice(1)
+                                        selected: paletteId === currentPaletteIndex
+
+                                        onClicked: {
+                                            if (backend) {
+                                                backend.setLedPalette(paletteId)
+                                                currentPaletteIndex = paletteId
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
                 }
                 }
             }
             }
         }
         }

+ 365 - 356
dune-weaver-touch/qml/pages/ModernPatternListPage.qml

@@ -1,356 +1,365 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-import "../components"
-import "../components" as Components
-
-Page {
-    id: page
-
-    property var patternModel
-    property var backend
-    property var stackView
-    property bool searchExpanded: false
-    property bool isRefreshing: false
-    property int patternCount: patternModel ? patternModel.rowCount() : 0
-
-    // Handle pattern refresh completion from backend
-    Connections {
-        target: backend
-        function onPatternsRefreshCompleted(success, message) {
-            if (patternModel) {
-                patternModel.refresh()
-            }
-            isRefreshing = false
-        }
-    }
-
-    // Update pattern count when model resets (rowCount() is not reactive)
-    Connections {
-        target: patternModel
-        function onModelReset() {
-            patternCount = patternModel.rowCount()
-        }
-    }
-
-    Rectangle {
-        anchors.fill: parent
-        color: Components.ThemeManager.backgroundColor
-    }
-
-    ColumnLayout {
-        anchors.fill: parent
-        spacing: 0
-
-        // Header with integrated search
-        Rectangle {
-            Layout.fillWidth: true
-            Layout.preferredHeight: 50
-            color: Components.ThemeManager.surfaceColor
-
-            // Bottom border
-            Rectangle {
-                anchors.bottom: parent.bottom
-                width: parent.width
-                height: 1
-                color: Components.ThemeManager.borderColor
-            }
-
-            RowLayout {
-                anchors.fill: parent
-                anchors.leftMargin: 15
-                anchors.rightMargin: 10
-
-                ConnectionStatus {
-                    backend: page.backend
-                    Layout.rightMargin: 8
-                    visible: !searchExpanded
-                }
-
-                Label {
-                    text: "Browse Patterns"
-                    font.pixelSize: 18
-                    font.bold: true
-                    color: Components.ThemeManager.textPrimary
-                    visible: !searchExpanded
-                }
-
-                // Pattern count
-                Label {
-                    text: patternCount + " patterns"
-                    font.pixelSize: 12
-                    color: Components.ThemeManager.textTertiary
-                    visible: !searchExpanded
-                }
-
-                // Refresh button
-                Rectangle {
-                    Layout.preferredWidth: 32
-                    Layout.preferredHeight: 32
-                    radius: 16
-                    color: refreshMouseArea.pressed ? Components.ThemeManager.buttonBackgroundHover :
-                           (refreshMouseArea.containsMouse ? Components.ThemeManager.cardColor : "transparent")
-                    visible: !searchExpanded
-
-                    Text {
-                        id: refreshIcon
-                        anchors.centerIn: parent
-                        text: "↻"
-                        font.pixelSize: 16
-                        color: isRefreshing ? Components.ThemeManager.accentBlue : Components.ThemeManager.textSecondary
-
-                        SequentialAnimation on opacity {
-                            running: isRefreshing
-                            loops: Animation.Infinite
-                            NumberAnimation { to: 0.4; duration: 500 }
-                            NumberAnimation { to: 1.0; duration: 500 }
-                        }
-                    }
-
-                    MouseArea {
-                        id: refreshMouseArea
-                        anchors.fill: parent
-                        hoverEnabled: true
-                        enabled: !isRefreshing
-                        onClicked: {
-                            if (backend) {
-                                isRefreshing = true
-                                backend.refreshPatterns()
-                            }
-                        }
-                    }
-                }
-
-                Item {
-                    Layout.fillWidth: true
-                    visible: !searchExpanded
-                }
-                
-                // Expandable search
-                Rectangle {
-                    Layout.fillWidth: searchExpanded
-                    Layout.preferredWidth: searchExpanded ? parent.width - 60 : 120
-                    Layout.preferredHeight: 32
-                    radius: 16
-                    color: searchExpanded ? Components.ThemeManager.surfaceColor : Components.ThemeManager.cardColor
-                    border.color: searchExpanded ? "#2563eb" : Components.ThemeManager.borderColor
-                    border.width: 1
-
-                    Behavior on Layout.preferredWidth {
-                        NumberAnimation { duration: 200 }
-                    }
-
-                    RowLayout {
-                        anchors.fill: parent
-                        anchors.leftMargin: 10
-                        anchors.rightMargin: 10
-                        spacing: 5
-
-                        Text {
-                            text: "⌕"
-                            font.pixelSize: 16
-                            font.family: "sans-serif"
-                            color: searchExpanded ? "#2563eb" : Components.ThemeManager.textSecondary
-                        }
-                        
-                        TextField {
-                            id: searchField
-                            Layout.fillWidth: true
-                            placeholderText: searchExpanded ? "Search patterns... (press Enter)" : "Search"
-                            placeholderTextColor: Components.ThemeManager.textTertiary
-                            font.pixelSize: 14
-                            color: Components.ThemeManager.textPrimary
-                            visible: searchExpanded || text.length > 0
-                            
-                            property string lastSearchText: ""
-                            property bool hasUnappliedSearch: text !== lastSearchText && text.length > 0
-                            
-                            background: Rectangle { 
-                                color: "transparent"
-                                border.color: searchField.hasUnappliedSearch ? "#f59e0b" : "transparent"
-                                border.width: searchField.hasUnappliedSearch ? 1 : 0
-                                radius: 4
-                            }
-                            
-                            // Remove automatic filtering on text change
-                            // onTextChanged: patternModel.filter(text)
-                            
-                            // Only filter when user presses Enter or field loses focus
-                            onAccepted: {
-                                patternModel.filter(text)
-                                lastSearchText = text
-                                Qt.inputMethod.hide()
-                                focus = false
-                            }
-                            
-                            // Enable virtual keyboard
-                            activeFocusOnPress: true
-                            selectByMouse: true
-                            inputMethodHints: Qt.ImhNoPredictiveText
-                            
-                            // Direct MouseArea for touch events
-                            MouseArea {
-                                anchors.fill: parent
-                                onPressed: {
-                                    searchField.forceActiveFocus()
-                                    Qt.inputMethod.show()
-                                    mouse.accepted = false // Pass through to TextField
-                                }
-                            }
-                            
-                            onActiveFocusChanged: {
-                                if (activeFocus) {
-                                    searchExpanded = true
-                                    // Force virtual keyboard to show
-                                    Qt.inputMethod.show()
-                                } else {
-                                    // Apply search when focus is lost
-                                    if (text !== lastSearchText) {
-                                        patternModel.filter(text)
-                                        lastSearchText = text
-                                    }
-                                }
-                            }
-                            
-                            // Handle Enter key - triggers onAccepted
-                            Keys.onReturnPressed: {
-                                // onAccepted will be called automatically
-                                // Just hide keyboard and unfocus
-                                Qt.inputMethod.hide()
-                                focus = false
-                            }
-                            
-                            Keys.onEscapePressed: {
-                                // Clear search and hide keyboard
-                                text = ""
-                                lastSearchText = ""
-                                patternModel.filter("")
-                                Qt.inputMethod.hide()
-                                focus = false
-                            }
-                        }
-                        
-                        Text {
-                            text: searchExpanded || searchField.text.length > 0 ? "Search" : ""
-                            font.pixelSize: 12
-                            color: Components.ThemeManager.textTertiary
-                            visible: !searchExpanded && searchField.text.length === 0
-                        }
-                    }
-                    
-                    MouseArea {
-                        anchors.fill: parent
-                        enabled: !searchExpanded
-                        onClicked: {
-                            searchExpanded = true
-                            searchField.forceActiveFocus()
-                            Qt.inputMethod.show()
-                        }
-                    }
-                }
-                
-                // Close button when expanded
-                Button {
-                    id: searchCloseBtn
-                    flat: true
-                    visible: searchExpanded
-                    Layout.preferredWidth: 32
-                    Layout.preferredHeight: 32
-                    onClicked: {
-                        searchExpanded = false
-                        searchField.text = ""
-                        searchField.lastSearchText = ""
-                        searchField.focus = false
-                        // Clear the filter when closing search
-                        patternModel.filter("")
-                    }
-                    contentItem: Text {
-                        text: "✕"
-                        font.pixelSize: 18
-                        color: Components.ThemeManager.textSecondary
-                        horizontalAlignment: Text.AlignHCenter
-                        verticalAlignment: Text.AlignVCenter
-                    }
-                    background: Rectangle {
-                        color: searchCloseBtn.pressed ? Components.ThemeManager.buttonBackgroundHover : "transparent"
-                        radius: 4
-                    }
-                }
-            }
-        }
-        
-        // Content - Pattern Grid
-        GridView {
-            id: gridView
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            cellWidth: 200
-            cellHeight: 220
-            model: patternModel
-            clip: true
-            
-            // Add smooth scrolling
-            ScrollBar.vertical: ScrollBar {
-                active: true
-                policy: ScrollBar.AsNeeded
-            }
-            
-            delegate: ModernPatternCard {
-                width: gridView.cellWidth - 10
-                height: gridView.cellHeight - 10
-                name: model.name
-                preview: model.preview
-                
-                onClicked: {
-                    if (stackView && backend) {
-                        stackView.push("PatternDetailPage.qml", {
-                            patternName: model.name,
-                            patternPath: model.path,
-                            patternPreview: model.preview,
-                            backend: backend
-                        })
-                    }
-                }
-            }
-            
-            // Add scroll animations
-            add: Transition {
-                NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 300 }
-                NumberAnimation { property: "scale"; from: 0.8; to: 1; duration: 300 }
-            }
-        }
-        
-        // Empty state
-        Item {
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            visible: patternCount === 0 && searchField.text !== ""
-
-            Column {
-                anchors.centerIn: parent
-                spacing: 20
-
-                Text {
-                    text: "⌕"
-                    font.pixelSize: 48
-                    anchors.horizontalCenter: parent.horizontalCenter
-                    color: Components.ThemeManager.placeholderText
-                }
-
-                Label {
-                    text: "No patterns found"
-                    anchors.horizontalCenter: parent.horizontalCenter
-                    color: Components.ThemeManager.textSecondary
-                    font.pixelSize: 18
-                }
-
-                Label {
-                    text: "Try a different search term"
-                    anchors.horizontalCenter: parent.horizontalCenter
-                    color: Components.ThemeManager.textTertiary
-                    font.pixelSize: 14
-                }
-            }
-        }
-    }
-}
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import "../components"
+import "../components" as Components
+
+Page {
+    id: page
+
+    property var patternModel
+    property var backend
+    property var stackView
+    property bool searchExpanded: false
+    property bool isRefreshing: false
+    property int patternCount: patternModel ? patternModel.rowCount() : 0
+
+    // Handle pattern refresh completion from backend
+    Connections {
+        target: backend
+        function onPatternsRefreshCompleted(success, message) {
+            if (patternModel) {
+                patternModel.refresh()
+            }
+            isRefreshing = false
+        }
+    }
+
+    // Update pattern count when model resets (rowCount() is not reactive)
+    Connections {
+        target: patternModel
+        function onModelReset() {
+            patternCount = patternModel.rowCount()
+        }
+    }
+
+    Rectangle {
+        anchors.fill: parent
+        color: Components.ThemeManager.backgroundColor
+    }
+
+    ColumnLayout {
+        anchors.fill: parent
+        spacing: 0
+
+        // Header with integrated search
+        Rectangle {
+            Layout.fillWidth: true
+            Layout.preferredHeight: Components.ThemeManager.headerHeight
+            color: Components.ThemeManager.surfaceColor
+
+            Rectangle {
+                anchors.bottom: parent.bottom
+                width: parent.width
+                height: 1
+                color: Components.ThemeManager.borderColor
+            }
+
+            RowLayout {
+                anchors.fill: parent
+                anchors.leftMargin: Components.ThemeManager.spaceLg
+                anchors.rightMargin: Components.ThemeManager.spaceMd
+                spacing: Components.ThemeManager.spaceSm
+
+                ConnectionStatus {
+                    backend: page.backend
+                    visible: !searchExpanded
+                }
+
+                Label {
+                    text: "Browse"
+                    font.family: Components.ThemeManager.fontDisplay
+                    font.pixelSize: Components.ThemeManager.fontSizeTitle
+                    color: Components.ThemeManager.textPrimary
+                    visible: !searchExpanded
+                }
+
+                Label {
+                    text: patternCount + " patterns"
+                    font.family: Components.ThemeManager.fontBody
+                    font.pixelSize: Components.ThemeManager.fontSizeCaption
+                    color: Components.ThemeManager.textTertiary
+                    visible: !searchExpanded
+                }
+
+                // Refresh button
+                Rectangle {
+                    Layout.preferredWidth: 40
+                    Layout.preferredHeight: 40
+                    radius: 20
+                    color: refreshMouseArea.pressed ? Components.ThemeManager.pressedColor : "transparent"
+                    visible: !searchExpanded
+
+                    Components.Icon {
+                        id: refreshIcon
+                        anchors.centerIn: parent
+                        name: "refresh"
+                        size: 20
+                        color: isRefreshing ? Components.ThemeManager.accent
+                                            : Components.ThemeManager.textSecondary
+
+                        SequentialAnimation on opacity {
+                            running: isRefreshing
+                            loops: Animation.Infinite
+                            NumberAnimation { to: 0.4; duration: 500 }
+                            NumberAnimation { to: 1.0; duration: 500 }
+                        }
+                    }
+
+                    MouseArea {
+                        id: refreshMouseArea
+                        anchors.fill: parent
+                        enabled: !isRefreshing
+                        onClicked: {
+                            if (backend) {
+                                isRefreshing = true
+                                backend.refreshPatterns()
+                            }
+                        }
+                    }
+                }
+
+                Item {
+                    Layout.fillWidth: true
+                    visible: !searchExpanded
+                }
+
+                // Expandable search pill
+                Rectangle {
+                    Layout.fillWidth: searchExpanded
+                    Layout.preferredWidth: searchExpanded ? parent.width - 60 : 130
+                    Layout.preferredHeight: 40
+                    radius: 20
+                    color: Components.ThemeManager.backgroundColor
+                    border.color: searchExpanded || searchField.hasUnappliedSearch
+                                  ? Components.ThemeManager.accent
+                                  : Components.ThemeManager.borderColor
+                    border.width: 1
+
+                    Behavior on Layout.preferredWidth {
+                        NumberAnimation { duration: 200 }
+                    }
+
+                    RowLayout {
+                        anchors.fill: parent
+                        anchors.leftMargin: Components.ThemeManager.spaceMd
+                        anchors.rightMargin: Components.ThemeManager.spaceMd
+                        spacing: Components.ThemeManager.spaceSm
+
+                        Components.Icon {
+                            name: "search"
+                            size: 17
+                            color: searchExpanded ? Components.ThemeManager.accent
+                                                  : Components.ThemeManager.textSecondary
+                        }
+
+                        TextField {
+                            id: searchField
+                            Layout.fillWidth: true
+                            placeholderText: searchExpanded ? "Search patterns, then press Enter" : "Search"
+                            placeholderTextColor: Components.ThemeManager.textTertiary
+                            font.family: Components.ThemeManager.fontBody
+                            font.pixelSize: Components.ThemeManager.fontSizeBody
+                            color: Components.ThemeManager.textPrimary
+                            visible: searchExpanded || text.length > 0
+
+                            property string lastSearchText: ""
+                            property bool hasUnappliedSearch: text !== lastSearchText && text.length > 0
+
+                            background: Rectangle {
+                                color: "transparent"
+                            }
+
+                            // Only filter when user presses Enter or field loses focus
+                            onAccepted: {
+                                patternModel.filter(text)
+                                lastSearchText = text
+                                Qt.inputMethod.hide()
+                                focus = false
+                            }
+
+                            // Enable virtual keyboard
+                            activeFocusOnPress: true
+                            selectByMouse: true
+                            inputMethodHints: Qt.ImhNoPredictiveText
+
+                            // Direct MouseArea for touch events
+                            MouseArea {
+                                anchors.fill: parent
+                                onPressed: {
+                                    searchField.forceActiveFocus()
+                                    Qt.inputMethod.show()
+                                    mouse.accepted = false // Pass through to TextField
+                                }
+                            }
+
+                            onActiveFocusChanged: {
+                                if (activeFocus) {
+                                    searchExpanded = true
+                                    // Force virtual keyboard to show
+                                    Qt.inputMethod.show()
+                                } else {
+                                    // Apply search when focus is lost
+                                    if (text !== lastSearchText) {
+                                        patternModel.filter(text)
+                                        lastSearchText = text
+                                    }
+                                }
+                            }
+
+                            Keys.onReturnPressed: {
+                                // onAccepted runs; just hide keyboard and unfocus
+                                Qt.inputMethod.hide()
+                                focus = false
+                            }
+
+                            Keys.onEscapePressed: {
+                                text = ""
+                                lastSearchText = ""
+                                patternModel.filter("")
+                                Qt.inputMethod.hide()
+                                focus = false
+                            }
+                        }
+
+                        Text {
+                            text: "Search"
+                            font.family: Components.ThemeManager.fontBody
+                            font.pixelSize: Components.ThemeManager.fontSizeCaption
+                            color: Components.ThemeManager.textTertiary
+                            visible: !searchExpanded && searchField.text.length === 0
+                        }
+                    }
+
+                    MouseArea {
+                        anchors.fill: parent
+                        enabled: !searchExpanded
+                        onClicked: {
+                            searchExpanded = true
+                            searchField.forceActiveFocus()
+                            Qt.inputMethod.show()
+                        }
+                    }
+                }
+
+                // Close button when expanded
+                Rectangle {
+                    Layout.preferredWidth: 40
+                    Layout.preferredHeight: 40
+                    radius: 20
+                    visible: searchExpanded
+                    color: searchCloseArea.pressed ? Components.ThemeManager.pressedColor : "transparent"
+
+                    Components.Icon {
+                        anchors.centerIn: parent
+                        name: "close"
+                        size: 20
+                        color: Components.ThemeManager.textSecondary
+                    }
+
+                    MouseArea {
+                        id: searchCloseArea
+                        anchors.fill: parent
+                        onClicked: {
+                            searchExpanded = false
+                            searchField.text = ""
+                            searchField.lastSearchText = ""
+                            searchField.focus = false
+                            patternModel.filter("")
+                        }
+                    }
+                }
+            }
+        }
+
+        // Content - Pattern Grid
+        GridView {
+            id: gridView
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            cellWidth: 200
+            cellHeight: 220
+            model: patternModel
+            clip: true
+            visible: patternCount > 0
+            // Recycle delegates and pre-build the next rows: creating a card
+            // mid-flick is the other half of the scroll stutter.
+            reuseItems: true
+            cacheBuffer: 440
+
+            ScrollBar.vertical: ScrollBar {
+                active: true
+                policy: ScrollBar.AsNeeded
+            }
+
+            delegate: Item {
+                width: gridView.cellWidth
+                height: gridView.cellHeight
+
+                ModernPatternCard {
+                    anchors.centerIn: parent
+                    width: gridView.cellWidth - 10
+                    height: gridView.cellHeight - 10
+                    name: model.name
+                    preview: model.preview
+
+                    onClicked: {
+                        if (stackView && backend) {
+                            stackView.push("PatternDetailPage.qml", {
+                                patternName: model.name,
+                                patternPath: model.path,
+                                patternPreview: model.preview,
+                                backend: backend
+                            })
+                        }
+                    }
+                }
+            }
+
+            add: Transition {
+                NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 300 }
+                NumberAnimation { property: "scale"; from: 0.8; to: 1; duration: 300 }
+            }
+        }
+
+        // Empty state — no search results, or no patterns at all
+        Item {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            visible: patternCount === 0
+
+            property bool searching: searchField.text !== ""
+
+            Column {
+                anchors.centerIn: parent
+                spacing: Components.ThemeManager.spaceLg
+
+                Components.Icon {
+                    name: parent.parent.searching ? "search" : "radio_unchecked"
+                    size: 44
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textTertiary
+                }
+
+                Label {
+                    text: parent.parent.searching ? "No patterns found" : "No patterns yet"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textSecondary
+                    font.family: Components.ThemeManager.fontDisplay
+                    font.pixelSize: Components.ThemeManager.fontSizeTitle
+                }
+
+                Label {
+                    text: parent.parent.searching
+                          ? "Try a different search term"
+                          : "Connect to a table on the Control page to load its patterns"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textTertiary
+                    font.family: Components.ThemeManager.fontBody
+                    font.pixelSize: Components.ThemeManager.fontSizeBody
+                    horizontalAlignment: Text.AlignHCenter
+                }
+            }
+        }
+    }
+}

+ 914 - 1426
dune-weaver-touch/qml/pages/ModernPlaylistPage.qml

@@ -1,1426 +1,914 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-import DuneWeaver 1.0
-import "../components"
-import "../components" as Components
-
-Page {
-    id: page
-    
-    property var backend: null
-    property var stackView: null
-    property var mainWindow: null
-    
-    // State management for navigation
-    property bool showingPlaylistDetail: false
-    property string selectedPlaylist: ""
-    property var selectedPlaylistData: null
-    property var currentPlaylistPatterns: []
-    property var currentPlaylistRawPatterns: []  // Raw patterns with full paths for API calls
-    
-    // Playlist execution settings (loaded from backend/persisted settings)
-    property real pauseTime: backend ? backend.pauseBetweenPatterns : 10800
-    property string clearPattern: backend ? backend.playlistClearPattern : "adaptive"
-    property string runMode: backend ? backend.playlistRunMode : "loop"
-    property bool shuffle: backend ? backend.playlistShuffle : true
-    
-    PlaylistModel {
-        id: playlistModel
-    }
-    
-    // Update patterns when playlist selection changes
-    onSelectedPlaylistChanged: {
-        if (selectedPlaylist) {
-            currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
-            currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
-        } else {
-            currentPlaylistPatterns = []
-            currentPlaylistRawPatterns = []
-        }
-    }
-
-    // Function to remove a pattern from the current playlist
-    function removePatternAtIndex(index) {
-        if (index >= 0 && index < currentPlaylistRawPatterns.length && backend) {
-            var updatedPatterns = currentPlaylistRawPatterns.slice()  // Create a copy
-            updatedPatterns.splice(index, 1)  // Remove the pattern at index
-            backend.updatePlaylistPatterns(selectedPlaylist, updatedPatterns)
-        }
-    }
-    
-    // Debug playlist loading
-    Component.onCompleted: {
-    }
-    
-    // Function to navigate to playlist detail
-    function showPlaylistDetail(playlistName, playlistData) {
-        selectedPlaylist = playlistName
-        selectedPlaylistData = playlistData
-        showingPlaylistDetail = true
-    }
-    
-    // Function to go back to playlist list
-    function showPlaylistList() {
-        showingPlaylistDetail = false
-        selectedPlaylist = ""
-        selectedPlaylistData = null
-    }
-    
-    Rectangle {
-        anchors.fill: parent
-        color: Components.ThemeManager.backgroundColor
-    }
-
-    // Playlist List View (shown by default)
-    Rectangle {
-        id: playlistListView
-        anchors.fill: parent
-        color: Components.ThemeManager.backgroundColor
-        visible: !showingPlaylistDetail
-        
-        ColumnLayout {
-            anchors.fill: parent
-            spacing: 0
-            
-            // Header
-            Rectangle {
-                Layout.fillWidth: true
-                Layout.preferredHeight: 50
-                color: Components.ThemeManager.surfaceColor
-
-                // Bottom border
-                Rectangle {
-                    anchors.bottom: parent.bottom
-                    width: parent.width
-                    height: 1
-                    color: Components.ThemeManager.borderColor
-                }
-                
-                RowLayout {
-                    anchors.fill: parent
-                    anchors.leftMargin: 15
-                    anchors.rightMargin: 10
-                    
-                    ConnectionStatus {
-                        backend: page.backend
-                        Layout.rightMargin: 8
-                    }
-                    
-                    Label {
-                        text: "Playlists"
-                        font.pixelSize: 18
-                        font.bold: true
-                        color: Components.ThemeManager.textPrimary
-                    }
-
-                    Label {
-                        text: playlistModel.rowCount() + " playlists"
-                        font.pixelSize: 12
-                        color: Components.ThemeManager.textTertiary
-                    }
-
-                    Item {
-                        Layout.fillWidth: true
-                    }
-
-                    // Create new playlist button
-                    Text {
-                        text: "+"
-                        font.pixelSize: 32
-                        font.bold: true
-                        color: createPlaylistMouseArea.pressed ? "#1e40af" : "#2563eb"
-
-                        MouseArea {
-                            id: createPlaylistMouseArea
-                            anchors.fill: parent
-                            anchors.margins: -8  // Increase touch area
-                            onClicked: createPlaylistDialog.open()
-                        }
-                    }
-                }
-            }
-            
-            // Playlist List
-            ListView {
-                Layout.fillWidth: true
-                Layout.fillHeight: true
-                Layout.margins: 15
-                model: playlistModel
-                spacing: 12
-                clip: true
-                
-                ScrollBar.vertical: ScrollBar {
-                    active: true
-                    policy: ScrollBar.AsNeeded
-                }
-                
-                delegate: Rectangle {
-                    width: ListView.view.width
-                    height: 80
-                    color: Components.ThemeManager.surfaceColor
-                    radius: 12
-                    border.color: Components.ThemeManager.borderColor
-                    border.width: 1
-                    
-                    // Press animation
-                    scale: mouseArea.pressed ? 0.98 : 1.0
-                    
-                    Behavior on scale {
-                        NumberAnimation { duration: 100; easing.type: Easing.OutQuad }
-                    }
-                    
-                    RowLayout {
-                        anchors.fill: parent
-                        anchors.margins: 20
-                        spacing: 15
-                        
-                        // Icon
-                        Rectangle {
-                            Layout.preferredWidth: 40
-                            Layout.preferredHeight: 40
-                            radius: 20
-                            color: Components.ThemeManager.darkMode ? "#1e3a5f" : "#e3f2fd"
-
-                            Text {
-                                anchors.centerIn: parent
-                                text: "♪"
-                                font.pixelSize: 18
-                                color: "#2196F3"
-                            }
-                        }
-                        
-                        // Playlist info
-                        Column {
-                            Layout.fillWidth: true
-                            spacing: 4
-                            
-                            Label {
-                                text: model.name
-                                font.pixelSize: 16
-                                font.bold: true
-                                color: Components.ThemeManager.textPrimary
-                                elide: Text.ElideRight
-                                width: parent.width
-                            }
-
-                            Label {
-                                text: model.itemCount + " patterns"
-                                color: Components.ThemeManager.textSecondary
-                                font.pixelSize: 12
-                            }
-                        }
-                        
-                        // Arrow
-                        Text {
-                            text: "▶"
-                            font.pixelSize: 16
-                            color: Components.ThemeManager.textTertiary
-                        }
-                    }
-                    
-                    MouseArea {
-                        id: mouseArea
-                        anchors.fill: parent
-                        onClicked: {
-                            showPlaylistDetail(model.name, model)
-                        }
-                    }
-                }
-                
-                // Empty state
-                Rectangle {
-                    anchors.fill: parent
-                    color: "transparent"
-                    visible: playlistModel.rowCount() === 0
-                    
-                    Column {
-                        anchors.centerIn: parent
-                        spacing: 15
-                        
-                        Text {
-                            text: "♪"
-                            color: Components.ThemeManager.placeholderText
-                            font.pixelSize: 64
-                            anchors.horizontalCenter: parent.horizontalCenter
-                        }
-
-                        Label {
-                            text: "No playlists found"
-                            anchors.horizontalCenter: parent.horizontalCenter
-                            color: Components.ThemeManager.textSecondary
-                            font.pixelSize: 18
-                        }
-
-                        Label {
-                            text: "Create playlists to organize\\nyour pattern collections"
-                            anchors.horizontalCenter: parent.horizontalCenter
-                            color: Components.ThemeManager.textTertiary
-                            font.pixelSize: 14
-                            horizontalAlignment: Text.AlignHCenter
-                        }
-                    }
-                }
-            }
-        }
-    }
-    
-    // Playlist Detail View (shown when a playlist is selected)
-    Rectangle {
-        id: playlistDetailView
-        anchors.fill: parent
-        color: Components.ThemeManager.backgroundColor
-        visible: showingPlaylistDetail
-        
-        ColumnLayout {
-            anchors.fill: parent
-            spacing: 0
-            
-            // Header with back button
-            Rectangle {
-                Layout.fillWidth: true
-                Layout.preferredHeight: 50
-                color: Components.ThemeManager.surfaceColor
-
-                // Bottom border
-                Rectangle {
-                    anchors.bottom: parent.bottom
-                    width: parent.width
-                    height: 1
-                    color: Components.ThemeManager.borderColor
-                }
-                
-                RowLayout {
-                    anchors.fill: parent
-                    anchors.leftMargin: 15
-                    anchors.rightMargin: 10
-                    spacing: 10
-                    
-                    ConnectionStatus {
-                        backend: page.backend
-                        Layout.rightMargin: 8
-                    }
-                    
-                    Button {
-                        text: "← Back"
-                        font.pixelSize: 14
-                        flat: true
-                        onClicked: showPlaylistList()
-
-                        contentItem: Text {
-                            text: parent.text
-                            font: parent.font
-                            color: Components.ThemeManager.textPrimary
-                            horizontalAlignment: Text.AlignHCenter
-                            verticalAlignment: Text.AlignVCenter
-                        }
-                    }
-                    
-                    Label {
-                        text: selectedPlaylist
-                        font.pixelSize: 18
-                        font.bold: true
-                        color: Components.ThemeManager.textPrimary
-                        Layout.fillWidth: true
-                        elide: Text.ElideRight
-                    }
-
-                    Label {
-                        text: currentPlaylistPatterns.length + " patterns"
-                        font.pixelSize: 12
-                        color: Components.ThemeManager.textTertiary
-                    }
-
-                    // Delete playlist button
-                    Text {
-                        text: "✕"
-                        font.pixelSize: 20
-                        color: deletePlaylistMouseArea.pressed ? "#991b1b" : "#dc2626"
-
-                        MouseArea {
-                            id: deletePlaylistMouseArea
-                            anchors.fill: parent
-                            anchors.margins: -8
-                            onClicked: deletePlaylistDialog.open()
-                        }
-                    }
-                }
-            }
-            
-            // Content - Pattern list on left, controls on right
-            Item {
-                Layout.fillWidth: true
-                Layout.fillHeight: true
-                
-                Row {
-                    anchors.fill: parent
-                    spacing: 0
-                    
-                    // Left side - Pattern List (40% of width)
-                    Rectangle {
-                        width: parent.width * 0.4
-                        height: parent.height
-                        color: Components.ThemeManager.surfaceColor
-                        
-                        ColumnLayout {
-                            anchors.fill: parent
-                            anchors.margins: 15
-                            spacing: 10
-
-                            RowLayout {
-                                Layout.fillWidth: true
-                                spacing: 10
-
-                                Label {
-                                    text: "Patterns"
-                                    font.pixelSize: 14
-                                    font.bold: true
-                                    color: Components.ThemeManager.textPrimary
-                                    Layout.fillWidth: true
-                                }
-
-                                // Add pattern button
-                                Text {
-                                    text: "+"
-                                    font.pixelSize: 24
-                                    font.bold: true
-                                    color: addPatternMouseArea.pressed ? "#1e40af" : "#2563eb"
-
-                                    MouseArea {
-                                        id: addPatternMouseArea
-                                        anchors.fill: parent
-                                        anchors.margins: -8
-                                        onClicked: {
-                                            // Navigate to full-page pattern selector
-                                            stackView.push("PatternSelectorPage.qml", {
-                                                backend: backend,
-                                                stackView: stackView,
-                                                playlistName: selectedPlaylist,
-                                                existingPatterns: currentPlaylistRawPatterns
-                                            })
-                                        }
-                                    }
-                                }
-                            }
-
-                            ScrollView {
-                                Layout.fillWidth: true
-                                Layout.fillHeight: true
-                                clip: true
-                                
-                                ListView {
-                                    id: patternListView
-                                    width: parent.width
-                                    model: currentPlaylistPatterns
-                                    spacing: 6
-                                    
-                                    delegate: Rectangle {
-                                        width: patternListView.width
-                                        height: 40
-                                        color: index % 2 === 0 ? Components.ThemeManager.cardColor : Components.ThemeManager.surfaceColor
-                                        radius: 6
-                                        border.color: Components.ThemeManager.borderColor
-                                        border.width: 1
-
-                                        RowLayout {
-                                            anchors.fill: parent
-                                            anchors.leftMargin: 10
-                                            anchors.rightMargin: 5
-                                            anchors.topMargin: 4
-                                            anchors.bottomMargin: 4
-                                            spacing: 6
-
-                                            Label {
-                                                text: (index + 1) + "."
-                                                font.pixelSize: 11
-                                                color: Components.ThemeManager.textSecondary
-                                                Layout.preferredWidth: 22
-                                            }
-
-                                            Label {
-                                                text: modelData
-                                                font.pixelSize: 11
-                                                color: Components.ThemeManager.textPrimary
-                                                Layout.fillWidth: true
-                                                elide: Text.ElideRight
-                                            }
-
-                                            // Remove pattern button - aligned right
-                                            Text {
-                                                text: "✕"
-                                                font.pixelSize: 16
-                                                color: removePatternArea.pressed ? "#ef4444" : Components.ThemeManager.textTertiary
-                                                Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
-
-                                                MouseArea {
-                                                    id: removePatternArea
-                                                    anchors.fill: parent
-                                                    anchors.margins: -8  // Increase touch area
-                                                    onClicked: {
-                                                        removePatternAtIndex(index)
-                                                    }
-                                                }
-                                            }
-                                        }
-                                    }
-                                }
-                            }
-                            
-                            // Empty playlist message
-                            Rectangle {
-                                Layout.fillWidth: true
-                                Layout.fillHeight: true
-                                color: "transparent"
-                                visible: currentPlaylistPatterns.length === 0
-                                
-                                Column {
-                                    anchors.centerIn: parent
-                                    spacing: 10
-                                    
-                                    Text {
-                                        text: "♪"
-                                        font.pixelSize: 32
-                                        color: Components.ThemeManager.placeholderText
-                                        anchors.horizontalCenter: parent.horizontalCenter
-                                    }
-
-                                    Label {
-                                        text: "Empty playlist"
-                                        anchors.horizontalCenter: parent.horizontalCenter
-                                        color: Components.ThemeManager.textSecondary
-                                        font.pixelSize: 14
-                                    }
-                                }
-                            }
-                        }
-                    }
-                    
-                    // Divider
-                    Rectangle {
-                        width: 1
-                        height: parent.height
-                        color: Components.ThemeManager.borderColor
-                    }
-
-                    // Right side - Full height controls (60% of width)
-                    Rectangle {
-                        width: parent.width * 0.6 - 1
-                        height: parent.height
-                        color: Components.ThemeManager.surfaceColor
-                        
-                        ColumnLayout {
-                            anchors.fill: parent
-                            anchors.margins: 15
-                            spacing: 15
-                            
-                            Label {
-                                text: "Playlist Controls"
-                                font.pixelSize: 16
-                                font.bold: true
-                                color: Components.ThemeManager.textPrimary
-                            }
-                            
-                            // Main execution buttons
-                            RowLayout {
-                                Layout.fillWidth: true
-                                spacing: 10
-                                
-                                // Play Playlist button
-                                Rectangle {
-                                    Layout.fillWidth: true
-                                    Layout.preferredHeight: 45
-                                    radius: 8
-                                    color: playMouseArea.pressed ? "#1e40af" : "#2563eb"
-                                    
-                                    Text {
-                                        anchors.centerIn: parent
-                                        text: "Play Playlist"
-                                        color: "white"
-                                        font.pixelSize: 14
-                                        font.bold: true
-                                    }
-                                    
-                                    MouseArea {
-                                        id: playMouseArea
-                                        anchors.fill: parent
-                                        onClicked: {
-                                            if (backend) {
-                                                backend.executePlaylist(selectedPlaylist, pauseTime, clearPattern, runMode, shuffle)
-
-                                                // Navigate to execution page
-                                                if (mainWindow) {
-                                                    mainWindow.shouldNavigateToExecution = true
-                                                }
-                                            }
-                                        }
-                                    }
-                                }
-                                
-                                // Shuffle toggle button
-                                Rectangle {
-                                    Layout.preferredWidth: 60
-                                    Layout.preferredHeight: 45
-                                    radius: 8
-                                    color: shuffle ? "#2563eb" : "#6b7280"
-                                    
-                                    Text {
-                                        anchors.centerIn: parent
-                                        text: "⇄"
-                                        color: "white"
-                                        font.pixelSize: 16
-                                    }
-                                    
-                                    MouseArea {
-                                        id: shuffleMouseArea
-                                        anchors.fill: parent
-                                        onClicked: {
-                                            // Don't assign directly to shuffle - that breaks the binding
-                                            // Just update backend and let the binding propagate the change
-                                            if (backend) backend.setPlaylistShuffle(!backend.playlistShuffle)
-                                        }
-                                    }
-                                }
-                            }
-                            
-                            // Settings section
-                            Rectangle {
-                                Layout.fillWidth: true
-                                Layout.fillHeight: true
-                                Layout.minimumHeight: 250
-                                radius: 10
-                                color: Components.ThemeManager.cardColor
-                                border.color: Components.ThemeManager.borderColor
-                                border.width: 1
-
-                                ColumnLayout {
-                                    anchors.fill: parent
-                                    anchors.margins: 15
-                                    spacing: 15
-
-                                    Label {
-                                        text: "Settings"
-                                        font.pixelSize: 14
-                                        font.bold: true
-                                        color: Components.ThemeManager.textPrimary
-                                    }
-
-                                    // Scrollable settings content
-                                    ScrollView {
-                                        Layout.fillWidth: true
-                                        Layout.fillHeight: true
-                                        clip: true
-
-                                        ScrollBar.vertical.policy: ScrollBar.AsNeeded
-
-                                        ColumnLayout {
-                                            width: parent.width
-                                            spacing: 15
-                                    
-                                    // Run mode
-                                    Column {
-                                        Layout.fillWidth: true
-                                        spacing: 8
-                                        
-                                        Label {
-                                            text: "Run Mode:"
-                                            font.pixelSize: 12
-                                            color: Components.ThemeManager.textSecondary
-                                            font.bold: true
-                                        }
-                                        
-                                        RowLayout {
-                                            width: parent.width
-                                            spacing: 15
-                                            
-                                            RadioButton {
-                                                id: singleModeRadio
-                                                text: "Single"
-                                                font.pixelSize: 11
-                                                checked: runMode === "single"
-                                                onClicked: {
-                                                    runMode = "single"
-                                                    if (backend) backend.setPlaylistRunMode("single")
-                                                }
-
-                                                contentItem: Text {
-                                                    text: parent.text
-                                                    font: parent.font
-                                                    color: Components.ThemeManager.textPrimary
-                                                    verticalAlignment: Text.AlignVCenter
-                                                    leftPadding: parent.indicator.width + parent.spacing
-                                                }
-                                            }
-
-                                            RadioButton {
-                                                id: loopModeRadio
-                                                text: "Loop"
-                                                font.pixelSize: 11
-                                                checked: runMode === "loop"
-                                                onClicked: {
-                                                    runMode = "loop"
-                                                    if (backend) backend.setPlaylistRunMode("loop")
-                                                }
-
-                                                contentItem: Text {
-                                                    text: parent.text
-                                                    font: parent.font
-                                                    color: Components.ThemeManager.textPrimary
-                                                    verticalAlignment: Text.AlignVCenter
-                                                    leftPadding: parent.indicator.width + parent.spacing
-                                                }
-                                            }
-                                        }
-                                    }
-                                    
-                                    // Pause Between Patterns
-                                    Column {
-                                        Layout.fillWidth: true
-                                        spacing: 15
-                                        
-                                        Label {
-                                            text: "Pause between patterns:"
-                                            font.pixelSize: 12
-                                            color: Components.ThemeManager.textSecondary
-                                            font.bold: true
-                                        }
-                                        
-                                        // Touch-friendly button row for pause options
-                                        RowLayout {
-                                            id: pauseGrid
-                                            Layout.fillWidth: true
-                                            spacing: 8
-                                            
-                                            property string currentSelection: backend ? backend.getCurrentPauseOption() : "0s"
-                                            
-                                            // 0s button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "0s" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "0s" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "0s"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "0s" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-                                                
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("0s")
-                                                            pauseGrid.currentSelection = "0s"
-                                                            pauseTime = 0
-                                                        }
-                                                    }
-                                                }
-                                            }
-                                            
-                                            // 1 min button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "1 min" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "1 min" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "1m"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "1 min" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-                                                
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("1 min")
-                                                            pauseGrid.currentSelection = "1 min"
-                                                            pauseTime = 60
-                                                        }
-                                                    }
-                                                }
-                                            }
-                                            
-                                            // 5 min button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "5 min" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "5 min" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "5m"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "5 min" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-                                                
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("5 min")
-                                                            pauseGrid.currentSelection = "5 min"
-                                                            pauseTime = 300
-                                                        }
-                                                    }
-                                                }
-                                            }
-                                            
-                                            // 15 min button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "15 min" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "15 min" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "15m"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "15 min" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-                                                
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("15 min")
-                                                            pauseGrid.currentSelection = "15 min"
-                                                            pauseTime = 900
-                                                        }
-                                                    }
-                                                }
-                                            }
-                                            
-                                            // 30 min button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "30 min" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "30 min" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "30m"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "30 min" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-                                                
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("30 min")
-                                                            pauseGrid.currentSelection = "30 min"
-                                                            pauseTime = 1800
-                                                        }
-                                                    }
-                                                }
-                                            }
-                                            
-                                            // 1 hour button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "1 hour" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "1 hour" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "1h"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "1 hour" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("1 hour")
-                                                            pauseGrid.currentSelection = "1 hour"
-                                                            pauseTime = 3600
-                                                        }
-                                                    }
-                                                }
-                                            }
-                                        }
-
-                                        // Second row for longer hour options
-                                        RowLayout {
-                                            Layout.fillWidth: true
-                                            spacing: 8
-
-                                            // 2h button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "2 hour" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "2 hour" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "2h"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "2 hour" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("2 hour")
-                                                            pauseGrid.currentSelection = "2 hour"
-                                                            pauseTime = 7200
-                                                        }
-                                                    }
-                                                }
-                                            }
-
-                                            // 3h button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "3 hour" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "3 hour" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "3h"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "3 hour" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("3 hour")
-                                                            pauseGrid.currentSelection = "3 hour"
-                                                            pauseTime = 10800
-                                                        }
-                                                    }
-                                                }
-                                            }
-
-                                            // 4h button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "4 hour" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "4 hour" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "4h"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "4 hour" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("4 hour")
-                                                            pauseGrid.currentSelection = "4 hour"
-                                                            pauseTime = 14400
-                                                        }
-                                                    }
-                                                }
-                                            }
-
-                                            // 5h button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "5 hour" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "5 hour" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "5h"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "5 hour" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("5 hour")
-                                                            pauseGrid.currentSelection = "5 hour"
-                                                            pauseTime = 18000
-                                                        }
-                                                    }
-                                                }
-                                            }
-
-                                            // 6h button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "6 hour" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "6 hour" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "6h"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "6 hour" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("6 hour")
-                                                            pauseGrid.currentSelection = "6 hour"
-                                                            pauseTime = 21600
-                                                        }
-                                                    }
-                                                }
-                                            }
-
-                                            // 12h button
-                                            Rectangle {
-                                                Layout.preferredWidth: 60
-                                                Layout.preferredHeight: 40
-                                                color: pauseGrid.currentSelection === "12 hour" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                                border.color: pauseGrid.currentSelection === "12 hour" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                                border.width: 2
-                                                radius: 8
-
-                                                Label {
-                                                    anchors.centerIn: parent
-                                                    text: "12h"
-                                                    font.pixelSize: 12
-                                                    font.bold: true
-                                                    color: pauseGrid.currentSelection === "12 hour" ? "white" : Components.ThemeManager.textPrimary
-                                                }
-
-                                                MouseArea {
-                                                    anchors.fill: parent
-                                                    onClicked: {
-                                                        if (backend) {
-                                                            backend.setPauseByOption("12 hour")
-                                                            pauseGrid.currentSelection = "12 hour"
-                                                            pauseTime = 43200
-                                                        }
-                                                    }
-                                                }
-                                            }
-
-                                            // Update selection when backend changes
-                                            Connections {
-                                                target: backend
-                                                function onPauseBetweenPatternsChanged(pause) {
-                                                    if (backend) {
-                                                        pauseGrid.currentSelection = backend.getCurrentPauseOption()
-                                                        pauseTime = backend.pauseBetweenPatterns
-                                                    }
-                                                }
-                                            }
-                                        }
-                                    }
-                                    
-                                    // Clear pattern
-                                    Column {
-                                        Layout.fillWidth: true
-                                        spacing: 8
-                                        
-                                        Label {
-                                            text: "Clear Pattern:"
-                                            font.pixelSize: 12
-                                            color: Components.ThemeManager.textSecondary
-                                            font.bold: true
-                                        }
-                                        
-                                        GridLayout {
-                                            width: parent.width
-                                            columns: 2
-                                            columnSpacing: 10
-                                            rowSpacing: 5
-                                            
-                                            RadioButton {
-                                                id: adaptiveRadio
-                                                text: "Adaptive"
-                                                font.pixelSize: 11
-                                                checked: clearPattern === "adaptive"
-                                                onClicked: {
-                                                    clearPattern = "adaptive"
-                                                    if (backend) backend.setPlaylistClearPattern("adaptive")
-                                                }
-
-                                                contentItem: Text {
-                                                    text: parent.text
-                                                    font: parent.font
-                                                    color: Components.ThemeManager.textPrimary
-                                                    verticalAlignment: Text.AlignVCenter
-                                                    leftPadding: parent.indicator.width + parent.spacing
-                                                }
-                                            }
-
-                                            RadioButton {
-                                                id: clearCenterRadio
-                                                text: "Clear Center"
-                                                font.pixelSize: 11
-                                                checked: clearPattern === "clear_center"
-                                                onClicked: {
-                                                    clearPattern = "clear_center"
-                                                    if (backend) backend.setPlaylistClearPattern("clear_center")
-                                                }
-
-                                                contentItem: Text {
-                                                    text: parent.text
-                                                    font: parent.font
-                                                    color: Components.ThemeManager.textPrimary
-                                                    verticalAlignment: Text.AlignVCenter
-                                                    leftPadding: parent.indicator.width + parent.spacing
-                                                }
-                                            }
-
-                                            RadioButton {
-                                                id: clearEdgeRadio
-                                                text: "Clear Edge"
-                                                font.pixelSize: 11
-                                                checked: clearPattern === "clear_perimeter"
-                                                onClicked: {
-                                                    clearPattern = "clear_perimeter"
-                                                    if (backend) backend.setPlaylistClearPattern("clear_perimeter")
-                                                }
-
-                                                contentItem: Text {
-                                                    text: parent.text
-                                                    font: parent.font
-                                                    color: Components.ThemeManager.textPrimary
-                                                    verticalAlignment: Text.AlignVCenter
-                                                    leftPadding: parent.indicator.width + parent.spacing
-                                                }
-                                            }
-
-                                            RadioButton {
-                                                id: noneRadio
-                                                text: "None"
-                                                font.pixelSize: 11
-                                                checked: clearPattern === "none"
-                                                onClicked: {
-                                                    clearPattern = "none"
-                                                    if (backend) backend.setPlaylistClearPattern("none")
-                                                }
-
-                                                contentItem: Text {
-                                                    text: parent.text
-                                                    font: parent.font
-                                                    color: Components.ThemeManager.textPrimary
-                                                    verticalAlignment: Text.AlignVCenter
-                                                    leftPadding: parent.indicator.width + parent.spacing
-                                                }
-                                            }
-                                        }
-                                    }
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    // ==================== Dialogs ====================
-
-    // Create Playlist Dialog
-    Popup {
-        id: createPlaylistDialog
-        modal: true
-        x: (parent.width - width) / 2
-        y: (parent.height - height) / 2
-        width: 320
-        height: 200
-        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
-
-        background: Rectangle {
-            color: Components.ThemeManager.surfaceColor
-            radius: 16
-            border.color: Components.ThemeManager.borderColor
-            border.width: 1
-        }
-
-        contentItem: ColumnLayout {
-            anchors.fill: parent
-            anchors.margins: 20
-            spacing: 15
-
-            Label {
-                text: "Create New Playlist"
-                font.pixelSize: 18
-                font.bold: true
-                color: Components.ThemeManager.textPrimary
-                Layout.alignment: Qt.AlignHCenter
-            }
-
-            TextField {
-                id: newPlaylistNameField
-                Layout.fillWidth: true
-                Layout.preferredHeight: 45
-                placeholderText: "Enter playlist name..."
-                placeholderTextColor: Components.ThemeManager.textTertiary
-                font.pixelSize: 14
-                color: Components.ThemeManager.textPrimary
-
-                background: Rectangle {
-                    color: Components.ThemeManager.backgroundColor
-                    radius: 8
-                    border.color: newPlaylistNameField.activeFocus ? "#2563eb" : Components.ThemeManager.borderColor
-                    border.width: 1
-                }
-
-                onAccepted: {
-                    if (text.trim().length > 0 && backend) {
-                        backend.createPlaylist(text.trim())
-                    }
-                }
-            }
-
-            RowLayout {
-                Layout.fillWidth: true
-                spacing: 10
-
-                // Cancel button
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: 45
-                    radius: 8
-                    color: cancelCreateArea.pressed ? Components.ThemeManager.buttonBackgroundHover : Components.ThemeManager.cardColor
-                    border.color: Components.ThemeManager.borderColor
-                    border.width: 1
-
-                    Text {
-                        anchors.centerIn: parent
-                        text: "Cancel"
-                        color: Components.ThemeManager.textPrimary
-                        font.pixelSize: 14
-                    }
-
-                    MouseArea {
-                        id: cancelCreateArea
-                        anchors.fill: parent
-                        onClicked: {
-                            newPlaylistNameField.text = ""
-                            createPlaylistDialog.close()
-                        }
-                    }
-                }
-
-                // Create button
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: 45
-                    radius: 8
-                    color: createArea.pressed ? "#1e40af" : "#2563eb"
-                    opacity: newPlaylistNameField.text.trim().length > 0 ? 1.0 : 0.5
-
-                    Text {
-                        anchors.centerIn: parent
-                        text: "Create"
-                        color: "white"
-                        font.pixelSize: 14
-                        font.bold: true
-                    }
-
-                    MouseArea {
-                        id: createArea
-                        anchors.fill: parent
-                        enabled: newPlaylistNameField.text.trim().length > 0
-                        onClicked: {
-                            if (backend) {
-                                backend.createPlaylist(newPlaylistNameField.text.trim())
-                            }
-                        }
-                    }
-                }
-            }
-        }
-
-        onOpened: {
-            newPlaylistNameField.text = ""
-            newPlaylistNameField.forceActiveFocus()
-        }
-    }
-
-    // Delete Playlist Confirmation Dialog
-    Popup {
-        id: deletePlaylistDialog
-        modal: true
-        x: (parent.width - width) / 2
-        y: (parent.height - height) / 2
-        width: 320
-        height: 180
-        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
-
-        background: Rectangle {
-            color: Components.ThemeManager.surfaceColor
-            radius: 16
-            border.color: Components.ThemeManager.borderColor
-            border.width: 1
-        }
-
-        contentItem: ColumnLayout {
-            anchors.fill: parent
-            anchors.margins: 20
-            spacing: 15
-
-            Label {
-                text: "Delete Playlist?"
-                font.pixelSize: 18
-                font.bold: true
-                color: Components.ThemeManager.textPrimary
-                Layout.alignment: Qt.AlignHCenter
-            }
-
-            Label {
-                text: "Are you sure you want to delete\n\"" + selectedPlaylist + "\"?"
-                font.pixelSize: 14
-                color: Components.ThemeManager.textSecondary
-                horizontalAlignment: Text.AlignHCenter
-                Layout.alignment: Qt.AlignHCenter
-            }
-
-            RowLayout {
-                Layout.fillWidth: true
-                spacing: 10
-
-                // Cancel button
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: 45
-                    radius: 8
-                    color: cancelDeleteArea.pressed ? Components.ThemeManager.buttonBackgroundHover : Components.ThemeManager.cardColor
-                    border.color: Components.ThemeManager.borderColor
-                    border.width: 1
-
-                    Text {
-                        anchors.centerIn: parent
-                        text: "Cancel"
-                        color: Components.ThemeManager.textPrimary
-                        font.pixelSize: 14
-                    }
-
-                    MouseArea {
-                        id: cancelDeleteArea
-                        anchors.fill: parent
-                        onClicked: deletePlaylistDialog.close()
-                    }
-                }
-
-                // Delete button
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: 45
-                    radius: 8
-                    color: confirmDeleteArea.pressed ? "#991b1b" : "#dc2626"
-
-                    Text {
-                        anchors.centerIn: parent
-                        text: "Delete"
-                        color: "white"
-                        font.pixelSize: 14
-                        font.bold: true
-                    }
-
-                    MouseArea {
-                        id: confirmDeleteArea
-                        anchors.fill: parent
-                        onClicked: {
-                            if (backend && selectedPlaylist) {
-                                backend.deletePlaylist(selectedPlaylist)
-                            }
-                            deletePlaylistDialog.close()
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    // ==================== Backend Signal Handlers ====================
-
-    Connections {
-        target: backend
-
-        function onPlaylistCreated(success, message) {
-            if (success) {
-                playlistModel.refresh()
-            }
-            newPlaylistNameField.text = ""
-            createPlaylistDialog.close()
-        }
-
-        function onPlaylistDeleted(success, message) {
-            if (success) {
-                playlistModel.refresh()
-                showPlaylistList()  // Navigate back to list
-            }
-        }
-
-        function onPatternAddedToPlaylist(success, message) {
-            if (success) {
-                playlistModel.refresh()
-                // Refresh current playlist patterns if we're viewing one
-                if (selectedPlaylist) {
-                    currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
-                    currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
-                }
-            }
-        }
-
-        function onPlaylistModified(success, message) {
-            if (success) {
-                playlistModel.refresh()
-                // Refresh current playlist patterns
-                if (selectedPlaylist) {
-                    currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
-                    currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
-                }
-            }
-        }
-    }
-}
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import DuneWeaver 1.0
+import "../components"
+import "../components" as Components
+
+Page {
+    id: page
+
+    property var backend: null
+    property var stackView: null
+    property var mainWindow: null
+
+    // State management for navigation
+    property bool showingPlaylistDetail: false
+    property string selectedPlaylist: ""
+    property var selectedPlaylistData: null
+    property var currentPlaylistPatterns: []
+    property var currentPlaylistRawPatterns: []  // Raw patterns with full paths for API calls
+
+    // Playlist execution settings (loaded from backend/persisted settings)
+    property real pauseTime: backend ? backend.pauseBetweenPatterns : 10800
+    property string clearPattern: backend ? backend.playlistClearPattern : "adaptive"
+    property string runMode: backend ? backend.playlistRunMode : "loop"
+    property bool shuffle: backend ? backend.playlistShuffle : true
+
+    PlaylistModel {
+        id: playlistModel
+    }
+
+    // Update patterns when playlist selection changes
+    onSelectedPlaylistChanged: {
+        if (selectedPlaylist) {
+            currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
+            currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
+        } else {
+            currentPlaylistPatterns = []
+            currentPlaylistRawPatterns = []
+        }
+    }
+
+    // Function to remove a pattern from the current playlist
+    function removePatternAtIndex(index) {
+        if (index >= 0 && index < currentPlaylistRawPatterns.length && backend) {
+            var updatedPatterns = currentPlaylistRawPatterns.slice()  // Create a copy
+            updatedPatterns.splice(index, 1)  // Remove the pattern at index
+            backend.updatePlaylistPatterns(selectedPlaylist, updatedPatterns)
+        }
+    }
+
+    // Function to navigate to playlist detail
+    function showPlaylistDetail(playlistName, playlistData) {
+        selectedPlaylist = playlistName
+        selectedPlaylistData = playlistData
+        showingPlaylistDetail = true
+    }
+
+    // Function to go back to playlist list
+    function showPlaylistList() {
+        showingPlaylistDetail = false
+        selectedPlaylist = ""
+        selectedPlaylistData = null
+    }
+
+    Rectangle {
+        anchors.fill: parent
+        color: Components.ThemeManager.backgroundColor
+    }
+
+    // Playlist List View (shown by default)
+    Item {
+        id: playlistListView
+        anchors.fill: parent
+        visible: !showingPlaylistDetail
+
+        ColumnLayout {
+            anchors.fill: parent
+            spacing: 0
+
+            // Header
+            Rectangle {
+                Layout.fillWidth: true
+                Layout.preferredHeight: Components.ThemeManager.headerHeight
+                color: Components.ThemeManager.surfaceColor
+
+                Rectangle {
+                    anchors.bottom: parent.bottom
+                    width: parent.width
+                    height: 1
+                    color: Components.ThemeManager.borderColor
+                }
+
+                RowLayout {
+                    anchors.fill: parent
+                    anchors.leftMargin: Components.ThemeManager.spaceLg
+                    anchors.rightMargin: Components.ThemeManager.spaceMd
+                    spacing: Components.ThemeManager.spaceSm
+
+                    ConnectionStatus {
+                        backend: page.backend
+                    }
+
+                    Label {
+                        text: "Playlists"
+                        font.family: Components.ThemeManager.fontDisplay
+                        font.pixelSize: Components.ThemeManager.fontSizeTitle
+                        color: Components.ThemeManager.textPrimary
+                    }
+
+                    Label {
+                        // ListView.count is reactive; rowCount() is a plain
+                        // function call and would only evaluate once.
+                        text: playlistsList.count + " playlists"
+                        font.family: Components.ThemeManager.fontBody
+                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                        color: Components.ThemeManager.textTertiary
+                    }
+
+                    Item {
+                        Layout.fillWidth: true
+                    }
+
+                    // Create new playlist
+                    Rectangle {
+                        Layout.preferredWidth: 44
+                        Layout.preferredHeight: 44
+                        radius: 22
+                        color: createPlaylistMouseArea.pressed ? Components.ThemeManager.accentPressed
+                                                               : Components.ThemeManager.accent
+
+                        Components.Icon {
+                            anchors.centerIn: parent
+                            name: "add"
+                            size: 22
+                            color: Components.ThemeManager.onAccent
+                        }
+
+                        MouseArea {
+                            id: createPlaylistMouseArea
+                            anchors.fill: parent
+                            onClicked: createPlaylistDialog.open()
+                        }
+                    }
+                }
+            }
+
+            // Playlist List
+            ListView {
+                id: playlistsList
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+                Layout.margins: Components.ThemeManager.spaceLg
+                model: playlistModel
+                spacing: Components.ThemeManager.spaceMd
+                clip: true
+
+                ScrollBar.vertical: ScrollBar {
+                    active: true
+                    policy: ScrollBar.AsNeeded
+                }
+
+                delegate: Rectangle {
+                    width: ListView.view.width
+                    height: 76
+                    color: mouseArea.pressed ? Components.ThemeManager.pressedColor
+                                             : Components.ThemeManager.surfaceColor
+                    radius: Components.ThemeManager.radiusMd
+                    border.color: Components.ThemeManager.borderColor
+                    border.width: 1
+
+                    scale: mouseArea.pressed ? 0.98 : 1.0
+                    Behavior on scale {
+                        NumberAnimation { duration: 100; easing.type: Easing.OutQuad }
+                    }
+
+                    RowLayout {
+                        anchors.fill: parent
+                        anchors.leftMargin: Components.ThemeManager.spaceLg
+                        anchors.rightMargin: Components.ThemeManager.spaceLg
+                        spacing: Components.ThemeManager.spaceLg
+
+                        Rectangle {
+                            Layout.preferredWidth: 44
+                            Layout.preferredHeight: 44
+                            radius: 22
+                            color: Components.ThemeManager.accentSoft
+
+                            Components.Icon {
+                                anchors.centerIn: parent
+                                name: "queue_music"
+                                size: 20
+                                color: Components.ThemeManager.accent
+                            }
+                        }
+
+                        Column {
+                            Layout.fillWidth: true
+                            spacing: 2
+
+                            Label {
+                                text: model.name
+                                font.family: Components.ThemeManager.fontDisplay
+                                font.pixelSize: 16
+                                color: Components.ThemeManager.textPrimary
+                                elide: Text.ElideRight
+                                width: parent.width
+                            }
+
+                            Label {
+                                text: model.itemCount + " patterns"
+                                color: Components.ThemeManager.textSecondary
+                                font.family: Components.ThemeManager.fontBody
+                                font.pixelSize: Components.ThemeManager.fontSizeCaption
+                            }
+                        }
+                    }
+
+                    MouseArea {
+                        id: mouseArea
+                        anchors.fill: parent
+                        onClicked: {
+                            showPlaylistDetail(model.name, model)
+                        }
+                    }
+                }
+
+                // Empty state
+                Item {
+                    anchors.fill: parent
+                    visible: playlistsList.count === 0
+
+                    Column {
+                        anchors.centerIn: parent
+                        spacing: Components.ThemeManager.spaceLg
+
+                        Components.Icon {
+                            name: "queue_music"
+                            size: 44
+                            color: Components.ThemeManager.textTertiary
+                            anchors.horizontalCenter: parent.horizontalCenter
+                        }
+
+                        Label {
+                            text: "No playlists yet"
+                            anchors.horizontalCenter: parent.horizontalCenter
+                            color: Components.ThemeManager.textSecondary
+                            font.family: Components.ThemeManager.fontDisplay
+                            font.pixelSize: Components.ThemeManager.fontSizeTitle
+                        }
+
+                        Label {
+                            text: "Tap + to gather patterns into a set\nthe table can weave through"
+                            anchors.horizontalCenter: parent.horizontalCenter
+                            color: Components.ThemeManager.textTertiary
+                            font.family: Components.ThemeManager.fontBody
+                            font.pixelSize: Components.ThemeManager.fontSizeBody
+                            horizontalAlignment: Text.AlignHCenter
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    // Playlist Detail View (shown when a playlist is selected)
+    Item {
+        id: playlistDetailView
+        anchors.fill: parent
+        visible: showingPlaylistDetail
+
+        ColumnLayout {
+            anchors.fill: parent
+            spacing: 0
+
+            // Header with back button
+            Rectangle {
+                Layout.fillWidth: true
+                Layout.preferredHeight: Components.ThemeManager.headerHeight
+                color: Components.ThemeManager.surfaceColor
+
+                Rectangle {
+                    anchors.bottom: parent.bottom
+                    width: parent.width
+                    height: 1
+                    color: Components.ThemeManager.borderColor
+                }
+
+                RowLayout {
+                    anchors.fill: parent
+                    anchors.leftMargin: Components.ThemeManager.spaceSm
+                    anchors.rightMargin: Components.ThemeManager.spaceMd
+                    spacing: Components.ThemeManager.spaceSm
+
+                    Rectangle {
+                        Layout.preferredWidth: 44
+                        Layout.preferredHeight: 44
+                        radius: 22
+                        color: backArea.pressed ? Components.ThemeManager.pressedColor : "transparent"
+
+                        Components.Icon {
+                            anchors.centerIn: parent
+                            name: "arrow_back"
+                            size: 20
+                            color: Components.ThemeManager.textPrimary
+                        }
+
+                        MouseArea {
+                            id: backArea
+                            anchors.fill: parent
+                            onClicked: showPlaylistList()
+                        }
+                    }
+
+                    Label {
+                        text: selectedPlaylist
+                        font.family: Components.ThemeManager.fontDisplay
+                        font.pixelSize: Components.ThemeManager.fontSizeTitle
+                        color: Components.ThemeManager.textPrimary
+                        Layout.fillWidth: true
+                        elide: Text.ElideRight
+                    }
+
+                    Label {
+                        text: currentPlaylistPatterns.length + " patterns"
+                        font.family: Components.ThemeManager.fontBody
+                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                        color: Components.ThemeManager.textTertiary
+                    }
+
+                    // Delete playlist
+                    Rectangle {
+                        Layout.preferredWidth: 44
+                        Layout.preferredHeight: 44
+                        radius: 22
+                        color: deletePlaylistMouseArea.pressed ? Components.ThemeManager.pressedColor : "transparent"
+
+                        Components.Icon {
+                            anchors.centerIn: parent
+                            name: "delete"
+                            size: 20
+                            color: Components.ThemeManager.danger
+                        }
+
+                        MouseArea {
+                            id: deletePlaylistMouseArea
+                            anchors.fill: parent
+                            onClicked: deletePlaylistDialog.open()
+                        }
+                    }
+                }
+            }
+
+            // Content - Pattern list on left, controls on right
+            RowLayout {
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+                spacing: 0
+
+                // Left side - Pattern List
+                Rectangle {
+                    Layout.fillHeight: true
+                    Layout.preferredWidth: page.width * 0.4
+                    color: Components.ThemeManager.surfaceColor
+
+                    ColumnLayout {
+                        anchors.fill: parent
+                        anchors.margins: Components.ThemeManager.spaceLg
+                        spacing: Components.ThemeManager.spaceMd
+
+                        RowLayout {
+                            Layout.fillWidth: true
+                            spacing: Components.ThemeManager.spaceSm
+
+                            SectionLabel {
+                                text: "Patterns"
+                                Layout.fillWidth: true
+                            }
+
+                            // Add pattern button
+                            Rectangle {
+                                Layout.preferredWidth: 40
+                                Layout.preferredHeight: 40
+                                radius: 20
+                                color: addPatternMouseArea.pressed ? Components.ThemeManager.pressedColor : "transparent"
+                                border.width: 1
+                                border.color: Components.ThemeManager.accent
+
+                                Components.Icon {
+                                    anchors.centerIn: parent
+                                    name: "add"
+                                    size: 20
+                                    color: Components.ThemeManager.accent
+                                }
+
+                                MouseArea {
+                                    id: addPatternMouseArea
+                                    anchors.fill: parent
+                                    onClicked: {
+                                        // Navigate to full-page pattern selector
+                                        stackView.push("PatternSelectorPage.qml", {
+                                            backend: backend,
+                                            stackView: stackView,
+                                            playlistName: selectedPlaylist,
+                                            existingPatterns: currentPlaylistRawPatterns
+                                        })
+                                    }
+                                }
+                            }
+                        }
+
+                        ListView {
+                            id: patternListView
+                            Layout.fillWidth: true
+                            Layout.fillHeight: true
+                            clip: true
+                            model: currentPlaylistPatterns
+                            spacing: Components.ThemeManager.spaceSm
+
+                            ScrollBar.vertical: ScrollBar {
+                                active: true
+                                policy: ScrollBar.AsNeeded
+                            }
+
+                            delegate: Rectangle {
+                                width: ListView.view.width
+                                height: 48
+                                color: Components.ThemeManager.cardColor
+                                radius: Components.ThemeManager.radiusSm
+                                border.color: Components.ThemeManager.borderLight
+                                border.width: 1
+
+                                RowLayout {
+                                    anchors.fill: parent
+                                    anchors.leftMargin: Components.ThemeManager.spaceMd
+                                    anchors.rightMargin: Components.ThemeManager.spaceXs
+                                    spacing: Components.ThemeManager.spaceSm
+
+                                    Label {
+                                        text: index + 1
+                                        font.family: Components.ThemeManager.fontMedium
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textTertiary
+                                        Layout.preferredWidth: 20
+                                        horizontalAlignment: Text.AlignRight
+                                    }
+
+                                    Label {
+                                        text: modelData
+                                        font.family: Components.ThemeManager.fontBody
+                                        font.pixelSize: 13
+                                        color: Components.ThemeManager.textPrimary
+                                        Layout.fillWidth: true
+                                        elide: Text.ElideRight
+                                    }
+
+                                    // Remove pattern
+                                    Rectangle {
+                                        Layout.preferredWidth: 40
+                                        Layout.preferredHeight: 40
+                                        radius: 20
+                                        color: removePatternArea.pressed ? Components.ThemeManager.pressedColor : "transparent"
+
+                                        Components.Icon {
+                                            anchors.centerIn: parent
+                                            name: "close"
+                                            size: 16
+                                            color: Components.ThemeManager.textTertiary
+                                        }
+
+                                        MouseArea {
+                                            id: removePatternArea
+                                            anchors.fill: parent
+                                            onClicked: {
+                                                removePatternAtIndex(index)
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+
+                        // Empty playlist message
+                        Item {
+                            Layout.fillWidth: true
+                            Layout.fillHeight: true
+                            visible: currentPlaylistPatterns.length === 0
+
+                            Column {
+                                anchors.centerIn: parent
+                                spacing: Components.ThemeManager.spaceSm
+
+                                Components.Icon {
+                                    name: "queue_music"
+                                    size: 30
+                                    color: Components.ThemeManager.textTertiary
+                                    anchors.horizontalCenter: parent.horizontalCenter
+                                }
+
+                                Label {
+                                    text: "No patterns yet — tap +"
+                                    anchors.horizontalCenter: parent.horizontalCenter
+                                    color: Components.ThemeManager.textSecondary
+                                    font.family: Components.ThemeManager.fontBody
+                                    font.pixelSize: Components.ThemeManager.fontSizeBody
+                                }
+                            }
+                        }
+                    }
+                }
+
+                Rectangle {
+                    Layout.fillHeight: true
+                    Layout.preferredWidth: 1
+                    color: Components.ThemeManager.borderColor
+                }
+
+                // Right side - controls
+                Rectangle {
+                    Layout.fillHeight: true
+                    Layout.fillWidth: true
+                    color: Components.ThemeManager.backgroundColor
+
+                    ColumnLayout {
+                        anchors.fill: parent
+                        anchors.margins: Components.ThemeManager.spaceLg
+                        spacing: Components.ThemeManager.spaceMd
+
+                        // Play + shuffle
+                        RowLayout {
+                            Layout.fillWidth: true
+                            spacing: Components.ThemeManager.spaceSm
+
+                            ModernControlButton {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: Components.ThemeManager.controlHeight
+                                icon: "play_arrow"
+                                text: "Weave this playlist"
+                                buttonColor: Components.ThemeManager.accent
+                                onClicked: {
+                                    if (backend) {
+                                        backend.executePlaylist(selectedPlaylist, pauseTime, clearPattern, runMode, shuffle)
+
+                                        // Navigate to execution page
+                                        if (mainWindow) {
+                                            mainWindow.shouldNavigateToExecution = true
+                                        }
+                                    }
+                                }
+                            }
+
+                            ChoiceChip {
+                                Layout.preferredWidth: 110
+                                Layout.preferredHeight: Components.ThemeManager.controlHeight
+                                label: "Shuffle"
+                                selected: shuffle
+                                onClicked: {
+                                    // Don't assign directly to shuffle - that breaks the binding.
+                                    // Update the backend and let the binding propagate the change.
+                                    if (backend) backend.setPlaylistShuffle(!backend.playlistShuffle)
+                                }
+                            }
+                        }
+
+                        // Settings
+                        ScrollView {
+                            Layout.fillWidth: true
+                            Layout.fillHeight: true
+                            contentWidth: availableWidth
+                            clip: true
+
+                            ScrollBar.vertical.policy: ScrollBar.AsNeeded
+
+                            ColumnLayout {
+                                width: parent.width
+                                spacing: Components.ThemeManager.spaceMd
+
+                                SectionLabel {
+                                    text: "Play order"
+                                }
+
+                                RowLayout {
+                                    Layout.fillWidth: true
+                                    spacing: Components.ThemeManager.spaceSm
+
+                                    ChoiceChip {
+                                        Layout.fillWidth: true
+                                        label: "Loop forever"
+                                        selected: runMode === "loop"
+                                        onClicked: {
+                                            runMode = "loop"
+                                            if (backend) backend.setPlaylistRunMode("loop")
+                                        }
+                                    }
+
+                                    ChoiceChip {
+                                        Layout.fillWidth: true
+                                        label: "Play once"
+                                        selected: runMode === "single"
+                                        onClicked: {
+                                            runMode = "single"
+                                            if (backend) backend.setPlaylistRunMode("single")
+                                        }
+                                    }
+                                }
+
+                                SectionLabel {
+                                    Layout.topMargin: Components.ThemeManager.spaceSm
+                                    text: "Rest between patterns"
+                                }
+
+                                GridLayout {
+                                    id: pauseGrid
+                                    Layout.fillWidth: true
+                                    columns: 6
+                                    rowSpacing: Components.ThemeManager.spaceSm
+                                    columnSpacing: Components.ThemeManager.spaceSm
+
+                                    property string currentSelection: backend ? backend.getCurrentPauseOption() : "0s"
+
+                                    Connections {
+                                        target: backend
+                                        function onPauseBetweenPatternsChanged(pause) {
+                                            if (backend) {
+                                                pauseGrid.currentSelection = backend.getCurrentPauseOption()
+                                                pauseTime = backend.pauseBetweenPatterns
+                                            }
+                                        }
+                                    }
+
+                                    Repeater {
+                                        model: [
+                                            { label: "0 s", option: "0s", seconds: 0 },
+                                            { label: "1 m", option: "1 min", seconds: 60 },
+                                            { label: "5 m", option: "5 min", seconds: 300 },
+                                            { label: "15 m", option: "15 min", seconds: 900 },
+                                            { label: "30 m", option: "30 min", seconds: 1800 },
+                                            { label: "1 h", option: "1 hour", seconds: 3600 },
+                                            { label: "2 h", option: "2 hour", seconds: 7200 },
+                                            { label: "3 h", option: "3 hour", seconds: 10800 },
+                                            { label: "4 h", option: "4 hour", seconds: 14400 },
+                                            { label: "5 h", option: "5 hour", seconds: 18000 },
+                                            { label: "6 h", option: "6 hour", seconds: 21600 },
+                                            { label: "12 h", option: "12 hour", seconds: 43200 }
+                                        ]
+
+                                        ChoiceChip {
+                                            required property var modelData
+
+                                            Layout.fillWidth: true
+                                            label: modelData.label
+                                            selected: pauseGrid.currentSelection === modelData.option
+
+                                            onClicked: {
+                                                if (backend) {
+                                                    backend.setPauseByOption(modelData.option)
+                                                    pauseGrid.currentSelection = modelData.option
+                                                    pauseTime = modelData.seconds
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+
+                                SectionLabel {
+                                    Layout.topMargin: Components.ThemeManager.spaceSm
+                                    text: "Clear before each pattern"
+                                }
+
+                                GridLayout {
+                                    Layout.fillWidth: true
+                                    columns: 2
+                                    rowSpacing: Components.ThemeManager.spaceSm
+                                    columnSpacing: Components.ThemeManager.spaceSm
+
+                                    Repeater {
+                                        model: [
+                                            { label: "Adaptive clear", value: "adaptive" },
+                                            { label: "Clear from center", value: "clear_center" },
+                                            { label: "Clear from edge", value: "clear_perimeter" },
+                                            { label: "Keep the sand", value: "none" }
+                                        ]
+
+                                        ChoiceChip {
+                                            required property var modelData
+
+                                            Layout.fillWidth: true
+                                            label: modelData.label
+                                            selected: clearPattern === modelData.value
+
+                                            onClicked: {
+                                                clearPattern = modelData.value
+                                                if (backend) backend.setPlaylistClearPattern(modelData.value)
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    // ==================== Dialogs ====================
+
+    // Create Playlist Dialog
+    Popup {
+        id: createPlaylistDialog
+        modal: true
+        x: (parent.width - width) / 2
+        y: (parent.height - height) / 2
+        width: 340
+        height: 210
+        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+        background: Rectangle {
+            color: Components.ThemeManager.surfaceColor
+            radius: Components.ThemeManager.radiusMd
+            border.color: Components.ThemeManager.borderColor
+            border.width: 1
+        }
+
+        contentItem: ColumnLayout {
+            anchors.fill: parent
+            anchors.margins: Components.ThemeManager.spaceLg
+            spacing: Components.ThemeManager.spaceMd
+
+            Label {
+                text: "New playlist"
+                font.family: Components.ThemeManager.fontDisplay
+                font.pixelSize: Components.ThemeManager.fontSizeTitle
+                color: Components.ThemeManager.textPrimary
+                Layout.alignment: Qt.AlignHCenter
+            }
+
+            TextField {
+                id: newPlaylistNameField
+                Layout.fillWidth: true
+                Layout.preferredHeight: 48
+                placeholderText: "Playlist name"
+                placeholderTextColor: Components.ThemeManager.textTertiary
+                font.family: Components.ThemeManager.fontBody
+                font.pixelSize: Components.ThemeManager.fontSizeBody
+                color: Components.ThemeManager.textPrimary
+                verticalAlignment: TextInput.AlignVCenter
+                leftPadding: Components.ThemeManager.spaceLg
+                rightPadding: Components.ThemeManager.spaceLg
+
+                background: Rectangle {
+                    color: Components.ThemeManager.backgroundColor
+                    radius: 24
+                    border.color: newPlaylistNameField.activeFocus ? Components.ThemeManager.accent
+                                                                   : Components.ThemeManager.borderColor
+                    border.width: 1
+                }
+
+                onAccepted: {
+                    if (text.trim().length > 0 && backend) {
+                        backend.createPlaylist(text.trim())
+                    }
+                }
+            }
+
+            RowLayout {
+                Layout.fillWidth: true
+                spacing: Components.ThemeManager.spaceSm
+
+                ModernControlButton {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: Components.ThemeManager.touchTarget
+                    text: "Cancel"
+                    outlined: true
+                    buttonColor: Components.ThemeManager.textSecondary
+                    onClicked: {
+                        newPlaylistNameField.text = ""
+                        createPlaylistDialog.close()
+                    }
+                }
+
+                ModernControlButton {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: Components.ThemeManager.touchTarget
+                    text: "Create"
+                    buttonColor: Components.ThemeManager.accent
+                    enabled: newPlaylistNameField.text.trim().length > 0
+                    onClicked: {
+                        if (backend) {
+                            backend.createPlaylist(newPlaylistNameField.text.trim())
+                        }
+                    }
+                }
+            }
+        }
+
+        onOpened: {
+            newPlaylistNameField.text = ""
+            newPlaylistNameField.forceActiveFocus()
+        }
+    }
+
+    // Delete Playlist Confirmation Dialog
+    Popup {
+        id: deletePlaylistDialog
+        modal: true
+        x: (parent.width - width) / 2
+        y: (parent.height - height) / 2
+        width: 340
+        height: 200
+        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+        background: Rectangle {
+            color: Components.ThemeManager.surfaceColor
+            radius: Components.ThemeManager.radiusMd
+            border.color: Components.ThemeManager.borderColor
+            border.width: 1
+        }
+
+        contentItem: ColumnLayout {
+            anchors.fill: parent
+            anchors.margins: Components.ThemeManager.spaceLg
+            spacing: Components.ThemeManager.spaceMd
+
+            Label {
+                text: "Delete playlist?"
+                font.family: Components.ThemeManager.fontDisplay
+                font.pixelSize: Components.ThemeManager.fontSizeTitle
+                color: Components.ThemeManager.textPrimary
+                Layout.alignment: Qt.AlignHCenter
+            }
+
+            Label {
+                text: "\"" + selectedPlaylist + "\" will be removed from the table. Its patterns stay in your library."
+                font.family: Components.ThemeManager.fontBody
+                font.pixelSize: Components.ThemeManager.fontSizeBody
+                color: Components.ThemeManager.textSecondary
+                horizontalAlignment: Text.AlignHCenter
+                wrapMode: Text.WordWrap
+                Layout.fillWidth: true
+            }
+
+            RowLayout {
+                Layout.fillWidth: true
+                spacing: Components.ThemeManager.spaceSm
+
+                ModernControlButton {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: Components.ThemeManager.touchTarget
+                    text: "Cancel"
+                    outlined: true
+                    buttonColor: Components.ThemeManager.textSecondary
+                    onClicked: deletePlaylistDialog.close()
+                }
+
+                ModernControlButton {
+                    Layout.fillWidth: true
+                    Layout.preferredHeight: Components.ThemeManager.touchTarget
+                    text: "Delete"
+                    buttonColor: Components.ThemeManager.danger
+                    onClicked: {
+                        if (backend && selectedPlaylist) {
+                            backend.deletePlaylist(selectedPlaylist)
+                        }
+                        deletePlaylistDialog.close()
+                    }
+                }
+            }
+        }
+    }
+
+    // ==================== Backend Signal Handlers ====================
+
+    Connections {
+        target: backend
+
+        function onPlaylistCreated(success, message) {
+            if (success) {
+                playlistModel.refresh()
+            }
+            newPlaylistNameField.text = ""
+            createPlaylistDialog.close()
+        }
+
+        function onPlaylistDeleted(success, message) {
+            if (success) {
+                playlistModel.refresh()
+                showPlaylistList()  // Navigate back to list
+            }
+        }
+
+        function onPatternAddedToPlaylist(success, message) {
+            if (success) {
+                playlistModel.refresh()
+                // Refresh current playlist patterns if we're viewing one
+                if (selectedPlaylist) {
+                    currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
+                    currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
+                }
+            }
+        }
+
+        function onPlaylistModified(success, message) {
+            if (success) {
+                playlistModel.refresh()
+                // Refresh current playlist patterns
+                if (selectedPlaylist) {
+                    currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
+                    currentPlaylistRawPatterns = playlistModel.getRawPatternsForPlaylist(selectedPlaylist)
+                }
+            }
+        }
+    }
+}

+ 392 - 504
dune-weaver-touch/qml/pages/PatternDetailPage.qml

@@ -1,504 +1,392 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-import DuneWeaver 1.0
-import "../components"
-import "../components" as Components
-
-Page {
-    id: page
-    property string patternName: ""
-    property string patternPath: ""
-    property string patternPreview: ""
-    property var backend: null
-    property bool showAddedFeedback: false
-
-    // Playlist model for selecting which playlist to add to
-    PlaylistModel {
-        id: playlistModel
-    }
-
-    Rectangle {
-        anchors.fill: parent
-        color: Components.ThemeManager.backgroundColor
-    }
-    
-    ColumnLayout {
-        anchors.fill: parent
-        spacing: 0
-        
-        // Header
-        Rectangle {
-            Layout.fillWidth: true
-            Layout.preferredHeight: 50
-            color: Components.ThemeManager.surfaceColor
-
-            // Bottom border
-            Rectangle {
-                anchors.bottom: parent.bottom
-                width: parent.width
-                height: 1
-                color: Components.ThemeManager.borderColor
-            }
-            
-            RowLayout {
-                anchors.fill: parent
-                anchors.margins: 10
-                
-                ConnectionStatus {
-                    backend: page.backend
-                    Layout.rightMargin: 8
-                }
-                
-                Button {
-                    text: "← Back"
-                    font.pixelSize: 14
-                    flat: true
-                    onClicked: stackView.pop()
-
-                    contentItem: Text {
-                        text: parent.text
-                        font: parent.font
-                        color: Components.ThemeManager.textPrimary
-                        horizontalAlignment: Text.AlignHCenter
-                        verticalAlignment: Text.AlignVCenter
-                    }
-                }
-                
-                Label {
-                    text: patternName
-                    Layout.fillWidth: true
-                    elide: Label.ElideRight
-                    font.pixelSize: 16
-                    font.bold: true
-                    color: Components.ThemeManager.textPrimary
-                }
-            }
-        }
-        
-        // Content - Side by side layout
-        Item {
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            
-            Row {
-                anchors.fill: parent
-                spacing: 0
-                
-                // Left side - Pattern Preview (60% of width)
-                Rectangle {
-                    width: parent.width * 0.6
-                    height: parent.height
-                    color: Components.ThemeManager.previewBackground
-
-                Image {
-                    anchors.fill: parent
-                    anchors.margins: 10
-                    source: patternPreview ? "file:///" + patternPreview : ""
-                    fillMode: Image.PreserveAspectFit
-
-                    Rectangle {
-                        anchors.fill: parent
-                        color: Components.ThemeManager.placeholderBackground
-                        visible: parent.status === Image.Error || parent.source == ""
-
-                        Column {
-                            anchors.centerIn: parent
-                            spacing: 10
-
-                            Text {
-                                text: "○"
-                                font.pixelSize: 48
-                                color: Components.ThemeManager.placeholderText
-                                anchors.horizontalCenter: parent.horizontalCenter
-                            }
-
-                            Text {
-                                text: "No Preview Available"
-                                color: Components.ThemeManager.textSecondary
-                                font.pixelSize: 14
-                                anchors.horizontalCenter: parent.horizontalCenter
-                            }
-                        }
-                    }
-                }
-            }
-                
-                // Divider
-                Rectangle {
-                    width: 1
-                    height: parent.height
-                    color: Components.ThemeManager.borderColor
-                }
-
-                // Right side - Controls (40% of width)
-                Rectangle {
-                    width: parent.width * 0.4 - 1
-                    height: parent.height
-                    color: Components.ThemeManager.surfaceColor
-                
-                Column {
-                    anchors.fill: parent
-                    anchors.margins: 10
-                    spacing: 15
-                    
-                    // Play Button - FIRST AND PROMINENT
-                    Rectangle {
-                        width: parent.width
-                        height: 50
-                        radius: 8
-                        color: playMouseArea.pressed ? "#1e40af" : (backend ? "#2563eb" : "#9ca3af")
-
-                        Text {
-                            anchors.centerIn: parent
-                            text: "▶ Play Pattern"
-                            color: "white"
-                            font.pixelSize: 16
-                            font.bold: true
-                        }
-
-                        MouseArea {
-                            id: playMouseArea
-                            anchors.fill: parent
-                            enabled: backend !== null
-                            onClicked: {
-                                if (backend) {
-                                    var preExecution = "adaptive"
-                                    if (centerRadio.checked) preExecution = "clear_center"
-                                    else if (perimeterRadio.checked) preExecution = "clear_perimeter"
-                                    else if (noneRadio.checked) preExecution = "none"
-
-                                    backend.executePattern(patternName, preExecution)
-                                }
-                            }
-                        }
-                    }
-
-                    // Add to Playlist Button
-                    Rectangle {
-                        width: parent.width
-                        height: 45
-                        radius: 8
-                        color: addToPlaylistArea.pressed ? "#065f46" : "#059669"
-
-                        Row {
-                            anchors.centerIn: parent
-                            spacing: 8
-
-                            Text {
-                                text: showAddedFeedback ? "✓" : "♪"
-                                font.pixelSize: 16
-                                color: "white"
-                            }
-
-                            Text {
-                                text: showAddedFeedback ? "Added!" : "Add to Playlist"
-                                color: "white"
-                                font.pixelSize: 14
-                                font.bold: true
-                            }
-                        }
-
-                        MouseArea {
-                            id: addToPlaylistArea
-                            anchors.fill: parent
-                            enabled: backend !== null && !showAddedFeedback
-                            onClicked: {
-                                playlistModel.refresh()  // Refresh playlist list
-                                playlistSelectorPopup.open()
-                            }
-                        }
-                    }
-
-                    // Pre-Execution Options
-                    Rectangle {
-                        width: parent.width
-                        height: 160  // Increased height to fit all options
-                        radius: 8
-                        color: Components.ThemeManager.cardColor
-                        border.color: Components.ThemeManager.borderColor
-                        border.width: 1
-
-                        Column {
-                            id: preExecColumn
-                            anchors.left: parent.left
-                            anchors.right: parent.right
-                            anchors.top: parent.top
-                            anchors.margins: 8  // Reduced margins to save space
-                            spacing: 6  // Reduced spacing
-
-                            Label {
-                                text: "Pre-Execution"
-                                font.pixelSize: 12
-                                font.bold: true
-                                color: Components.ThemeManager.textPrimary
-                            }
-                            
-                            RadioButton {
-                                id: adaptiveRadio
-                                text: "Adaptive"
-                                checked: true
-                                font.pixelSize: 10
-
-                                contentItem: Text {
-                                    text: parent.text
-                                    font: parent.font
-                                    color: Components.ThemeManager.textPrimary
-                                    verticalAlignment: Text.AlignVCenter
-                                    leftPadding: parent.indicator.width + parent.spacing
-                                }
-                            }
-
-                            RadioButton {
-                                id: centerRadio
-                                text: "Clear Center"
-                                font.pixelSize: 10
-
-                                contentItem: Text {
-                                    text: parent.text
-                                    font: parent.font
-                                    color: Components.ThemeManager.textPrimary
-                                    verticalAlignment: Text.AlignVCenter
-                                    leftPadding: parent.indicator.width + parent.spacing
-                                }
-                            }
-
-                            RadioButton {
-                                id: perimeterRadio
-                                text: "Clear Edge"
-                                font.pixelSize: 10
-
-                                contentItem: Text {
-                                    text: parent.text
-                                    font: parent.font
-                                    color: Components.ThemeManager.textPrimary
-                                    verticalAlignment: Text.AlignVCenter
-                                    leftPadding: parent.indicator.width + parent.spacing
-                                }
-                            }
-
-                            RadioButton {
-                                id: noneRadio
-                                text: "None"
-                                font.pixelSize: 10
-
-                                contentItem: Text {
-                                    text: parent.text
-                                    font: parent.font
-                                    color: Components.ThemeManager.textPrimary
-                                    verticalAlignment: Text.AlignVCenter
-                                    leftPadding: parent.indicator.width + parent.spacing
-                                }
-                            }
-                        }
-                    }
-                    
-                    // Pattern Info
-                    Rectangle {
-                        width: parent.width
-                        height: 80
-                        radius: 8
-                        color: Components.ThemeManager.cardColor
-                        border.color: Components.ThemeManager.borderColor
-                        border.width: 1
-
-                        Column {
-                            id: infoColumn
-                            anchors.left: parent.left
-                            anchors.right: parent.right
-                            anchors.top: parent.top
-                            anchors.margins: 10
-                            spacing: 6
-
-                            Label {
-                                text: "Pattern Info"
-                                font.pixelSize: 14
-                                font.bold: true
-                                color: Components.ThemeManager.textPrimary
-                            }
-
-                            Label {
-                                text: "Name: " + patternName
-                                font.pixelSize: 11
-                                color: Components.ThemeManager.textSecondary
-                                elide: Text.ElideRight
-                                width: parent.width
-                            }
-
-                            Label {
-                                text: "Type: Sand Pattern"
-                                font.pixelSize: 11
-                                color: Components.ThemeManager.textSecondary
-                            }
-                        }
-                    }
-                }
-            }
-            }
-        }
-    }
-
-    // ==================== Playlist Selector Popup ====================
-
-    Popup {
-        id: playlistSelectorPopup
-        modal: true
-        x: (parent.width - width) / 2
-        y: (parent.height - height) / 2
-        width: 320
-        height: Math.min(400, 120 + playlistModel.rowCount() * 50)
-        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
-
-        background: Rectangle {
-            color: Components.ThemeManager.surfaceColor
-            radius: 16
-            border.color: Components.ThemeManager.borderColor
-            border.width: 1
-        }
-
-        contentItem: ColumnLayout {
-            anchors.fill: parent
-            anchors.margins: 15
-            spacing: 10
-
-            Label {
-                text: "Add to Playlist"
-                font.pixelSize: 18
-                font.bold: true
-                color: Components.ThemeManager.textPrimary
-                Layout.alignment: Qt.AlignHCenter
-            }
-
-            // Playlist list
-            ListView {
-                Layout.fillWidth: true
-                Layout.fillHeight: true
-                clip: true
-                model: playlistModel
-                spacing: 6
-
-                delegate: Rectangle {
-                    width: ListView.view.width
-                    height: 45
-                    radius: 8
-                    color: playlistItemArea.pressed ? Components.ThemeManager.selectedBackground : Components.ThemeManager.cardColor
-                    border.color: Components.ThemeManager.borderColor
-                    border.width: 1
-
-                    RowLayout {
-                        anchors.fill: parent
-                        anchors.margins: 12
-                        spacing: 10
-
-                        Text {
-                            text: "♪"
-                            font.pixelSize: 16
-                            color: "#2196F3"
-                        }
-
-                        Label {
-                            text: model.name
-                            font.pixelSize: 14
-                            color: Components.ThemeManager.textPrimary
-                            Layout.fillWidth: true
-                            elide: Text.ElideRight
-                        }
-
-                        Label {
-                            text: model.itemCount + " patterns"
-                            font.pixelSize: 11
-                            color: Components.ThemeManager.textTertiary
-                        }
-                    }
-
-                    MouseArea {
-                        id: playlistItemArea
-                        anchors.fill: parent
-                        onClicked: {
-                            if (backend) {
-                                backend.addPatternToPlaylist(model.name, patternName)
-                            }
-                            playlistSelectorPopup.close()
-                        }
-                    }
-                }
-            }
-
-            // Empty state
-            Column {
-                Layout.fillWidth: true
-                Layout.fillHeight: true
-                spacing: 10
-                visible: playlistModel.rowCount() === 0
-
-                Item { Layout.fillHeight: true }
-
-                Text {
-                    text: "♪"
-                    font.pixelSize: 32
-                    color: Components.ThemeManager.placeholderText
-                    anchors.horizontalCenter: parent.horizontalCenter
-                }
-
-                Label {
-                    text: "No playlists yet"
-                    anchors.horizontalCenter: parent.horizontalCenter
-                    color: Components.ThemeManager.textSecondary
-                    font.pixelSize: 14
-                }
-
-                Label {
-                    text: "Create a playlist first"
-                    anchors.horizontalCenter: parent.horizontalCenter
-                    color: Components.ThemeManager.textTertiary
-                    font.pixelSize: 12
-                }
-
-                Item { Layout.fillHeight: true }
-            }
-
-            // Cancel button
-            Rectangle {
-                Layout.fillWidth: true
-                Layout.preferredHeight: 40
-                radius: 8
-                color: cancelArea.pressed ? Components.ThemeManager.buttonBackgroundHover : Components.ThemeManager.cardColor
-                border.color: Components.ThemeManager.borderColor
-                border.width: 1
-
-                Text {
-                    anchors.centerIn: parent
-                    text: "Cancel"
-                    color: Components.ThemeManager.textPrimary
-                    font.pixelSize: 14
-                }
-
-                MouseArea {
-                    id: cancelArea
-                    anchors.fill: parent
-                    onClicked: playlistSelectorPopup.close()
-                }
-            }
-        }
-    }
-
-    // ==================== Backend Signal Handlers ====================
-
-    Connections {
-        target: backend
-
-        function onPatternAddedToPlaylist(success, message) {
-            if (success) {
-                // Show feedback
-                showAddedFeedback = true
-                feedbackTimer.start()
-            }
-        }
-    }
-
-    Timer {
-        id: feedbackTimer
-        interval: 2000  // Show "Added!" for 2 seconds
-        onTriggered: showAddedFeedback = false
-    }
-}
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import DuneWeaver 1.0
+import "../components"
+import "../components" as Components
+
+Page {
+    id: page
+    property string patternName: ""
+    property string patternPath: ""
+    property string patternPreview: ""
+    property var backend: null
+    property bool showAddedFeedback: false
+
+    // Selected clear mode ("adaptive" | "clear_center" | "clear_perimeter" | "none")
+    property string clearMode: "adaptive"
+
+    property string cleanName: {
+        var parts = patternName.split('/')
+        return parts[parts.length - 1].replace('.thr', '')
+    }
+
+    // Playlist model for selecting which playlist to add to
+    PlaylistModel {
+        id: playlistModel
+    }
+
+    Rectangle {
+        anchors.fill: parent
+        color: Components.ThemeManager.backgroundColor
+    }
+
+    ColumnLayout {
+        anchors.fill: parent
+        spacing: 0
+
+        // Header
+        Rectangle {
+            Layout.fillWidth: true
+            Layout.preferredHeight: Components.ThemeManager.headerHeight
+            color: Components.ThemeManager.surfaceColor
+
+            Rectangle {
+                anchors.bottom: parent.bottom
+                width: parent.width
+                height: 1
+                color: Components.ThemeManager.borderColor
+            }
+
+            RowLayout {
+                anchors.fill: parent
+                anchors.leftMargin: Components.ThemeManager.spaceSm
+                anchors.rightMargin: Components.ThemeManager.spaceLg
+                spacing: Components.ThemeManager.spaceSm
+
+                // Back button
+                Rectangle {
+                    Layout.preferredWidth: 44
+                    Layout.preferredHeight: 44
+                    radius: 22
+                    color: backArea.pressed ? Components.ThemeManager.pressedColor : "transparent"
+
+                    Components.Icon {
+                        anchors.centerIn: parent
+                        name: "arrow_back"
+                        size: 20
+                        color: Components.ThemeManager.textPrimary
+                    }
+
+                    MouseArea {
+                        id: backArea
+                        anchors.fill: parent
+                        onClicked: stackView.pop()
+                    }
+                }
+
+                Label {
+                    text: cleanName
+                    Layout.fillWidth: true
+                    elide: Label.ElideRight
+                    font.family: Components.ThemeManager.fontDisplay
+                    font.pixelSize: Components.ThemeManager.fontSizeTitle
+                    color: Components.ThemeManager.textPrimary
+                }
+
+                ConnectionStatus {
+                    backend: page.backend
+                }
+            }
+        }
+
+        // Content
+        RowLayout {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            spacing: 0
+
+            // ---- Left: circular preview ----
+            Item {
+                Layout.fillHeight: true
+                Layout.preferredWidth: page.width * 0.55
+
+                Image {
+                    id: previewImage
+                    anchors.centerIn: parent
+                    width: Math.min(parent.width, parent.height) - 2 * Components.ThemeManager.spaceXl
+                    height: width
+                    source: patternPreview ? "file:///" + patternPreview : ""
+                    fillMode: Image.PreserveAspectFit
+                    asynchronous: true
+                }
+
+                // Empty dish placeholder
+                Rectangle {
+                    anchors.centerIn: parent
+                    width: previewImage.width
+                    height: width
+                    radius: width / 2
+                    color: Components.ThemeManager.surfaceColor
+                    border.width: 1
+                    border.color: Components.ThemeManager.borderColor
+                    visible: previewImage.status === Image.Error || previewImage.source == ""
+
+                    Column {
+                        anchors.centerIn: parent
+                        spacing: Components.ThemeManager.spaceSm
+
+                        Components.Icon {
+                            name: "radio_unchecked"
+                            size: 34
+                            color: Components.ThemeManager.textTertiary
+                            anchors.horizontalCenter: parent.horizontalCenter
+                        }
+
+                        Label {
+                            text: "No preview yet"
+                            color: Components.ThemeManager.textSecondary
+                            font.family: Components.ThemeManager.fontMedium
+                            font.pixelSize: Components.ThemeManager.fontSizeBody
+                            anchors.horizontalCenter: parent.horizontalCenter
+                        }
+                    }
+                }
+            }
+
+            // ---- Right: actions ----
+            Rectangle {
+                Layout.fillHeight: true
+                Layout.fillWidth: true
+                color: Components.ThemeManager.surfaceColor
+
+                Rectangle {
+                    anchors.left: parent.left
+                    width: 1
+                    height: parent.height
+                    color: Components.ThemeManager.borderColor
+                }
+
+                ColumnLayout {
+                    anchors.fill: parent
+                    anchors.margins: Components.ThemeManager.spaceXl
+                    spacing: 0
+
+                    Label {
+                        text: "Before weaving"
+                        font.family: Components.ThemeManager.fontDisplay
+                        font.pixelSize: 11
+                        font.letterSpacing: 1.4
+                        font.capitalization: Font.AllUppercase
+                        color: Components.ThemeManager.textTertiary
+                    }
+
+                    // Clear-mode chips
+                    GridLayout {
+                        Layout.fillWidth: true
+                        Layout.topMargin: Components.ThemeManager.spaceSm
+                        columns: 2
+                        rowSpacing: Components.ThemeManager.spaceSm
+                        columnSpacing: Components.ThemeManager.spaceSm
+
+                        Repeater {
+                            model: [
+                                { label: "Adaptive clear", value: "adaptive" },
+                                { label: "Clear from center", value: "clear_center" },
+                                { label: "Clear from edge", value: "clear_perimeter" },
+                                { label: "Keep the sand", value: "none" }
+                            ]
+
+                            ChoiceChip {
+                                required property var modelData
+
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: Components.ThemeManager.touchTarget
+                                label: modelData.label
+                                selected: page.clearMode === modelData.value
+
+                                onClicked: page.clearMode = modelData.value
+                            }
+                        }
+                    }
+
+                    Item { Layout.fillHeight: true }
+
+                    // Play — the one thing this page is for
+                    ModernControlButton {
+                        Layout.fillWidth: true
+                        Layout.preferredHeight: Components.ThemeManager.controlHeight
+                        icon: "play_arrow"
+                        text: "Weave this pattern"
+                        buttonColor: Components.ThemeManager.accent
+                        enabled: backend !== null
+                        onClicked: {
+                            if (backend) backend.executePattern(patternName, page.clearMode)
+                        }
+                    }
+
+                    ModernControlButton {
+                        Layout.fillWidth: true
+                        Layout.topMargin: Components.ThemeManager.spaceSm
+                        Layout.preferredHeight: Components.ThemeManager.touchTarget
+                        icon: showAddedFeedback ? "check" : "queue_music"
+                        text: showAddedFeedback ? "Added" : "Add to playlist"
+                        outlined: true
+                        buttonColor: showAddedFeedback ? Components.ThemeManager.ok
+                                                       : Components.ThemeManager.accent
+                        enabled: backend !== null && !showAddedFeedback
+                        onClicked: {
+                            playlistModel.refresh()
+                            playlistSelectorPopup.open()
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    // ==================== Playlist Selector Popup ====================
+
+    Popup {
+        id: playlistSelectorPopup
+        modal: true
+        x: (parent.width - width) / 2
+        y: (parent.height - height) / 2
+        width: 340
+        // ListView.count is reactive; rowCount() would only evaluate once
+        // (at creation, before playlists have loaded).
+        height: Math.min(400, 130 + playlistSelectorList.count * 62)
+        closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+        background: Rectangle {
+            color: Components.ThemeManager.surfaceColor
+            radius: Components.ThemeManager.radiusMd
+            border.color: Components.ThemeManager.borderColor
+            border.width: 1
+        }
+
+        contentItem: ColumnLayout {
+            anchors.fill: parent
+            anchors.margins: Components.ThemeManager.spaceLg
+            spacing: Components.ThemeManager.spaceMd
+
+            Label {
+                text: "Add to playlist"
+                font.family: Components.ThemeManager.fontDisplay
+                font.pixelSize: Components.ThemeManager.fontSizeTitle
+                color: Components.ThemeManager.textPrimary
+                Layout.alignment: Qt.AlignHCenter
+            }
+
+            // Playlist list
+            ListView {
+                id: playlistSelectorList
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+                clip: true
+                model: playlistModel
+                spacing: Components.ThemeManager.spaceSm
+
+                delegate: Rectangle {
+                    width: ListView.view.width
+                    height: 56
+                    radius: Components.ThemeManager.radiusSm
+                    color: playlistItemArea.pressed ? Components.ThemeManager.pressedColor
+                                                    : Components.ThemeManager.cardColor
+                    border.color: Components.ThemeManager.borderColor
+                    border.width: 1
+
+                    RowLayout {
+                        anchors.fill: parent
+                        anchors.margins: Components.ThemeManager.spaceMd
+                        spacing: Components.ThemeManager.spaceMd
+
+                        Components.Icon {
+                            name: "queue_music"
+                            size: 18
+                            color: Components.ThemeManager.accent
+                        }
+
+                        Label {
+                            text: model.name
+                            font.family: Components.ThemeManager.fontMedium
+                            font.pixelSize: Components.ThemeManager.fontSizeBody
+                            color: Components.ThemeManager.textPrimary
+                            Layout.fillWidth: true
+                            elide: Text.ElideRight
+                        }
+
+                        Label {
+                            text: model.itemCount + " patterns"
+                            font.family: Components.ThemeManager.fontBody
+                            font.pixelSize: Components.ThemeManager.fontSizeCaption
+                            color: Components.ThemeManager.textTertiary
+                        }
+                    }
+
+                    MouseArea {
+                        id: playlistItemArea
+                        anchors.fill: parent
+                        onClicked: {
+                            if (backend) {
+                                backend.addPatternToPlaylist(model.name, patternName)
+                            }
+                            playlistSelectorPopup.close()
+                        }
+                    }
+                }
+            }
+
+            // Empty state
+            Column {
+                Layout.fillWidth: true
+                Layout.fillHeight: true
+                spacing: Components.ThemeManager.spaceSm
+                visible: playlistSelectorList.count === 0
+
+                Item { height: Components.ThemeManager.spaceSm; width: 1 }
+
+                Components.Icon {
+                    name: "queue_music"
+                    size: 30
+                    color: Components.ThemeManager.textTertiary
+                    anchors.horizontalCenter: parent.horizontalCenter
+                }
+
+                Label {
+                    text: "No playlists yet"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textSecondary
+                    font.family: Components.ThemeManager.fontMedium
+                    font.pixelSize: Components.ThemeManager.fontSizeBody
+                }
+
+                Label {
+                    text: "Create one on the Playlists page first"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textTertiary
+                    font.family: Components.ThemeManager.fontBody
+                    font.pixelSize: Components.ThemeManager.fontSizeCaption
+                }
+            }
+
+            ModernControlButton {
+                Layout.fillWidth: true
+                Layout.preferredHeight: Components.ThemeManager.touchTarget
+                text: "Cancel"
+                outlined: true
+                buttonColor: Components.ThemeManager.textSecondary
+                onClicked: playlistSelectorPopup.close()
+            }
+        }
+    }
+
+    // ==================== Backend Signal Handlers ====================
+
+    Connections {
+        target: backend
+
+        function onPatternAddedToPlaylist(success, message) {
+            if (success) {
+                showAddedFeedback = true
+                feedbackTimer.start()
+            }
+        }
+    }
+
+    Timer {
+        id: feedbackTimer
+        interval: 2000  // Show "Added" for 2 seconds
+        onTriggered: showAddedFeedback = false
+    }
+}

+ 0 - 71
dune-weaver-touch/qml/pages/PatternListPage.qml

@@ -1,71 +0,0 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-import "../components"
-import "../components" as Components
-
-Page {
-    id: page
-    
-    header: ToolBar {
-        RowLayout {
-            anchors.fill: parent
-            anchors.margins: 10
-            
-            TextField {
-                id: searchField
-                Layout.fillWidth: true
-                placeholderText: "Search patterns..."
-                placeholderTextColor: Components.ThemeManager.textTertiary
-                color: Components.ThemeManager.textPrimary
-                onTextChanged: patternModel.filter(text)
-                font.pixelSize: 16
-            }
-            
-            Button {
-                text: "Playlists"
-                Layout.preferredHeight: 50
-                font.pixelSize: 16
-                onClicked: stackView.push("PlaylistPage.qml")
-            }
-        }
-    }
-    
-    GridView {
-        id: gridView
-        anchors.fill: parent
-        anchors.margins: 10
-        cellWidth: 200
-        cellHeight: 250
-        model: patternModel
-        
-        delegate: PatternCard {
-            width: gridView.cellWidth - 10
-            height: gridView.cellHeight - 10
-            name: model.name
-            preview: model.preview
-            
-            onClicked: {
-                stackView.push("PatternDetailPage.qml", {
-                    patternName: model.name,
-                    patternPath: model.path,
-                    patternPreview: model.preview
-                })
-            }
-        }
-    }
-    
-    BusyIndicator {
-        anchors.centerIn: parent
-        running: patternModel.rowCount() === 0
-        visible: running
-    }
-    
-    Label {
-        anchors.centerIn: parent
-        text: "No patterns found"
-        visible: patternModel.rowCount() === 0 && searchField.text !== ""
-        color: Components.ThemeManager.textTertiary
-        font.pixelSize: 18
-    }
-}

+ 375 - 401
dune-weaver-touch/qml/pages/PatternSelectorPage.qml

@@ -1,401 +1,375 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-import DuneWeaver 1.0
-import "../components"
-import "../components" as Components
-
-Page {
-    id: page
-
-    property var backend: null
-    property var stackView: null
-    property string playlistName: ""
-    property var existingPatterns: []  // Raw pattern names already in playlist
-
-    // Track patterns added in this session for immediate visual feedback
-    property var sessionAddedPatterns: []
-
-    // Local pattern model for this page
-    PatternModel {
-        id: patternModel
-    }
-
-    // Search state
-    property bool searchExpanded: false
-    property int patternCount: patternModel ? patternModel.rowCount() : 0
-
-    // Update pattern count when model resets
-    Connections {
-        target: patternModel
-        function onModelReset() {
-            patternCount = patternModel.rowCount()
-        }
-    }
-
-    // Check if a pattern is already in the playlist
-    function isPatternInPlaylist(patternName) {
-        // Check original existing patterns
-        if (existingPatterns.indexOf(patternName) !== -1) {
-            return true
-        }
-        // Check patterns added during this session
-        if (sessionAddedPatterns.indexOf(patternName) !== -1) {
-            return true
-        }
-        return false
-    }
-
-    Rectangle {
-        anchors.fill: parent
-        color: Components.ThemeManager.backgroundColor
-    }
-
-    ColumnLayout {
-        anchors.fill: parent
-        spacing: 0
-
-        // Header with back button
-        Rectangle {
-            Layout.fillWidth: true
-            Layout.preferredHeight: 50
-            color: Components.ThemeManager.surfaceColor
-
-            // Bottom border
-            Rectangle {
-                anchors.bottom: parent.bottom
-                width: parent.width
-                height: 1
-                color: Components.ThemeManager.borderColor
-            }
-
-            RowLayout {
-                anchors.fill: parent
-                anchors.leftMargin: 15
-                anchors.rightMargin: 10
-                spacing: 10
-
-                // Back button
-                Button {
-                    text: "← Back"
-                    font.pixelSize: 14
-                    flat: true
-                    visible: !searchExpanded
-                    onClicked: stackView.pop()
-
-                    contentItem: Text {
-                        text: parent.text
-                        font: parent.font
-                        color: Components.ThemeManager.textPrimary
-                        horizontalAlignment: Text.AlignHCenter
-                        verticalAlignment: Text.AlignVCenter
-                    }
-                }
-
-                // Title
-                Label {
-                    text: "Add to \"" + playlistName + "\""
-                    font.pixelSize: 16
-                    font.bold: true
-                    color: Components.ThemeManager.textPrimary
-                    Layout.fillWidth: true
-                    elide: Text.ElideRight
-                    visible: !searchExpanded
-                }
-
-                // Pattern count
-                Label {
-                    text: patternCount + " patterns"
-                    font.pixelSize: 12
-                    color: Components.ThemeManager.textTertiary
-                    visible: !searchExpanded
-                }
-
-                Item {
-                    Layout.fillWidth: true
-                    visible: !searchExpanded
-                }
-
-                // Expandable search (matching ModernPatternListPage)
-                Rectangle {
-                    Layout.fillWidth: searchExpanded
-                    Layout.preferredWidth: searchExpanded ? parent.width - 60 : 120
-                    Layout.preferredHeight: 32
-                    radius: 16
-                    color: searchExpanded ? Components.ThemeManager.surfaceColor : Components.ThemeManager.cardColor
-                    border.color: searchExpanded ? "#2563eb" : Components.ThemeManager.borderColor
-                    border.width: 1
-
-                    Behavior on Layout.preferredWidth {
-                        NumberAnimation { duration: 200 }
-                    }
-
-                    RowLayout {
-                        anchors.fill: parent
-                        anchors.leftMargin: 10
-                        anchors.rightMargin: 10
-                        spacing: 5
-
-                        Text {
-                            text: "⌕"
-                            font.pixelSize: 16
-                            font.family: "sans-serif"
-                            color: searchExpanded ? "#2563eb" : Components.ThemeManager.textSecondary
-                        }
-
-                        TextField {
-                            id: searchField
-                            Layout.fillWidth: true
-                            placeholderText: searchExpanded ? "Search patterns... (press Enter)" : "Search"
-                            placeholderTextColor: Components.ThemeManager.textTertiary
-                            font.pixelSize: 14
-                            color: Components.ThemeManager.textPrimary
-                            visible: searchExpanded || text.length > 0
-
-                            property string lastSearchText: ""
-                            property bool hasUnappliedSearch: text !== lastSearchText && text.length > 0
-
-                            background: Rectangle {
-                                color: "transparent"
-                                border.color: searchField.hasUnappliedSearch ? "#f59e0b" : "transparent"
-                                border.width: searchField.hasUnappliedSearch ? 1 : 0
-                                radius: 4
-                            }
-
-                            onAccepted: {
-                                patternModel.filter(text)
-                                lastSearchText = text
-                                Qt.inputMethod.hide()
-                                focus = false
-                            }
-
-                            activeFocusOnPress: true
-                            selectByMouse: true
-                            inputMethodHints: Qt.ImhNoPredictiveText
-
-                            MouseArea {
-                                anchors.fill: parent
-                                onPressed: {
-                                    searchField.forceActiveFocus()
-                                    Qt.inputMethod.show()
-                                    mouse.accepted = false
-                                }
-                            }
-
-                            onActiveFocusChanged: {
-                                if (activeFocus) {
-                                    searchExpanded = true
-                                    Qt.inputMethod.show()
-                                } else {
-                                    if (text !== lastSearchText) {
-                                        patternModel.filter(text)
-                                        lastSearchText = text
-                                    }
-                                }
-                            }
-
-                            Keys.onReturnPressed: {
-                                Qt.inputMethod.hide()
-                                focus = false
-                            }
-
-                            Keys.onEscapePressed: {
-                                text = ""
-                                lastSearchText = ""
-                                patternModel.filter("")
-                                Qt.inputMethod.hide()
-                                focus = false
-                            }
-                        }
-
-                        Text {
-                            text: searchExpanded || searchField.text.length > 0 ? "Search" : ""
-                            font.pixelSize: 12
-                            color: Components.ThemeManager.textTertiary
-                            visible: !searchExpanded && searchField.text.length === 0
-                        }
-                    }
-
-                    MouseArea {
-                        anchors.fill: parent
-                        enabled: !searchExpanded
-                        onClicked: {
-                            searchExpanded = true
-                            searchField.forceActiveFocus()
-                            Qt.inputMethod.show()
-                        }
-                    }
-                }
-
-                // Close button when search expanded
-                Button {
-                    id: searchCloseBtn
-                    flat: true
-                    visible: searchExpanded
-                    Layout.preferredWidth: 32
-                    Layout.preferredHeight: 32
-                    onClicked: {
-                        searchExpanded = false
-                        searchField.text = ""
-                        searchField.lastSearchText = ""
-                        searchField.focus = false
-                        patternModel.filter("")
-                    }
-                    contentItem: Text {
-                        text: "✕"
-                        font.pixelSize: 18
-                        color: Components.ThemeManager.textSecondary
-                        horizontalAlignment: Text.AlignHCenter
-                        verticalAlignment: Text.AlignVCenter
-                    }
-                    background: Rectangle {
-                        color: searchCloseBtn.pressed ? Components.ThemeManager.buttonBackgroundHover : "transparent"
-                        radius: 4
-                    }
-                }
-            }
-        }
-
-        // Pattern Grid
-        GridView {
-            id: gridView
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            cellWidth: 200
-            cellHeight: 220
-            model: patternModel
-            clip: true
-
-            ScrollBar.vertical: ScrollBar {
-                active: true
-                policy: ScrollBar.AsNeeded
-            }
-
-            delegate: Item {
-                width: gridView.cellWidth - 10
-                height: gridView.cellHeight - 10
-
-                // Check if pattern is already in playlist
-                property bool isInPlaylist: isPatternInPlaylist(model.name)
-
-                ModernPatternCard {
-                    id: patternCard
-                    anchors.fill: parent
-                    name: model.name
-                    preview: model.preview
-
-                    onClicked: {
-                        // Use the tracking function for immediate visual feedback
-                        page.addPatternToPlaylist(model.name)
-                    }
-                }
-
-                // Selection overlay for patterns already in playlist
-                Rectangle {
-                    anchors.fill: parent
-                    color: "transparent"
-                    border.color: isInPlaylist ? "#2563eb" : "transparent"
-                    border.width: isInPlaylist ? 3 : 0
-                    radius: 12
-
-                    // Checkmark badge for selected patterns
-                    Rectangle {
-                        visible: isInPlaylist
-                        anchors.top: parent.top
-                        anchors.right: parent.right
-                        anchors.topMargin: 12
-                        anchors.rightMargin: 12
-                        width: 28
-                        height: 28
-                        radius: 14
-                        color: "#2563eb"
-
-                        Text {
-                            anchors.centerIn: parent
-                            text: "✓"
-                            font.pixelSize: 16
-                            font.bold: true
-                            color: "white"
-                        }
-                    }
-                }
-            }
-
-            // Add scroll animations
-            add: Transition {
-                NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 300 }
-                NumberAnimation { property: "scale"; from: 0.8; to: 1; duration: 300 }
-            }
-        }
-
-        // Empty state when searching
-        Item {
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            visible: patternCount === 0 && searchField.text !== ""
-
-            Column {
-                anchors.centerIn: parent
-                spacing: 20
-
-                Text {
-                    text: "⌕"
-                    font.pixelSize: 48
-                    anchors.horizontalCenter: parent.horizontalCenter
-                    color: Components.ThemeManager.placeholderText
-                }
-
-                Label {
-                    text: "No patterns found"
-                    anchors.horizontalCenter: parent.horizontalCenter
-                    color: Components.ThemeManager.textSecondary
-                    font.pixelSize: 18
-                }
-
-                Label {
-                    text: "Try a different search term"
-                    anchors.horizontalCenter: parent.horizontalCenter
-                    color: Components.ThemeManager.textTertiary
-                    font.pixelSize: 14
-                }
-            }
-        }
-    }
-
-    // Handle pattern added signal for live updates
-    Connections {
-        target: backend
-
-        function onPatternAddedToPlaylist(success, message) {
-            if (success) {
-                // Extract the pattern name from the message if possible
-                // The message format is typically "Pattern added to playlist"
-                // We'll track additions in sessionAddedPatterns instead
-
-                // Re-trigger binding evaluation by updating the array reference
-                var temp = sessionAddedPatterns.slice()
-                // Try to extract pattern name from recent action
-                // Since we don't get the pattern name directly, we need another approach
-                sessionAddedPatterns = temp
-            }
-        }
-    }
-
-    // Track which pattern was last clicked for visual feedback
-    property string lastClickedPattern: ""
-
-    // Override the click handler to track additions
-    Component.onCompleted: {
-    }
-
-    // Function to add pattern and track it
-    function addPatternToPlaylist(patternName) {
-        if (!isPatternInPlaylist(patternName) && backend) {
-            backend.addPatternToPlaylist(playlistName, patternName)
-            // Immediately add to session tracking for instant visual feedback
-            var temp = sessionAddedPatterns.slice()
-            temp.push(patternName)
-            sessionAddedPatterns = temp
-        }
-    }
-}
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import DuneWeaver 1.0
+import "../components"
+import "../components" as Components
+
+Page {
+    id: page
+
+    property var backend: null
+    property var stackView: null
+    property string playlistName: ""
+    property var existingPatterns: []  // Raw pattern names already in playlist
+
+    // Track patterns added in this session for immediate visual feedback
+    property var sessionAddedPatterns: []
+
+    // Local pattern model for this page
+    PatternModel {
+        id: patternModel
+    }
+
+    // Search state
+    property bool searchExpanded: false
+    property int patternCount: patternModel ? patternModel.rowCount() : 0
+
+    // Update pattern count when model resets
+    Connections {
+        target: patternModel
+        function onModelReset() {
+            patternCount = patternModel.rowCount()
+        }
+    }
+
+    // Check if a pattern is already in the playlist
+    function isPatternInPlaylist(patternName) {
+        if (existingPatterns.indexOf(patternName) !== -1) {
+            return true
+        }
+        if (sessionAddedPatterns.indexOf(patternName) !== -1) {
+            return true
+        }
+        return false
+    }
+
+    // Add pattern and track it for instant visual feedback
+    function addPatternToPlaylist(patternName) {
+        if (!isPatternInPlaylist(patternName) && backend) {
+            backend.addPatternToPlaylist(playlistName, patternName)
+            var temp = sessionAddedPatterns.slice()
+            temp.push(patternName)
+            sessionAddedPatterns = temp
+        }
+    }
+
+    Rectangle {
+        anchors.fill: parent
+        color: Components.ThemeManager.backgroundColor
+    }
+
+    ColumnLayout {
+        anchors.fill: parent
+        spacing: 0
+
+        // Header with back button + search
+        Rectangle {
+            Layout.fillWidth: true
+            Layout.preferredHeight: Components.ThemeManager.headerHeight
+            color: Components.ThemeManager.surfaceColor
+
+            Rectangle {
+                anchors.bottom: parent.bottom
+                width: parent.width
+                height: 1
+                color: Components.ThemeManager.borderColor
+            }
+
+            RowLayout {
+                anchors.fill: parent
+                anchors.leftMargin: Components.ThemeManager.spaceSm
+                anchors.rightMargin: Components.ThemeManager.spaceMd
+                spacing: Components.ThemeManager.spaceSm
+
+                // Back button
+                Rectangle {
+                    Layout.preferredWidth: 44
+                    Layout.preferredHeight: 44
+                    radius: 22
+                    color: backArea.pressed ? Components.ThemeManager.pressedColor : "transparent"
+                    visible: !searchExpanded
+
+                    Components.Icon {
+                        anchors.centerIn: parent
+                        name: "arrow_back"
+                        size: 20
+                        color: Components.ThemeManager.textPrimary
+                    }
+
+                    MouseArea {
+                        id: backArea
+                        anchors.fill: parent
+                        onClicked: stackView.pop()
+                    }
+                }
+
+                Label {
+                    text: "Add to \"" + playlistName + "\""
+                    font.family: Components.ThemeManager.fontDisplay
+                    font.pixelSize: Components.ThemeManager.fontSizeTitle
+                    color: Components.ThemeManager.textPrimary
+                    Layout.fillWidth: true
+                    elide: Text.ElideRight
+                    visible: !searchExpanded
+                }
+
+                Label {
+                    text: patternCount + " patterns"
+                    font.family: Components.ThemeManager.fontBody
+                    font.pixelSize: Components.ThemeManager.fontSizeCaption
+                    color: Components.ThemeManager.textTertiary
+                    visible: !searchExpanded
+                }
+
+                // Expandable search pill (matching ModernPatternListPage)
+                Rectangle {
+                    Layout.fillWidth: searchExpanded
+                    Layout.preferredWidth: searchExpanded ? parent.width - 60 : 130
+                    Layout.preferredHeight: 40
+                    radius: 20
+                    color: Components.ThemeManager.backgroundColor
+                    border.color: searchExpanded || searchField.hasUnappliedSearch
+                                  ? Components.ThemeManager.accent
+                                  : Components.ThemeManager.borderColor
+                    border.width: 1
+
+                    Behavior on Layout.preferredWidth {
+                        NumberAnimation { duration: 200 }
+                    }
+
+                    RowLayout {
+                        anchors.fill: parent
+                        anchors.leftMargin: Components.ThemeManager.spaceMd
+                        anchors.rightMargin: Components.ThemeManager.spaceMd
+                        spacing: Components.ThemeManager.spaceSm
+
+                        Components.Icon {
+                            name: "search"
+                            size: 17
+                            color: searchExpanded ? Components.ThemeManager.accent
+                                                  : Components.ThemeManager.textSecondary
+                        }
+
+                        TextField {
+                            id: searchField
+                            Layout.fillWidth: true
+                            placeholderText: searchExpanded ? "Search patterns, then press Enter" : "Search"
+                            placeholderTextColor: Components.ThemeManager.textTertiary
+                            font.family: Components.ThemeManager.fontBody
+                            font.pixelSize: Components.ThemeManager.fontSizeBody
+                            color: Components.ThemeManager.textPrimary
+                            visible: searchExpanded || text.length > 0
+
+                            property string lastSearchText: ""
+                            property bool hasUnappliedSearch: text !== lastSearchText && text.length > 0
+
+                            background: Rectangle {
+                                color: "transparent"
+                            }
+
+                            onAccepted: {
+                                patternModel.filter(text)
+                                lastSearchText = text
+                                Qt.inputMethod.hide()
+                                focus = false
+                            }
+
+                            activeFocusOnPress: true
+                            selectByMouse: true
+                            inputMethodHints: Qt.ImhNoPredictiveText
+
+                            MouseArea {
+                                anchors.fill: parent
+                                onPressed: {
+                                    searchField.forceActiveFocus()
+                                    Qt.inputMethod.show()
+                                    mouse.accepted = false
+                                }
+                            }
+
+                            onActiveFocusChanged: {
+                                if (activeFocus) {
+                                    searchExpanded = true
+                                    Qt.inputMethod.show()
+                                } else {
+                                    if (text !== lastSearchText) {
+                                        patternModel.filter(text)
+                                        lastSearchText = text
+                                    }
+                                }
+                            }
+
+                            Keys.onReturnPressed: {
+                                Qt.inputMethod.hide()
+                                focus = false
+                            }
+
+                            Keys.onEscapePressed: {
+                                text = ""
+                                lastSearchText = ""
+                                patternModel.filter("")
+                                Qt.inputMethod.hide()
+                                focus = false
+                            }
+                        }
+
+                        Text {
+                            text: "Search"
+                            font.family: Components.ThemeManager.fontBody
+                            font.pixelSize: Components.ThemeManager.fontSizeCaption
+                            color: Components.ThemeManager.textTertiary
+                            visible: !searchExpanded && searchField.text.length === 0
+                        }
+                    }
+
+                    MouseArea {
+                        anchors.fill: parent
+                        enabled: !searchExpanded
+                        onClicked: {
+                            searchExpanded = true
+                            searchField.forceActiveFocus()
+                            Qt.inputMethod.show()
+                        }
+                    }
+                }
+
+                // Close button when search expanded
+                Rectangle {
+                    Layout.preferredWidth: 40
+                    Layout.preferredHeight: 40
+                    radius: 20
+                    visible: searchExpanded
+                    color: searchCloseArea.pressed ? Components.ThemeManager.pressedColor : "transparent"
+
+                    Components.Icon {
+                        anchors.centerIn: parent
+                        name: "close"
+                        size: 20
+                        color: Components.ThemeManager.textSecondary
+                    }
+
+                    MouseArea {
+                        id: searchCloseArea
+                        anchors.fill: parent
+                        onClicked: {
+                            searchExpanded = false
+                            searchField.text = ""
+                            searchField.lastSearchText = ""
+                            searchField.focus = false
+                            patternModel.filter("")
+                        }
+                    }
+                }
+            }
+        }
+
+        // Pattern Grid
+        GridView {
+            id: gridView
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            cellWidth: 200
+            cellHeight: 220
+            model: patternModel
+            clip: true
+            visible: patternCount > 0
+            reuseItems: true
+            cacheBuffer: 440
+
+            ScrollBar.vertical: ScrollBar {
+                active: true
+                policy: ScrollBar.AsNeeded
+            }
+
+            delegate: Item {
+                width: gridView.cellWidth
+                height: gridView.cellHeight
+
+                // Check if pattern is already in playlist
+                property bool isInPlaylist: isPatternInPlaylist(model.name)
+
+                ModernPatternCard {
+                    id: patternCard
+                    anchors.centerIn: parent
+                    width: gridView.cellWidth - 10
+                    height: gridView.cellHeight - 10
+                    name: model.name
+                    preview: model.preview
+
+                    onClicked: {
+                        page.addPatternToPlaylist(model.name)
+                    }
+                }
+
+                // Selection ring + badge for patterns already in the playlist
+                Rectangle {
+                    anchors.fill: patternCard
+                    color: "transparent"
+                    border.color: isInPlaylist ? Components.ThemeManager.accent : "transparent"
+                    border.width: isInPlaylist ? 2 : 0
+                    radius: Components.ThemeManager.radiusMd
+
+                    Rectangle {
+                        visible: isInPlaylist
+                        anchors.top: parent.top
+                        anchors.right: parent.right
+                        anchors.topMargin: Components.ThemeManager.spaceSm
+                        anchors.rightMargin: Components.ThemeManager.spaceSm
+                        width: 28
+                        height: 28
+                        radius: 14
+                        color: Components.ThemeManager.accent
+
+                        Components.Icon {
+                            anchors.centerIn: parent
+                            name: "check"
+                            size: 16
+                            color: Components.ThemeManager.onAccent
+                        }
+                    }
+                }
+            }
+
+            add: Transition {
+                NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 300 }
+                NumberAnimation { property: "scale"; from: 0.8; to: 1; duration: 300 }
+            }
+        }
+
+        // Empty state when searching
+        Item {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            visible: patternCount === 0
+
+            Column {
+                anchors.centerIn: parent
+                spacing: Components.ThemeManager.spaceLg
+
+                Components.Icon {
+                    name: "search"
+                    size: 44
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textTertiary
+                }
+
+                Label {
+                    text: "No patterns found"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textSecondary
+                    font.family: Components.ThemeManager.fontDisplay
+                    font.pixelSize: Components.ThemeManager.fontSizeTitle
+                }
+
+                Label {
+                    text: "Try a different search term"
+                    anchors.horizontalCenter: parent.horizontalCenter
+                    color: Components.ThemeManager.textTertiary
+                    font.family: Components.ThemeManager.fontBody
+                    font.pixelSize: Components.ThemeManager.fontSizeBody
+                }
+            }
+        }
+    }
+}

+ 0 - 113
dune-weaver-touch/qml/pages/PlaylistPage.qml

@@ -1,113 +0,0 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-import DuneWeaver 1.0
-import "../components" as Components
-
-Page {
-    background: Rectangle {
-        color: Components.ThemeManager.backgroundColor
-    }
-
-    header: ToolBar {
-        background: Rectangle {
-            color: Components.ThemeManager.surfaceColor
-            border.color: Components.ThemeManager.borderColor
-            border.width: 1
-        }
-
-        RowLayout {
-            anchors.fill: parent
-            anchors.margins: 10
-
-            Button {
-                text: "← Back"
-                font.pixelSize: 14
-                flat: true
-                onClicked: stackView.pop()
-                contentItem: Text {
-                    text: parent.text
-                    font: parent.font
-                    color: Components.ThemeManager.textPrimary
-                    horizontalAlignment: Text.AlignHCenter
-                    verticalAlignment: Text.AlignVCenter
-                }
-            }
-
-            Label {
-                text: "Playlists"
-                Layout.fillWidth: true
-                font.pixelSize: 20
-                font.bold: true
-                color: Components.ThemeManager.textPrimary
-            }
-        }
-    }
-
-    PlaylistModel {
-        id: playlistModel
-    }
-
-    ListView {
-        anchors.fill: parent
-        anchors.margins: 20
-        model: playlistModel
-        spacing: 10
-
-        delegate: Rectangle {
-            width: parent.width
-            height: 80
-            color: mouseArea.pressed ? Components.ThemeManager.buttonBackgroundHover : Components.ThemeManager.cardColor
-            radius: 8
-            border.color: Components.ThemeManager.borderColor
-
-            RowLayout {
-                anchors.fill: parent
-                anchors.margins: 15
-                spacing: 15
-
-                Column {
-                    Layout.fillWidth: true
-                    spacing: 5
-
-                    Label {
-                        text: model.name
-                        font.pixelSize: 16
-                        font.bold: true
-                        color: Components.ThemeManager.textPrimary
-                    }
-
-                    Label {
-                        text: model.itemCount + " patterns"
-                        color: Components.ThemeManager.textSecondary
-                        font.pixelSize: 14
-                    }
-                }
-
-                Button {
-                    text: "Play"
-                    Layout.preferredWidth: 80
-                    Layout.preferredHeight: 40
-                    font.pixelSize: 14
-                    enabled: false // TODO: Implement playlist execution
-                }
-            }
-
-            MouseArea {
-                id: mouseArea
-                anchors.fill: parent
-                onClicked: {
-                    // TODO: Navigate to playlist detail page
-                }
-            }
-        }
-    }
-
-    Label {
-        anchors.centerIn: parent
-        text: "No playlists found"
-        visible: playlistModel.rowCount() === 0
-        color: Components.ThemeManager.textTertiary
-        font.pixelSize: 18
-    }
-}

+ 677 - 646
dune-weaver-touch/qml/pages/TableControlPage.qml

@@ -1,646 +1,677 @@
-import QtQuick 2.15
-import QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-// import QtQuick.Effects  // Disabled - causes issues on linuxfb
-import "../components"
-import "../components" as Components
-
-Page {
-    id: page
-    property var backend: null
-    property var serialPorts: []
-    property string selectedPort: ""
-    property bool isSerialConnected: false
-    property bool autoPlayOnBoot: false
-    
-    // Backend signal connections
-    Connections {
-        target: backend
-        
-        function onSerialPortsUpdated(ports) {
-            serialPorts = ports
-        }
-        
-        function onSerialConnectionChanged(connected) {
-            isSerialConnected = connected
-        }
-        
-        function onCurrentPortChanged(port) {
-            if (port) {
-                selectedPort = port
-            }
-        }
-        
-        
-        function onSettingsLoaded() {
-            if (backend) {
-                autoPlayOnBoot = backend.autoPlayOnBoot
-                isSerialConnected = backend.serialConnected
-                // Screen timeout is now managed by button selection, no need to convert
-                if (backend.currentPort) {
-                    selectedPort = backend.currentPort
-                }
-            }
-        }
-    }
-    
-    // Refresh serial ports on page load
-    Component.onCompleted: {
-        refreshSerialPorts()
-        loadSettings()
-    }
-    
-    function refreshSerialPorts() {
-        if (backend) {
-            backend.refreshSerialPorts()
-        }
-    }
-    
-    function loadSettings() {
-        if (backend) {
-            backend.loadControlSettings()
-        }
-    }
-    
-    Rectangle {
-        anchors.fill: parent
-        color: Components.ThemeManager.backgroundColor
-    }
-    
-    ColumnLayout {
-        anchors.fill: parent
-        spacing: 0
-        
-        // Header
-        Rectangle {
-            Layout.fillWidth: true
-            Layout.preferredHeight: 50
-            color: Components.ThemeManager.surfaceColor
-
-            Rectangle {
-                anchors.bottom: parent.bottom
-                width: parent.width
-                height: 1
-                color: Components.ThemeManager.borderColor
-            }
-
-            RowLayout {
-                anchors.fill: parent
-                anchors.leftMargin: 15
-                anchors.rightMargin: 10
-
-                ConnectionStatus {
-                    backend: page.backend
-                    Layout.rightMargin: 8
-                }
-
-                Label {
-                    text: "Table Control"
-                    font.pixelSize: 18
-                    font.bold: true
-                    color: Components.ThemeManager.textPrimary
-                }
-
-                Item {
-                    Layout.fillWidth: true
-                }
-            }
-        }
-        
-        // Main Content
-        ScrollView {
-            Layout.fillWidth: true
-            Layout.fillHeight: true
-            contentWidth: availableWidth
-            
-            ColumnLayout {
-                width: parent.width
-                anchors.margins: 5
-                spacing: 2
-                
-                // Serial Connection Section
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: 160
-                    Layout.margins: 5
-                    radius: 8
-                    color: Components.ThemeManager.surfaceColor
-
-                    ColumnLayout {
-                        anchors.fill: parent
-                        anchors.margins: 15
-                        spacing: 10
-
-                        Label {
-                            text: "Serial Connection"
-                            font.pixelSize: 14
-                            font.bold: true
-                            color: Components.ThemeManager.textPrimary
-                        }
-                        
-                        RowLayout {
-                            Layout.fillWidth: true
-                            spacing: 10
-                            
-                            Rectangle {
-                                Layout.fillWidth: true
-                                Layout.preferredHeight: 40
-                                radius: 6
-                                color: isSerialConnected ? (Components.ThemeManager.darkMode ? "#1b4332" : "#e8f5e8") : Components.ThemeManager.cardColor
-                                border.color: isSerialConnected ? "#4CAF50" : Components.ThemeManager.borderColor
-                                border.width: 1
-
-                                RowLayout {
-                                    anchors.fill: parent
-                                    anchors.margins: 8
-
-                                    Label {
-                                        text: isSerialConnected ?
-                                              (selectedPort ? `Connected: ${selectedPort}` : "Connected") :
-                                              (selectedPort || "No port selected")
-                                        color: isSerialConnected ? "#4CAF50" : (selectedPort ? Components.ThemeManager.textPrimary : Components.ThemeManager.textTertiary)
-                                        font.pixelSize: 12
-                                        font.bold: isSerialConnected
-                                        Layout.fillWidth: true
-                                    }
-
-                                    Text {
-                                        text: "▼"
-                                        color: Components.ThemeManager.textSecondary
-                                        font.pixelSize: 10
-                                        visible: !isSerialConnected
-                                    }
-                                }
-                                
-                                MouseArea {
-                                    anchors.fill: parent
-                                    enabled: !isSerialConnected
-                                    onClicked: portMenu.open()
-                                }
-                                
-                                Menu {
-                                    id: portMenu
-                                    y: parent.height
-
-                                    // Note: Menu rotation for Pi 5 handled differently
-                                    // transform property not well supported on Menu
-
-                                    Repeater {
-                                        model: serialPorts
-                                        MenuItem {
-                                            text: modelData
-                                            onTriggered: {
-                                                selectedPort = modelData
-                                            }
-                                        }
-                                    }
-                                    
-                                    MenuSeparator {}
-                                    
-                                    MenuItem {
-                                        text: "Refresh Ports"
-                                        onTriggered: refreshSerialPorts()
-                                    }
-                                }
-                            }
-                            
-                            ModernControlButton {
-                                Layout.preferredWidth: 150
-                                Layout.preferredHeight: 40
-                                text: isSerialConnected ? "Disconnect" : "Connect"
-                                icon: isSerialConnected ? "◉" : "○"
-                                iconSize: 20
-                                buttonColor: isSerialConnected ? "#dc2626" : "#059669"
-                                fontSize: 11
-                                enabled: isSerialConnected || selectedPort !== ""
-                                
-                                onClicked: {
-                                    if (backend) {
-                                        if (isSerialConnected) {
-                                            backend.disconnectSerial()
-                                        } else {
-                                            backend.connectSerial(selectedPort)
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                        
-                        RowLayout {
-                            Layout.fillWidth: true
-                            spacing: 8
-                            visible: !isSerialConnected
-                            
-                            ModernControlButton {
-                                Layout.fillWidth: true
-                                Layout.preferredHeight: 35
-                                text: "Refresh Ports"
-                                icon: "↻"
-                                iconSize: 18
-                                buttonColor: "#6b7280"
-                                fontSize: 10
-                                
-                                onClicked: refreshSerialPorts()
-                            }
-                        }
-                    }
-                }
-                
-                // Hardware Movement Section
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: 100
-                    Layout.margins: 5
-                    radius: 8
-                    color: Components.ThemeManager.surfaceColor
-
-                    ColumnLayout {
-                        anchors.fill: parent
-                        anchors.margins: 15
-                        spacing: 10
-
-                        Label {
-                            text: "Table Movement"
-                            font.pixelSize: 14
-                            font.bold: true
-                            color: Components.ThemeManager.textPrimary
-                        }
-                        
-                        GridLayout {
-                            Layout.fillWidth: true
-                            columns: 3
-                            rowSpacing: 8
-                            columnSpacing: 8
-                            
-                            ModernControlButton {
-                                Layout.fillWidth: true
-                                Layout.preferredHeight: 45
-                                text: "Home"
-                                icon: "⌂"
-                                iconSize: 20
-                                buttonColor: "#2563eb"
-                                fontSize: 12
-                                enabled: isSerialConnected
-                                
-                                onClicked: {
-                                    if (backend) backend.sendHome()
-                                }
-                            }
-                            
-                            ModernControlButton {
-                                Layout.fillWidth: true
-                                Layout.preferredHeight: 45
-                                text: "Center"
-                                icon: "◎"
-                                iconSize: 20
-                                buttonColor: "#2563eb"
-                                fontSize: 12
-                                enabled: isSerialConnected
-                                
-                                onClicked: {
-                                    if (backend) backend.moveToCenter()
-                                }
-                            }
-                            
-                            ModernControlButton {
-                                Layout.fillWidth: true
-                                Layout.preferredHeight: 45
-                                text: "Perimeter"
-                                icon: "○"
-                                iconSize: 20
-                                buttonColor: "#2563eb"
-                                fontSize: 12
-                                enabled: isSerialConnected
-                                
-                                onClicked: {
-                                    if (backend) backend.moveToPerimeter()
-                                }
-                            }
-                        }
-                    }
-                }
-                
-                
-                // Auto Play on Boot Section
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: 200  // Reduced from 280 for single row layout
-                    Layout.margins: 5
-                    radius: 8
-                    color: Components.ThemeManager.surfaceColor
-
-                    ColumnLayout {
-                        anchors.fill: parent
-                        anchors.margins: 15
-                        spacing: 10
-
-                        Label {
-                            text: "Auto Play Settings"
-                            font.pixelSize: 14
-                            font.bold: true
-                            color: Components.ThemeManager.textPrimary
-                        }
-
-                        RowLayout {
-                            Layout.fillWidth: true
-                            spacing: 10
-
-                            Label {
-                                text: "Auto play on boot:"
-                                font.pixelSize: 12
-                                color: Components.ThemeManager.textSecondary
-                                Layout.fillWidth: true
-                            }
-                            
-                            Switch {
-                                id: autoPlaySwitch
-                                checked: autoPlayOnBoot
-                                
-                                onToggled: {
-                                    autoPlayOnBoot = checked
-                                    if (backend) {
-                                        backend.setAutoPlayOnBoot(checked)
-                                    }
-                                }
-                            }
-                        }
-                        
-                        ColumnLayout {
-                            Layout.fillWidth: true
-                            spacing: 15
-
-                            Label {
-                                text: "Screen timeout:"
-                                font.pixelSize: 14
-                                font.bold: true
-                                color: Components.ThemeManager.textPrimary
-                                Layout.alignment: Qt.AlignLeft
-                            }
-                            
-                            // Touch-friendly button row for timeout options
-                            RowLayout {
-                                id: timeoutGrid
-                                Layout.fillWidth: true
-                                spacing: 8
-                                
-                                property string currentSelection: backend ? backend.getCurrentScreenTimeoutOption() : "5 minutes"
-                                
-                                // 30 seconds button
-                                Rectangle {
-                                    Layout.preferredWidth: 100
-                                    Layout.preferredHeight: 50
-                                    color: timeoutGrid.currentSelection === "30 seconds" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                    border.color: timeoutGrid.currentSelection === "30 seconds" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                    border.width: 2
-                                    radius: 8
-
-                                    Label {
-                                        anchors.centerIn: parent
-                                        text: "30s"
-                                        font.pixelSize: 14
-                                        font.bold: true
-                                        color: timeoutGrid.currentSelection === "30 seconds" ? "white" : Components.ThemeManager.textPrimary
-                                    }
-                                    
-                                    MouseArea {
-                                        anchors.fill: parent
-                                        onClicked: {
-                                            if (backend) {
-                                                backend.setScreenTimeoutByOption("30 seconds")
-                                                timeoutGrid.currentSelection = "30 seconds"
-                                            }
-                                        }
-                                    }
-                                }
-                                
-                                // 1 minute button
-                                Rectangle {
-                                    Layout.preferredWidth: 100
-                                    Layout.preferredHeight: 50
-                                    color: timeoutGrid.currentSelection === "1 minute" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                    border.color: timeoutGrid.currentSelection === "1 minute" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                    border.width: 2
-                                    radius: 8
-
-                                    Label {
-                                        anchors.centerIn: parent
-                                        text: "1min"
-                                        font.pixelSize: 14
-                                        font.bold: true
-                                        color: timeoutGrid.currentSelection === "1 minute" ? "white" : Components.ThemeManager.textPrimary
-                                    }
-
-                                    MouseArea {
-                                        anchors.fill: parent
-                                        onClicked: {
-                                            if (backend) {
-                                                backend.setScreenTimeoutByOption("1 minute")
-                                                timeoutGrid.currentSelection = "1 minute"
-                                            }
-                                        }
-                                    }
-                                }
-
-                                // 5 minutes button
-                                Rectangle {
-                                    Layout.preferredWidth: 100
-                                    Layout.preferredHeight: 50
-                                    color: timeoutGrid.currentSelection === "5 minutes" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                    border.color: timeoutGrid.currentSelection === "5 minutes" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                    border.width: 2
-                                    radius: 8
-
-                                    Label {
-                                        anchors.centerIn: parent
-                                        text: "5min"
-                                        font.pixelSize: 14
-                                        font.bold: true
-                                        color: timeoutGrid.currentSelection === "5 minutes" ? "white" : Components.ThemeManager.textPrimary
-                                    }
-
-                                    MouseArea {
-                                        anchors.fill: parent
-                                        onClicked: {
-                                            if (backend) {
-                                                backend.setScreenTimeoutByOption("5 minutes")
-                                                timeoutGrid.currentSelection = "5 minutes"
-                                            }
-                                        }
-                                    }
-                                }
-
-                                // 10 minutes button
-                                Rectangle {
-                                    Layout.preferredWidth: 100
-                                    Layout.preferredHeight: 50
-                                    color: timeoutGrid.currentSelection === "10 minutes" ? Components.ThemeManager.selectedBackground : Components.ThemeManager.buttonBackground
-                                    border.color: timeoutGrid.currentSelection === "10 minutes" ? Components.ThemeManager.selectedBorder : Components.ThemeManager.buttonBorder
-                                    border.width: 2
-                                    radius: 8
-
-                                    Label {
-                                        anchors.centerIn: parent
-                                        text: "10min"
-                                        font.pixelSize: 14
-                                        font.bold: true
-                                        color: timeoutGrid.currentSelection === "10 minutes" ? "white" : Components.ThemeManager.textPrimary
-                                    }
-                                    
-                                    MouseArea {
-                                        anchors.fill: parent
-                                        onClicked: {
-                                            if (backend) {
-                                                backend.setScreenTimeoutByOption("10 minutes")
-                                                timeoutGrid.currentSelection = "10 minutes"
-                                            }
-                                        }
-                                    }
-                                }
-                                
-                                // Never button
-                                Rectangle {
-                                    Layout.preferredWidth: 100
-                                    Layout.preferredHeight: 50
-                                    color: timeoutGrid.currentSelection === "Never" ? "#FF9800" : "#f0f0f0"
-                                    border.color: timeoutGrid.currentSelection === "Never" ? "#F57C00" : "#ccc"
-                                    border.width: 2
-                                    radius: 8
-                                    
-                                    Label {
-                                        anchors.centerIn: parent
-                                        text: "Never"
-                                        font.pixelSize: 14
-                                        font.bold: true
-                                        color: timeoutGrid.currentSelection === "Never" ? "white" : "#333"
-                                    }
-                                    
-                                    MouseArea {
-                                        anchors.fill: parent
-                                        onClicked: {
-                                            if (backend) {
-                                                backend.setScreenTimeoutByOption("Never")
-                                                timeoutGrid.currentSelection = "Never"
-                                            }
-                                        }
-                                    }
-                                }
-                                
-                                // Update selection when backend changes
-                                Connections {
-                                    target: backend
-                                    function onScreenTimeoutChanged() {
-                                        if (backend) {
-                                            timeoutGrid.currentSelection = backend.getCurrentScreenTimeoutOption()
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                    }
-                }
-
-                // Theme Settings Section
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: 100
-                    Layout.margins: 5
-                    radius: 8
-                    color: Components.ThemeManager.surfaceColor
-
-                    ColumnLayout {
-                        anchors.fill: parent
-                        anchors.margins: 15
-                        spacing: 10
-
-                        Label {
-                            text: "Appearance"
-                            font.pixelSize: 14
-                            font.bold: true
-                            color: Components.ThemeManager.textPrimary
-                        }
-
-                        RowLayout {
-                            Layout.fillWidth: true
-                            spacing: 10
-
-                            Label {
-                                text: "Dark mode:"
-                                font.pixelSize: 12
-                                color: Components.ThemeManager.textSecondary
-                                Layout.fillWidth: true
-                            }
-
-                            Switch {
-                                id: darkModeSwitch
-                                checked: Components.ThemeManager.darkMode
-
-                                onToggled: {
-                                    Components.ThemeManager.darkMode = checked
-                                }
-                            }
-                        }
-                    }
-                }
-
-                // System Controls Section
-                Rectangle {
-                    Layout.fillWidth: true
-                    Layout.preferredHeight: 100
-                    Layout.margins: 5
-                    radius: 8
-                    color: Components.ThemeManager.surfaceColor
-
-                    ColumnLayout {
-                        anchors.fill: parent
-                        anchors.margins: 15
-                        spacing: 10
-
-                        Label {
-                            text: "System Controls"
-                            font.pixelSize: 14
-                            font.bold: true
-                            color: Components.ThemeManager.textPrimary
-                        }
-
-                        RowLayout {
-                            Layout.fillWidth: true
-                            spacing: 8
-
-                            ModernControlButton {
-                                Layout.fillWidth: true
-                                Layout.preferredHeight: 45
-                                text: "Restart Backend"
-                                icon: "↻"
-                                iconSize: 20
-                                buttonColor: "#f59e0b"
-                                fontSize: 11
-
-                                onClicked: {
-                                    if (backend) backend.restartBackend()
-                                }
-                            }
-
-                            ModernControlButton {
-                                Layout.fillWidth: true
-                                Layout.preferredHeight: 45
-                                text: "Shutdown Pi"
-                                icon: ""
-                                buttonColor: "#dc2626"
-                                fontSize: 11
-
-                                onClicked: {
-                                    if (backend) backend.shutdownPi()
-                                }
-                            }
-                        }
-                    }
-                }
-
-                // Add some bottom spacing for better scrolling
-                Item {
-                    Layout.preferredHeight: 20
-                }
-            }
-        }
-    }
-}
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import "../components"
+import "../components" as Components
+
+Page {
+    id: page
+    property var backend: null
+    property string selectedPort: ""
+    property bool isSerialConnected: false
+    property bool autoPlayOnBoot: false
+
+    // Backend signal connections
+    Connections {
+        target: backend
+
+        function onSerialConnectionChanged(connected) {
+            isSerialConnected = connected
+        }
+
+        function onCurrentPortChanged(port) {
+            if (port) {
+                selectedPort = port
+            }
+        }
+
+        function onSettingsLoaded() {
+            if (backend) {
+                autoPlayOnBoot = backend.autoPlayOnBoot
+                isSerialConnected = backend.serialConnected
+                if (backend.currentPort) {
+                    selectedPort = backend.currentPort
+                }
+            }
+        }
+    }
+
+    // backend is injected by the Loader after creation, so kick off the
+    // initial mDNS browse (and settings load) when it arrives — at
+    // Component.onCompleted it is still null and these would be no-ops.
+    onBackendChanged: {
+        if (backend) {
+            refreshSerialPorts()
+            loadSettings()
+        }
+    }
+
+    function refreshSerialPorts() {
+        if (backend) {
+            backend.refreshSerialPorts()
+        }
+    }
+
+    function loadSettings() {
+        if (backend) {
+            backend.loadControlSettings()
+        }
+    }
+
+    Rectangle {
+        anchors.fill: parent
+        color: Components.ThemeManager.backgroundColor
+    }
+
+    ColumnLayout {
+        anchors.fill: parent
+        spacing: 0
+
+        // Header
+        Rectangle {
+            Layout.fillWidth: true
+            Layout.preferredHeight: Components.ThemeManager.headerHeight
+            color: Components.ThemeManager.surfaceColor
+
+            Rectangle {
+                anchors.bottom: parent.bottom
+                width: parent.width
+                height: 1
+                color: Components.ThemeManager.borderColor
+            }
+
+            RowLayout {
+                anchors.fill: parent
+                anchors.leftMargin: Components.ThemeManager.spaceLg
+                anchors.rightMargin: Components.ThemeManager.spaceLg
+
+                ConnectionStatus {
+                    backend: page.backend
+                    Layout.rightMargin: Components.ThemeManager.spaceSm
+                }
+
+                Label {
+                    text: "Control"
+                    font.family: Components.ThemeManager.fontDisplay
+                    font.pixelSize: Components.ThemeManager.fontSizeTitle
+                    color: Components.ThemeManager.textPrimary
+                }
+
+                Item {
+                    Layout.fillWidth: true
+                }
+            }
+        }
+
+        // Main content — two columns: the landscape panel wastes half its
+        // width on full-page cards, so connection lives left and the
+        // movement/device settings live right; both scroll together.
+        ScrollView {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            contentWidth: availableWidth
+
+            RowLayout {
+                width: parent.width
+                spacing: 0
+
+                // ---- Left column: table connection ----
+                ColumnLayout {
+                    Layout.fillWidth: true
+                    Layout.maximumWidth: Math.round(page.width * 0.47)
+                    Layout.alignment: Qt.AlignTop
+                    spacing: 0
+
+                    SettingsCard {
+                        Layout.rightMargin: Components.ThemeManager.spaceSm
+                        Layout.bottomMargin: Components.ThemeManager.spaceLg
+                        Layout.preferredHeight: connectionColumn.implicitHeight + 2 * Components.ThemeManager.spaceLg
+
+                        ColumnLayout {
+                            id: connectionColumn
+                            anchors.fill: parent
+                            anchors.margins: Components.ThemeManager.spaceLg
+                            spacing: Components.ThemeManager.spaceMd
+
+                            SectionLabel {
+                                text: "Table connection"
+                            }
+
+                            // Connection status row — info rect + button as
+                            // siblings, so the button column lines up with
+                            // every other row's (Save, Refresh, Connect).
+                            RowLayout {
+                                Layout.fillWidth: true
+                                spacing: Components.ThemeManager.spaceSm
+
+                                Rectangle {
+                                    Layout.fillWidth: true
+                                    Layout.preferredHeight: 60
+                                    radius: Components.ThemeManager.radiusSm
+                                    color: isSerialConnected ? Components.ThemeManager.okSoft
+                                                             : Components.ThemeManager.cardColor
+                                    border.color: isSerialConnected ? Components.ThemeManager.ok
+                                                                    : Components.ThemeManager.borderColor
+                                    border.width: 1
+
+                                    Column {
+                                        anchors.verticalCenter: parent.verticalCenter
+                                        anchors.left: parent.left
+                                        anchors.right: parent.right
+                                        anchors.leftMargin: Components.ThemeManager.spaceLg
+                                        anchors.rightMargin: Components.ThemeManager.spaceMd
+                                        spacing: 2
+
+                                        Label {
+                                            text: isSerialConnected
+                                                  ? (backend && backend.tableName ? backend.tableName : "Connected")
+                                                  : "Not connected"
+                                            color: isSerialConnected ? Components.ThemeManager.ok
+                                                                     : Components.ThemeManager.textSecondary
+                                            font.family: Components.ThemeManager.fontDisplay
+                                            font.pixelSize: Components.ThemeManager.fontSizeBody
+                                            elide: Text.ElideRight
+                                            width: parent.width
+                                        }
+
+                                        Label {
+                                            text: isSerialConnected
+                                                  ? selectedPort
+                                                  : (backend ? backend.reconnectStatus : "")
+                                            visible: text !== ""
+                                            color: Components.ThemeManager.textSecondary
+                                            font.family: Components.ThemeManager.fontBody
+                                            font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                            elide: Text.ElideRight
+                                            width: parent.width
+                                        }
+                                    }
+                                }
+
+                                ModernControlButton {
+                                    Layout.preferredWidth: 116
+                                    Layout.preferredHeight: 44
+                                    visible: isSerialConnected
+                                    text: "Disconnect"
+                                    outlined: true
+                                    buttonColor: Components.ThemeManager.danger
+                                    fontSize: 12
+
+                                    onClicked: {
+                                        if (backend) backend.disconnectSerial()
+                                    }
+                                }
+                            }
+
+                            // Table password ($Sand/Password, sent as X-Sand-Key).
+                            // Empty + Save clears a stored password.
+                            RowLayout {
+                                Layout.fillWidth: true
+                                spacing: Components.ThemeManager.spaceSm
+
+                                TextField {
+                                    id: tablePasswordField
+                                    Layout.fillWidth: true
+                                    Layout.preferredHeight: 44
+                                    echoMode: TextInput.Password
+                                    placeholderText: backend && backend.hasTablePassword
+                                                     ? "Table password (saved)"
+                                                     : "Table password (if set)"
+                                    placeholderTextColor: Components.ThemeManager.textTertiary
+                                    font.family: Components.ThemeManager.fontBody
+                                    font.pixelSize: Components.ThemeManager.fontSizeBody
+                                    color: Components.ThemeManager.textPrimary
+                                    verticalAlignment: TextInput.AlignVCenter
+                                    leftPadding: Components.ThemeManager.spaceLg
+                                    rightPadding: Components.ThemeManager.spaceLg
+
+                                    background: Rectangle {
+                                        color: Components.ThemeManager.backgroundColor
+                                        radius: 22
+                                        border.color: tablePasswordField.activeFocus ? Components.ThemeManager.accent
+                                                                                     : Components.ThemeManager.borderColor
+                                        border.width: 1
+                                    }
+                                }
+
+                                ModernControlButton {
+                                    Layout.preferredWidth: 116
+                                    Layout.preferredHeight: 44
+                                    text: "Save"
+                                    buttonColor: Components.ThemeManager.accent
+                                    fontSize: 12
+
+                                    onClicked: {
+                                        if (backend) {
+                                            backend.setTablePassword(tablePasswordField.text)
+                                            tablePasswordField.text = ""
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+
+                    // Tables found on the network (mDNS)
+                    SettingsCard {
+                        Layout.rightMargin: Components.ThemeManager.spaceSm
+                        Layout.bottomMargin: Components.ThemeManager.spaceLg
+                        Layout.preferredHeight: networkColumn.implicitHeight + 2 * Components.ThemeManager.spaceLg
+
+                        ColumnLayout {
+                            id: networkColumn
+                            anchors.fill: parent
+                            anchors.margins: Components.ThemeManager.spaceLg
+                            spacing: Components.ThemeManager.spaceMd
+
+                            RowLayout {
+                                Layout.fillWidth: true
+                                spacing: Components.ThemeManager.spaceSm
+
+                                SectionLabel {
+                                    text: "Tables on your network"
+                                    Layout.fillWidth: true
+                                }
+
+                                ModernControlButton {
+                                    Layout.preferredWidth: 116
+                                    Layout.preferredHeight: 44
+                                    text: "Refresh"
+                                    icon: "refresh"
+                                    outlined: true
+                                    buttonColor: Components.ThemeManager.textSecondary
+                                    fontSize: 12
+
+                                    onClicked: refreshSerialPorts()
+                                }
+                            }
+
+                            // The connected table already sits in the status
+                            // card above, so it is filtered from this list.
+                            Repeater {
+                                model: backend ? backend.discoveredTables : []
+
+                                delegate: RowLayout {
+                                    required property var modelData
+                                    readonly property bool isCurrent: isSerialConnected && modelData.url === selectedPort
+
+                                    visible: !isCurrent
+                                    Layout.fillWidth: true
+                                    spacing: Components.ThemeManager.spaceSm
+
+                                    Rectangle {
+                                        Layout.fillWidth: true
+                                        Layout.preferredHeight: 60
+                                        radius: Components.ThemeManager.radiusSm
+                                        color: Components.ThemeManager.cardColor
+                                        border.color: Components.ThemeManager.borderColor
+                                        border.width: 1
+
+                                        Column {
+                                            anchors.verticalCenter: parent.verticalCenter
+                                            anchors.left: parent.left
+                                            anchors.right: parent.right
+                                            anchors.leftMargin: Components.ThemeManager.spaceLg
+                                            anchors.rightMargin: Components.ThemeManager.spaceMd
+                                            spacing: 2
+
+                                            Label {
+                                                text: modelData.name || modelData.url
+                                                color: Components.ThemeManager.textPrimary
+                                                font.family: Components.ThemeManager.fontDisplay
+                                                font.pixelSize: Components.ThemeManager.fontSizeBody
+                                                elide: Text.ElideRight
+                                                width: parent.width
+                                            }
+
+                                            Label {
+                                                text: modelData.url
+                                                color: Components.ThemeManager.textSecondary
+                                                font.family: Components.ThemeManager.fontBody
+                                                font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                                elide: Text.ElideRight
+                                                width: parent.width
+                                            }
+                                        }
+                                    }
+
+                                    ModernControlButton {
+                                        Layout.preferredWidth: 116
+                                        Layout.preferredHeight: 44
+                                        text: "Connect"
+                                        buttonColor: Components.ThemeManager.accent
+                                        fontSize: 12
+
+                                        onClicked: {
+                                            if (backend) backend.connectSerial(modelData.url)
+                                        }
+                                    }
+                                }
+                            }
+
+                            Label {
+                                visible: !backend || backend.discoveredTables.length === 0
+                                text: "No tables found. Tap Refresh, or enter the address below."
+                                font.family: Components.ThemeManager.fontBody
+                                font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                color: Components.ThemeManager.textTertiary
+                                Layout.fillWidth: true
+                                wrapMode: Text.WordWrap
+                            }
+
+                            // Manual address entry
+                            RowLayout {
+                                Layout.fillWidth: true
+                                spacing: Components.ThemeManager.spaceSm
+
+                                TextField {
+                                    id: manualAddress
+                                    Layout.fillWidth: true
+                                    Layout.preferredHeight: 44
+                                    placeholderText: "IP or host address"
+                                    placeholderTextColor: Components.ThemeManager.textTertiary
+                                    font.family: Components.ThemeManager.fontBody
+                                    font.pixelSize: Components.ThemeManager.fontSizeBody
+                                    color: Components.ThemeManager.textPrimary
+                                    verticalAlignment: TextInput.AlignVCenter
+                                    leftPadding: Components.ThemeManager.spaceLg
+                                    rightPadding: Components.ThemeManager.spaceLg
+                                    inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhPreferLowercase
+
+                                    background: Rectangle {
+                                        color: Components.ThemeManager.backgroundColor
+                                        radius: 22
+                                        border.color: manualAddress.activeFocus ? Components.ThemeManager.accent
+                                                                                : Components.ThemeManager.borderColor
+                                        border.width: 1
+                                    }
+                                }
+
+                                ModernControlButton {
+                                    Layout.preferredWidth: 116
+                                    Layout.preferredHeight: 44
+                                    text: "Connect"
+                                    buttonColor: Components.ThemeManager.accent
+                                    fontSize: 12
+                                    enabled: manualAddress.text.trim() !== ""
+
+                                    onClicked: {
+                                        if (backend) {
+                                            backend.connectSerial(manualAddress.text.trim())
+                                            manualAddress.text = ""
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+
+                // ---- Right column: movement + device settings ----
+                ColumnLayout {
+                    Layout.fillWidth: true
+                    Layout.alignment: Qt.AlignTop
+                    spacing: 0
+
+                    // The table itself: movement + auto play
+                    SettingsCard {
+                        Layout.leftMargin: Components.ThemeManager.spaceSm
+                        Layout.preferredHeight: movementColumn.implicitHeight + 2 * Components.ThemeManager.spaceLg
+
+                        ColumnLayout {
+                            id: movementColumn
+                            anchors.fill: parent
+                            anchors.margins: Components.ThemeManager.spaceLg
+                            spacing: Components.ThemeManager.spaceMd
+
+                            SectionLabel {
+                                text: "Table"
+                            }
+
+                            RowLayout {
+                                Layout.fillWidth: true
+                                spacing: Components.ThemeManager.spaceSm
+
+                                ModernControlButton {
+                                    Layout.fillWidth: true
+                                    Layout.preferredHeight: Components.ThemeManager.touchTarget
+                                    text: "Home"
+                                    icon: "home"
+                                    outlined: true
+                                    buttonColor: Components.ThemeManager.accent
+                                    fontSize: 13
+                                    enabled: isSerialConnected
+
+                                    onClicked: {
+                                        if (backend) backend.sendHome()
+                                    }
+                                }
+
+                                ModernControlButton {
+                                    Layout.fillWidth: true
+                                    Layout.preferredHeight: Components.ThemeManager.touchTarget
+                                    text: "Center"
+                                    icon: "adjust"
+                                    outlined: true
+                                    buttonColor: Components.ThemeManager.accent
+                                    fontSize: 13
+                                    enabled: isSerialConnected
+
+                                    onClicked: {
+                                        if (backend) backend.moveToCenter()
+                                    }
+                                }
+
+                                ModernControlButton {
+                                    Layout.fillWidth: true
+                                    Layout.preferredHeight: Components.ThemeManager.touchTarget
+                                    text: "Edge"
+                                    icon: "radio_unchecked"
+                                    outlined: true
+                                    buttonColor: Components.ThemeManager.accent
+                                    fontSize: 13
+                                    enabled: isSerialConnected
+
+                                    onClicked: {
+                                        if (backend) backend.moveToPerimeter()
+                                    }
+                                }
+                            }
+
+                            Rectangle {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: 1
+                                color: Components.ThemeManager.borderLight
+                            }
+
+                            RowLayout {
+                                Layout.fillWidth: true
+                                spacing: Components.ThemeManager.spaceSm
+
+                                ColumnLayout {
+                                    Layout.fillWidth: true
+                                    spacing: 2
+
+                                    Label {
+                                        text: "Auto play"
+                                        font.family: Components.ThemeManager.fontMedium
+                                        font.pixelSize: Components.ThemeManager.fontSizeBody
+                                        color: Components.ThemeManager.textPrimary
+                                    }
+
+                                    Label {
+                                        text: "Start playing when the table powers on"
+                                        font.family: Components.ThemeManager.fontBody
+                                        font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                        color: Components.ThemeManager.textSecondary
+                                        wrapMode: Text.WordWrap
+                                        Layout.fillWidth: true
+                                    }
+                                }
+
+                                DwSwitch {
+                                    id: autoPlaySwitch
+                                    checked: autoPlayOnBoot
+
+                                    onToggled: {
+                                        autoPlayOnBoot = checked
+                                        if (backend) {
+                                            backend.setAutoPlayOnBoot(checked)
+                                        }
+                                    }
+
+                                    // A user toggle breaks the declarative binding;
+                                    // this keeps the switch following loaded settings.
+                                    Binding {
+                                        target: autoPlaySwitch
+                                        property: "checked"
+                                        value: autoPlayOnBoot
+                                    }
+                                }
+                            }
+
+                            Rectangle {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: 1
+                                color: Components.ThemeManager.borderLight
+                            }
+
+                            // Reboots the FluidNC controller ($Bye soft-reset);
+                            // the board re-homes on the way back up.
+                            ModernControlButton {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: Components.ThemeManager.touchTarget
+                                text: "Restart table"
+                                icon: "restart"
+                                outlined: true
+                                buttonColor: Components.ThemeManager.textSecondary
+                                fontSize: 13
+                                enabled: isSerialConnected
+
+                                onClicked: {
+                                    if (backend) backend.restartBackend()
+                                }
+                            }
+                        }
+                    }
+
+                    // This screen: sleep, theme, power
+                    SettingsCard {
+                        Layout.leftMargin: Components.ThemeManager.spaceSm
+                        Layout.bottomMargin: Components.ThemeManager.spaceLg
+                        Layout.preferredHeight: screenColumn.implicitHeight + 2 * Components.ThemeManager.spaceLg
+
+                        ColumnLayout {
+                            id: screenColumn
+                            anchors.fill: parent
+                            anchors.margins: Components.ThemeManager.spaceLg
+                            spacing: Components.ThemeManager.spaceMd
+
+                            SectionLabel {
+                                text: "This screen"
+                            }
+
+                            Label {
+                                text: "Sleeps after"
+                                font.family: Components.ThemeManager.fontMedium
+                                font.pixelSize: Components.ThemeManager.fontSizeCaption
+                                color: Components.ThemeManager.textSecondary
+                            }
+
+                            RowLayout {
+                                id: timeoutGrid
+                                Layout.fillWidth: true
+                                spacing: Components.ThemeManager.spaceSm
+
+                                property string currentSelection: backend ? backend.getCurrentScreenTimeoutOption() : "5 minutes"
+
+                                Connections {
+                                    target: backend
+                                    function onScreenTimeoutChanged() {
+                                        if (backend) {
+                                            timeoutGrid.currentSelection = backend.getCurrentScreenTimeoutOption()
+                                        }
+                                    }
+                                }
+
+                                Repeater {
+                                    model: [
+                                        { label: "30 s", value: "30 seconds" },
+                                        { label: "1 m", value: "1 minute" },
+                                        { label: "5 m", value: "5 minutes" },
+                                        { label: "10 m", value: "10 minutes" },
+                                        { label: "Never", value: "Never" }
+                                    ]
+
+                                    ChoiceChip {
+                                        required property var modelData
+
+                                        Layout.fillWidth: true
+                                        Layout.preferredHeight: Components.ThemeManager.touchTarget
+                                        label: modelData.label
+                                        selected: timeoutGrid.currentSelection === modelData.value
+
+                                        onClicked: {
+                                            if (backend) {
+                                                backend.setScreenTimeoutByOption(modelData.value)
+                                                timeoutGrid.currentSelection = modelData.value
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+
+                            Rectangle {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: 1
+                                color: Components.ThemeManager.borderLight
+                            }
+
+                            RowLayout {
+                                Layout.fillWidth: true
+                                spacing: Components.ThemeManager.spaceSm
+
+                                Label {
+                                    text: "Night mode"
+                                    font.family: Components.ThemeManager.fontMedium
+                                    font.pixelSize: Components.ThemeManager.fontSizeBody
+                                    color: Components.ThemeManager.textPrimary
+                                    Layout.fillWidth: true
+                                }
+
+                                DwSwitch {
+                                    id: darkModeSwitch
+                                    checked: Components.ThemeManager.darkMode
+
+                                    onToggled: {
+                                        Components.ThemeManager.darkMode = checked
+                                    }
+                                }
+                            }
+
+                            Rectangle {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: 1
+                                color: Components.ThemeManager.borderLight
+                            }
+
+                            ModernControlButton {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: Components.ThemeManager.touchTarget
+                                text: "Shut down Pi"
+                                icon: "power"
+                                outlined: true
+                                buttonColor: Components.ThemeManager.danger
+                                fontSize: 13
+
+                                onClicked: {
+                                    if (backend) backend.shutdownPi()
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 159 - 36
dune-weaver-touch/thr_preview.py

@@ -28,11 +28,31 @@ except ImportError:  # pragma: no cover
     ImageDraw = None
     ImageDraw = None
 
 
 CACHE_ROOT = Path(__file__).parent / "preview_cache"
 CACHE_ROOT = Path(__file__).parent / "preview_cache"
-IMAGE_SIZE = 320          # rendered PNG is IMAGE_SIZE x IMAGE_SIZE
-_MARGIN = 12
-# Limit concurrent renders so a full grid doesn't stampede the ESP32's small
-# HTTP socket pool.
-_semaphore = asyncio.Semaphore(4)
+# The dune-weaver backend's pattern catalog, when co-located (the touch app
+# normally runs on the same Pi as the backend). Reading .thr files from here
+# is instant; fetching them from the board's SD over HTTP runs at ESP32 speed
+# (measured ~30-60 KB/s), so local files are always preferred.
+LOCAL_PATTERNS_DIR = Path(__file__).parent.parent / "patterns"
+# Same supersampling scheme as the web backend (modules/core/preview.py):
+# draw at RENDER_SIZE, LANCZOS-downsample to IMAGE_SIZE for smooth thin lines.
+IMAGE_SIZE = 512          # final PNG is IMAGE_SIZE x IMAGE_SIZE
+_RENDER_SIZE = 2048       # supersampled draw size
+_MARGIN = 12              # dish margin, in IMAGE_SIZE pixels
+_LINE_WIDTH = 8           # in RENDER_SIZE pixels -> 2px at IMAGE_SIZE (web parity)
+# Bump when the rendering itself changes (colors, orientation, size): the
+# version is part of the cache filename, so stale renders are simply ignored.
+_RENDER_VERSION = 4
+# One download at a time: the board's web server serializes requests, and a
+# single in-flight .thr transfer already delays /sand_status by seconds
+# (measured: 2 concurrent transfers starve the 1 Hz status poll almost
+# completely). Previews are cached after first fetch, so this only slows the
+# very first grid load.
+_semaphore = asyncio.Semaphore(1)
+# Rendering is CPU-heavy (2048px supersampled draw + LANCZOS downscale).
+# A fast flick through uncached patterns schedules a render per tile; without
+# a cap they saturate every core on the Pi and starve the GUI thread. Two at
+# a time keeps a core free for the UI and one for the warmer/board I/O.
+_cpu_semaphore = asyncio.Semaphore(2)
 
 
 
 
 def _slug(base_url: str) -> str:
 def _slug(base_url: str) -> str:
@@ -42,7 +62,45 @@ def _slug(base_url: str) -> str:
 
 
 def _cache_path(base_url: str, rel_path: str, content_hash: str) -> Path:
 def _cache_path(base_url: str, rel_path: str, content_hash: str) -> Path:
     safe = re.sub(r"[^A-Za-z0-9_.-]", "_", rel_path)
     safe = re.sub(r"[^A-Za-z0-9_.-]", "_", rel_path)
-    return CACHE_ROOT / _slug(base_url) / f"{safe}.{content_hash}.png"
+    return CACHE_ROOT / _slug(base_url) / f"{safe}.{content_hash}.v{_RENDER_VERSION}.png"
+
+
+_local_index: dict[str, Path] | None = None
+
+
+def _find_local_thr(rel_path: str) -> Path | None:
+    """Map a board-relative pattern path to a file in the host catalog.
+
+    The reverse of the backend's ``make_sd_path_resolver`` matching: exact
+    relative path first, then a host path whose *suffix* equals the board
+    path (host 'custom_patterns/sand-patterns/patterns/x.thr' matches board
+    'sand-patterns/patterns/x.thr'), then a UNIQUE basename match. Ambiguity
+    or no match returns None and the caller falls back to fetching from the
+    board.
+    """
+    global _local_index
+    if _local_index is None:
+        index: dict[str, Path] = {}
+        try:
+            for p in LOCAL_PATTERNS_DIR.rglob("*.thr"):
+                index[p.relative_to(LOCAL_PATTERNS_DIR).as_posix()] = p
+        except OSError:
+            pass
+        _local_index = index
+    rel = rel_path.replace("\\", "/").lstrip("/")
+    hit = _local_index.get(rel)
+    if hit:
+        return hit
+    suffix_hits = [p for r, p in _local_index.items()
+                   if r == rel or r.endswith("/" + rel)]
+    if len(suffix_hits) == 1:
+        return suffix_hits[0]
+    if suffix_hits:
+        return None  # ambiguous
+    base = rel.rsplit("/", 1)[-1]
+    base_hits = [p for r, p in _local_index.items()
+                 if r.rsplit("/", 1)[-1] == base]
+    return base_hits[0] if len(base_hits) == 1 else None
 
 
 
 
 def _parse_thr(text: str) -> list[tuple[float, float]]:
 def _parse_thr(text: str) -> list[tuple[float, float]]:
@@ -66,29 +124,39 @@ def _parse_thr(text: str) -> list[tuple[float, float]]:
 def _render_png(points: list[tuple[float, float]], out_path: Path) -> bool:
 def _render_png(points: list[tuple[float, float]], out_path: Path) -> bool:
     if Image is None:
     if Image is None:
         return False
         return False
-    size = IMAGE_SIZE
+    size = _RENDER_SIZE
+    scale = _RENDER_SIZE / IMAGE_SIZE
+    margin = _MARGIN * scale
     img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
     img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
     draw = ImageDraw.Draw(img)
     draw = ImageDraw.Draw(img)
     center = size / 2
     center = size / 2
-    radius = center - _MARGIN
+    radius = center - margin
 
 
-    # Sand-table dish backdrop.
+    # Sand-table dish backdrop — warm basalt, matching the UI's night palette
+    # (the disc reads as "a window onto the table" in both UI themes).
     draw.ellipse(
     draw.ellipse(
-        [_MARGIN, _MARGIN, size - _MARGIN, size - _MARGIN],
-        fill=(28, 28, 30, 255), outline=(70, 70, 74, 255), width=2,
+        [margin, margin, size - margin, size - margin],
+        fill=(27, 23, 18, 255), outline=(62, 54, 44, 255), width=round(2 * scale),
     )
     )
 
 
     xy = []
     xy = []
+    # Same orientation as the web backend's previews (modules/core/preview.py:
+    # CENTER - r*cos/sin then a 180° rotate, which nets to CENTER + r*cos/sin).
     for theta, rho in points:
     for theta, rho in points:
         rho = max(0.0, min(1.0, rho))
         rho = max(0.0, min(1.0, rho))
         r = rho * radius
         r = rho * radius
-        xy.append((center + r * math.cos(theta), center - r * math.sin(theta)))
+        xy.append((center + r * math.cos(theta), center + r * math.sin(theta)))
 
 
+    # Sand under warm light rather than clinical white.
+    line_color = (216, 181, 120, 255)
     if len(xy) >= 2:
     if len(xy) >= 2:
-        draw.line(xy, fill=(0xF5, 0xC9, 0x6b, 255), width=2, joint="curve")
+        draw.line(xy, fill=line_color, width=_LINE_WIDTH, joint="curve")
     elif len(xy) == 1:
     elif len(xy) == 1:
         x, y = xy[0]
         x, y = xy[0]
-        draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=(0xF5, 0xC9, 0x6b, 255))
+        r = _LINE_WIDTH
+        draw.ellipse([x - r, y - r, x + r, y + r], fill=line_color)
+
+    img = img.resize((IMAGE_SIZE, IMAGE_SIZE), Image.Resampling.LANCZOS)
 
 
     out_path.parent.mkdir(parents=True, exist_ok=True)
     out_path.parent.mkdir(parents=True, exist_ok=True)
     tmp = out_path.with_suffix(".tmp.png")
     tmp = out_path.with_suffix(".tmp.png")
@@ -108,37 +176,92 @@ def cached_preview(base_url: str, rel_path: str) -> str:
     safe = re.sub(r"[^A-Za-z0-9_.-]", "_", rel_path)
     safe = re.sub(r"[^A-Za-z0-9_.-]", "_", rel_path)
     if not folder.exists():
     if not folder.exists():
         return ""
         return ""
-    matches = sorted(folder.glob(f"{safe}.*.png"))
+    matches = sorted(folder.glob(f"{safe}.*.v{_RENDER_VERSION}.png"))
     return str(matches[0].absolute()) if matches else ""
     return str(matches[0].absolute()) if matches else ""
 
 
 
 
-async def render_preview(client, base_url: str, rel_path: str) -> str:
+def safe_name(rel_path: str) -> str:
+    """The sanitized form of a pattern path used in cache filenames."""
+    return re.sub(r"[^A-Za-z0-9_.-]", "_", rel_path)
+
+
+def has_local_source(rel_path: str) -> bool:
+    """True when the pattern's .thr resolves in the co-located host catalog.
+
+    Used by the background cache warmer to render only patterns that cost
+    no board I/O — board-only patterns stay lazy (rendered when viewed).
+    """
+    return _find_local_thr(rel_path) is not None
+
+
+def preview_index(base_url: str) -> dict[str, str]:
+    """Map ``safe_name(rel_path)`` -> cached PNG path, in ONE directory scan.
+
+    ``cached_preview`` globs the cache folder per pattern — fine for a lookup
+    or two, quadratic when a 1000-pattern grid asks row by row (and the model
+    asks from the GUI thread). Callers scan once per catalogue refresh and
+    hand rows out of the dict instead.
+    """
+    folder = CACHE_ROOT / _slug(base_url)
+    suffix = f".v{_RENDER_VERSION}.png"
+    index: dict[str, str] = {}
+    if not folder.exists():
+        return index
+    try:
+        for p in sorted(folder.iterdir()):
+            name = p.name
+            if not name.endswith(suffix):
+                continue
+            # Filename shape: <safe>.<contenthash>.v<N>.png
+            parts = name.rsplit(".", 3)
+            if len(parts) != 4:
+                continue
+            index.setdefault(parts[0], str(p.absolute()))
+    except OSError:
+        pass
+    return index
+
+
+async def render_preview(client, base_url: str, rel_path: str) -> str | None:
     """Fetch ``rel_path`` from the table and render its preview PNG.
     """Fetch ``rel_path`` from the table and render its preview PNG.
 
 
-    ``client`` is a :class:`firmware_client.FirmwareClient`. Returns the absolute
-    path to the cached PNG, or "" on failure.
+    ``client`` is a :class:`firmware_client.FirmwareClient`. Returns the
+    absolute path to the cached PNG, "" when the pattern genuinely has no
+    renderable preview (empty/unparseable), or ``None`` on a *transient*
+    failure (fetch timeout, board busy) that is worth retrying later —
+    callers must not cache ``None`` as a permanent miss.
     """
     """
     if Image is None:
     if Image is None:
         logger.warning("Pillow not available - cannot render previews")
         logger.warning("Pillow not available - cannot render previews")
         return ""
         return ""
-    async with _semaphore:
+    # Local catalog first: instant, no board I/O, no semaphore needed.
+    local = _find_local_thr(rel_path)
+    if local is not None:
         try:
         try:
-            data = await client.fetch_sd_file(f"/patterns/{rel_path}")
-        except Exception as exc:
-            logger.debug(f"preview fetch failed for {rel_path}: {exc}")
+            data = await asyncio.to_thread(local.read_bytes)
+        except OSError as exc:
+            logger.debug(f"local pattern read failed for {rel_path}: {exc}")
+            local = None
+    if local is None:
+        async with _semaphore:
+            try:
+                data = await client.fetch_sd_file(f"/patterns/{rel_path}")
+            except Exception as exc:
+                logger.debug(f"preview fetch failed for {rel_path}: {exc}")
+                return None
+    content_hash = hashlib.sha1(data).hexdigest()[:10]
+    out_path = _cache_path(base_url, rel_path, content_hash)
+    if out_path.exists():
+        return str(out_path.absolute())
+    try:
+        text = data.decode("utf-8", errors="ignore")
+        points = _parse_thr(text)
+        if not points:
             return ""
             return ""
-        content_hash = hashlib.sha1(data).hexdigest()[:10]
-        out_path = _cache_path(base_url, rel_path, content_hash)
-        if out_path.exists():
-            return str(out_path.absolute())
-        try:
-            text = data.decode("utf-8", errors="ignore")
-            points = _parse_thr(text)
-            if not points:
-                return ""
-            # Rendering is CPU-bound; keep it off the event loop.
+        # Rendering is CPU-bound; keep it off the event loop and capped.
+        async with _cpu_semaphore:
             ok = await asyncio.to_thread(_render_png, points, out_path)
             ok = await asyncio.to_thread(_render_png, points, out_path)
-            return str(out_path.absolute()) if ok else ""
-        except Exception as exc:
-            logger.error(f"preview render failed for {rel_path}: {exc}")
-            return ""
+        return str(out_path.absolute()) if ok else ""
+    except Exception as exc:
+        logger.error(f"preview render failed for {rel_path}: {exc}")
+        return ""

+ 189 - 0
frontend/src/components/AlignOrientationDialog.tsx

@@ -0,0 +1,189 @@
+import { useRef, useState } from 'react'
+import { toast } from 'sonner'
+import { apiClient } from '@/lib/apiClient'
+import { useStatusStore } from '@/stores/useStatusStore'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from '@/components/ui/dialog'
+
+const DEG = Math.PI / 180
+
+/**
+ * Crash-homing orientation alignment (parity with dune-weaver-mobile's
+ * AlignOrientation). Crash homing never moves theta — whatever direction the
+ * arm points when the table homes BECOMES theta=0, so pattern orientation is
+ * set physically: walk the ball to the table's "East" (directly to your right
+ * when facing the table), then home to lock it in.
+ *
+ * Nudges are absolute jogs seeded from the live status theta. Taps accumulate
+ * into a target angle; one pump loop pushes the latest value, retrying while
+ * the firmware answers 409 (previous jog still finishing).
+ */
+export function AlignOrientationDialog() {
+  const [open, setOpen] = useState(false)
+  const [homing, setHoming] = useState(false)
+  const isRunning = useStatusStore((s) => s.status?.is_running ?? false)
+  const isPaused = useStatusStore((s) => s.status?.is_paused ?? false)
+  const isHoming = useStatusStore((s) => s.status?.is_homing ?? false)
+  const isAlarm = useStatusStore((s) => s.status?.is_alarm ?? false)
+
+  const canNudge = !isAlarm && !isRunning && !isPaused && !isHoming
+  const canHome = !isRunning && !isPaused && !isHoming
+
+  const targetRef = useRef<number | null>(null)
+  const pumping = useRef(false)
+
+  const nudge = (deg: number) => {
+    const liveTheta = useStatusStore.getState().status?.current_theta ?? 0
+    const cur = targetRef.current ?? liveTheta
+    targetRef.current = cur + deg * DEG
+    void pump()
+  }
+
+  const pump = async () => {
+    if (pumping.current) return
+    pumping.current = true
+    try {
+      let sent: number | null = null
+      let waitedMs = 0
+      while (targetRef.current != null && targetRef.current !== sent) {
+        sent = targetRef.current
+        try {
+          await apiClient.post('/api/board/rotate', { theta: sent })
+          waitedMs = 0
+        } catch (e) {
+          const msg = e instanceof Error ? e.message : ''
+          if (!msg.startsWith('HTTP 409') || waitedMs > 15000) throw e
+          await new Promise((r) => setTimeout(r, 400))
+          waitedMs += 400
+          sent = null // resend the (possibly updated) target
+        }
+      }
+    } catch {
+      toast.error('Could not rotate the arm')
+      targetRef.current = null
+    } finally {
+      pumping.current = false
+    }
+  }
+
+  const toEdge = async () => {
+    try {
+      await apiClient.post('/move_to_perimeter')
+      toast.success('Moving to the edge')
+    } catch {
+      toast.error('Could not move the ball')
+    }
+  }
+
+  // Homing re-declares the current arm angle as theta=0 (crash mode), so the
+  // accumulated nudge frame is stale afterwards — drop it.
+  const setOrientation = async () => {
+    setHoming(true)
+    try {
+      await apiClient.post('/send_home')
+      targetRef.current = null
+      setOpen(false)
+      toast.success('Orientation set - homing now')
+    } catch {
+      toast.error('Could not home the table')
+    } finally {
+      setHoming(false)
+    }
+  }
+
+  return (
+    <Dialog
+      open={open}
+      onOpenChange={(o) => {
+        if (!o) targetRef.current = null
+        setOpen(o)
+      }}
+    >
+      <DialogTrigger asChild>
+        <Button variant="outline" className="gap-2">
+          <span className="material-icons-outlined text-base">screen_rotation</span>
+          Align Pattern Orientation
+        </Button>
+      </DialogTrigger>
+      <DialogContent className="max-w-md">
+        <DialogHeader>
+          <DialogTitle>Align orientation</DialogTitle>
+          <DialogDescription>
+            With crash homing, patterns are oriented by where the arm points when the
+            table homes. Align it once so patterns come out matching their previews.
+          </DialogDescription>
+        </DialogHeader>
+
+        {/* Table seen from above, viewer at the bottom. Target = 3 o'clock,
+            i.e. theta=0 in the preview frame ("arm points East"). */}
+        <div className="flex justify-center py-2">
+          <div className="relative w-[190px] h-[190px]">
+            <svg width={190} height={190}>
+              <circle cx={95} cy={95} r={83} className="stroke-border fill-muted/40" strokeWidth={2} />
+              <circle cx={95 + 83 - 9} cy={95} r={9} className="fill-primary" />
+            </svg>
+            <div className="absolute left-1/2 -translate-x-1/2 bottom-[-6px] flex items-center gap-1 px-2 py-0.5 rounded-full border bg-background text-muted-foreground text-xs">
+              <span className="material-icons-outlined text-sm">person</span>
+              You
+            </div>
+          </div>
+        </div>
+
+        <ol className="text-xs text-muted-foreground list-decimal pl-4 space-y-1">
+          <li>Stand where you normally look at the table from.</li>
+          <li>
+            Use the arrows to walk the ball around the edge until it sits directly to
+            your <span className="font-medium text-foreground">right</span> (the
+            highlighted spot).
+          </li>
+          <li>Press "Set orientation" - the table homes and locks that in.</li>
+        </ol>
+
+        <div className="flex justify-center gap-2">
+          {(
+            [
+              ['rotate_left', -45, '45°'],
+              ['rotate_left', -10, '10°'],
+              ['rotate_right', 10, '10°'],
+              ['rotate_right', 45, '45°'],
+            ] as const
+          ).map(([icon, deg, label]) => (
+            <Button
+              key={deg}
+              variant="outline"
+              size="sm"
+              disabled={!canNudge}
+              onClick={() => nudge(deg)}
+              className="gap-1"
+            >
+              <span className="material-icons-outlined text-base">{icon}</span>
+              {label}
+            </Button>
+          ))}
+        </div>
+
+        <div className="flex flex-col gap-2">
+          <Button variant="secondary" disabled={!canNudge} onClick={toEdge} className="gap-2">
+            <span className="material-icons-outlined text-base">trip_origin</span>
+            Move ball to the edge
+          </Button>
+          <Button disabled={!canHome || homing} onClick={setOrientation} className="gap-2">
+            {homing ? (
+              <span className="material-icons-outlined text-base animate-spin">sync</span>
+            ) : (
+              <span className="material-icons-outlined text-base">check</span>
+            )}
+            Set orientation (homes the table)
+          </Button>
+        </div>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 736 - 0
frontend/src/components/LedRingPreview.tsx

@@ -0,0 +1,736 @@
+import { useEffect, useRef } from 'react'
+
+// Client-side port of the firmware LED engine (dune-weaver-firmware
+// FluidNC/src/Leds.cpp) so the ring preview animates exactly like the table.
+// Effect ids, palettes and the 8-bit math mirror the firmware; the phase is a
+// uint16 in 8.8 fixed point advanced by speed*8 every FRAME_MS.
+
+const NUM_LEDS = 60
+const FRAME_MS = 33
+
+// Firmware effect ids (Leds.h EFFECT_*)
+const FX = {
+  OFF: 0, STATIC: 1, RAINBOW: 2, BREATHE: 3, COLORLOOP: 4, THEATER: 5,
+  SCAN: 6, RUNNING: 7, SINE: 8, GRADIENT: 9, SINELON: 10, TWINKLE: 11,
+  SPARKLE: 12, FIRE: 13, CANDLE: 14, METEOR: 15, BOUNCING: 16, WIPE: 17,
+  DUALSCAN: 18, JUGGLE: 19, MULTICOMET: 20, GLITTER: 21, DISSOLVE: 22,
+  RIPPLE: 23, DRIP: 24, LIGHTNING: 25, FIREWORKS: 26, PLASMA: 27,
+  HEARTBEAT: 28, STROBE: 29, POLICE: 30, CHASE: 31, RAILWAY: 32,
+  PACIFICA: 33, AURORA: 34, PRIDE: 35, COLORWAVES: 36, BPM: 37, BALL: 38,
+} as const
+
+// 16-entry packed-RGB palettes (Leds.cpp); ids 1..7, id 0 = wheel()
+const PALETTES: number[][] = [
+  [0x191970, 0x00008B, 0x191970, 0x000080, 0x00008B, 0x0000CD, 0x2E8B57, 0x008080,
+   0x5F9EA0, 0x0000FF, 0x008B8B, 0x6495ED, 0x7FFFD4, 0x2E8B57, 0x00FFFF, 0x87CEFA], // ocean
+  [0x000000, 0x800000, 0x000000, 0x800000, 0x8B0000, 0x800000, 0x8B0000, 0x8B0000,
+   0x8B0000, 0x8B0000, 0xFF0000, 0xFFA500, 0xFFFFFF, 0xFFA500, 0xFF0000, 0x8B0000], // lava
+  [0x006400, 0x006400, 0x556B2F, 0x006400, 0x008000, 0x228B22, 0x6B8E23, 0x008000,
+   0x2E8B57, 0x66CDAA, 0x32CD32, 0x9ACD32, 0x90EE90, 0x7CFC00, 0x66CDAA, 0x228B22], // forest
+  [0x5500AB, 0x84007C, 0xB5004B, 0xE5001B, 0xE81700, 0xB84700, 0xAB7700, 0xABAB00,
+   0xAB5500, 0xDD2200, 0xF2000E, 0xC2003E, 0x8F0071, 0x5F00A1, 0x2F00D0, 0x0700E9], // party
+  [0x0000FF, 0x00008B, 0x00008B, 0x00008B, 0x00008B, 0x00008B, 0x00008B, 0x00008B,
+   0x0000FF, 0x00008B, 0x87CEEB, 0x87CEEB, 0xADD8E6, 0xFFFFFF, 0xADD8E6, 0x87CEEB], // cloud
+  [0x000000, 0x330000, 0x660000, 0x990000, 0xCC0000, 0xFF0000, 0xFF3300, 0xFF6600,
+   0xFF9900, 0xFFCC00, 0xFFFF00, 0xFFFF33, 0xFFFF66, 0xFFFF99, 0xFFFFCC, 0xFFFFFF], // heat
+  [0x00008B, 0x4B0082, 0x800080, 0xC71585, 0xFF1493, 0xFF4500, 0xFF8C00, 0xFFD700,
+   0xFFA500, 0xFF4500, 0xC71585, 0x800080, 0x4B0082, 0x191970, 0x00008B, 0x000033], // sunset
+]
+
+const PAL_OCEAN = PALETTES[0]
+const PAL_AURORA = [
+  0x001A0A, 0x003311, 0x004D1A, 0x006622, 0x00994D, 0x00CC66, 0x00FF7F, 0x2E8B57,
+  0x008080, 0x4B0082, 0x800080, 0x9400D3, 0x4B0082, 0x008080, 0x00994D, 0x003311,
+]
+
+export interface BallPreviewParams {
+  size: number
+  direction: 'cw' | 'ccw'
+  align: number
+  fgbright: number
+  bgbright: number
+  bgEffectId: number
+}
+
+interface EngineParams {
+  effectId: number
+  paletteId: number
+  color1: [number, number, number]
+  color2: [number, number, number]
+  brightness255: number
+  speed: number
+  ball: BallPreviewParams
+}
+
+function hexToRgb(hex: string): [number, number, number] {
+  const v = parseInt(hex.replace('#', ''), 16) || 0
+  return [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]
+}
+
+// WS2812s emit linear light but canvas colors are sRGB: without gamma
+// correction a half-brightness LED renders as a muddy, tinted mid-tone.
+const GAMMA = new Uint8Array(256)
+for (let i = 0; i < 256; i++) {
+  GAMMA[i] = Math.round(255 * Math.pow(i / 255, 1 / 2.2))
+}
+
+class LedEngine {
+  fb = new Uint8Array(NUM_LEDS * 3)
+  heat = new Uint8Array(NUM_LEDS)
+  rng = (Math.floor(performance.now()) * 2654435761) >>> 0 || 1
+  phase = 0 // uint16, 8.8 fixed point
+  candle = 200
+  aux = 0
+  ballPos = [0, 0, 0]
+  ballVel = [4.4, 3.9, 3.4]
+  lastEffect = -1
+  lastBgEffect = -1
+  ballTrack = -1
+  simBallFrac = 0 // simulated sand-ball angle (the browser has no kinematics)
+  p!: EngineParams
+
+  random8(): number {
+    let x = this.rng
+    x = (x ^ (x << 13)) >>> 0
+    x = (x ^ (x >>> 17)) >>> 0
+    x = (x ^ (x << 5)) >>> 0
+    this.rng = x
+    return x & 0xff
+  }
+  random8lim(lim: number): number {
+    return (this.random8() * lim) >> 8
+  }
+  sin8(theta: number): number {
+    return (Math.sin(((theta & 0xff) * 2 * Math.PI) / 256) * 127.5 + 127.5) | 0
+  }
+  scale8(v: number, s: number): number {
+    return (v * (s + 1)) >> 8
+  }
+  qadd8(a: number, b: number): number {
+    return Math.min(255, a + b)
+  }
+  qsub8(a: number, b: number): number {
+    return Math.max(0, a - b)
+  }
+  lerp8(a: number, b: number, t: number): number {
+    return a + (((b - a) * t) >> 8)
+  }
+
+  setFb(i: number, r: number, g: number, b: number): void {
+    if (i < 0 || i >= NUM_LEDS) return
+    this.fb[i * 3] = r
+    this.fb[i * 3 + 1] = g
+    this.fb[i * 3 + 2] = b
+  }
+  addFb(i: number, r: number, g: number, b: number): void {
+    if (i < 0 || i >= NUM_LEDS) return
+    this.fb[i * 3] = this.qadd8(this.fb[i * 3], r)
+    this.fb[i * 3 + 1] = this.qadd8(this.fb[i * 3 + 1], g)
+    this.fb[i * 3 + 2] = this.qadd8(this.fb[i * 3 + 2], b)
+  }
+  fadeBy(amount: number): void {
+    const keep = 256 - amount
+    for (let i = 0; i < NUM_LEDS * 3; i++) this.fb[i] = (this.fb[i] * keep) >> 8
+  }
+
+  wheel(pos: number): [number, number, number] {
+    pos = (255 - pos) & 0xff
+    if (pos < 85) return [255 - pos * 3, 0, pos * 3]
+    if (pos < 170) {
+      pos -= 85
+      return [0, pos * 3, 255 - pos * 3]
+    }
+    pos -= 170
+    return [pos * 3, 255 - pos * 3, 0]
+  }
+  heatColor(heat: number): [number, number, number] {
+    const t192 = this.scale8(heat, 191)
+    const ramp = (t192 & 0x3f) << 2
+    if (t192 & 0x80) return [255, 255, ramp]
+    if (t192 & 0x40) return [255, ramp, 0]
+    return [ramp, 0, 0]
+  }
+  sampleTable(pal: number[], index: number): [number, number, number] {
+    const e = index >> 4
+    const f = (index & 0x0f) << 4
+    const c0 = pal[e]
+    const c1 = pal[(e + 1) & 15]
+    return [
+      this.lerp8((c0 >> 16) & 0xff, (c1 >> 16) & 0xff, f),
+      this.lerp8((c0 >> 8) & 0xff, (c1 >> 8) & 0xff, f),
+      this.lerp8(c0 & 0xff, c1 & 0xff, f),
+    ]
+  }
+  palColor(index: number): [number, number, number] {
+    const p = this.p.paletteId
+    if (p <= 0 || p > PALETTES.length) return this.wheel(index & 0xff)
+    return this.sampleTable(PALETTES[p - 1], index & 0xff)
+  }
+
+  tick(): void {
+    this.phase = (this.phase + this.p.speed * 8) & 0xffff
+    this.simBallFrac = (this.simBallFrac + FRAME_MS / 15000) % 1
+    this.renderEffect(this.p.effectId, this.p.speed, false)
+  }
+
+  renderEffect(effect: number, speed: number, nested: boolean): void {
+    if (nested ? effect !== this.lastBgEffect : effect !== this.lastEffect) {
+      if (nested) this.lastBgEffect = effect
+      else this.lastEffect = effect
+      this.fb.fill(0)
+      this.heat.fill(0)
+      this.candle = 200
+      this.aux = 0
+      for (let k = 0; k < 3; k++) {
+        this.ballPos[k] = 0
+        this.ballVel[k] = 4.4 - 0.5 * k
+      }
+      if (!nested) this.ballTrack = -1
+    }
+
+    const hi = (this.phase >> 8) & 0xff
+    const N = NUM_LEDS
+    let rgb: [number, number, number]
+
+    switch (effect) {
+      case FX.STATIC: {
+        const [r, g, b] = this.p.color1
+        for (let i = 0; i < N; i++) this.setFb(i, r, g, b)
+        break
+      }
+      case FX.RAINBOW:
+        for (let i = 0; i < N; i++) {
+          rgb = this.palColor((hi + ((i * 256) / N)) & 0xff)
+          this.setFb(i, rgb[0], rgb[1], rgb[2])
+        }
+        break
+      case FX.BREATHE: {
+        const [r, g, b] = this.p.color1
+        const lvl = 16 + ((this.sin8(hi) * 239) >> 8)
+        for (let i = 0; i < N; i++) this.setFb(i, (r * lvl) >> 8, (g * lvl) >> 8, (b * lvl) >> 8)
+        break
+      }
+      case FX.COLORLOOP: {
+        rgb = this.palColor(hi)
+        for (let i = 0; i < N; i++) this.setFb(i, rgb[0], rgb[1], rgb[2])
+        break
+      }
+      case FX.THEATER: {
+        const [r, g, b] = this.p.color1
+        const off = (this.phase >> 9) % 3
+        for (let i = 0; i < N; i++) {
+          if (i % 3 === off) this.setFb(i, r, g, b)
+          else this.setFb(i, 0, 0, 0)
+        }
+        break
+      }
+      case FX.SCAN: {
+        const [r, g, b] = this.p.color1
+        this.fadeBy(90)
+        this.setFb(this.scale8(this.sin8(hi), N - 1), r, g, b)
+        break
+      }
+      case FX.RUNNING: {
+        const [r, g, b] = this.p.color1
+        for (let i = 0; i < N; i++) {
+          const s = this.sin8((i * ((256 / N) | 0) - hi) & 0xff)
+          this.setFb(i, (r * s) >> 8, (g * s) >> 8, (b * s) >> 8)
+        }
+        break
+      }
+      case FX.SINE: {
+        const [r, g, b] = this.p.color1
+        for (let i = 0; i < N; i++) {
+          const s = this.sin8((i * ((512 / N) | 0) + hi) & 0xff)
+          this.setFb(i, (r * s) >> 8, (g * s) >> 8, (b * s) >> 8)
+        }
+        break
+      }
+      case FX.GRADIENT: {
+        const [r1, g1, b1] = this.p.color1
+        const [r2, g2, b2] = this.p.color2
+        for (let i = 0; i < N; i++) {
+          const t = (((i * 256) / N) + hi) & 0xff
+          const tri = t < 128 ? t * 2 : (255 - t) * 2
+          this.setFb(i, this.lerp8(r1, r2, tri), this.lerp8(g1, g2, tri), this.lerp8(b1, b2, tri))
+        }
+        break
+      }
+      case FX.SINELON: {
+        this.fadeBy(40)
+        const pos = this.scale8(this.sin8(hi), N - 1)
+        rgb = this.palColor((this.phase >> 7) & 0xff)
+        this.addFb(pos, rgb[0], rgb[1], rgb[2])
+        break
+      }
+      case FX.TWINKLE:
+        this.fadeBy(28)
+        if (this.random8() < speed) {
+          rgb = this.palColor(this.random8())
+          this.setFb(this.random8lim(N), rgb[0], rgb[1], rgb[2])
+        }
+        break
+      case FX.SPARKLE: {
+        const [r, g, b] = this.p.color1
+        for (let i = 0; i < N; i++) this.setFb(i, (r * 40) >> 8, (g * 40) >> 8, (b * 40) >> 8)
+        this.setFb(this.random8lim(N), 255, 255, 255)
+        break
+      }
+      case FX.FIRE: {
+        for (let i = 0; i < N; i++) {
+          this.heat[i] = this.qsub8(this.heat[i], this.random8lim(((55 * 10) / N + 2) | 0))
+        }
+        for (let k = N - 1; k >= 2; k--) {
+          this.heat[k] = ((this.heat[k - 1] + this.heat[k - 2] + this.heat[k - 2]) / 3) | 0
+        }
+        if (this.random8() < 120) {
+          const y = this.random8lim(7)
+          this.heat[y] = this.qadd8(this.heat[y], this.random8lim(95) + 160)
+        }
+        for (let i = 0; i < N; i++) {
+          rgb = this.heatColor(this.heat[i])
+          this.setFb(i, rgb[0], rgb[1], rgb[2])
+        }
+        break
+      }
+      case FX.CANDLE: {
+        const [r, g, b] = this.p.color1
+        const target = 100 + this.random8lim(155)
+        this.candle = (this.candle * 7 + target) >> 3
+        for (let i = 0; i < N; i++) {
+          this.setFb(i, (r * this.candle) >> 8, (g * this.candle) >> 8, (b * this.candle) >> 8)
+        }
+        break
+      }
+      case FX.METEOR: {
+        const [r, g, b] = this.p.color1
+        this.fadeBy(40 + this.random8lim(40))
+        this.setFb((this.phase >> 8) % N, r, g, b)
+        break
+      }
+      case FX.BOUNCING: {
+        const [r, g, b] = this.p.color1
+        this.fadeBy(120)
+        const dt = (FRAME_MS / 1000) * (speed / 50)
+        for (let k = 0; k < 3; k++) {
+          this.ballVel[k] -= 9.8 * dt
+          this.ballPos[k] += this.ballVel[k] * dt
+          if (this.ballPos[k] < 0) {
+            this.ballPos[k] = 0
+            this.ballVel[k] = -this.ballVel[k] * 0.9
+            if (this.ballVel[k] < 0.6) this.ballVel[k] = 4.4 - 0.5 * k
+          }
+          this.addFb((this.ballPos[k] * (N - 1)) | 0, r, g, b)
+        }
+        break
+      }
+      case FX.WIPE: {
+        const [r, g, b] = this.p.color1
+        const [r2, g2, b2] = this.p.color2
+        const span = (this.phase >> 7) % (2 * N)
+        for (let i = 0; i < N; i++) {
+          if (span < N) {
+            if (i <= span) this.setFb(i, r, g, b)
+            else this.setFb(i, r2, g2, b2)
+          } else {
+            if (i <= span - N) this.setFb(i, r2, g2, b2)
+            else this.setFb(i, r, g, b)
+          }
+        }
+        break
+      }
+      case FX.DUALSCAN: {
+        const [r, g, b] = this.p.color1
+        const [r2, g2, b2] = this.p.color2
+        this.fadeBy(80)
+        const pos = this.scale8(this.sin8(hi), N - 1)
+        this.addFb(pos, r, g, b)
+        this.addFb(N - 1 - pos, r2, g2, b2)
+        break
+      }
+      case FX.JUGGLE:
+        this.fadeBy(40)
+        for (let k = 0; k < 3; k++) {
+          const pos = this.scale8(this.sin8(((this.phase >> 8) * (k + 2)) & 0xff), N - 1)
+          rgb = this.palColor((k * 85 + hi) & 0xff)
+          this.addFb(pos, rgb[0], rgb[1], rgb[2])
+        }
+        break
+      case FX.MULTICOMET:
+        this.fadeBy(48)
+        for (let k = 0; k < 3; k++) {
+          const pos = ((((this.phase >> 8) * (k + 1)) >> 1) + k * ((N / 3) | 0)) % N
+          rgb = this.palColor((k * 85) & 0xff)
+          this.addFb(pos, rgb[0], rgb[1], rgb[2])
+        }
+        break
+      case FX.GLITTER:
+        for (let i = 0; i < N; i++) {
+          rgb = this.palColor((hi + ((i * 256) / N)) & 0xff)
+          this.setFb(i, (rgb[0] * 180) >> 8, (rgb[1] * 180) >> 8, (rgb[2] * 180) >> 8)
+        }
+        if (this.random8() < 60) this.setFb(this.random8lim(N), 255, 255, 255)
+        break
+      case FX.DISSOLVE: {
+        const [r, g, b] = this.p.color1
+        const [r2, g2, b2] = this.p.color2
+        const converts = ((N / 12) | 0) + 1
+        for (let n = 0; n < converts; n++) this.heat[this.random8lim(N)] = this.aux
+        let remaining = 0
+        for (let i = 0; i < N; i++) {
+          if (this.heat[i] !== this.aux) remaining++
+          if (this.heat[i]) this.setFb(i, r2, g2, b2)
+          else this.setFb(i, r, g, b)
+        }
+        if (remaining === 0) this.aux ^= 1
+        break
+      }
+      case FX.RIPPLE: {
+        this.fadeBy(40)
+        const step = 0.5 * (speed / 50)
+        for (let k = 0; k < 3; k++) {
+          if (this.ballVel[k] <= 0 || this.ballVel[k] > N / 2) {
+            this.ballPos[k] = this.random8lim(N)
+            this.ballVel[k] = 0.01
+          }
+          const center = this.ballPos[k] | 0
+          const radius = this.ballVel[k] | 0
+          const fade = 255 - this.scale8(((this.ballVel[k] * 512) / N) & 0xff, 255)
+          rgb = this.palColor((center * 5) & 0xff)
+          this.addFb((center + radius) % N, (rgb[0] * fade) >> 8, (rgb[1] * fade) >> 8, (rgb[2] * fade) >> 8)
+          this.addFb((center - radius + N) % N, (rgb[0] * fade) >> 8, (rgb[1] * fade) >> 8, (rgb[2] * fade) >> 8)
+          this.ballVel[k] += step
+        }
+        break
+      }
+      case FX.DRIP: {
+        const [r, g, b] = this.p.color1
+        this.fadeBy(60)
+        const accel = 0.02 * (speed / 50)
+        for (let k = 0; k < 3; k++) {
+          this.ballVel[k] += accel
+          this.ballPos[k] -= this.ballVel[k]
+          if (this.ballPos[k] <= 0) {
+            this.setFb(0, 255, 255, 255)
+            this.setFb(1, (r * 128) >> 8, (g * 128) >> 8, (b * 128) >> 8)
+            this.ballPos[k] = N - 1 - this.random8lim(N >> 1)
+            this.ballVel[k] = 0
+          }
+          this.addFb(this.ballPos[k] | 0, r, g, b)
+        }
+        break
+      }
+      case FX.LIGHTNING:
+        this.fadeBy(120)
+        if (this.random8() < 12) {
+          const start = this.random8lim(N)
+          const len = 2 + this.random8lim((N / 3) | 0)
+          for (let j = 0; j < len; j++) this.setFb((start + j) % N, 255, 255, 255)
+        }
+        break
+      case FX.FIREWORKS:
+        this.fadeBy(48)
+        if (this.random8() < 50) {
+          const center = this.random8lim(N)
+          rgb = this.palColor(this.random8())
+          this.addFb(center, rgb[0], rgb[1], rgb[2])
+          this.addFb((center + 1) % N, rgb[0] >> 1, rgb[1] >> 1, rgb[2] >> 1)
+          this.addFb((center - 1 + N) % N, rgb[0] >> 1, rgb[1] >> 1, rgb[2] >> 1)
+        }
+        break
+      case FX.PLASMA:
+        for (let i = 0; i < N; i++) {
+          const x = ((i * 256) / N) & 0xff
+          const v1 = this.sin8((x * 2 + hi) & 0xff)
+          const v2 = this.sin8((x * 3 - hi * 2) & 0xff)
+          rgb = this.palColor((v1 + v2) >> 1)
+          this.setFb(i, rgb[0], rgb[1], rgb[2])
+        }
+        break
+      case FX.HEARTBEAT: {
+        const [r, g, b] = this.p.color1
+        const t = hi
+        let lvl: number
+        if (t < 32) lvl = this.sin8((t * 4) & 0xff)
+        else if (t < 96) lvl = 24
+        else if (t < 128) lvl = this.sin8(((t - 96) * 4) & 0xff)
+        else lvl = 24
+        if (lvl < 24) lvl = 24
+        for (let i = 0; i < N; i++) this.setFb(i, (r * lvl) >> 8, (g * lvl) >> 8, (b * lvl) >> 8)
+        break
+      }
+      case FX.STROBE: {
+        const [r, g, b] = this.p.color1
+        const on = hi < 32
+        for (let i = 0; i < N; i++) {
+          if (on) this.setFb(i, r, g, b)
+          else this.setFb(i, 0, 0, 0)
+        }
+        break
+      }
+      case FX.POLICE: {
+        const phaseA = (this.phase >> 9) & 1
+        const half = N >> 1
+        for (let i = 0; i < N; i++) {
+          const left = i < half
+          if (left === Boolean(phaseA)) this.setFb(i, left ? 255 : 0, 0, left ? 0 : 255)
+          else this.setFb(i, 0, 0, 0)
+        }
+        break
+      }
+      case FX.CHASE: {
+        const [r, g, b] = this.p.color1
+        const [r2, g2, b2] = this.p.color2
+        for (let i = 0; i < N; i++) this.setFb(i, (r2 * 40) >> 8, (g2 * 40) >> 8, (b2 * 40) >> 8)
+        const pos = (this.phase >> 8) % N
+        for (let j = 0; j < 3; j++) this.setFb((pos + j) % N, r, g, b)
+        break
+      }
+      case FX.RAILWAY: {
+        const [r, g, b] = this.p.color1
+        const [r2, g2, b2] = this.p.color2
+        for (let i = 0; i < N; i++) {
+          const s = this.sin8((i * ((256 / N) | 0) - hi) & 0xff)
+          const sc = 100 + ((s * 155) >> 8)
+          if (i & 1) this.setFb(i, (r * sc) >> 8, (g * sc) >> 8, (b * sc) >> 8)
+          else this.setFb(i, (r2 * sc) >> 8, (g2 * sc) >> 8, (b2 * sc) >> 8)
+        }
+        break
+      }
+      case FX.PACIFICA:
+        for (let i = 0; i < N; i++) {
+          const a = this.sin8((i * 4 + (this.phase >> 8)) & 0xff)
+          const c = this.sin8((i * 5 - (this.phase >> 7)) & 0xff)
+          const d = this.sin8((i * 3 + (this.phase >> 9)) & 0xff)
+          rgb = this.sampleTable(PAL_OCEAN, ((a + c + d) / 3) | 0)
+          this.setFb(i, rgb[0], rgb[1], rgb[2])
+          const cap = this.sin8((i * 9 - (this.phase >> 5)) & 0xff)
+          if (cap > 236) {
+            const w = (cap - 236) * 8
+            this.addFb(i, w, w, w)
+          }
+        }
+        break
+      case FX.AURORA:
+        for (let i = 0; i < N; i++) {
+          const a = this.sin8((i * 3 + (this.phase >> 8)) & 0xff)
+          const c = this.sin8((i * 2 - (this.phase >> 9)) & 0xff)
+          rgb = this.sampleTable(PAL_AURORA, (a + c) >> 1)
+          const w = this.sin8((i * 5 + (this.phase >> 7)) & 0xff)
+          const bri = 40 + ((w * 215) >> 8)
+          this.setFb(i, (rgb[0] * bri) >> 8, (rgb[1] * bri) >> 8, (rgb[2] * bri) >> 8)
+        }
+        break
+      case FX.PRIDE:
+        for (let i = 0; i < N; i++) {
+          rgb = this.wheel(((this.phase >> 8) + i * 2) & 0xff)
+          const bw = this.sin8((i * 7 + (this.phase >> 6)) & 0xff)
+          const bri = 60 + ((bw * 195) >> 8)
+          this.setFb(i, (rgb[0] * bri) >> 8, (rgb[1] * bri) >> 8, (rgb[2] * bri) >> 8)
+        }
+        break
+      case FX.COLORWAVES:
+        for (let i = 0; i < N; i++) {
+          rgb = this.palColor(((this.phase >> 8) + i * 3) & 0xff)
+          const bw = this.sin8((i * 7 + (this.phase >> 6)) & 0xff)
+          const bri = 60 + ((bw * 195) >> 8)
+          this.setFb(i, (rgb[0] * bri) >> 8, (rgb[1] * bri) >> 8, (rgb[2] * bri) >> 8)
+        }
+        break
+      case FX.BPM: {
+        const beat = this.sin8((this.phase >> 6) & 0xff)
+        for (let i = 0; i < N; i++) {
+          rgb = this.palColor(((this.phase >> 8) + i * 3) & 0xff)
+          let bri = this.qsub8(beat, (i * 2) & 0xff)
+          if (bri < 40) bri = 40
+          this.setFb(i, (rgb[0] * bri) >> 8, (rgb[1] * bri) >> 8, (rgb[2] * bri) >> 8)
+        }
+        break
+      }
+      case FX.BALL: {
+        const ball = this.p.ball
+        let bg = ball.bgEffectId
+        if (bg === FX.BALL) bg = FX.STATIC
+        if (bg === FX.OFF) {
+          this.fb.fill(0)
+        } else if (bg === FX.STATIC) {
+          const [br, bgc, bb] = this.p.color2
+          for (let i = 0; i < N; i++) this.setFb(i, br, bgc, bb)
+        } else {
+          this.renderEffect(bg, speed, true)
+        }
+        if (ball.bgbright < 255) {
+          for (let i = 0; i < N * 3; i++) this.fb[i] = this.scale8(this.fb[i], ball.bgbright)
+        }
+
+        // The firmware reads the real sand-ball angle; the preview orbits a
+        // simulated one, with the same shortest-path smoothing glide.
+        let pf = this.simBallFrac * (ball.direction === 'ccw' ? -1 : 1) + ball.align / 360
+        pf -= Math.floor(pf)
+        if (this.ballTrack < 0) this.ballTrack = pf
+        const alpha = Math.max(speed / 255, 0.03)
+        let delta = pf - this.ballTrack
+        delta -= Math.round(delta)
+        this.ballTrack += delta * alpha
+        this.ballTrack -= Math.floor(this.ballTrack)
+
+        const posf = this.ballTrack * N
+        const size = Math.max(1, ball.size)
+        let [r, g, b] = this.p.color1
+        r = this.scale8(r, ball.fgbright)
+        g = this.scale8(g, ball.fgbright)
+        b = this.scale8(b, ball.fgbright)
+        for (let i = 0; i < N; i++) {
+          let d = Math.abs(i - posf)
+          d = Math.min(d, N - d)
+          let t = 1 - d / size
+          if (t <= 0) continue
+          t = t * t * (3 - 2 * t)
+          const cr = this.fb[i * 3]
+          const cg = this.fb[i * 3 + 1]
+          const cb = this.fb[i * 3 + 2]
+          this.setFb(i, (cr + (r - cr) * t) | 0, (cg + (g - cg) * t) | 0, (cb + (b - cb) * t) | 0)
+        }
+        break
+      }
+      default: // EFFECT_OFF
+        this.fb.fill(0)
+        break
+    }
+  }
+}
+
+export interface LedRingPreviewProps {
+  effectId: number
+  paletteId: number
+  color1: string
+  color2: string
+  brightness: number // UI percent 0..100
+  speed: number // firmware range 1..255
+  powerOn: boolean
+  ball: BallPreviewParams
+  className?: string
+}
+
+export function LedRingPreview({
+  effectId,
+  paletteId,
+  color1,
+  color2,
+  brightness,
+  speed,
+  powerOn,
+  ball,
+  className,
+}: LedRingPreviewProps) {
+  const canvasRef = useRef<HTMLCanvasElement | null>(null)
+  const engineRef = useRef<LedEngine | null>(null)
+  const paramsRef = useRef<EngineParams & { powerOn: boolean }>(null as never)
+
+  paramsRef.current = {
+    effectId,
+    paletteId,
+    color1: hexToRgb(color1),
+    color2: hexToRgb(color2),
+    brightness255: Math.round((Math.max(0, Math.min(100, brightness)) * 255) / 100),
+    speed: Math.max(1, Math.min(255, speed)),
+    ball,
+    powerOn,
+  }
+
+  const reduceMotion =
+    typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
+  // With reduced motion there is no animation loop, so the static frame must
+  // re-render whenever any visual input changes.
+  const staticKey = reduceMotion
+    ? JSON.stringify([effectId, paletteId, color1, color2, brightness, speed, ball])
+    : ''
+
+  useEffect(() => {
+    const canvas = canvasRef.current
+    if (!canvas) return
+    const ctx = canvas.getContext('2d')
+    if (!ctx) return
+    if (!engineRef.current) engineRef.current = new LedEngine()
+    const engine = engineRef.current
+
+    const cssSize = canvas.clientWidth || 144
+    const dpr = window.devicePixelRatio || 1
+    canvas.width = cssSize * dpr
+    canvas.height = cssSize * dpr
+    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
+
+    const thickness = cssSize * 0.075
+    const radius = cssSize / 2 - thickness / 2 - cssSize * 0.06 // margin for glow
+    const cx = cssSize / 2
+    const cy = cssSize / 2
+    const segAngle = (2 * Math.PI) / NUM_LEDS
+    // slight overlap hides the anti-aliased seams between adjacent arcs,
+    // so the ring reads as one continuous band (no per-pixel dividers)
+    const eps = 0.6 / radius
+
+    const drawFrame = () => {
+      const p = paramsRef.current
+      ctx.clearRect(0, 0, cssSize, cssSize)
+      ctx.lineWidth = thickness
+      ctx.lineCap = 'butt'
+      for (let i = 0; i < NUM_LEDS; i++) {
+        let r = 0
+        let g = 0
+        let b = 0
+        if (p.powerOn) {
+          // same master-brightness scaling as the firmware's commit(),
+          // then linear -> sRGB so on-screen hues match the physical LEDs
+          const s = p.brightness255 + 1
+          r = GAMMA[(engine.fb[i * 3] * s) >> 8]
+          g = GAMMA[(engine.fb[i * 3 + 1] * s) >> 8]
+          b = GAMMA[(engine.fb[i * 3 + 2] * s) >> 8]
+        }
+        // LED 0 at 12 o'clock, increasing clockwise (matches ball align=0)
+        const a0 = -Math.PI / 2 + i * segAngle - eps
+        const a1 = a0 + segAngle + 2 * eps
+        const lit = r + g + b > 6
+        ctx.beginPath()
+        ctx.arc(cx, cy, radius, a0, a1)
+        if (lit) {
+          ctx.shadowColor = `rgb(${r},${g},${b})`
+          ctx.shadowBlur = cssSize * 0.05
+          ctx.strokeStyle = `rgb(${r},${g},${b})`
+        } else {
+          ctx.shadowBlur = 0
+          // unlit stretch: faint neutral so the ring shape reads in both themes
+          ctx.strokeStyle = p.powerOn ? `rgba(127,127,127,0.12)` : `rgba(127,127,127,0.22)`
+        }
+        ctx.stroke()
+      }
+      ctx.shadowBlur = 0
+    }
+
+    if (reduceMotion) {
+      // static preview: run enough frames for stateful effects to develop
+      engine.p = paramsRef.current
+      for (let k = 0; k < 45; k++) engine.tick()
+      drawFrame()
+      return
+    }
+
+    let raf = 0
+    let last = 0
+    const loop = (ts: number) => {
+      raf = requestAnimationFrame(loop)
+      if (ts - last < FRAME_MS) return
+      last = ts
+      if (paramsRef.current.powerOn) {
+        engine.p = paramsRef.current
+        engine.tick()
+      }
+      drawFrame()
+    }
+    raf = requestAnimationFrame(loop)
+    return () => cancelAnimationFrame(raf)
+    // Animated mode rebinds only on power changes; per-frame params flow
+    // through paramsRef without restarting the loop.
+  }, [powerOn, reduceMotion, staticKey])
+
+  return <canvas ref={canvasRef} className={className} aria-hidden="true" />
+}

+ 32 - 18
frontend/src/components/NowPlayingBar.tsx

@@ -85,7 +85,7 @@ function QueueItem({ file, index, previewUrl, requestPreview }: QueueItemProps)
 
 
       {/* Pattern name */}
       {/* Pattern name */}
       <div className="flex-1 min-w-0">
       <div className="flex-1 min-w-0">
-        <p className="text-sm truncate">{formatPatternName(file)}</p>
+        <p className="text-sm font-display font-medium truncate">{formatPatternName(file)}</p>
         <p className="text-xs text-muted-foreground">#{index + 1}</p>
         <p className="text-xs text-muted-foreground">#{index + 1}</p>
       </div>
       </div>
     </div>
     </div>
@@ -133,16 +133,18 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
     touchStartY.current = null
     touchStartY.current = null
   }
   }
 
 
-  // Prevent background scroll when Now Playing bar is visible
+  // Lock background scroll only when the bar is EXPANDED (full-screen modal
+  // state with a fixed inset-0 backdrop). In the collapsed mini-bar state the
+  // page must stay scrollable — Layout's <main> already reserves bottom padding
+  // for the bar, and locking scroll there hides content permanently behind it.
   useEffect(() => {
   useEffect(() => {
-    if (isVisible) {
-      // Lock body scroll when bar is visible on mobile
+    if (isVisible && isExpanded) {
       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(() => {
@@ -273,13 +275,17 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
 
 
   const getThemeColors = useCallback(() => {
   const getThemeColors = useCallback(() => {
     const isDark = document.documentElement.classList.contains('dark')
     const isDark = document.documentElement.classList.contains('dark')
+    const styles = getComputedStyle(document.documentElement)
+    const token = (name: string, fallback: string) =>
+      styles.getPropertyValue(name).trim() || fallback
     return {
     return {
       isDark,
       isDark,
-      bgOuter: isDark ? '#1a1a1a' : '#f5f5f5',
-      bgInner: isDark ? '#262626' : '#ffffff',
-      borderColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(128, 128, 128, 0.3)',
-      lineColor: isDark ? '#e5e5e5' : '#333333',
-      markerBorder: isDark ? '#333333' : '#ffffff',
+      bgOuter: token('--color-background', isDark ? '#171310' : '#F5EFE6'),
+      bgInner: token('--color-card', isDark ? '#211C17' : '#FDFAF4'),
+      borderColor: token('--color-border', isDark ? '#352D23' : '#E2D6C2'),
+      lineColor: token('--color-foreground', isDark ? '#F2EAD9' : '#292219'),
+      markerColor: token('--color-live', isDark ? '#7BC4B0' : '#35836F'),
+      markerBorder: token('--color-card', isDark ? '#211C17' : '#FDFAF4'),
     }
     }
   }, [])
   }, [])
 
 
@@ -384,20 +390,28 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
       const interpRho = currentCoord[1] + (nextCoord[1] - currentCoord[1]) * fraction
       const interpRho = currentCoord[1] + (nextCoord[1] - currentCoord[1]) * fraction
 
 
       const currentPoint = polarToCartesian(interpTheta, interpRho, size)
       const currentPoint = polarToCartesian(interpTheta, interpRho, size)
+      ctx.save()
       ctx.beginPath()
       ctx.beginPath()
       ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
       ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
-      ctx.fillStyle = '#0b80ee'
+      ctx.shadowColor = colors.markerColor
+      ctx.shadowBlur = 8
+      ctx.fillStyle = colors.markerColor
       ctx.fill()
       ctx.fill()
+      ctx.restore()
       ctx.strokeStyle = colors.markerBorder
       ctx.strokeStyle = colors.markerBorder
       ctx.lineWidth = 2
       ctx.lineWidth = 2
       ctx.stroke()
       ctx.stroke()
     } else if (coords.length > 0 && adjustedIndex < coords.length) {
     } else if (coords.length > 0 && adjustedIndex < coords.length) {
       // At the last coordinate, just draw without interpolation
       // At the last coordinate, just draw without interpolation
       const currentPoint = polarToCartesian(coords[adjustedIndex][0], coords[adjustedIndex][1], size)
       const currentPoint = polarToCartesian(coords[adjustedIndex][0], coords[adjustedIndex][1], size)
+      ctx.save()
       ctx.beginPath()
       ctx.beginPath()
       ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
       ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
-      ctx.fillStyle = '#0b80ee'
+      ctx.shadowColor = colors.markerColor
+      ctx.shadowBlur = 8
+      ctx.fillStyle = colors.markerColor
       ctx.fill()
       ctx.fill()
+      ctx.restore()
       ctx.strokeStyle = colors.markerBorder
       ctx.strokeStyle = colors.markerBorder
       ctx.lineWidth = 2
       ctx.lineWidth = 2
       ctx.stroke()
       ctx.stroke()
@@ -778,7 +792,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                             </>
                             </>
                           ) : (
                           ) : (
                             <>
                             <>
-                              <p className="text-sm md:text-base font-semibold truncate">
+                              <p className="text-sm md:text-base font-display font-semibold truncate">
                                 {patternName}
                                 {patternName}
                               </p>
                               </p>
                               {status.playlist && (
                               {status.playlist && (
@@ -801,7 +815,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                       ) : (
                       ) : (
                         <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} className="h-2 flex-1 bg-live/20 [&>div]:bg-live" />
                           <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'}
@@ -909,7 +923,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                 ) : (
                 ) : (
                   <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} className="h-2 flex-1 bg-live/20 [&>div]:bg-live" />
                     <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 className="material-icons-outlined text-sm">history</span>}
                       -{formatTime(remainingTime)}
                       -{formatTime(remainingTime)}
@@ -970,7 +984,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                       </>
                       </>
                     ) : (
                     ) : (
                       <>
                       <>
-                        <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
+                        <h2 className="text-lg md:text-xl font-display font-semibold truncate">{patternName}</h2>
                         {status?.playlist && (
                         {status?.playlist && (
                           <p className="text-sm text-muted-foreground">
                           <p className="text-sm text-muted-foreground">
                             Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
                             Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
@@ -992,7 +1006,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                   </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} className="h-1.5 md:h-2 bg-live/20 [&>div]:bg-live" />
                     <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>
@@ -1074,7 +1088,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                     </div>
                     </div>
                     <div className="min-w-0 flex-1">
                     <div className="min-w-0 flex-1">
                       <p className="text-xs text-muted-foreground">Up Next</p>
                       <p className="text-xs text-muted-foreground">Up Next</p>
-                      <p className="text-sm font-medium truncate">
+                      <p className="text-sm font-display font-medium truncate">
                         {formatPatternName(status.playlist.next_file)}
                         {formatPatternName(status.playlist.next_file)}
                       </p>
                       </p>
                     </div>
                     </div>

+ 265 - 4
frontend/src/components/TableSelector.tsx

@@ -5,8 +5,10 @@
  * discovered tables or add new ones manually.
  * discovered tables or add new ones manually.
  */
  */
 
 
-import { useState } from 'react'
+import { useState, useEffect, useCallback } from 'react'
 import { useTable, type Table } from '@/contexts/TableContext'
 import { useTable, type Table } from '@/contexts/TableContext'
+import { apiClient } from '@/lib/apiClient'
+import { useStatusStore } from '@/stores/useStatusStore'
 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 { Badge } from '@/components/ui/badge'
 import { Badge } from '@/components/ui/badge'
@@ -29,12 +31,84 @@ import {
   Check,
   Check,
   Pencil,
   Pencil,
   Trash2,
   Trash2,
+  RefreshCw,
+  Loader2,
 } from 'lucide-react'
 } from 'lucide-react'
 
 
 interface TableSelectorProps {
 interface TableSelectorProps {
   children?: React.ReactNode
   children?: React.ReactNode
 }
 }
 
 
+/** A FluidNC controller board found on the LAN via mDNS (`/api/discovered-boards`). */
+interface DiscoveredBoard {
+  name: string
+  hostname: string | null
+  host: string
+  port: number
+  url: string
+  mac: string | null
+}
+
+/** `/serial_status` — which board this backend is bound to (its "added" board). */
+interface ConnStatus {
+  connected: boolean
+  port?: string
+  hostname?: string
+}
+
+type BoardStatus = 'connected' | 'added-offline' | 'available'
+type BoardRow = DiscoveredBoard & { status: BoardStatus }
+
+const hostFromUrl = (url: string): string => {
+  try {
+    return new URL(url).hostname
+  } catch {
+    return url.replace(/^https?:\/\//, '').split(':')[0]
+  }
+}
+
+/**
+ * Merge the mDNS board list with the backend's currently-configured board so the
+ * dropdown can show "all boards + whether added or not":
+ *  - connected     → this backend is bound to it right now (the added board, online)
+ *  - added-offline → configured board that mDNS can't currently see
+ *  - available     → discovered on the LAN but not connected here
+ */
+function buildBoardRows(boards: DiscoveredBoard[], conn: ConnStatus | null): BoardRow[] {
+  const configuredUrl = conn?.port || null
+  const matchesConfigured = (b: DiscoveredBoard): boolean =>
+    !!configuredUrl &&
+    (configuredUrl.includes(b.host) ||
+      (!!b.hostname && configuredUrl.toLowerCase().includes(b.hostname.toLowerCase())))
+
+  const rows: BoardRow[] = boards.map((b) => ({
+    ...b,
+    status: matchesConfigured(b) && conn?.connected ? 'connected' : 'available',
+  }))
+
+  // The configured board isn't advertising on mDNS right now — still show it,
+  // so a moved/offline table doesn't silently vanish from the list.
+  if (configuredUrl && !boards.some(matchesConfigured)) {
+    rows.unshift({
+      name: conn?.hostname || hostFromUrl(configuredUrl),
+      hostname: conn?.hostname || null,
+      host: hostFromUrl(configuredUrl),
+      port: 80,
+      url: configuredUrl,
+      mac: null,
+      status: conn?.connected ? 'connected' : 'added-offline',
+    })
+  }
+
+  // Stable, connection-independent order so the list doesn't reshuffle when you
+  // switch which board is connected — only the highlight moves.
+  return rows.sort(
+    (a, b) =>
+      a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) ||
+      a.host.localeCompare(b.host)
+  )
+}
+
 export function TableSelector({ children }: TableSelectorProps) {
 export function TableSelector({ children }: TableSelectorProps) {
   const {
   const {
     tables,
     tables,
@@ -54,6 +128,74 @@ export function TableSelector({ children }: TableSelectorProps) {
   const [renameValue, setRenameValue] = useState('')
   const [renameValue, setRenameValue] = useState('')
   const [isAdding, setIsAdding] = useState(false)
   const [isAdding, setIsAdding] = useState(false)
 
 
+  // Controller boards on the LAN. The backend browses mDNS continuously, so a
+  // GET is a cheap cache read; /serial_status tells us which board *this*
+  // backend is currently bound to (the "added" one).
+  const [boards, setBoards] = useState<DiscoveredBoard[]>([])
+  const [conn, setConn] = useState<ConnStatus | null>(null)
+  const [boardsLoading, setBoardsLoading] = useState(false)
+  const [connectingUrl, setConnectingUrl] = useState<string | null>(null)
+
+  const fetchBoards = useCallback(async () => {
+    setBoardsLoading(true)
+    try {
+      const [boardData, connData] = await Promise.all([
+        apiClient.get<{ boards: DiscoveredBoard[] }>('/api/discovered-boards').catch(() => null),
+        apiClient.get<ConnStatus>('/serial_status').catch(() => null),
+      ])
+      setBoards(boardData?.boards || [])
+      setConn(connData)
+    } finally {
+      setBoardsLoading(false)
+    }
+  }, [])
+
+  // Refresh the board list whenever the dropdown opens.
+  useEffect(() => {
+    if (isOpen) fetchBoards()
+  }, [isOpen, fetchBoards])
+
+  // Also refresh when the connected board changes from elsewhere (Settings
+  // connect/disconnect, or the server-side reconnect/relocate watchdog). Without
+  // this, the dropdown keeps whatever it read on its last open and can disagree
+  // with the Settings panel about which board is connected.
+  useEffect(() => {
+    const handler = () => fetchBoards()
+    window.addEventListener('board-connected', handler)
+    return () => window.removeEventListener('board-connected', handler)
+  }, [fetchBoards])
+
+  // The live connection flag comes over the status WebSocket. When it flips
+  // (e.g. the watchdog re-points board_url after a DHCP move, with no explicit
+  // event), re-read /serial_status so "Connected" tracks the real board.
+  const liveConnected = useStatusStore((s) => s.status?.connection_status ?? false)
+  useEffect(() => {
+    fetchBoards()
+  }, [liveConnected, fetchBoards])
+
+  const handleConnectBoard = async (board: BoardRow) => {
+    setConnectingUrl(board.url)
+    try {
+      const data = await apiClient.post<{ success?: boolean }>('/connect', { port: board.url })
+      if (data.success) {
+        toast.success(`Connected to ${board.name}`)
+        // Let the header refresh its name to the newly-connected board.
+        window.dispatchEvent(new CustomEvent('board-connected'))
+        await fetchBoards()
+      } else {
+        toast.error(`Couldn't connect to ${board.name}`)
+      }
+    } catch (error) {
+      if (error instanceof Error && error.message.startsWith('HTTP 401')) {
+        toast.error(`${board.name} is password-protected — connect it from Settings`)
+      } else {
+        toast.error(`Couldn't reach ${board.name}`)
+      }
+    } finally {
+      setConnectingUrl(null)
+    }
+  }
+
   const handleSelectTable = (table: Table) => {
   const handleSelectTable = (table: Table) => {
     if (table.id !== activeTable?.id) {
     if (table.id !== activeTable?.id) {
       setActiveTable(table)
       setActiveTable(table)
@@ -144,9 +286,11 @@ export function TableSelector({ children }: TableSelectorProps) {
               <span className="text-sm font-medium">Sand Tables</span>
               <span className="text-sm font-medium">Sand Tables</span>
             </div>
             </div>
 
 
-            {/* Table list */}
+            {/* Table list — only *other* Dune Weaver backends. The current
+                backend isn't shown as a selectable row (the header already
+                names it, and the connected board below carries "current"). */}
             <div className="space-y-1">
             <div className="space-y-1">
-              {tables.map(table => (
+              {tables.filter(t => !t.isCurrent).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 cursor-pointer hover:bg-accent group ${
@@ -172,7 +316,7 @@ export function TableSelector({ children }: TableSelectorProps) {
                     {/* Online status dot */}
                     {/* Online status dot */}
                     <span
                     <span
                       className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-popover ${
                       className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-popover ${
-                        table.isOnline ? 'bg-green-500' : 'bg-red-500'
+                        table.isOnline ? 'bg-success' : 'bg-destructive'
                       }`}
                       }`}
                     />
                     />
                   </div>
                   </div>
@@ -235,6 +379,123 @@ export function TableSelector({ children }: TableSelectorProps) {
               ))}
               ))}
             </div>
             </div>
 
 
+            {/* Controller boards on the LAN (mDNS), with added/connected status */}
+            <div className="pt-1">
+              <div className="flex items-center justify-between px-2 py-1">
+                <span className="text-xs font-medium text-muted-foreground">
+                  Boards on your network
+                </span>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="h-6 w-6 p-0 text-muted-foreground"
+                  onClick={fetchBoards}
+                  disabled={boardsLoading}
+                  title="Rescan"
+                >
+                  <RefreshCw className={`h-3.5 w-3.5 ${boardsLoading ? 'animate-spin' : ''}`} />
+                </Button>
+              </div>
+
+              {(() => {
+                const boardRows = buildBoardRows(boards, conn)
+                if (boardRows.length === 0) {
+                  return (
+                    <p className="px-2 py-1.5 text-xs text-muted-foreground">
+                      {boardsLoading ? 'Scanning…' : 'No boards found on the network yet.'}
+                    </p>
+                  )
+                }
+                return (
+                  <div className="space-y-1">
+                    {boardRows.map((board) => (
+                      <div
+                        key={board.mac || board.url}
+                        className={`flex items-center gap-2 px-2 py-2 rounded-md ${
+                          board.status === 'connected' ? 'bg-accent' : ''
+                        }`}
+                      >
+                        <div className="relative flex-shrink-0">
+                          <img
+                            src="/static/android-chrome-192x192.png"
+                            alt={board.name}
+                            className="w-8 h-8 rounded-full object-cover"
+                          />
+                          {/* The dot is reachability ("can we ping it"): a board
+                              on mDNS is reachable (green); a configured board
+                              mDNS can't see is unreachable (red). Connection is
+                              shown separately by the "Connected" label. */}
+                          <span
+                            className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-popover ${
+                              board.status === 'added-offline' ? 'bg-destructive' : 'bg-success'
+                            }`}
+                            title={board.status === 'added-offline' ? 'Unreachable' : 'Reachable'}
+                          />
+                        </div>
+
+                        <div className="flex-1 min-w-0">
+                          <div className="flex items-center gap-2">
+                            <span className="text-sm truncate">{board.name}</span>
+                            {board.status === 'connected' && (
+                              <Badge variant="secondary" className="text-[10px] px-1 py-0">
+                                Added
+                              </Badge>
+                            )}
+                            {board.status === 'added-offline' && (
+                              <Badge variant="outline" className="text-[10px] px-1 py-0">
+                                Added · offline
+                              </Badge>
+                            )}
+                          </div>
+                          <span className="text-xs text-muted-foreground truncate block">
+                            {board.host}
+                          </span>
+                        </div>
+
+                        {board.status === 'connected' ? (
+                          <div className="flex items-center gap-1.5 shrink-0">
+                            <span className="text-xs font-medium text-success">
+                              Connected
+                            </span>
+                            <Check className="h-4 w-4 text-primary" />
+                          </div>
+                        ) : board.status === 'available' ? (
+                          <Button
+                            variant="outline"
+                            size="sm"
+                            className="h-7 shrink-0"
+                            disabled={connectingUrl === board.url}
+                            onClick={() => handleConnectBoard(board)}
+                          >
+                            {connectingUrl === board.url ? (
+                              <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                            ) : (
+                              'Connect'
+                            )}
+                          </Button>
+                        ) : (
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            className="h-7 shrink-0 text-muted-foreground"
+                            disabled={connectingUrl === board.url}
+                            onClick={() => handleConnectBoard(board)}
+                            title="Retry connection"
+                          >
+                            {connectingUrl === board.url ? (
+                              <Loader2 className="h-3.5 w-3.5 animate-spin" />
+                            ) : (
+                              'Reconnect'
+                            )}
+                          </Button>
+                        )}
+                      </div>
+                    ))}
+                  </div>
+                )
+              })()}
+            </div>
+
             {/* Add table button */}
             {/* Add table button */}
             <Button
             <Button
               variant="secondary"
               variant="secondary"

+ 253 - 0
frontend/src/components/TableWifiCard.tsx

@@ -0,0 +1,253 @@
+import { useEffect, useRef, useState } from 'react'
+import { toast } from 'sonner'
+import { apiClient } from '@/lib/apiClient'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+
+interface WifiStatus {
+  supported: boolean
+  mode?: 'sta' | 'fallback' | 'standalone'
+  sta_ssid?: string
+  ap_ssid?: string
+  fail?: string
+}
+
+interface WifiNetwork {
+  ssid: string
+  rssi: number
+  secure: number | boolean
+}
+
+const MODE_LABELS: Record<string, string> = {
+  sta: 'On home Wi-Fi',
+  fallback: 'Setup hotspot (could not join home Wi-Fi)',
+  standalone: 'Standalone hotspot',
+}
+
+/**
+ * Manage the FluidNC board's own Wi-Fi (fw >= v0.1.8) — distinct from the
+ * host's Wi-Fi setup page. Moving the table to a new network reboots it; the
+ * backend's mDNS relocate watchdog re-finds it afterwards.
+ */
+export function TableWifiCard({ isConnected }: { isConnected: boolean }) {
+  const [status, setStatus] = useState<WifiStatus | null>(null)
+  const [expanded, setExpanded] = useState(false)
+  const [scanning, setScanning] = useState(false)
+  const [networks, setNetworks] = useState<WifiNetwork[]>([])
+  const [selectedSsid, setSelectedSsid] = useState('')
+  const [password, setPassword] = useState('')
+  const [busy, setBusy] = useState<string | null>(null)
+  const scanTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  const fetchStatus = async () => {
+    try {
+      const data = await apiClient.get<WifiStatus>('/api/board/wifi/status')
+      setStatus(data)
+    } catch {
+      setStatus(null)
+    }
+  }
+
+  useEffect(() => {
+    if (isConnected) fetchStatus()
+    return () => {
+      if (scanTimer.current) clearTimeout(scanTimer.current)
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [isConnected])
+
+  const pollScan = async (rescan: boolean) => {
+    try {
+      const data = await apiClient.get<{ status: string; aps?: WifiNetwork[] }>(
+        `/api/board/wifi/scan${rescan ? '?rescan=true' : ''}`
+      )
+      if (data.status === 'ok') {
+        setScanning(false)
+        setNetworks(
+          (data.aps || [])
+            .filter((a) => a.ssid)
+            .sort((a, b) => b.rssi - a.rssi)
+        )
+      } else {
+        scanTimer.current = setTimeout(() => pollScan(false), 2000)
+      }
+    } catch {
+      setScanning(false)
+      toast.error('Wi-Fi scan failed')
+    }
+  }
+
+  const startScan = () => {
+    setScanning(true)
+    setNetworks([])
+    pollScan(true)
+  }
+
+  const joinNetwork = async () => {
+    if (!selectedSsid || password.length < 8) return
+    setBusy('join')
+    try {
+      const data = await apiClient.post<{ success?: boolean; rebooting?: boolean }>(
+        '/api/board/wifi/save',
+        { ssid: selectedSsid, password }
+      )
+      if (data.rebooting) {
+        toast.success(
+          `Table is rebooting onto "${selectedSsid}". If its address changes, it will be re-found automatically.`,
+          { duration: 8000 }
+        )
+      } else {
+        toast.success(`Table joined "${selectedSsid}"`)
+      }
+      setExpanded(false)
+      setPassword('')
+      setSelectedSsid('')
+    } catch (e) {
+      const msg = e instanceof Error ? e.message : ''
+      toast.error(
+        msg.includes('busy')
+          ? 'The table is busy - stop the current pattern first'
+          : 'The table rejected those credentials'
+      )
+    } finally {
+      setBusy(null)
+    }
+  }
+
+  const switchToStandalone = async () => {
+    if (
+      !window.confirm(
+        'Switch the table to standalone hotspot mode? It will LEAVE this network and broadcast its own Wi-Fi - this server will lose the connection until the table is back on the same network.'
+      )
+    ) {
+      return
+    }
+    setBusy('standalone')
+    try {
+      await apiClient.post('/api/board/wifi/standalone')
+      toast.success('Table is switching to hotspot mode and leaving this network', {
+        duration: 8000,
+      })
+    } catch {
+      toast.error('Could not switch to standalone mode')
+    } finally {
+      setBusy(null)
+    }
+  }
+
+  if (!isConnected || !status) return null
+  if (!status.supported) return null
+
+  return (
+    <div className="p-4 rounded-lg border space-y-3">
+      <div className="flex items-center justify-between gap-3">
+        <div>
+          <p className="font-medium flex items-center gap-2">
+            <span className="material-icons-outlined text-base">router</span>
+            Table Wi-Fi
+          </p>
+          <p className="text-xs text-muted-foreground mt-1">
+            {MODE_LABELS[status.mode || ''] || 'Unknown mode'}
+            {status.mode === 'sta' && status.sta_ssid ? ` - "${status.sta_ssid}"` : ''}
+            {status.mode !== 'sta' && status.ap_ssid ? ` - "${status.ap_ssid}"` : ''}
+            {status.fail ? ` (join failed: ${status.fail})` : ''}
+          </p>
+        </div>
+        <Button
+          variant="outline"
+          size="sm"
+          onClick={() => {
+            setExpanded(!expanded)
+            if (!expanded && networks.length === 0) startScan()
+          }}
+        >
+          {expanded ? 'Close' : 'Change Wi-Fi'}
+        </Button>
+      </div>
+
+      {expanded && (
+        <div className="space-y-3 pt-1">
+          <div className="flex items-center justify-between">
+            <Label>Networks in range</Label>
+            <Button
+              variant="ghost"
+              size="sm"
+              className="gap-1.5 text-muted-foreground"
+              disabled={scanning}
+              onClick={startScan}
+            >
+              <span className={`material-icons-outlined text-base ${scanning ? 'animate-spin' : ''}`}>
+                {scanning ? 'sync' : 'refresh'}
+              </span>
+              {scanning ? 'Scanning…' : 'Rescan'}
+            </Button>
+          </div>
+          <div className="max-h-48 overflow-y-auto space-y-1">
+            {networks.map((n) => (
+              <button
+                key={n.ssid}
+                type="button"
+                onClick={() => setSelectedSsid(n.ssid)}
+                className={`w-full flex items-center justify-between px-3 py-2 rounded-md border text-sm text-left transition-colors ${
+                  selectedSsid === n.ssid ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
+                }`}
+              >
+                <span className="truncate">{n.ssid}</span>
+                <span className="flex items-center gap-1 text-muted-foreground shrink-0">
+                  {(n.secure === 1 || n.secure === true) && (
+                    <span className="material-icons-outlined text-sm">lock</span>
+                  )}
+                  <span className="material-icons-outlined text-sm">
+                    {n.rssi > -60 ? 'wifi' : n.rssi > -75 ? 'wifi_2_bar' : 'wifi_1_bar'}
+                  </span>
+                </span>
+              </button>
+            ))}
+            {!scanning && networks.length === 0 && (
+              <p className="text-xs text-muted-foreground py-2">No networks found - rescan.</p>
+            )}
+          </div>
+          {selectedSsid && (
+            <div className="flex gap-3">
+              <Input
+                type="password"
+                value={password}
+                onChange={(e) => setPassword(e.target.value)}
+                placeholder={`Password for "${selectedSsid}" (8-64 chars)`}
+                autoCapitalize="none"
+                autoCorrect="off"
+                className="flex-1"
+              />
+              <Button
+                disabled={busy === 'join' || password.length < 8}
+                onClick={joinNetwork}
+              >
+                {busy === 'join' ? (
+                  <span className="material-icons-outlined animate-spin">sync</span>
+                ) : (
+                  'Connect'
+                )}
+              </Button>
+            </div>
+          )}
+          <p className="text-xs text-muted-foreground">
+            The table reboots to join the new network. If it lands on a different
+            address, this server re-finds it automatically via mDNS.
+          </p>
+          <div className="pt-1">
+            <Button
+              variant="outline"
+              size="sm"
+              className="text-destructive border-destructive/40 hover:bg-destructive/10"
+              disabled={busy === 'standalone' || status.mode === 'standalone'}
+              onClick={switchToStandalone}
+            >
+              Switch to standalone hotspot
+            </Button>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 290 - 107
frontend/src/components/layout/Layout.tsx

@@ -72,20 +72,28 @@ export function Layout() {
     if (typeof window !== 'undefined') {
     if (typeof window !== 'undefined') {
       const saved = localStorage.getItem('theme')
       const saved = localStorage.getItem('theme')
       if (saved) return saved === 'dark'
       if (saved) return saved === 'dark'
-      return window.matchMedia('(prefers-color-scheme: dark)').matches
+      // "Table at night" is the signature look — default to night mode
+      return true
     }
     }
-    return false
+    return true
   })
   })
 
 
   // App customization
   // App customization
   const [appName, setAppName] = useState(DEFAULT_APP_NAME)
   const [appName, setAppName] = useState(DEFAULT_APP_NAME)
   const [customLogo, setCustomLogo] = useState<string | null>(null)
   const [customLogo, setCustomLogo] = useState<string | null>(null)
+  // The connected controller board's network hostname (e.g. "DWMP") — the
+  // table's real identity in the firmware-delegated model. Drives the header
+  // name so connecting a different board updates it.
+  const [boardHostname, setBoardHostname] = useState<string | null>(null)
+  // Bumped when the connected board changes; keys the <Outlet> so the current
+  // page remounts and refetches everything from the newly connected board.
+  const [boardEpoch, setBoardEpoch] = useState(0)
 
 
   // Display name: when multiple tables exist, use the active table's name; otherwise use app settings
   // Display name: when multiple tables exist, use the active table's name; otherwise use app settings
   // Get the table from the tables array (most up-to-date source) to ensure we have current data
   // Get the table from the tables array (most up-to-date source) to ensure we have current data
   const activeTableData = tables.find(t => t.id === activeTable?.id)
   const activeTableData = tables.find(t => t.id === activeTable?.id)
   const tableName = activeTableData?.name || activeTable?.name
   const tableName = activeTableData?.name || activeTable?.name
-  const displayName = hasMultipleTables && tableName ? tableName : appName
+  const displayName = boardHostname || (hasMultipleTables && tableName ? tableName : appName)
 
 
   // Connection & status from shared store
   // Connection & status from shared store
   const isBackendConnected = useStatusStore((s) => s.isBackendConnected)
   const isBackendConnected = useStatusStore((s) => s.isBackendConnected)
@@ -93,8 +101,7 @@ export function Layout() {
   const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
   const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
   const isHoming = useStatusStore((s) => s.status?.is_homing ?? false)
   const isHoming = useStatusStore((s) => s.status?.is_homing ?? false)
   const sensorHomingFailed = useStatusStore((s) => s.status?.sensor_homing_failed ?? false)
   const sensorHomingFailed = useStatusStore((s) => s.status?.sensor_homing_failed ?? false)
-  const firmwareVersion = useStatusStore((s) => s.status?.firmware_version ?? null)
-  const tableType = useStatusStore((s) => s.status?.table_type ?? null)
+  const isAlarm = useStatusStore((s) => s.status?.is_alarm ?? false)
   const statusCurrentFile = useStatusStore((s) => s.status?.current_file ?? null)
   const statusCurrentFile = useStatusStore((s) => s.status?.current_file ?? null)
   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)
@@ -123,12 +130,13 @@ export function Layout() {
   const [passwordInput, setPasswordInput] = useState('')
   const [passwordInput, setPasswordInput] = useState('')
   const [passwordError, setPasswordError] = useState(false)
   const [passwordError, setPasswordError] = useState(false)
 
 
-  // FluidNC version warning — each table type has an expected version
-  const expectedFirmwareVersion = tableType === 'dune_weaver_mini' ? 'v3.8.3' : 'v3.9.5'
-  const showFirmwareWarning = useMemo(() => {
-    if (!firmwareVersion) return false
-    return firmwareVersion !== expectedFirmwareVersion
-  }, [firmwareVersion, expectedFirmwareVersion])
+  // Read the connected board's hostname from /serial_status (it isn't carried
+  // on the status WebSocket). Cleared to null when no board is connected.
+  const fetchBoardName = () => {
+    apiClient.get<{ connected?: boolean; hostname?: string }>('/serial_status')
+      .then((s) => setBoardHostname(s.connected && s.hostname ? s.hostname : null))
+      .catch(() => setBoardHostname(null))
+  }
 
 
   // Fetch app settings
   // Fetch app settings
   const fetchAppSettings = () => {
   const fetchAppSettings = () => {
@@ -150,6 +158,7 @@ export function Layout() {
 
 
   useEffect(() => {
   useEffect(() => {
     fetchAppSettings()
     fetchAppSettings()
+    fetchBoardName()
 
 
     // Listen for branding/security updates from Settings page
     // Listen for branding/security updates from Settings page
     const handleBrandingUpdate = () => {
     const handleBrandingUpdate = () => {
@@ -158,16 +167,34 @@ export function Layout() {
     const handleSecurityUpdate = () => {
     const handleSecurityUpdate = () => {
       fetchAppSettings()
       fetchAppSettings()
     }
     }
+    // A board was (dis)connected elsewhere (e.g. the table selector) — refresh
+    // the header name and remount the routed page so whatever the user is
+    // looking at (LED, Table Control, …) reloads from the new board. Settings
+    // opts out via detail.source: it refreshes itself, and a remount would
+    // collapse the section the connect button lives in.
+    const handleBoardConnected = (e: Event) => {
+      fetchBoardName()
+      if ((e as CustomEvent).detail?.source !== 'settings') {
+        setBoardEpoch((n) => n + 1)
+      }
+    }
     window.addEventListener('branding-updated', handleBrandingUpdate)
     window.addEventListener('branding-updated', handleBrandingUpdate)
     window.addEventListener('security-updated', handleSecurityUpdate)
     window.addEventListener('security-updated', handleSecurityUpdate)
+    window.addEventListener('board-connected', handleBoardConnected)
 
 
     return () => {
     return () => {
       window.removeEventListener('branding-updated', handleBrandingUpdate)
       window.removeEventListener('branding-updated', handleBrandingUpdate)
       window.removeEventListener('security-updated', handleSecurityUpdate)
       window.removeEventListener('security-updated', handleSecurityUpdate)
+      window.removeEventListener('board-connected', handleBoardConnected)
     }
     }
     // Refetch when active table changes
     // Refetch when active table changes
   }, [activeTable?.id])
   }, [activeTable?.id])
 
 
+  // Keep the header name in sync when the live connection flips.
+  useEffect(() => {
+    fetchBoardName()
+  }, [isConnected])
+
   // Check for software updates on mount
   // Check for software updates on mount
   useEffect(() => {
   useEffect(() => {
     apiClient.get<{ update_available?: boolean }>('/api/version')
     apiClient.get<{ update_available?: boolean }>('/api/version')
@@ -328,6 +355,47 @@ export function Layout() {
   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) // Track how many logs we've loaded (for offset)
 
 
+  // Which log the drawer is showing. 'app' = live host backend logs (WebSocket),
+  // 'table'/'boot' = board logs pulled on demand from the FluidNC controller.
+  const [logTab, setLogTab] = useState<'app' | 'table' | 'boot'>('app')
+  const [tableLog, setTableLog] = useState<string[]>([])
+  const [tableLogLoading, setTableLogLoading] = useState(false)
+  const [bootLog, setBootLog] = useState<string | null>(null)
+  const [bootLogLoading, setBootLogLoading] = useState(false)
+
+  const fetchTableLog = useCallback(async () => {
+    setTableLogLoading(true)
+    try {
+      const data = await apiClient.get<{ lines: string[] }>('/api/board/logs?limit=1000')
+      setTableLog(data.lines || [])
+    } catch {
+      // History is best-effort — the board may be disconnected.
+    } finally {
+      setTableLogLoading(false)
+    }
+  }, [])
+
+  const fetchBootLog = useCallback(async () => {
+    setBootLogLoading(true)
+    try {
+      const data = await apiClient.get<{ text: string }>('/api/board/bootlog')
+      setBootLog(data.text ?? '')
+    } catch {
+      toast.error('Could not read the boot log')
+    } finally {
+      setBootLogLoading(false)
+    }
+  }, [])
+
+  // Lazily load the board logs the first time their tab is opened. The Refresh
+  // button re-fetches; there's no live stream for these (no board WebSocket).
+  useEffect(() => {
+    if (!isLogsOpen) return
+    if (logTab === 'table' && tableLog.length === 0 && !tableLogLoading) fetchTableLog()
+    if (logTab === 'boot' && bootLog === null && !bootLogLoading) fetchBootLog()
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [isLogsOpen, logTab])
+
   // Connect to logs WebSocket when drawer opens
   // Connect to logs WebSocket when drawer opens
   useEffect(() => {
   useEffect(() => {
     if (!isLogsOpen) {
     if (!isLogsOpen) {
@@ -551,12 +619,18 @@ export function Layout() {
     }
     }
   }
   }
 
 
-  // Copy logs to clipboard (with fallback for non-HTTPS)
-  const handleCopyLogs = () => {
-    const text = filteredLogs
+  // Plain text of whichever log tab is active — used by copy + download.
+  const activeLogText = () => {
+    if (logTab === 'table') return tableLog.join('\n')
+    if (logTab === 'boot') return bootLog ?? ''
+    return filteredLogs
       .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`)
       .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`)
       .join('\n')
       .join('\n')
-    copyToClipboard(text)
+  }
+
+  // Copy logs to clipboard (with fallback for non-HTTPS)
+  const handleCopyLogs = () => {
+    copyToClipboard(activeLogText())
   }
   }
 
 
   // Helper to copy text with fallback for non-secure contexts
   // Helper to copy text with fallback for non-secure contexts
@@ -587,26 +661,29 @@ export function Layout() {
 
 
   // Download logs as file
   // Download logs as file
   const handleDownloadLogs = () => {
   const handleDownloadLogs = () => {
-    const text = filteredLogs
-      .map((log) => `${log.timestamp} [${log.level}] [${log.logger}] ${log.message}`)
-      .join('\n')
+    const text = logTab === 'app'
+      ? filteredLogs
+          .map((log) => `${log.timestamp} [${log.level}] [${log.logger}] ${log.message}`)
+          .join('\n')
+      : activeLogText()
+    const name = logTab === 'app' ? 'logs' : logTab === 'table' ? 'table-log' : 'boot-log'
     const blob = new Blob([text], { type: 'text/plain' })
     const blob = new Blob([text], { type: 'text/plain' })
     const url = URL.createObjectURL(blob)
     const url = URL.createObjectURL(blob)
     const a = document.createElement('a')
     const a = document.createElement('a')
     a.href = url
     a.href = url
-    a.download = `dune-weaver-logs-${new Date().toISOString().split('T')[0]}.txt`
+    a.download = `dune-weaver-${name}-${new Date().toISOString().split('T')[0]}.txt`
     a.click()
     a.click()
     URL.revokeObjectURL(url)
     URL.revokeObjectURL(url)
   }
   }
 
 
   const handleRestart = async () => {
   const handleRestart = async () => {
-    if (!confirm('Are you sure you want to restart Dune Weaver?')) return
+    if (!confirm('Restart the table? The controller (DLC32) will reboot and re-home — any running pattern stops.')) return
 
 
     try {
     try {
-      await apiClient.post('/api/system/restart')
-      toast.success('Dune Weaver is restarting...')
+      await apiClient.post('/api/board/restart')
+      toast.success('Table is restarting…')
     } catch {
     } catch {
-      toast.error('Failed to restart Dune Weaver')
+      toast.error('Failed to restart the table')
     }
     }
   }
   }
 
 
@@ -1087,7 +1164,7 @@ export function Layout() {
             <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 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-display 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>
             <form onSubmit={handlePasswordSubmit} className="space-y-3">
             <form onSubmit={handlePasswordSubmit} className="space-y-3">
               <Input
               <Input
@@ -1115,7 +1192,7 @@ export function Layout() {
                 <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 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-display 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>
               </div>
               </div>
               <form onSubmit={handlePasswordSubmit} className="space-y-3">
               <form onSubmit={handlePasswordSubmit} className="space-y-3">
@@ -1157,16 +1234,16 @@ export function Layout() {
                     error_outline
                     error_outline
                   </span>
                   </span>
                 </div>
                 </div>
-                <h2 className="text-xl font-semibold">Sensor Homing Failed</h2>
+                <h2 className="text-xl font-display 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>
 
 
-                <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-sm text-left">
-                  <p className="text-amber-600 dark:text-amber-400 font-medium mb-2">
+                <div className="bg-primary/10 border border-primary/20 p-3 rounded-lg text-sm text-left">
+                  <p className="text-primary font-medium mb-2">
                     Troubleshooting steps:
                     Troubleshooting steps:
                   </p>
                   </p>
-                  <ul className="text-amber-600 dark:text-amber-400 space-y-1 list-disc list-inside">
+                  <ul className="text-primary space-y-1 list-disc list-inside">
                     <li>Check that the limit sensors are properly connected</li>
                     <li>Check that the limit sensors are properly connected</li>
                     <li>Verify the sensor positions are correct</li>
                     <li>Verify the sensor positions are correct</li>
                     <li>Ensure nothing is blocking the sensor path</li>
                     <li>Ensure nothing is blocking the sensor path</li>
@@ -1222,7 +1299,7 @@ export function Layout() {
                   cached
                   cached
                 </span>
                 </span>
               </div>
               </div>
-              <h2 className="text-2xl font-bold">Initializing Pattern Cache</h2>
+              <h2 className="text-2xl font-display font-bold">Initializing Pattern Cache</h2>
               <p className="text-muted-foreground">
               <p className="text-muted-foreground">
                 Preparing your pattern previews...
                 Preparing your pattern previews...
               </p>
               </p>
@@ -1273,13 +1350,13 @@ export function Layout() {
                     download_for_offline
                     download_for_offline
                   </span>
                   </span>
                 </div>
                 </div>
-                <h2 className="text-xl font-semibold">Cache All Pattern Previews?</h2>
+                <h2 className="text-xl font-display 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>
 
 
-                <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-sm">
-                  <p className="text-amber-600 dark:text-amber-400">
+                <div className="bg-primary/10 border border-primary/20 p-3 rounded-lg text-sm">
+                  <p className="text-primary">
                     <strong>Note:</strong> This cache is browser-specific. You'll need to repeat this for each browser you use.
                     <strong>Note:</strong> This cache is browser-specific. You'll need to repeat this for each browser you use.
                   </p>
                   </p>
                 </div>
                 </div>
@@ -1318,7 +1395,7 @@ export function Layout() {
                 {/* Completion message */}
                 {/* Completion message */}
                 {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-success flex items-center justify-center gap-2">
                       <span className="material-icons text-base">check_circle</span>
                       <span className="material-icons text-base">check_circle</span>
                       All {cacheAllProgress.total} previews cached successfully!
                       All {cacheAllProgress.total} previews cached successfully!
                     </p>
                     </p>
@@ -1343,22 +1420,22 @@ export function Layout() {
             <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 mb-2 ${
               <div className={`inline-flex items-center justify-center w-16 h-16 rounded-full mb-2 ${
                 homingJustCompleted
                 homingJustCompleted
-                  ? 'bg-green-500/10'
+                  ? 'bg-success/10'
                   : isHoming
                   : isHoming
                     ? 'bg-primary/10'
                     ? 'bg-primary/10'
-                    : 'bg-amber-500/10'
+                    : 'bg-primary/10'
               }`}>
               }`}>
                 <span className={`material-icons-outlined text-4xl ${
                 <span className={`material-icons-outlined text-4xl ${
                   homingJustCompleted
                   homingJustCompleted
-                    ? 'text-green-500'
+                    ? 'text-success'
                     : isHoming
                     : isHoming
                       ? 'text-primary animate-spin'
                       ? 'text-primary animate-spin'
-                      : 'text-amber-500 animate-pulse'
+                      : 'text-primary animate-pulse'
                 }`}>
                 }`}>
                   {homingJustCompleted ? 'check_circle' : 'sync'}
                   {homingJustCompleted ? 'check_circle' : 'sync'}
                 </span>
                 </span>
               </div>
               </div>
-              <h2 className="text-2xl font-bold">
+              <h2 className="text-2xl font-display font-bold">
                 {homingJustCompleted
                 {homingJustCompleted
                   ? 'Homing Complete'
                   ? 'Homing Complete'
                   : isHoming
                   : isHoming
@@ -1379,10 +1456,10 @@ export function Layout() {
               <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
               <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
                 <span className={`w-2 h-2 rounded-full ${
                 <span className={`w-2 h-2 rounded-full ${
                   homingJustCompleted
                   homingJustCompleted
-                    ? 'bg-green-500'
+                    ? 'bg-success'
                     : isHoming
                     : isHoming
                       ? 'bg-primary animate-pulse'
                       ? 'bg-primary animate-pulse'
-                      : 'bg-amber-500 animate-pulse'
+                      : 'bg-primary animate-pulse'
                 }`} />
                 }`} />
                 <span>
                 <span>
                   {homingJustCompleted
                   {homingJustCompleted
@@ -1435,8 +1512,8 @@ export function Layout() {
                       {formatTimestamp(log.timestamp)}
                       {formatTimestamp(log.timestamp)}
                     </span>
                     </span>
                     <span className={`shrink-0 font-semibold ${
                     <span className={`shrink-0 font-semibold ${
-                      log.level === 'ERROR' ? 'text-red-500' :
-                      log.level === 'WARNING' ? 'text-amber-500' :
+                      log.level === 'ERROR' ? 'text-destructive' :
+                      log.level === 'WARNING' ? 'text-primary' :
                       log.level === 'DEBUG' ? 'text-muted-foreground' :
                       log.level === 'DEBUG' ? 'text-muted-foreground' :
                       'text-foreground'
                       'text-foreground'
                     }`}>
                     }`}>
@@ -1535,10 +1612,10 @@ export function Layout() {
               <button className="flex items-center gap-1.5 hover:opacity-80 transition-opacity group">
               <button className="flex items-center gap-1.5 hover:opacity-80 transition-opacity group">
                 <ShinyText
                 <ShinyText
                   text={displayName}
                   text={displayName}
-                  className="font-semibold text-lg"
+                  className="font-display font-semibold text-lg"
                   speed={4}
                   speed={4}
-                  color={isDark ? '#a8a8a8' : '#555555'}
-                  shineColor={isDark ? '#ffffff' : '#999999'}
+                  color={isDark ? '#A08F77' : '#8A7A63'}
+                  shineColor={isDark ? '#D9B98A' : '#A87F45'}
                   spread={75}
                   spread={75}
                 />
                 />
                 <span className="material-icons-outlined text-muted-foreground text-sm group-hover:text-foreground transition-colors">
                 <span className="material-icons-outlined text-muted-foreground text-sm group-hover:text-foreground transition-colors">
@@ -1547,10 +1624,10 @@ export function Layout() {
                 <span
                 <span
                   className={`w-2 h-2 rounded-full ${
                   className={`w-2 h-2 rounded-full ${
                     !isBackendConnected
                     !isBackendConnected
-                      ? 'bg-gray-400'
+                      ? 'bg-muted-foreground'
                       : isConnected
                       : isConnected
-                        ? 'bg-green-500 animate-pulse'
-                        : 'bg-red-500'
+                        ? 'bg-success animate-pulse'
+                        : 'bg-destructive'
                   }`}
                   }`}
                   title={
                   title={
                     !isBackendConnected
                     !isBackendConnected
@@ -1564,12 +1641,26 @@ export function Layout() {
             </TableSelector>
             </TableSelector>
           </div>
           </div>
 
 
-          {showFirmwareWarning && (
-            <div className="flex items-center gap-1.5 min-w-0 mx-2 text-amber-500">
+          {/* Alarm state: the board refuses motion until unlocked ($X) */}
+          {isAlarm && (
+            <div className="flex items-center gap-2 min-w-0 mx-2 text-primary">
               <span className="material-icons-outlined text-base shrink-0">warning</span>
               <span className="material-icons-outlined text-base shrink-0">warning</span>
-              <span className="text-xs truncate">
-                FluidNC {firmwareVersion} — change to {expectedFirmwareVersion}
-              </span>
+              <span className="text-xs truncate hidden sm:inline">Table alarm</span>
+              <Button
+                variant="outline"
+                size="sm"
+                className="h-7 px-2 text-xs border-primary text-primary hover:bg-primary/10"
+                onClick={async () => {
+                  try {
+                    await apiClient.post('/api/board/unlock')
+                    toast.success('Table unlocked')
+                  } catch {
+                    toast.error('Could not unlock the table')
+                  }
+                }}
+              >
+                Unlock
+              </Button>
             </div>
             </div>
           )}
           )}
 
 
@@ -1590,7 +1681,7 @@ export function Layout() {
               <Link to="/settings?section=version" title="Software update available">
               <Link to="/settings?section=version" title="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 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-live animate-pulse" />
                 </span>
                 </span>
               </Link>
               </Link>
             )}
             )}
@@ -1636,14 +1727,14 @@ export function Layout() {
                   <Separator className="my-1" />
                   <Separator className="my-1" />
                   <button
                   <button
                     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-primary"
                   >
                   >
                     <span className="material-icons-outlined text-xl">restart_alt</span>
                     <span 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-destructive"
                   >
                   >
                     <span className="material-icons-outlined text-xl">power_settings_new</span>
                     <span className="material-icons-outlined text-xl">power_settings_new</span>
                     Shutdown
                     Shutdown
@@ -1670,7 +1761,7 @@ export function Layout() {
               <Link to="/settings?section=version" title="Software update available">
               <Link to="/settings?section=version" title="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 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-live animate-pulse" />
                 </span>
                 </span>
               </Link>
               </Link>
             )}
             )}
@@ -1727,7 +1818,7 @@ export function Layout() {
                       handleRestart()
                       handleRestart()
                       setIsMobileMenuOpen(false)
                       setIsMobileMenuOpen(false)
                     }}
                     }}
-                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors 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-primary"
                   >
                   >
                     <span className="material-icons-outlined text-xl">restart_alt</span>
                     <span className="material-icons-outlined text-xl">restart_alt</span>
                     Restart
                     Restart
@@ -1737,7 +1828,7 @@ export function Layout() {
                       handleShutdown()
                       handleShutdown()
                       setIsMobileMenuOpen(false)
                       setIsMobileMenuOpen(false)
                     }}
                     }}
-                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors 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-destructive"
                   >
                   >
                     <span className="material-icons-outlined text-xl">power_settings_new</span>
                     <span className="material-icons-outlined text-xl">power_settings_new</span>
                     Shutdown
                     Shutdown
@@ -1765,7 +1856,7 @@ export function Layout() {
               : 'calc(8rem + env(safe-area-inset-bottom, 0px))' // floating pill + nav + safe area
               : 'calc(8rem + env(safe-area-inset-bottom, 0px))' // floating pill + nav + safe area
         }}
         }}
       >
       >
-        <Outlet context={{ isPlayOnlyActive }} />
+        <Outlet key={boardEpoch} context={{ isPlayOnlyActive }} />
       </main>
       </main>
 
 
       {/* Now Playing Bar */}
       {/* Now Playing Bar */}
@@ -1802,18 +1893,41 @@ export function Layout() {
             {/* Logs Header */}
             {/* Logs Header */}
             <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50 gap-2">
             <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50 gap-2">
               <div className="flex items-center gap-2 sm:gap-3 flex-wrap min-w-0">
               <div className="flex items-center gap-2 sm:gap-3 flex-wrap min-w-0">
-                <span className="text-sm font-medium whitespace-nowrap">Application Logs</span>
-                <select
-                  value={logLevelFilter}
-                  onChange={(e) => setLogLevelFilter(e.target.value)}
-                  className="text-xs bg-background border rounded px-2 py-1"
-                >
-                  <option value="ALL">All Levels</option>
-                  <option value="DEBUG">Debug</option>
-                  <option value="INFO">Info</option>
-                  <option value="WARNING">Warning</option>
-                  <option value="ERROR">Error</option>
-                </select>
+                {/* Tab switcher: host app logs vs. the board's own logs */}
+                <div className="flex items-center rounded-md bg-background border p-0.5">
+                  {([
+                    { id: 'app', label: 'Application' },
+                    { id: 'table', label: 'Table Log' },
+                    { id: 'boot', label: 'Boot Log' },
+                  ] as const).map((tab) => (
+                    <button
+                      key={tab.id}
+                      onClick={() => setLogTab(tab.id)}
+                      className={`text-xs px-2 py-1 rounded transition-colors whitespace-nowrap ${
+                        logTab === tab.id
+                          ? 'bg-primary text-primary-foreground'
+                          : 'text-muted-foreground hover:text-foreground'
+                      }`}
+                    >
+                      {tab.label}
+                    </button>
+                  ))}
+                </div>
+
+                {/* Level filter is meaningful only for the structured app logs */}
+                {logTab === 'app' && (
+                  <select
+                    value={logLevelFilter}
+                    onChange={(e) => setLogLevelFilter(e.target.value)}
+                    className="text-xs bg-background border rounded px-2 py-1"
+                  >
+                    <option value="ALL">All Levels</option>
+                    <option value="DEBUG">Debug</option>
+                    <option value="INFO">Info</option>
+                    <option value="WARNING">Warning</option>
+                    <option value="ERROR">Error</option>
+                  </select>
+                )}
                 <input
                 <input
                   type="text"
                   type="text"
                   value={logSearchQuery}
                   value={logSearchQuery}
@@ -1826,10 +1940,27 @@ export function Layout() {
                     <span className="material-icons-outlined text-sm">close</span>
                     <span className="material-icons-outlined text-sm">close</span>
                   </Button>
                   </Button>
                 )}
                 )}
-                <span className="text-xs text-muted-foreground">
-                  {filteredLogs.length}{logsTotal > 0 ? ` of ${logsTotal}` : ''} entries
-                  {logsHasMore && <span className="text-primary ml-1">↑ scroll for more</span>}
-                </span>
+                {/* Board logs have no live stream — offer a manual refresh instead */}
+                {logTab !== 'app' && (
+                  <Button
+                    variant="ghost"
+                    size="icon-sm"
+                    onClick={logTab === 'table' ? fetchTableLog : fetchBootLog}
+                    disabled={logTab === 'table' ? tableLogLoading : bootLogLoading}
+                    className="rounded-full"
+                    title="Refresh"
+                  >
+                    <span className={`material-icons-outlined text-base ${
+                      (logTab === 'table' ? tableLogLoading : bootLogLoading) ? 'animate-spin' : ''
+                    }`}>refresh</span>
+                  </Button>
+                )}
+                {logTab === 'app' && (
+                  <span className="text-xs text-muted-foreground">
+                    {filteredLogs.length}{logsTotal > 0 ? ` of ${logsTotal}` : ''} entries
+                    {logsHasMore && <span className="text-primary ml-1">↑ scroll for more</span>}
+                  </span>
+                )}
               </div>
               </div>
 
 
               <div className="flex items-center gap-1 shrink-0">
               <div className="flex items-center gap-1 shrink-0">
@@ -1868,39 +1999,91 @@ export function Layout() {
               ref={logsContainerRef}
               ref={logsContainerRef}
               className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
               className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
             >
             >
-              {/* Loading indicator for older logs */}
-              {isLoadingMoreLogs && (
-                <div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
-                  <span className="material-icons-outlined text-sm animate-spin">sync</span>
-                  <span>Loading older logs...</span>
-                </div>
-              )}
-              {/* Load more hint */}
-              {logsHasMore && !isLoadingMoreLogs && (
-                <div className="text-center py-2 text-muted-foreground text-xs">
-                  ↑ Scroll up to load older logs
-                </div>
+              {logTab === 'app' && (
+                <>
+                  {/* Loading indicator for older logs */}
+                  {isLoadingMoreLogs && (
+                    <div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
+                      <span className="material-icons-outlined text-sm animate-spin">sync</span>
+                      <span>Loading older logs...</span>
+                    </div>
+                  )}
+                  {/* Load more hint */}
+                  {logsHasMore && !isLoadingMoreLogs && (
+                    <div className="text-center py-2 text-muted-foreground text-xs">
+                      ↑ Scroll up to load older logs
+                    </div>
+                  )}
+                  {filteredLogs.length > 0 ? (
+                    filteredLogs.map((log, i) => (
+                      <div key={i} className="py-0.5 flex gap-2">
+                        <span className="text-muted-foreground shrink-0">
+                          {formatTimestamp(log.timestamp)}
+                        </span>
+                        <span className={`shrink-0 font-semibold ${
+                          log.level === 'ERROR' ? 'text-destructive' :
+                          log.level === 'WARNING' ? 'text-primary' :
+                          log.level === 'DEBUG' ? 'text-muted-foreground' :
+                          'text-foreground'
+                        }`}>
+                          [{log.level || 'LOG'}]
+                        </span>
+                        <span className="break-all">{log.message || ''}</span>
+                      </div>
+                    ))
+                  ) : (
+                    <p className="text-muted-foreground text-center py-4">No logs available</p>
+                  )}
+                </>
               )}
               )}
-              {filteredLogs.length > 0 ? (
-                filteredLogs.map((log, i) => (
-                  <div key={i} className="py-0.5 flex gap-2">
-                    <span className="text-muted-foreground shrink-0">
-                      {formatTimestamp(log.timestamp)}
-                    </span>
-                    <span className={`shrink-0 font-semibold ${
-                      log.level === 'ERROR' ? 'text-red-500' :
-                      log.level === 'WARNING' ? 'text-amber-500' :
-                      log.level === 'DEBUG' ? 'text-muted-foreground' :
-                      'text-foreground'
-                    }`}>
-                      [{log.level || 'LOG'}]
-                    </span>
-                    <span className="break-all">{log.message || ''}</span>
+
+              {/* Table Log: the board's own history, harvested so it survives reboots */}
+              {logTab === 'table' && (() => {
+                const q = logSearchQuery.toLowerCase()
+                const lines = q ? tableLog.filter((l) => l.toLowerCase().includes(q)) : tableLog
+                if (lines.length > 0) {
+                  return lines.map((line, i) => (
+                    <div key={i} className="py-0.5 text-muted-foreground break-all whitespace-pre-wrap">
+                      {line}
+                    </div>
+                  ))
+                }
+                return (
+                  <p className="text-muted-foreground text-center py-4">
+                    {tableLogLoading
+                      ? 'Loading…'
+                      : tableLog.length === 0
+                        ? 'No table log collected yet — press refresh. History builds up while the table is connected.'
+                        : 'No lines match your search.'}
+                  </p>
+                )
+              })()}
+
+              {/* Boot Log: the board's on-device crash breadcrumb (survives a panic) */}
+              {logTab === 'boot' && (() => {
+                if (bootLog === null) {
+                  return (
+                    <p className="text-muted-foreground text-center py-4">
+                      {bootLogLoading ? 'Reading…' : 'Press refresh to read the boot log.'}
+                    </p>
+                  )
+                }
+                const q = logSearchQuery.toLowerCase()
+                const text = bootLog.trim()
+                if (!text) {
+                  return <p className="text-muted-foreground text-center py-4">(empty)</p>
+                }
+                const lines = text.split('\n')
+                const shown = q ? lines.filter((l) => l.toLowerCase().includes(q)) : lines
+                if (shown.length === 0) {
+                  return <p className="text-muted-foreground text-center py-4">No lines match your search.</p>
+                }
+                return shown.map((line, i) => (
+                  <div key={i} className="py-0.5 text-success break-all whitespace-pre-wrap">
+                    {line}
                   </div>
                   </div>
                 ))
                 ))
-              ) : (
-                <p className="text-muted-foreground text-center py-4">No logs available</p>
-              )}
+              })()}
             </div>
             </div>
           </>
           </>
         )}
         )}

+ 7 - 5
frontend/src/index.css

@@ -164,15 +164,17 @@ body {
 }
 }
 
 
 /* Tint pattern previews (black ink on transparent, from the backend renderer)
 /* Tint pattern previews (black ink on transparent, from the backend renderer)
-   to the theme foreground — the web equivalent of the mobile app's tintColor
-   on its webp thumbnails. Chains are numerically solved to land exactly on
-   the foreground tokens: #292219 (day ink) / #F2EAD9 (lit sand, night). */
+   to the sand accent — the golden hue the touch app renders its previews in.
+   Foreground-tinted lines averaged out to near-white/gray at grid size; the
+   primary tokens keep them reading as sand under warm light. Chains are
+   numerically solved to land exactly on the primary tokens:
+   #A87F45 (deep sand, day) / #D9B98A (dune sand, night). */
 .pattern-preview {
 .pattern-preview {
-  filter: brightness(0) saturate(100%) invert(9%) sepia(11%) saturate(1771%) hue-rotate(354deg) brightness(98%) contrast(88%);
+  filter: brightness(0) saturate(100%) invert(32%) sepia(100%) saturate(349%) hue-rotate(356deg) brightness(134%) contrast(61%);
 }
 }
 
 
 .dark .pattern-preview {
 .dark .pattern-preview {
-  filter: brightness(0) saturate(100%) invert(89%) sepia(29%) saturate(179%) hue-rotate(335deg) brightness(104%) contrast(90%);
+  filter: brightness(0) saturate(100%) invert(86%) sepia(20%) saturate(1860%) hue-rotate(309deg) brightness(127%) contrast(70%);
 }
 }
 
 
 /* Marquee animation for scrolling text */
 /* Marquee animation for scrolling text */

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 522 - 379
frontend/src/pages/LEDPage.tsx


+ 381 - 89
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect } from 'react'
 import { useState, useEffect } from 'react'
-import { useSearchParams, useNavigate } from 'react-router-dom'
+import { useSearchParams } from 'react-router-dom'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { apiClient } from '@/lib/apiClient'
 import { apiClient } from '@/lib/apiClient'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
@@ -25,6 +25,8 @@ 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 { AlignOrientationDialog } from '@/components/AlignOrientationDialog'
+import { TableWifiCard } from '@/components/TableWifiCard'
 
 
 // Types
 // Types
 
 
@@ -79,6 +81,17 @@ interface BoardTime {
   tz: string
   tz: string
 }
 }
 
 
+// A FluidNC board found on the LAN via mDNS (same records the mobile app
+// browses for). `name` is the firmware hostname, e.g. "DWMP".
+interface DiscoveredBoard {
+  name: string
+  hostname: string | null
+  host: string
+  port: number
+  url: string
+  mac: string | null
+}
+
 interface LedConfig {
 interface LedConfig {
   provider: 'none' | 'wled' | 'board'
   provider: 'none' | 'wled' | 'board'
   wled_ip?: string
   wled_ip?: string
@@ -98,7 +111,6 @@ interface MqttConfig {
 
 
 export function SettingsPage() {
 export function SettingsPage() {
   const [searchParams, setSearchParams] = useSearchParams()
   const [searchParams, setSearchParams] = useSearchParams()
-  const navigate = useNavigate()
   const sectionParam = searchParams.get('section')
   const sectionParam = searchParams.get('section')
 
 
   // Connection state — the board is reached over HTTP (FluidNC firmware),
   // Connection state — the board is reached over HTTP (FluidNC firmware),
@@ -106,6 +118,17 @@ export function SettingsPage() {
   const [boardAddress, setBoardAddress] = useState('')
   const [boardAddress, setBoardAddress] = useState('')
   const [isConnected, setIsConnected] = useState(false)
   const [isConnected, setIsConnected] = useState(false)
   const [connectionStatus, setConnectionStatus] = useState('Disconnected')
   const [connectionStatus, setConnectionStatus] = useState('Disconnected')
+  // Table name = the board's network hostname (e.g. "DWMP"), reported by the firmware
+  const [tableName, setTableName] = useState<string | null>(null)
+  // The address we're actually connected to (vs. whatever is typed in the input)
+  const [connectedUrl, setConnectedUrl] = useState<string | null>(null)
+  const [discoveredBoards, setDiscoveredBoards] = useState<DiscoveredBoard[]>([])
+  // Board API password ($Sand/Password): boardLocked = board rejected us with
+  // 401; hasBoardKey = a password is saved on this backend.
+  const [boardLocked, setBoardLocked] = useState(false)
+  const [hasBoardKey, setHasBoardKey] = useState(false)
+  const [boardPassword, setBoardPassword] = useState('')
+  const [tablePasswordInput, setTablePasswordInput] = useState('')
 
 
   // Settings state
   // Settings state
   const [settings, setSettings] = useState<Settings>({})
   const [settings, setSettings] = useState<Settings>({})
@@ -186,6 +209,14 @@ export function SettingsPage() {
   } | null>(null)
   } | null>(null)
   const [updateDialogOpen, setUpdateDialogOpen] = useState(false)
   const [updateDialogOpen, setUpdateDialogOpen] = useState(false)
 
 
+  // Board firmware version state (OTA via the backend)
+  const [firmwareInfo, setFirmwareInfo] = useState<{
+    current: string | null
+    latest: string | null
+    update_available: boolean
+    release_url: string | null
+  } | null>(null)
+
   // Helper to scroll to element with header offset
   // Helper to scroll to element with header offset
   const scrollToSection = (sectionId: string) => {
   const scrollToSection = (sectionId: string) => {
     const element = document.getElementById(`section-${sectionId}`)
     const element = document.getElementById(`section-${sectionId}`)
@@ -273,6 +304,39 @@ export function SettingsPage() {
     } catch (error) {
     } catch (error) {
       console.error('Failed to fetch version info:', error)
       console.error('Failed to fetch version info:', error)
     }
     }
+    try {
+      const fw = await apiClient.get<{
+        current: string | null
+        latest: string | null
+        update_available: boolean
+        release_url: string | null
+      }>('/api/firmware/version')
+      setFirmwareInfo(fw)
+    } catch (error) {
+      console.error('Failed to fetch firmware info:', error)
+    }
+  }
+
+  const handleFirmwareUpdate = async () => {
+    setIsLoading('firmwareUpdate')
+    toast.info('Updating the table firmware - this takes a few minutes. Keep the table powered.')
+    try {
+      const data = await apiClient.post<{ success?: boolean; version?: string }>('/api/firmware/update')
+      toast.success(`Firmware updated - table now runs ${data.version || 'the latest version'}`)
+      fetchVersionInfo()
+      fetchConnection()
+    } catch (error) {
+      const msg = error instanceof Error ? error.message : ''
+      if (msg.includes('busy') || msg.includes('Stop the current')) {
+        toast.error('The table is busy - stop the current pattern first')
+      } else if (msg.includes('too old')) {
+        toast.error('This firmware is too old for OTA - update once via the web installer')
+      } else {
+        toast.error('Firmware update failed - the table keeps its old firmware')
+      }
+    } finally {
+      setIsLoading(null)
+    }
   }
   }
 
 
   // Handle accordion open/close and trigger data loading
   // Handle accordion open/close and trigger data loading
@@ -306,9 +370,20 @@ export function SettingsPage() {
 
 
   const fetchConnection = async () => {
   const fetchConnection = async () => {
     try {
     try {
-      // The backend reports the configured board URL as the single "port".
-      const statusData = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
+      // The backend reports the configured board URL as the single "port",
+      // plus the board's network hostname (the table's display name).
+      const statusData = await apiClient.get<{
+        connected: boolean
+        port?: string
+        hostname?: string
+        locked?: boolean
+        has_key?: boolean
+      }>('/serial_status')
       setIsConnected(statusData.connected || false)
       setIsConnected(statusData.connected || false)
+      setBoardLocked(!!statusData.locked)
+      setHasBoardKey(!!statusData.has_key)
+      setTableName(statusData.hostname || null)
+      setConnectedUrl(statusData.connected ? statusData.port || null : null)
       setConnectionStatus(
       setConnectionStatus(
         statusData.connected ? `Connected to ${statusData.port || 'board'}` : 'Disconnected'
         statusData.connected ? `Connected to ${statusData.port || 'board'}` : 'Disconnected'
       )
       )
@@ -323,16 +398,41 @@ export function SettingsPage() {
     }
     }
   }
   }
 
 
+  // Boards the backend has spotted on the LAN via mDNS (browsing runs
+  // continuously server-side, so this is a cheap cache read).
+  const fetchDiscoveredBoards = async () => {
+    try {
+      const data = await apiClient.get<{ boards: DiscoveredBoard[] }>('/api/discovered-boards')
+      setDiscoveredBoards(data.boards || [])
+    } catch {
+      // mDNS is best-effort; the manual address input always works
+    }
+  }
+
   // Always fetch connection state on mount since connection is the default section
   // Always fetch connection state on mount since connection is the default section
   useEffect(() => {
   useEffect(() => {
     fetchConnection()
     fetchConnection()
+    fetchDiscoveredBoards()
   }, [])
   }, [])
 
 
   // Refetch when backend reconnects
   // Refetch when backend reconnects
   useOnBackendConnected(() => {
   useOnBackendConnected(() => {
     fetchConnection()
     fetchConnection()
+    fetchDiscoveredBoards()
   })
   })
 
 
+  // Refetch when the connected board changes from elsewhere (header selector,
+  // or the server-side reconnect/relocate watchdog) so this panel never shows a
+  // stale "Connected" board relative to the rest of the UI.
+  useEffect(() => {
+    const handler = () => {
+      fetchConnection()
+      fetchDiscoveredBoards()
+    }
+    window.addEventListener('board-connected', handler)
+    return () => window.removeEventListener('board-connected', handler)
+  }, [])
+
   const fetchSettings = async () => {
   const fetchSettings = async () => {
     try {
     try {
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -439,23 +539,42 @@ export function SettingsPage() {
     }
     }
   }
   }
 
 
-  const handleConnect = async () => {
-    if (!boardAddress.trim()) {
+  const handleConnect = async (overrideAddress?: string) => {
+    const target = (overrideAddress ?? boardAddress).trim()
+    if (!target) {
       toast.error('Enter the table IP or hostname')
       toast.error('Enter the table IP or hostname')
       return
       return
     }
     }
+    if (overrideAddress) setBoardAddress(overrideAddress)
     setIsLoading('connect')
     setIsLoading('connect')
     try {
     try {
-      const data = await apiClient.post<{ success?: boolean; message?: string }>('/connect', { port: boardAddress.trim() })
+      const data = await apiClient.post<{ success?: boolean; message?: string }>('/connect', {
+        port: target,
+        ...(boardPassword.trim() ? { password: boardPassword.trim() } : {}),
+      })
       if (data.success) {
       if (data.success) {
         setIsConnected(true)
         setIsConnected(true)
-        setConnectionStatus(`Connected to ${boardAddress.trim()}`)
+        setConnectedUrl(target)
+        setBoardLocked(false)
+        setBoardPassword('')
+        setConnectionStatus(`Connected to ${target}`)
         toast.success('Connected to the table')
         toast.success('Connected to the table')
+        // Re-read status to pick up the board's hostname for the name display
+        fetchConnection()
+        // Notify other surfaces (header selector, Layout) that the connected
+        // board changed, so they refresh instead of showing a stale winner.
+        // source: 'settings' tells Layout not to remount this page for it.
+        window.dispatchEvent(new CustomEvent('board-connected', { detail: { source: 'settings' } }))
       } else {
       } else {
         throw new Error(data.message || 'Connection failed')
         throw new Error(data.message || 'Connection failed')
       }
       }
     } catch (error) {
     } catch (error) {
-      toast.error('Could not reach the table at that address')
+      if (error instanceof Error && error.message.startsWith('HTTP 401')) {
+        setBoardLocked(true)
+        toast.error('This table is password-protected - enter its password below')
+      } else {
+        toast.error('Could not reach the table at that address')
+      }
     } finally {
     } finally {
       setIsLoading(null)
       setIsLoading(null)
     }
     }
@@ -467,8 +586,11 @@ export function SettingsPage() {
       const data = await apiClient.post<{ success?: boolean }>('/disconnect')
       const data = await apiClient.post<{ success?: boolean }>('/disconnect')
       if (data.success) {
       if (data.success) {
         setIsConnected(false)
         setIsConnected(false)
+        setConnectedUrl(null)
         setConnectionStatus('Disconnected')
         setConnectionStatus('Disconnected')
         toast.success('Disconnected')
         toast.success('Disconnected')
+        // Same signal on disconnect — the "connected" board is now none.
+        window.dispatchEvent(new CustomEvent('board-connected', { detail: { source: 'settings' } }))
       }
       }
     } catch (error) {
     } catch (error) {
       toast.error('Failed to disconnect')
       toast.error('Failed to disconnect')
@@ -731,7 +853,7 @@ export function SettingsPage() {
     <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
     <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
       {/* Page Header */}
       {/* Page Header */}
       <div className="space-y-0.5 sm:space-y-1 pl-1">
       <div className="space-y-0.5 sm:space-y-1 pl-1">
-        <h1 className="text-xl font-semibold tracking-tight">Settings</h1>
+        <h1 className="text-xl font-semibold tracking-tight font-display">Settings</h1>
         <p className="text-xs text-muted-foreground">
         <p className="text-xs text-muted-foreground">
           Configure your sand table
           Configure your sand table
         </p>
         </p>
@@ -764,14 +886,15 @@ export function SettingsPage() {
             {/* Connection Status */}
             {/* Connection Status */}
             <div className="flex items-center justify-between p-4 rounded-lg border">
             <div className="flex items-center justify-between p-4 rounded-lg border">
               <div className="flex items-center gap-3">
               <div className="flex items-center gap-3">
-                <div className={`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'}`}>
+                <div className={`w-10 h-10 flex items-center justify-center rounded-lg ${isConnected ? 'bg-success/15' : 'bg-muted'}`}>
+                  <span className={`material-icons ${isConnected ? 'text-success' : 'text-muted-foreground'}`}>
                     {isConnected ? 'wifi' : 'wifi_off'}
                     {isConnected ? 'wifi' : 'wifi_off'}
                   </span>
                   </span>
                 </div>
                 </div>
                 <div>
                 <div>
-                  <p className="font-medium">Status</p>
-                  <p className={`text-sm ${isConnected ? 'text-green-600' : 'text-destructive'}`}>
+                  {/* Table name = the board's hostname (e.g. "DWMP"), like the mobile app */}
+                  <p className="font-medium">{isConnected && tableName ? tableName : 'Status'}</p>
+                  <p className={`text-sm ${isConnected ? 'text-success' : 'text-destructive'}`}>
                     {connectionStatus}
                     {connectionStatus}
                   </p>
                   </p>
                 </div>
                 </div>
@@ -788,6 +911,68 @@ export function SettingsPage() {
               )}
               )}
             </div>
             </div>
 
 
+            {/* Tables found on the network (mDNS, like the mobile app) */}
+            {discoveredBoards.length > 0 && (
+              <div className="space-y-3">
+                <div className="flex items-center justify-between">
+                  <Label>Tables on your network</Label>
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    size="sm"
+                    className="gap-1.5 text-muted-foreground"
+                    onClick={fetchDiscoveredBoards}
+                  >
+                    <span className="material-icons-outlined text-base">refresh</span>
+                    Refresh
+                  </Button>
+                </div>
+                <div className="space-y-2">
+                  {discoveredBoards.map((board) => {
+                    const isCurrent =
+                      isConnected &&
+                      !!connectedUrl &&
+                      (connectedUrl.includes(board.host) ||
+                        (!!board.hostname &&
+                          connectedUrl.toLowerCase().includes(board.hostname.toLowerCase())))
+                    return (
+                      <div
+                        key={board.mac || board.url}
+                        className="flex items-center justify-between gap-3 p-3 rounded-lg border"
+                      >
+                        <div className="flex items-center gap-3 min-w-0">
+                          <span className="material-icons-outlined text-muted-foreground">
+                            wifi_find
+                          </span>
+                          <div className="min-w-0">
+                            <p className="font-medium truncate">{board.name}</p>
+                            <p className="text-xs text-muted-foreground truncate">{board.url}</p>
+                          </div>
+                        </div>
+                        {isCurrent ? (
+                          <span className="text-xs font-medium text-success shrink-0">
+                            Connected
+                          </span>
+                        ) : (
+                          <Button
+                            size="sm"
+                            variant="outline"
+                            disabled={isLoading === 'connect'}
+                            onClick={() => handleConnect(board.url)}
+                          >
+                            Connect
+                          </Button>
+                        )}
+                      </div>
+                    )
+                  })}
+                </div>
+                <p className="text-xs text-muted-foreground">
+                  Found automatically on your Wi-Fi via mDNS.
+                </p>
+              </div>
+            )}
+
             {/* Board address */}
             {/* Board address */}
             <div className="space-y-3">
             <div className="space-y-3">
               <Label htmlFor="board-address">Table address</Label>
               <Label htmlFor="board-address">Table address</Label>
@@ -802,7 +987,7 @@ export function SettingsPage() {
                   className="flex-1"
                   className="flex-1"
                 />
                 />
                 <Button
                 <Button
-                  onClick={handleConnect}
+                  onClick={() => handleConnect()}
                   disabled={isLoading === 'connect' || !boardAddress.trim()}
                   disabled={isLoading === 'connect' || !boardAddress.trim()}
                   className="gap-2"
                   className="gap-2"
                 >
                 >
@@ -814,12 +999,34 @@ export function SettingsPage() {
                   Connect
                   Connect
                 </Button>
                 </Button>
               </div>
               </div>
+              {(boardLocked || boardPassword) && (
+                <div className="flex gap-3">
+                  <Input
+                    type="password"
+                    value={boardPassword}
+                    onChange={(e) => setBoardPassword(e.target.value)}
+                    placeholder="Table password"
+                    autoCapitalize="none"
+                    autoCorrect="off"
+                    className="flex-1"
+                  />
+                </div>
+              )}
+              {boardLocked && (
+                <p className="text-xs text-primary">
+                  This table is password-protected. Enter its password (set from the mobile
+                  app) and press Connect - it will be remembered on this server.
+                </p>
+              )}
               <p className="text-xs text-muted-foreground">
               <p className="text-xs text-muted-foreground">
                 The FluidNC controller inside the table, on the same network as this server.
                 The FluidNC controller inside the table, on the same network as this server.
                 The address is saved and reconnected automatically on startup.
                 The address is saved and reconnected automatically on startup.
               </p>
               </p>
             </div>
             </div>
 
 
+            {/* Table Wi-Fi (the board's own network; distinct from host Wi-Fi setup) */}
+            <TableWifiCard isConnected={isConnected} />
+
             {/* Home on Connect */}
             {/* Home on Connect */}
             <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">
@@ -902,6 +1109,18 @@ export function SettingsPage() {
               </RadioGroup>
               </RadioGroup>
             </div>
             </div>
 
 
+            {/* Crash-mode orientation alignment: with crash homing, the arm's
+                physical direction at home time becomes theta=0 */}
+            {(settings.homing_mode ?? 0) === 0 && (
+              <div className="space-y-2">
+                <AlignOrientationDialog />
+                <p className="text-xs text-muted-foreground">
+                  Crash homing keeps whatever direction the arm points as the pattern
+                  reference. Align it once so patterns match their previews.
+                </p>
+              </div>
+            )}
+
             {/* Sensor Offset (only visible for sensor mode) */}
             {/* Sensor Offset (only visible for sensor mode) */}
             {settings.homing_mode === 1 && (
             {settings.homing_mode === 1 && (
               <div className="space-y-3">
               <div className="space-y-3">
@@ -970,31 +1189,6 @@ export function SettingsPage() {
               )}
               )}
             </div>
             </div>
 
 
-            {/* Machine Reset on Theta Normalization */}
-            <div className="p-4 rounded-lg border space-y-3">
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="font-medium flex items-center gap-2">
-                    <span className="material-icons-outlined text-base">restart_alt</span>
-                    Reset Machine on Theta Normalization
-                  </p>
-                  <p className="text-xs text-muted-foreground mt-1">
-                    Also reset the machine controller when normalizing theta
-                  </p>
-                </div>
-                <Switch
-                  checked={settings.hard_reset_theta || false}
-                  onCheckedChange={(checked) =>
-                    setSettings({ ...settings, hard_reset_theta: checked })
-                  }
-                />
-              </div>
-              <p className="text-xs text-muted-foreground">
-                When disabled (default), theta normalization only adjusts the angle mathematically.
-                When enabled, also resets the machine controller to clear position counters.
-              </p>
-            </div>
-
             <Button
             <Button
               onClick={handleSaveHomingConfig}
               onClick={handleSaveHomingConfig}
               disabled={isLoading === 'homing'}
               disabled={isLoading === 'homing'}
@@ -2185,36 +2379,85 @@ export function SettingsPage() {
               )}
               )}
               Save Security Settings
               Save Security Settings
             </Button>
             </Button>
-          </AccordionContent>
-        </AccordionItem>
 
 
-        {/* WiFi */}
-        <AccordionItem value="wifi" id="section-wifi" className="border rounded-lg px-4 overflow-visible bg-card">
-          <AccordionTrigger className="hover:no-underline">
-            <div className="flex items-center gap-3">
-              <span className="material-icons-outlined text-muted-foreground">
-                wifi
-              </span>
-              <div className="text-left">
-                <div className="font-semibold">WiFi</div>
-                <div className="text-sm text-muted-foreground font-normal">
-                  Network connection settings
-                </div>
+            {/* Table API password ($Sand/Password) — locks the board itself, so
+                every client (this server, mobile apps) must present the key */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <div>
+                <p className="font-medium flex items-center gap-2">
+                  <span className="material-icons-outlined text-base">key</span>
+                  Table Password
+                </p>
+                <p className="text-xs text-muted-foreground mt-1">
+                  Locks the table's own API so only clients with the password can control
+                  it (needs firmware v0.1.11+). This server{' '}
+                  {hasBoardKey ? 'has the password saved.' : 'has no password saved.'}
+                </p>
               </div>
               </div>
+              {isConnected ? (
+                <div className="flex gap-3">
+                  <Input
+                    type="password"
+                    value={tablePasswordInput}
+                    onChange={(e) => setTablePasswordInput(e.target.value)}
+                    placeholder={hasBoardKey ? 'New password (4-64 chars)' : 'Password (4-64 chars)'}
+                    autoCapitalize="none"
+                    autoCorrect="off"
+                    className="flex-1"
+                  />
+                  <Button
+                    variant="outline"
+                    disabled={isLoading === 'tablePassword' || tablePasswordInput.trim().length < 4}
+                    onClick={async () => {
+                      setIsLoading('tablePassword')
+                      try {
+                        await apiClient.post('/api/board/password', {
+                          action: 'set',
+                          password: tablePasswordInput.trim(),
+                        })
+                        setHasBoardKey(true)
+                        setTablePasswordInput('')
+                        toast.success('Table password set')
+                      } catch {
+                        toast.error('Failed to set the table password')
+                      } finally {
+                        setIsLoading(null)
+                      }
+                    }}
+                  >
+                    {hasBoardKey ? 'Change' : 'Set'}
+                  </Button>
+                  {hasBoardKey && (
+                    <Button
+                      variant="destructive"
+                      disabled={isLoading === 'tablePassword'}
+                      onClick={async () => {
+                        setIsLoading('tablePassword')
+                        try {
+                          await apiClient.post('/api/board/password', { action: 'remove' })
+                          setHasBoardKey(false)
+                          toast.success('Table password removed')
+                        } catch {
+                          toast.error('Failed to remove the table password')
+                        } finally {
+                          setIsLoading(null)
+                        }
+                      }}
+                    >
+                      Remove
+                    </Button>
+                  )}
+                </div>
+              ) : (
+                <p className="text-xs text-muted-foreground">
+                  Connect to the table to manage its password.
+                </p>
+              )}
+              <p className="text-xs text-muted-foreground">
+                If you lose the password, it can be cleared over USB serial with{' '}
+                <code className="font-mono">$Sand/Password=</code>.
+              </p>
             </div>
             </div>
-          </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6 space-y-3">
-            <p className="text-sm text-muted-foreground">
-              Manage WiFi connections, scan for networks, and configure hotspot mode.
-            </p>
-            <Button
-              variant="outline"
-              className="w-full gap-2"
-              onClick={() => navigate('/wifi-setup')}
-            >
-              <span className="material-icons-outlined">settings</span>
-              Open WiFi Setup
-            </Button>
           </AccordionContent>
           </AccordionContent>
         </AccordionItem>
         </AccordionItem>
 
 
@@ -2234,45 +2477,94 @@ export function SettingsPage() {
             </div>
             </div>
           </AccordionTrigger>
           </AccordionTrigger>
           <AccordionContent className="pt-4 pb-6 space-y-3">
           <AccordionContent className="pt-4 pb-6 space-y-3">
+            {/* App version — current + latest in one row (mirrors the Table
+                Firmware row below). */}
             <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 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="text-sm text-muted-foreground">
-                  {versionInfo?.current ? `v${versionInfo.current}` : 'Loading...'}
+                <p className="font-medium">App Version</p>
+                <p className={`text-sm ${versionInfo?.update_available ? 'text-success font-medium' : 'text-muted-foreground'}`}>
+                  <span className="font-mono">{versionInfo?.current ? `v${versionInfo.current}` : 'Loading...'}</span>
+                  {versionInfo?.latest && (
+                    <>
+                      {' · latest '}
+                      <a
+                        href={`https://github.com/tuanchris/dune-weaver/releases/tag/v${versionInfo.latest}`}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        className="font-mono underline underline-offset-2 hover:opacity-80 transition-opacity"
+                      >
+                        v{versionInfo.latest}
+                      </a>
+                    </>
+                  )}
+                  {versionInfo?.update_available && ' (Update available!)'}
                 </p>
                 </p>
               </div>
               </div>
             </div>
             </div>
 
 
+            {versionInfo?.update_available && (
+              <Button onClick={() => setUpdateDialogOpen(true)} className="w-full">
+                <span className="material-icons text-base mr-2">system_update</span>
+                Update Now
+              </Button>
+            )}
+
+            {/* Table firmware (the board's own software, updated over OTA) */}
             <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 className="material-icons text-muted-foreground">memory</span>
               </div>
               </div>
               <div className="flex-1">
               <div className="flex-1">
-                <p className="font-medium">Latest Version</p>
-                <p className={`text-sm ${versionInfo?.update_available ? 'text-green-600 dark:text-green-400 font-medium' : 'text-muted-foreground'}`}>
-                  {versionInfo?.latest ? (
-                    <a
-                      href={`https://github.com/tuanchris/dune-weaver/releases/tag/v${versionInfo.latest}`}
-                      target="_blank"
-                      rel="noopener noreferrer"
-                      className="underline underline-offset-2 hover:opacity-80 transition-opacity"
-                    >
-                      v{versionInfo.latest}
-                    </a>
-                  ) : 'Checking...'}
-                  {versionInfo?.update_available && ' (Update available!)'}
+                <p className="font-medium">Table Firmware</p>
+                <p className={`text-sm ${firmwareInfo?.update_available ? 'text-success font-medium' : 'text-muted-foreground'}`}>
+                  <span className="font-mono">{firmwareInfo?.current || 'Unknown (connect to the table)'}</span>
+                  {firmwareInfo?.latest && (
+                    <>
+                      {' · latest '}
+                      {firmwareInfo.release_url ? (
+                        <a
+                          href={firmwareInfo.release_url}
+                          target="_blank"
+                          rel="noopener noreferrer"
+                          className="font-mono underline underline-offset-2 hover:opacity-80 transition-opacity"
+                        >
+                          {firmwareInfo.latest}
+                        </a>
+                      ) : (
+                        <span className="font-mono">{firmwareInfo.latest}</span>
+                      )}
+                    </>
+                  )}
                 </p>
                 </p>
               </div>
               </div>
             </div>
             </div>
 
 
-            {versionInfo?.update_available && (
-              <Button onClick={() => setUpdateDialogOpen(true)} className="w-full">
-                <span className="material-icons text-base mr-2">system_update</span>
-                Update Now
-              </Button>
+            {firmwareInfo?.update_available && (
+              <>
+                <Button
+                  variant="outline"
+                  className="w-full"
+                  onClick={handleFirmwareUpdate}
+                  disabled={isLoading === 'firmwareUpdate' || !isConnected}
+                >
+                  {isLoading === 'firmwareUpdate' ? (
+                    <span className="material-icons text-base mr-2 animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons text-base mr-2">memory</span>
+                  )}
+                  {isLoading === 'firmwareUpdate'
+                    ? 'Updating table firmware…'
+                    : `Update Table Firmware to ${firmwareInfo.latest}`}
+                </Button>
+                {isLoading === 'firmwareUpdate' && (
+                  <p className="text-xs text-muted-foreground text-center">
+                    Flashing and rebooting the table - do not power it off. This takes a few minutes.
+                  </p>
+                )}
+              </>
             )}
             )}
 
 
             <UpdateDialog
             <UpdateDialog

+ 141 - 16
frontend/src/pages/TableControlPage.tsx

@@ -30,6 +30,36 @@ import {
 import { apiClient } from '@/lib/apiClient'
 import { apiClient } from '@/lib/apiClient'
 import { useStatusStore } from '@/stores/useStatusStore'
 import { useStatusStore } from '@/stores/useStatusStore'
 
 
+// Reset causes that mean the board crashed (vs. a clean power/software reset).
+const CRASH_RESETS = ['panic', 'int_wdt', 'task_wdt', 'wdt', 'brownout']
+
+const formatKB = (n: number | null | undefined): string =>
+  n === null || n === undefined ? '—' : `${Math.round(n / 1024)} KB`
+
+const formatUptime = (s: number | null | undefined): string => {
+  if (s === null || s === undefined) return '—'
+  const d = Math.floor(s / 86400)
+  const h = Math.floor((s % 86400) / 3600)
+  const m = Math.floor((s % 3600) / 60)
+  if (d > 0) return `${d}d ${h}h`
+  if (h > 0) return `${h}h ${m}m`
+  return `${m}m`
+}
+
+// Human-readable label for a last_reset cause.
+const RESET_LABELS: Record<string, string> = {
+  power_on: 'Power on',
+  software: 'Software',
+  panic: 'Panic (crash)',
+  int_wdt: 'Interrupt watchdog',
+  task_wdt: 'Task watchdog',
+  wdt: 'Watchdog',
+  brownout: 'Brownout',
+  external: 'External',
+  deepsleep: 'Deep sleep',
+  unknown: 'Unknown',
+}
+
 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)
@@ -41,15 +71,21 @@ export function TableControlPage() {
   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
   )
   )
+  // Board health telemetry (heap/last_reset/sd_ok) rides the /ws/status payload.
+  const health = useStatusStore((s) => s.status?.health ?? null)
 
 
   // Sync speed from store into local state (for the badge display)
   // Sync speed from store into local state (for the badge display)
   useEffect(() => {
   useEffect(() => {
     if (speed !== null) setCurrentSpeed(speed)
     if (speed !== null) setCurrentSpeed(speed)
   }, [speed])
   }, [speed])
 
 
-  // Serial terminal state
+  // Command console state
   const [consoleCommand, setConsoleCommand] = useState('')
   const [consoleCommand, setConsoleCommand] = useState('')
   const [consoleHistory, setConsoleHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
   const [consoleHistory, setConsoleHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
+
+  // Table Log and Boot Log now live in the shared "View Logs" drawer (Layout),
+  // so they can sit next to the application logs. See Layout.tsx.
+
   const [consoleLoading, setConsoleLoading] = useState(false)
   const [consoleLoading, setConsoleLoading] = useState(false)
   const consoleOutputRef = useRef<HTMLDivElement>(null)
   const consoleOutputRef = useRef<HTMLDivElement>(null)
   const consoleInputRef = useRef<HTMLInputElement>(null)
   const consoleInputRef = useRef<HTMLInputElement>(null)
@@ -240,7 +276,7 @@ export function TableControlPage() {
       <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
       <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
         {/* Page Header */}
         {/* Page Header */}
         <div className="space-y-0.5 sm:space-y-1 pl-1">
         <div className="space-y-0.5 sm:space-y-1 pl-1">
-          <h1 className="text-xl font-semibold tracking-tight">Table Control</h1>
+          <h1 className="text-xl font-semibold tracking-tight font-display">Table Control</h1>
           <p className="text-xs text-muted-foreground">
           <p className="text-xs text-muted-foreground">
             Manual controls for your sand table
             Manual controls for your sand table
           </p>
           </p>
@@ -323,9 +359,9 @@ export function TableControlPage() {
                         This will send a soft reset to the controller.
                         This will send a soft reset to the controller.
                       </DialogDescription>
                       </DialogDescription>
                     </DialogHeader>
                     </DialogHeader>
-                    <Alert className="flex items-center border-amber-500/50">
-                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
-                      <AlertDescription className="text-amber-600 dark:text-amber-400">
+                    <Alert className="flex items-center border-primary/40">
+                      <span className="material-icons-outlined text-primary text-base mr-2 shrink-0">warning</span>
+                      <AlertDescription className="text-primary">
                         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>
                     </Alert>
                     </Alert>
@@ -476,11 +512,11 @@ export function TableControlPage() {
 
 
                     <Separator />
                     <Separator />
 
 
-                    <Alert className="flex items-start border-amber-500/50">
-                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">
+                    <Alert className="flex items-start border-primary/40">
+                      <span className="material-icons-outlined text-primary text-base mr-2 shrink-0">
                         warning
                         warning
                       </span>
                       </span>
-                      <AlertDescription className="text-amber-600 dark:text-amber-400">
+                      <AlertDescription className="text-primary">
                         Only perform this when you want to change the orientation reference.
                         Only perform this when you want to change the orientation reference.
                       </AlertDescription>
                       </AlertDescription>
                     </Alert>
                     </Alert>
@@ -590,6 +626,95 @@ export function TableControlPage() {
           </Card>
           </Card>
         </div>
         </div>
 
 
+        {/* Board Diagnostics — health telemetry + on-device crash breadcrumbs */}
+        <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+          <CardHeader className="pb-3">
+            <CardTitle className="text-lg flex items-center gap-2">
+              <span className="material-icons-outlined text-xl">monitor_heart</span>
+              Diagnostics
+            </CardTitle>
+            <CardDescription>
+              Board health and crash breadcrumbs reported by the firmware.
+            </CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            {!health ? (
+              <p className="text-sm text-muted-foreground">
+                No telemetry yet — connect to a table running firmware that reports health.
+              </p>
+            ) : (
+              <>
+                {/* Crash / SD / fragmentation banners */}
+                {health.last_reset && CRASH_RESETS.includes(health.last_reset) && (
+                  <Alert variant="destructive">
+                    <AlertDescription>
+                      The table last rebooted from a{' '}
+                      <strong>{RESET_LABELS[health.last_reset] ?? health.last_reset}</strong>.
+                      Open the menu → View Logs → Boot Log for the cause.
+                    </AlertDescription>
+                  </Alert>
+                )}
+                {health.sd_ok === false && (
+                  <Alert className="border-primary/40 text-primary">
+                    <AlertDescription>
+                      SD card unreadable at boot — patterns and playlists may be unavailable.
+                      Re-seat the card and reboot the table.
+                    </AlertDescription>
+                  </Alert>
+                )}
+                {health.heap_largest !== null && health.heap_largest !== undefined &&
+                  health.heap_largest < 12000 && (
+                  <Alert className="border-primary/40 text-primary">
+                    <AlertDescription>
+                      Low memory: largest free block is {formatKB(health.heap_largest)}. Heap
+                      fragmentation is trending toward an out-of-memory reboot.
+                    </AlertDescription>
+                  </Alert>
+                )}
+
+                {/* Stat grid */}
+                <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
+                  <div className="space-y-1">
+                    <div className="text-xs text-muted-foreground">Last reset</div>
+                    <Badge variant={health.last_reset && CRASH_RESETS.includes(health.last_reset)
+                      ? 'destructive' : 'secondary'}>
+                      {health.last_reset
+                        ? (RESET_LABELS[health.last_reset] ?? health.last_reset)
+                        : '—'}
+                    </Badge>
+                  </div>
+                  <div className="space-y-1">
+                    <div className="text-xs text-muted-foreground">SD card</div>
+                    <Badge variant={health.sd_ok === false ? 'destructive' : 'secondary'}>
+                      {health.sd_ok === true ? 'OK' : health.sd_ok === false ? 'Error' : '—'}
+                    </Badge>
+                  </div>
+                  <div className="space-y-1">
+                    <div className="text-xs text-muted-foreground">Uptime</div>
+                    <div className="text-sm font-medium font-mono">{formatUptime(health.uptime)}</div>
+                  </div>
+                  <div className="space-y-1">
+                    <div className="text-xs text-muted-foreground">Free heap</div>
+                    <div className="text-sm font-medium font-mono">{formatKB(health.heap)}</div>
+                  </div>
+                  <div className="space-y-1">
+                    <div className="text-xs text-muted-foreground">Min heap</div>
+                    <div className="text-sm font-medium font-mono">{formatKB(health.heap_min)}</div>
+                  </div>
+                  <div className="space-y-1">
+                    <div className="text-xs text-muted-foreground">Largest block</div>
+                    <div className="text-sm font-medium font-mono">{formatKB(health.heap_largest)}</div>
+                  </div>
+                </div>
+              </>
+            )}
+
+            <p className="text-xs text-muted-foreground pt-1">
+              The boot log and the table's full history live in the menu → View Logs drawer.
+            </p>
+          </CardContent>
+        </Card>
+
         {/* Board Command Console */}
         {/* Board Command Console */}
         <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
         <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
           <CardHeader className="pb-3 space-y-3">
           <CardHeader className="pb-3 space-y-3">
@@ -602,9 +727,9 @@ export function TableControlPage() {
                 <CardDescription className="hidden sm:block">
                 <CardDescription className="hidden sm:block">
                   Send $-commands to the table's firmware (e.g. $Sand/HomingMode, $LED/Effect=fire)
                   Send $-commands to the table's firmware (e.g. $Sand/HomingMode, $LED/Effect=fire)
                 </CardDescription>
                 </CardDescription>
-                <Alert className="flex items-center border-amber-500/50 py-2">
-                  <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
-                  <AlertDescription className="text-xs text-amber-600 dark:text-amber-400">
+                <Alert className="flex items-center border-primary/40 py-2">
+                  <span className="material-icons-outlined text-primary text-base mr-2 shrink-0">warning</span>
+                  <AlertDescription className="text-xs text-primary">
                     For advanced use. Motion commands sent here can interfere with a running pattern.
                     For advanced use. Motion commands sent here can interfere with a running pattern.
                   </AlertDescription>
                   </AlertDescription>
                 </Alert>
                 </Alert>
@@ -633,19 +758,19 @@ export function TableControlPage() {
                     key={i}
                     key={i}
                     className={`${
                     className={`${
                       entry.type === 'cmd'
                       entry.type === 'cmd'
-                        ? 'text-cyan-400'
+                        ? 'text-live'
                         : entry.type === 'error'
                         : entry.type === 'error'
-                          ? 'text-red-400'
-                          : 'text-green-400'
+                          ? 'text-destructive'
+                          : 'text-success'
                     }`}
                     }`}
                   >
                   >
-                    <span className="text-gray-500 text-xs mr-2">{entry.time}</span>
+                    <span className="text-muted-foreground text-xs mr-2">{entry.time}</span>
                     {entry.type === 'cmd' ? '> ' : ''}
                     {entry.type === 'cmd' ? '> ' : ''}
                     {entry.text}
                     {entry.text}
                   </div>
                   </div>
                 ))
                 ))
               ) : (
               ) : (
-                <div className="text-gray-500 italic">
+                <div className="text-muted-foreground italic">
                   Ready. Enter a firmware command (e.g. $Sand/HomingMode, $Playlist/List)
                   Ready. Enter a firmware command (e.g. $Sand/HomingMode, $Playlist/List)
                 </div>
                 </div>
               )}
               )}

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

@@ -5,6 +5,7 @@ export interface StatusData {
   current_file: string | null
   current_file: string | null
   is_paused: boolean
   is_paused: boolean
   is_running: boolean
   is_running: boolean
+  is_alarm: boolean
   is_homing: boolean
   is_homing: boolean
   is_clearing: boolean
   is_clearing: boolean
   sensor_homing_failed: boolean
   sensor_homing_failed: boolean
@@ -39,6 +40,16 @@ export interface StatusData {
   current_rho: number
   current_rho: number
   firmware_version: string | null
   firmware_version: string | null
   table_type: string | null
   table_type: string | null
+  // Board health telemetry from /sand_status (firmware API.md). Fields are
+  // null on older firmware that doesn't report them.
+  health?: {
+    heap: number | null
+    heap_min: number | null
+    heap_largest: number | null
+    last_reset: string | null
+    sd_ok: boolean | null
+    uptime: number | null
+  } | null
 }
 }
 
 
 interface StatusStore {
 interface StatusStore {

+ 17 - 0
frontend/src/test/mocks/handlers.ts

@@ -324,6 +324,23 @@ export const handlers = [
     return HttpResponse.json(['/dev/ttyUSB0', '/dev/ttyUSB1'])
     return HttpResponse.json(['/dev/ttyUSB0', '/dev/ttyUSB1'])
   }),
   }),
 
 
+  // Board diagnostics
+  http.get('/api/board/logs', () => {
+    return HttpResponse.json({ lines: [] })
+  }),
+
+  http.get('/api/board/bootlog', () => {
+    return HttpResponse.json({ text: '[+0] boot ok\n' })
+  }),
+
+  http.get('/api/board/coredump', () => {
+    return HttpResponse.json({ present: false })
+  }),
+
+  http.post('/api/board/restart', () => {
+    return HttpResponse.json({ success: true, rebooting: true })
+  }),
+
   // Debug serial endpoints
   // Debug serial endpoints
   http.post('/api/debug-serial/open', () => {
   http.post('/api/debug-serial/open', () => {
     return HttpResponse.json({ success: true })
     return HttpResponse.json({ success: true })

+ 331 - 25
main.py

@@ -33,6 +33,7 @@ import base64
 import hashlib
 import hashlib
 import time
 import time
 import subprocess
 import subprocess
+import requests
 
 
 # Get log level from environment variable, default to INFO
 # Get log level from environment variable, default to INFO
 log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
 log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
@@ -162,11 +163,6 @@ async def lifespan(app: FastAPI):
             state.led_controller = LEDInterface("board")
             state.led_controller = LEDInterface("board")
             logger.info("LED controller initialized: table's built-in LEDs (firmware-controlled)")
             logger.info("LED controller initialized: table's built-in LEDs (firmware-controlled)")
         else:
         else:
-            if state.led_provider == "dw_leds":
-                # Host GPIO NeoPixels were removed — the table's own ring
-                # (firmware-controlled) replaced them.
-                logger.warning("LED provider 'dw_leds' no longer exists; "
-                               "select 'Table LEDs' in Settings")
             state.led_controller = None
             state.led_controller = None
             logger.info("LED controller not configured")
             logger.info("LED controller not configured")
 
 
@@ -267,6 +263,8 @@ def get_preview_semaphore() -> asyncio.Semaphore:
 # Pydantic models for request/response validation
 # Pydantic models for request/response validation
 class ConnectRequest(BaseModel):
 class ConnectRequest(BaseModel):
     port: Optional[str] = None
     port: Optional[str] = None
+    # Board API password ($Sand/Password); stored and sent as X-Sand-Key.
+    password: Optional[str] = None
 
 
 class auto_playModeRequest(BaseModel):
 class auto_playModeRequest(BaseModel):
     enabled: bool
     enabled: bool
@@ -317,7 +315,7 @@ class WLEDRequest(BaseModel):
     wled_ip: Optional[str] = None
     wled_ip: Optional[str] = None
 
 
 class LEDConfigRequest(BaseModel):
 class LEDConfigRequest(BaseModel):
-    provider: str  # "wled", "dw_leds", or "none"
+    provider: str  # "wled", "board", or "none"
     ip_address: Optional[str] = None  # For WLED only
     ip_address: Optional[str] = None  # For WLED only
     # DW LED specific fields
     # DW LED specific fields
     num_leds: Optional[int] = None
     num_leds: Optional[int] = None
@@ -954,6 +952,286 @@ async def update_board_settings(update: BoardSettingsUpdate):
 class BoardCommandRequest(BaseModel):
 class BoardCommandRequest(BaseModel):
     command: str
     command: str
 
 
+@app.get("/api/firmware/version", tags=["settings"])
+async def firmware_version():
+    """Board firmware version + latest published release (GitHub, cached)."""
+    from modules.update import firmware_updater
+    release = await asyncio.to_thread(firmware_updater.get_latest_release)
+    current = state.firmware_version
+    latest = release["version"] if release else None
+    return {
+        "current": current,
+        "latest": latest,
+        "update_available": bool(release and firmware_updater.is_newer(latest, current)),
+        "release_url": release["release_url"] if release else None,
+    }
+
+@app.post("/api/firmware/update", tags=["settings"])
+async def firmware_update():
+    """Flash the latest firmware release onto the board over OTA and wait for
+    it to reboot. A failed/interrupted upload leaves the old image running."""
+    from modules.update import firmware_updater
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+    if execution.get_cached_status().get("is_running"):
+        raise HTTPException(status_code=409, detail="Stop the current pattern first")
+
+    probe = await asyncio.to_thread(state.conn.update_probe)
+    if probe.get("status") == "busy":
+        raise HTTPException(status_code=409, detail="The table is busy - stop the current pattern first")
+    if probe.get("status") != "ready":
+        raise HTTPException(
+            status_code=400,
+            detail="This firmware is too old for OTA updates - update it once via the web installer")
+
+    release = await asyncio.to_thread(firmware_updater.get_latest_release, True)
+    if not release:
+        raise HTTPException(status_code=502, detail="Could not fetch the latest firmware release")
+    try:
+        image = await asyncio.to_thread(firmware_updater.download_image, release["download_url"])
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Firmware download failed: {e}")
+
+    # The board's web server is single-threaded: suspend the status poller for
+    # the whole flash + reboot window.
+    execution.observer.suspended = True
+    try:
+        logger.info(f"Flashing firmware {release['version']} ({len(image)} bytes) to the board")
+        result = await asyncio.to_thread(state.conn.upload_firmware, image)
+        if result.get("status") != "ok":
+            detail = ("The table is busy - stop the current pattern first"
+                      if result.get("status") == "busy" else "The table rejected the update")
+            raise HTTPException(status_code=502, detail=detail)
+
+        # Board reboots ~1s after "ok"; give it a head start, then poll.
+        await asyncio.sleep(8)
+        deadline = time.time() + 120
+        while True:
+            try:
+                st = await asyncio.to_thread(state.conn.get_status)
+                state.firmware_version = st.get("fw") or state.firmware_version
+                logger.info(f"Firmware update complete - board reports {state.firmware_version}")
+                return {"success": True, "version": state.firmware_version}
+            except Exception:
+                if time.time() > deadline:
+                    raise HTTPException(
+                        status_code=504,
+                        detail="Update sent, but the table has not come back online - check it in a minute")
+                await asyncio.sleep(3)
+    finally:
+        execution.observer.suspended = False
+
+# --- Table (board) Wi-Fi management — fw >= v0.1.8 --------------------------
+# Distinct from /api/wifi/* (the host Pi's Wi-Fi): these reconfigure the
+# FluidNC board's own network. Writes can reboot the board; the observer's
+# reconnect/relocate watchdog re-finds it afterwards (by MAC via mDNS).
+
+def _require_board():
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+
+@app.get("/api/board/wifi/status", tags=["settings"])
+async def board_wifi_status():
+    _require_board()
+    try:
+        status = await asyncio.to_thread(state.conn.wifi_status)
+        return {"supported": True, **status}
+    except requests.HTTPError as e:
+        if e.response is not None and e.response.status_code == 404:
+            return {"supported": False}
+        raise HTTPException(status_code=502, detail=f"Wi-Fi status failed: {e}")
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Wi-Fi status failed: {e}")
+
+@app.get("/api/board/wifi/scan", tags=["settings"])
+async def board_wifi_scan(rescan: bool = False):
+    """Async scan: returns {status:'scanning'} until results are ready - poll."""
+    _require_board()
+    try:
+        return await asyncio.to_thread(state.conn.wifi_scan, rescan)
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Wi-Fi scan failed: {e}")
+
+class BoardWifiSaveRequest(BaseModel):
+    ssid: str
+    password: str
+
+@app.post("/api/board/wifi/save", tags=["settings"])
+async def board_wifi_save(request: BoardWifiSaveRequest):
+    """Point the table at a home Wi-Fi network. The table reboots; a lost
+    reply means the reboot raced the response and is treated as success
+    (same as the mobile app / captive portal)."""
+    _require_board()
+    if not request.ssid.strip():
+        raise HTTPException(status_code=400, detail="SSID is required")
+    if not (8 <= len(request.password) <= 64):
+        raise HTTPException(status_code=400, detail="Password must be 8-64 characters")
+    try:
+        result = await asyncio.to_thread(
+            state.conn.wifi_save, request.ssid.strip(), request.password)
+    except Exception:
+        # Reboot raced the reply - the write almost certainly landed.
+        return {"success": True, "rebooting": True}
+    if result.get("status") == "busy":
+        raise HTTPException(status_code=409, detail="The table is busy - stop the current pattern first")
+    if result.get("status") == "error":
+        raise HTTPException(status_code=502, detail=result.get("message") or "The table rejected those credentials")
+    return {"success": True, "rebooting": bool(result.get("reboot"))}
+
+@app.post("/api/board/wifi/standalone", tags=["settings"])
+async def board_wifi_standalone():
+    """Switch the table to standalone hotspot mode ($WiFi/Mode=AP). From home
+    Wi-Fi the table reboots and leaves this network."""
+    _require_board()
+    try:
+        result = await asyncio.to_thread(state.conn.wifi_standalone)
+    except Exception:
+        return {"success": True, "rebooting": True}
+    if result.get("status") == "busy":
+        raise HTTPException(status_code=409, detail="The table is busy - stop the current pattern first")
+    if result.get("status") == "error":
+        raise HTTPException(status_code=502, detail=result.get("message") or "Could not switch to standalone mode")
+    return {"success": True, "rebooting": bool(result.get("reboot"))}
+
+class RotateRequest(BaseModel):
+    theta: float  # absolute target, radians
+
+@app.post("/api/board/rotate", tags=["settings"])
+async def board_rotate(request: RotateRequest):
+    """Jog the arm to an absolute theta at the perimeter (crash-homing
+    orientation alignment). The firmware answers 409 while a previous jog is
+    still finishing — the client retries."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+    try:
+        await asyncio.to_thread(state.conn.goto, request.theta, 1.0)
+        return {"success": True}
+    except requests.HTTPError as e:
+        code = e.response.status_code if e.response is not None else 502
+        raise HTTPException(status_code=code if code in (401, 409) else 502,
+                            detail=f"Rotate rejected: {e}")
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Rotate failed: {e}")
+
+@app.get("/api/board/logs", tags=["settings"])
+async def board_logs(limit: int = 500):
+    """Persistent table log history harvested from the board's /sand_log ring
+    buffer by the observer (outlives board reboots, unlike the ring itself)."""
+    try:
+        with open(execution.BOARD_LOG_FILE, "r", encoding="utf-8", errors="replace") as f:
+            lines = f.read().splitlines()
+    except FileNotFoundError:
+        lines = []
+    return {"lines": lines[-max(1, min(limit, execution.BOARD_LOG_MAX_LINES)):]}
+
+@app.get("/api/board/bootlog", tags=["settings"])
+async def board_bootlog():
+    """The board's boot log ($SS startup log). After a panic it preserves the
+    *previous* boot's log — the on-device crash breadcrumb. Read live."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+    try:
+        text = await asyncio.to_thread(state.conn.get_bootlog)
+        return {"text": text}
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Could not read boot log: {e}")
+
+@app.get("/api/board/coredump", tags=["settings"])
+async def board_coredump():
+    """JSON crash report from the coredump partition, written on any panic
+    (incl. task-WDT hang). {present, task, pc, backtrace, ...}. Read live."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+    try:
+        return await asyncio.to_thread(state.conn.get_coredump)
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Could not read coredump: {e}")
+
+@app.post("/api/board/coredump/erase", tags=["settings"])
+async def board_coredump_erase():
+    """Clear the stored coredump so a fresh crash is unambiguous."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+    try:
+        await asyncio.to_thread(state.conn.erase_coredump)
+        return {"success": True}
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Could not erase coredump: {e}")
+
+@app.post("/api/board/unlock", tags=["settings"])
+async def board_unlock():
+    """Clear a GRBL Alarm state ($X) so the table accepts commands again."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+    try:
+        await asyncio.to_thread(state.conn.run_command, "$X")
+        return {"success": True}
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Unlock failed: {e}")
+
+@app.post("/api/board/restart", tags=["settings"])
+async def board_restart():
+    """Reboot the DLC32 controller (FluidNC) via $Bye. Position is lost, so the
+    table re-homes on the way back up. The observer's reconnect watchdog picks
+    the board back up once it's finished rebooting."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+    # A lost reply just means the reboot raced the "ok" response — the board is
+    # restarting either way, so (like the Wi-Fi endpoints) we treat it as success.
+    await connection_manager.perform_soft_reset()
+    return {"success": True, "rebooting": True}
+
+class BoardPasswordRequest(BaseModel):
+    # 'set' writes $Sand/Password on the board and stores it locally;
+    # 'remove' clears it on the board and locally;
+    # 'save_local' only verifies + stores an existing password on this backend.
+    action: str
+    password: Optional[str] = None
+
+@app.post("/api/board/password", tags=["settings"])
+async def board_password(request: BoardPasswordRequest):
+    """Manage the table's API password ($Sand/Password, fw >= v0.1.11)."""
+    action = request.action
+    password = (request.password or "").strip()
+    if action not in ("set", "remove", "save_local"):
+        raise HTTPException(status_code=400, detail="Unknown action")
+    if action in ("set", "save_local") and not (4 <= len(password) <= 64):
+        raise HTTPException(status_code=400, detail="Password must be 4-64 characters")
+
+    if action == "save_local":
+        # Verify against the board (which may currently be rejecting us).
+        from modules.connection.fluidnc_client import FluidNCClient
+        probe = state.conn or FluidNCClient(connection_manager.board_url())
+        try:
+            ok = await asyncio.to_thread(probe.test_key, password)
+        except Exception as e:
+            raise HTTPException(status_code=502, detail=f"Could not reach the table: {e}")
+        if not ok:
+            raise HTTPException(status_code=401, detail="Wrong password")
+        state.board_api_key = password
+        state.board_locked = False
+        if state.conn:
+            state.conn.api_key = password
+        state.save()
+        return {"success": True}
+
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=409, detail="Not connected to the board")
+    try:
+        resp = await asyncio.to_thread(state.conn.set_password,
+                                       password if action == "set" else "")
+        if "error" in (resp or "").lower():
+            raise HTTPException(status_code=502, detail=f"Board rejected the change: {resp.strip()}")
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Failed to update the table password: {e}")
+    state.board_api_key = password if action == "set" else None
+    state.conn.api_key = state.board_api_key
+    state.board_locked = False
+    state.save()
+    return {"success": True}
+
 @app.post("/api/board/command", tags=["settings"])
 @app.post("/api/board/command", tags=["settings"])
 async def board_command(request: BoardCommandRequest):
 async def board_command(request: BoardCommandRequest):
     """Advanced console: send a $-command to the board and return the recent
     """Advanced console: send a $-command to the board and return the recent
@@ -1050,6 +1328,16 @@ async def get_discovered_tables():
     """
     """
     return {"tables": mdns_discovery.get_tables()}
     return {"tables": mdns_discovery.get_tables()}
 
 
+@app.get("/api/discovered-boards", tags=["multi-table"])
+async def get_discovered_boards():
+    """
+    Get FluidNC controller boards auto-discovered via mDNS (_http._tcp with
+    the firmware's sandtable TXT records) - candidates for /connect. Unlike
+    /api/discovered-tables (peer backends), these are the boards themselves.
+    Returns an empty list when mDNS is unavailable.
+    """
+    return {"boards": mdns_discovery.get_boards()}
+
 @app.get("/api/known-tables", tags=["multi-table"])
 @app.get("/api/known-tables", tags=["multi-table"])
 async def get_known_tables():
 async def get_known_tables():
     """
     """
@@ -1257,8 +1545,10 @@ async def set_homing_config(request: HomingConfigRequest):
 
 
 @app.get("/list_serial_ports")
 @app.get("/list_serial_ports")
 async def list_ports():
 async def list_ports():
-    logger.debug("Listing available serial ports")
-    return await asyncio.to_thread(connection_manager.list_serial_ports)
+    # Legacy route name kept for the frontend/touch-app contract; there are no
+    # serial ports — it returns the board's HTTP URL as the single "port".
+    logger.debug("Listing board URLs")
+    return await asyncio.to_thread(connection_manager.list_board_urls)
 
 
 @app.post("/connect")
 @app.post("/connect")
 async def connect(request: ConnectRequest):
 async def connect(request: ConnectRequest):
@@ -1269,11 +1559,20 @@ async def connect(request: ConnectRequest):
         url = connection_manager._normalize_board_url(request.port or "") or connection_manager.board_url()
         url = connection_manager._normalize_board_url(request.port or "") or connection_manager.board_url()
         state.board_url = url
         state.board_url = url
         state.port = url
         state.port = url
+        state.user_disconnected = False
+        if request.password is not None:
+            state.board_api_key = request.password.strip() or None
         state.save()
         state.save()
-        state.conn = FluidNCClient(url)
+        state.conn = FluidNCClient(url, api_key=state.board_api_key)
         if not state.conn.reachable():
         if not state.conn.reachable():
+            state.board_locked = state.conn.locked
             state.conn = None
             state.conn = None
+            if state.board_locked:
+                raise HTTPException(
+                    status_code=401,
+                    detail="The table is password-protected - enter its password to connect")
             raise HTTPException(status_code=500, detail=f"Board not reachable at {url}")
             raise HTTPException(status_code=500, detail=f"Board not reachable at {url}")
+        state.board_locked = False
         if not await asyncio.to_thread(connection_manager.device_init, False):
         if not await asyncio.to_thread(connection_manager.device_init, False):
             raise HTTPException(status_code=500, detail="Failed to initialize board")
             raise HTTPException(status_code=500, detail="Failed to initialize board")
         logger.info(f"Successfully connected to board at {url}")
         logger.info(f"Successfully connected to board at {url}")
@@ -1287,25 +1586,26 @@ async def connect(request: ConnectRequest):
 @app.post("/disconnect")
 @app.post("/disconnect")
 async def disconnect():
 async def disconnect():
     try:
     try:
+        state.user_disconnected = True  # suppress the observer's auto-reconnect
         state.conn.close()
         state.conn.close()
-        logger.info('Successfully disconnected from serial port')
+        logger.info('Successfully disconnected from board')
         return {"success": True}
         return {"success": True}
     except Exception as e:
     except Exception as e:
-        logger.error(f'Failed to disconnect serial: {str(e)}')
+        logger.error(f'Failed to disconnect from board: {str(e)}')
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
 
 
 @app.post("/restart_connection")
 @app.post("/restart_connection")
 async def restart(request: ConnectRequest):
 async def restart(request: ConnectRequest):
     if not request.port:
     if not request.port:
-        logger.warning("Restart serial request received without port")
+        logger.warning("Restart connection request received without a board address")
         raise HTTPException(status_code=400, detail="No port provided")
         raise HTTPException(status_code=400, detail="No port provided")
 
 
     try:
     try:
-        logger.info(f"Restarting connection on port {request.port}")
+        logger.info(f"Restarting connection to board {request.port}")
         connection_manager.restart_connection()
         connection_manager.restart_connection()
         return {"success": True}
         return {"success": True}
     except Exception as e:
     except Exception as e:
-        logger.error(f"Failed to restart serial on port {request.port}: {str(e)}")
+        logger.error(f"Failed to restart connection to board {request.port}: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
@@ -2022,6 +2322,12 @@ async def serial_status():
     return {
     return {
         "connected": connected,
         "connected": connected,
         "port": port,
         "port": port,
+        # Board's network hostname (e.g. "DWMP") - the table's display name.
+        "hostname": state.board_hostname,
+        # True when the board rejects us with 401 (password-protected).
+        "locked": (state.conn.locked if state.conn else False) or state.board_locked,
+        # Whether a board password is saved on this backend.
+        "has_key": bool(state.board_api_key),
     }
     }
 
 
 @app.post("/pause_execution")
 @app.post("/pause_execution")
@@ -2960,7 +3266,7 @@ async def led_control_page(request: Request):
 @app.get("/api/dw_leds/status")
 @app.get("/api/dw_leds/status")
 async def dw_leds_status():
 async def dw_leds_status():
     """Get DW LED controller status"""
     """Get DW LED controller status"""
-    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+    if not state.led_controller or state.led_provider != "board":
         return {"connected": False, "message": "DW LEDs not configured"}
         return {"connected": False, "message": "DW LEDs not configured"}
 
 
     try:
     try:
@@ -2972,7 +3278,7 @@ async def dw_leds_status():
 @app.post("/api/dw_leds/power")
 @app.post("/api/dw_leds/power")
 async def dw_leds_power(request: dict):
 async def dw_leds_power(request: dict):
     """Control DW LED power (0=off, 1=on, 2=toggle)"""
     """Control DW LED power (0=off, 1=on, 2=toggle)"""
-    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+    if not state.led_controller or state.led_provider != "board":
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
 
 
     state_value = request.get("state", 1)
     state_value = request.get("state", 1)
@@ -2996,7 +3302,7 @@ async def dw_leds_power(request: dict):
 @app.post("/api/dw_leds/brightness")
 @app.post("/api/dw_leds/brightness")
 async def dw_leds_brightness(request: dict):
 async def dw_leds_brightness(request: dict):
     """Set DW LED brightness (0-100)"""
     """Set DW LED brightness (0-100)"""
-    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+    if not state.led_controller or state.led_provider != "board":
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
 
 
     value = request.get("value", 50)
     value = request.get("value", 50)
@@ -3014,7 +3320,7 @@ async def dw_leds_brightness(request: dict):
 @app.post("/api/dw_leds/color")
 @app.post("/api/dw_leds/color")
 async def dw_leds_color(request: dict):
 async def dw_leds_color(request: dict):
     """Set solid color (manual UI control - always powers on LEDs)"""
     """Set solid color (manual UI control - always powers on LEDs)"""
-    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+    if not state.led_controller or state.led_provider != "board":
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
 
 
     # Accept both formats: {"r": 255, "g": 0, "b": 0} or {"color": [255, 0, 0]}
     # Accept both formats: {"r": 255, "g": 0, "b": 0} or {"color": [255, 0, 0]}
@@ -3045,7 +3351,7 @@ async def dw_leds_color(request: dict):
 @app.post("/api/dw_leds/colors")
 @app.post("/api/dw_leds/colors")
 async def dw_leds_colors(request: dict):
 async def dw_leds_colors(request: dict):
     """Set effect colors (color1, color2, color3) - manual UI control - always powers on LEDs"""
     """Set effect colors (color1, color2, color3) - manual UI control - always powers on LEDs"""
-    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+    if not state.led_controller or state.led_provider != "board":
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
 
 
     # Parse colors from request
     # Parse colors from request
@@ -3092,7 +3398,7 @@ async def dw_leds_colors(request: dict):
 @app.get("/api/dw_leds/effects")
 @app.get("/api/dw_leds/effects")
 async def dw_leds_effects():
 async def dw_leds_effects():
     """Get list of available effects"""
     """Get list of available effects"""
-    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+    if not state.led_controller or state.led_provider != "board":
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
 
 
     try:
     try:
@@ -3114,7 +3420,7 @@ async def dw_leds_effects():
 @app.get("/api/dw_leds/palettes")
 @app.get("/api/dw_leds/palettes")
 async def dw_leds_palettes():
 async def dw_leds_palettes():
     """Get list of available palettes"""
     """Get list of available palettes"""
-    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+    if not state.led_controller or state.led_provider != "board":
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
 
 
     try:
     try:
@@ -3133,7 +3439,7 @@ async def dw_leds_palettes():
 @app.post("/api/dw_leds/effect")
 @app.post("/api/dw_leds/effect")
 async def dw_leds_effect(request: dict):
 async def dw_leds_effect(request: dict):
     """Set effect by ID (manual UI control - always powers on LEDs)"""
     """Set effect by ID (manual UI control - always powers on LEDs)"""
-    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+    if not state.led_controller or state.led_provider != "board":
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
 
 
     effect_id = request.get("effect_id", 0)
     effect_id = request.get("effect_id", 0)
@@ -3155,7 +3461,7 @@ async def dw_leds_effect(request: dict):
 @app.post("/api/dw_leds/palette")
 @app.post("/api/dw_leds/palette")
 async def dw_leds_palette(request: dict):
 async def dw_leds_palette(request: dict):
     """Set palette by ID (manual UI control - always powers on LEDs)"""
     """Set palette by ID (manual UI control - always powers on LEDs)"""
-    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+    if not state.led_controller or state.led_provider != "board":
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
 
 
     palette_id = request.get("palette_id", 0)
     palette_id = request.get("palette_id", 0)
@@ -3175,7 +3481,7 @@ async def dw_leds_palette(request: dict):
 @app.post("/api/dw_leds/speed")
 @app.post("/api/dw_leds/speed")
 async def dw_leds_speed(request: dict):
 async def dw_leds_speed(request: dict):
     """Set effect speed (0-255)"""
     """Set effect speed (0-255)"""
-    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+    if not state.led_controller or state.led_provider != "board":
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
 
 
     value = request.get("speed", 128)
     value = request.get("speed", 128)
@@ -3210,7 +3516,7 @@ async def dw_leds_ball(request: dict):
 @app.post("/api/dw_leds/intensity")
 @app.post("/api/dw_leds/intensity")
 async def dw_leds_intensity(request: dict):
 async def dw_leds_intensity(request: dict):
     """Set effect intensity (0-255)"""
     """Set effect intensity (0-255)"""
-    if not state.led_controller or state.led_provider not in ("dw_leds", "board"):
+    if not state.led_controller or state.led_provider != "board":
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
         raise HTTPException(status_code=400, detail="DW LEDs not configured")
 
 
     value = request.get("intensity", 128)
     value = request.get("intensity", 128)

+ 26 - 4
modules/connection/connection_manager.py

@@ -6,7 +6,7 @@ The board firmware now owns kinematics, `.thr` playback, progress and homing
 uses to talk to it: it builds a FluidNCClient, exposes the same public functions
 uses to talk to it: it builds a FluidNCClient, exposes the same public functions
 the rest of the app already calls (connect_device, device_init, home,
 the rest of the app already calls (connect_device, device_init, home,
 check_idle_async, is_machine_idle, update_machine_position, perform_soft_reset,
 check_idle_async, is_machine_idle, update_machine_position, perform_soft_reset,
-restart_connection, list_serial_ports), and keeps the LED hooks intact. The old
+restart_connection, list_board_urls), and keeps the LED hooks intact. The old
 serial/websocket GRBL transport, coordinate streaming, and the $H/$J homing
 serial/websocket GRBL transport, coordinate streaming, and the $H/$J homing
 handshake are gone — the firmware does all of that itself.
 handshake are gone — the firmware does all of that itself.
 """
 """
@@ -78,6 +78,17 @@ def apply_status(st: dict) -> None:
         state.current_rho = st["rho"]
         state.current_rho = st["rho"]
     if st.get("feed"):
     if st.get("feed"):
         state.speed = st["feed"]
         state.speed = st["feed"]
+    # Health telemetry (present on fw that reports it; left unchanged otherwise).
+    for src, dst in (
+        ("heap", "board_heap"),
+        ("heap_min", "board_heap_min"),
+        ("heap_largest", "board_heap_largest"),
+        ("last_reset", "board_last_reset"),
+        ("sd_ok", "board_sd_ok"),
+        ("uptime", "board_uptime"),
+    ):
+        if src in st:
+            setattr(state, dst, st[src])
 
 
 
 
 def poll_status_once() -> dict | None:
 def poll_status_once() -> dict | None:
@@ -97,7 +108,7 @@ def poll_status_once() -> dict | None:
 # Connection lifecycle
 # Connection lifecycle
 ###############################################################################
 ###############################################################################
 
 
-def list_serial_ports():
+def list_board_urls():
     """
     """
     There are no serial ports anymore — the board is reached over HTTP. Return
     There are no serial ports anymore — the board is reached over HTTP. Return
     the board URL as the single selectable "port" so the frontend's connection
     the board URL as the single selectable "port" so the frontend's connection
@@ -152,6 +163,11 @@ def device_init(homing=True):
 
 
     state.firmware_type = "fluidnc"
     state.firmware_type = "fluidnc"
     state.firmware_version = st.get("fw") or state.firmware_version or "fluidnc"
     state.firmware_version = st.get("fw") or state.firmware_version or "fluidnc"
+    state.board_hostname = st.get("hostname") or state.board_hostname
+    mac = (st.get("mac") or "").lower() or None
+    if mac and mac != state.board_mac:
+        state.board_mac = mac
+        state.save_debounced()
     apply_status(st)
     apply_status(st)
     _sync_homing_settings()
     _sync_homing_settings()
 
 
@@ -185,11 +201,17 @@ def connect_device(homing=True):
 
 
     url = board_url()
     url = board_url()
     logger.info(f"Connecting to FluidNC board at {url}")
     logger.info(f"Connecting to FluidNC board at {url}")
-    state.conn = FluidNCClient(url)
+    state.user_disconnected = False
+    state.conn = FluidNCClient(url, api_key=state.board_api_key)
     if not state.conn.reachable():
     if not state.conn.reachable():
-        logger.error(f"Board not reachable at {url}")
+        state.board_locked = state.conn.locked
+        if state.board_locked:
+            logger.error(f"Board at {url} is password-protected (401) - set its password in Settings")
+        else:
+            logger.error(f"Board not reachable at {url}")
         state.conn = None
         state.conn = None
     else:
     else:
+        state.board_locked = False
         state.port = url
         state.port = url
         logger.info(f"Connected to board at {url}")
         logger.info(f"Connected to board at {url}")
         device_init(homing)
         device_init(homing)

+ 161 - 4
modules/connection/fluidnc_client.py

@@ -17,25 +17,96 @@ Design notes:
 """
 """
 
 
 import logging
 import logging
+import random
+import socket
+import time
+from urllib.parse import urlparse
+
 import requests
 import requests
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# The board's single-threaded web server answers ``503 busy: low memory`` for
+# every route except the status/stop/pause/resume lifeline when free heap drops
+# below ~10 KB (firmware API.md, load-shedding). These transient sheds — often
+# triggered by an app's launch-burst of concurrent reads — resolve in a few
+# seconds, so idempotent GETs back off and retry rather than failing hard.
+_RETRY_503_ATTEMPTS = 3      # total tries (1 initial + 2 retries)
+_RETRY_503_BASE = 0.3        # seconds; base for exponential backoff + jitter
+
+
+def _pin_ipv4(base_url: str) -> str:
+    """Rewrite a hostname base URL to its numeric IPv4 address.
+
+    The boards publish no AAAA record over mDNS, and a dual-stack getaddrinfo
+    for a ``.local`` name stalls ~5s waiting for the IPv6 answer — longer than
+    the 3s status timeout, so every poll would die in name resolution. Resolve
+    the A record once here (milliseconds) and pin it; clients are rebuilt on
+    every connect/relocate, so a DHCP move is still handled by the watchdog.
+    Unresolvable hosts pass through unchanged for reachable() to report.
+    """
+    parsed = urlparse(base_url)
+    host = parsed.hostname or ""
+    if not host:
+        return base_url
+    try:
+        socket.inet_aton(host)
+        return base_url  # already a numeric IPv4 address
+    except OSError:
+        pass
+    try:
+        info = socket.getaddrinfo(host, parsed.port or 80,
+                                  socket.AF_INET, socket.SOCK_STREAM)
+    except OSError:
+        return base_url
+    address = info[0][4][0]
+    port = f":{parsed.port}" if parsed.port else ""
+    return f"{parsed.scheme}://{address}{port}"
+
 
 
 class FluidNCClient:
 class FluidNCClient:
     """Stateless HTTP handle to one FluidNC sand-table board."""
     """Stateless HTTP handle to one FluidNC sand-table board."""
 
 
-    def __init__(self, base_url: str, timeout: float = 10.0):
+    def __init__(self, base_url: str, timeout: float = 10.0, api_key: str | None = None):
         # base_url like "http://192.168.68.160"
         # base_url like "http://192.168.68.160"
-        self.base_url = base_url.rstrip("/")
+        self.base_url = _pin_ipv4(base_url.rstrip("/"))
         self.timeout = timeout
         self.timeout = timeout
+        # $Sand/Password key (fw >= v0.1.11), sent as X-Sand-Key on every request.
+        self.api_key = api_key or None
         # Mirrors the BaseConnection contract main.py relies on (is_connected/close).
         # Mirrors the BaseConnection contract main.py relies on (is_connected/close).
         self._connected = False
         self._connected = False
+        # True when the board rejected us with 401 (locked, wrong/missing key).
+        self.locked = False
 
 
     # -- low-level -----------------------------------------------------------
     # -- low-level -----------------------------------------------------------
 
 
+    def _headers(self) -> dict:
+        return {"X-Sand-Key": self.api_key} if self.api_key else {}
+
+    def _send_with_retry(self, method: str, url: str, **kwargs):
+        """Issue an HTTP request, retrying on the firmware's ``503 low memory``.
+
+        Only used for idempotent GETs (never for uploads/OTA/wifi writes, which
+        are non-retryable). Backs off with exponential + jittered delay; the
+        final 503 is returned to the caller like any other response.
+        """
+        send = getattr(requests, method.lower())  # requests.get / requests.post
+        for attempt in range(_RETRY_503_ATTEMPTS):
+            r = send(url, **kwargs)
+            if r.status_code != 503 or attempt == _RETRY_503_ATTEMPTS - 1:
+                return r
+            delay = _RETRY_503_BASE * (2 ** attempt) + random.uniform(0, _RETRY_503_BASE)
+            logger.debug(
+                f"Board 503 (low memory) on {url}; retry {attempt + 1}/"
+                f"{_RETRY_503_ATTEMPTS - 1} in {delay:.2f}s")
+            time.sleep(delay)
+        return r  # unreachable, but keeps type-checkers happy
+
     def _get(self, path: str, params: dict | None = None, timeout: float | None = None):
     def _get(self, path: str, params: dict | None = None, timeout: float | None = None):
-        r = requests.get(self.base_url + path, params=params, timeout=timeout or self.timeout)
+        r = self._send_with_retry("GET", self.base_url + path, params=params,
+                                  timeout=timeout or self.timeout, headers=self._headers())
+        if r.status_code == 401:
+            self.locked = True
         r.raise_for_status()
         r.raise_for_status()
         return r
         return r
 
 
@@ -52,17 +123,52 @@ class FluidNCClient:
         try:
         try:
             self._get("/sand_status", timeout=3.0)
             self._get("/sand_status", timeout=3.0)
             self._connected = True
             self._connected = True
+            self.locked = False
         except Exception as e:
         except Exception as e:
             logger.debug(f"Board unreachable at {self.base_url}: {e}")
             logger.debug(f"Board unreachable at {self.base_url}: {e}")
             self._connected = False
             self._connected = False
         return self._connected
         return self._connected
 
 
+    # -- API password ($Sand/Password, fw >= v0.1.11) --------------------------
+
+    def test_key(self, key: str | None) -> bool:
+        """Probe whether `key` unlocks the board (a cheap $G through /command).
+
+        True = accepted (or the board isn't locked); False = 401 rejected.
+        Other errors (unreachable, etc.) propagate.
+        """
+        headers = {"X-Sand-Key": key} if key else {}
+        r = self._send_with_retry("GET", self.base_url + "/command", params={"plain": "$G"},
+                                  timeout=6.0, headers=headers)
+        if r.status_code == 401:
+            return False
+        r.raise_for_status()
+        return True
+
+    def set_password(self, password: str) -> str:
+        """Set (non-empty) or remove (empty) the board's API password."""
+        return self.run_command(f"$Sand/Password={password or ''}")
+
     # -- reads ---------------------------------------------------------------
     # -- reads ---------------------------------------------------------------
 
 
     def get_status(self) -> dict:
     def get_status(self) -> dict:
         """The board's /sand_status object (schema in API.md)."""
         """The board's /sand_status object (schema in API.md)."""
         return self._get("/sand_status", timeout=3.0).json()
         return self._get("/sand_status", timeout=3.0).json()
 
 
+    def get_bootlog(self) -> str:
+        """Plain-text boot log ($SS startup log). After a panic reset it still
+        holds the *previous* boot's log — the on-device crash breadcrumb."""
+        return self._get("/sand_bootlog", timeout=6.0).text
+
+    def get_coredump(self) -> dict:
+        """JSON crash report from the coredump flash partition, written on any
+        panic (incl. task-WDT hang). {present, task, pc, backtrace, ...}."""
+        return self._get("/sand_coredump", timeout=6.0).json()
+
+    def erase_coredump(self) -> None:
+        """Clear the stored coredump (?erase=1)."""
+        self._get("/sand_coredump", params={"erase": "1"}, timeout=6.0)
+
     def get_settings(self) -> dict:
     def get_settings(self) -> dict:
         """Flat string map of board settings (keys like 'Sand/HomingMode')."""
         """Flat string map of board settings (keys like 'Sand/HomingMode')."""
         return self._get("/sand_settings").json()
         return self._get("/sand_settings").json()
@@ -79,7 +185,8 @@ class FluidNCClient:
 
 
     def file_exists(self, sd_path: str) -> bool:
     def file_exists(self, sd_path: str) -> bool:
         try:
         try:
-            r = requests.get(self.base_url + "/sd" + sd_path, stream=True, timeout=5.0)
+            r = self._send_with_retry("GET", self.base_url + "/sd" + sd_path, stream=True,
+                                      timeout=5.0, headers=self._headers())
             ok = r.status_code == 200
             ok = r.status_code == 200
             r.close()
             r.close()
             return ok
             return ok
@@ -179,6 +286,55 @@ class FluidNCClient:
         """Reboot the controller (loses position) — host re-homes afterward."""
         """Reboot the controller (loses position) — host re-homes afterward."""
         return self.run_command("$Bye")
         return self.run_command("$Bye")
 
 
+    # -- board Wi-Fi (fw >= v0.1.8) -------------------------------------------
+
+    def wifi_status(self) -> dict:
+        """{mode: sta|fallback|standalone, sta_ssid, ap_ssid, fail}. Older
+        firmware 404s (propagates as HTTPError)."""
+        return self._get("/wifi_status", timeout=6.0).json()
+
+    def wifi_scan(self, rescan: bool = False) -> dict:
+        """{status:'scanning'} while the async scan runs; then {status:'ok', aps:[...]}"""
+        return self._get("/wifi_scan", params={"rescan": "1"} if rescan else None,
+                         timeout=8.0).json()
+
+    def _post_wifi(self, path: str, form: dict | None = None) -> dict:
+        """Wi-Fi writes are form-urlencoded POSTs answering {status, reboot, message?}.
+        'busy' = idle-gated (boot auto-home / running pattern); on 'ok' with
+        reboot the table restarts ~0.5s later and the link drops."""
+        r = requests.post(self.base_url + path, data=form or {}, timeout=10.0,
+                          headers=self._headers())
+        return r.json()
+
+    def wifi_save(self, ssid: str, password: str) -> dict:
+        return self._post_wifi("/wifi_save", {"ssid": ssid, "password": password})
+
+    def wifi_standalone(self) -> dict:
+        return self._post_wifi("/wifi_standalone")
+
+    # -- firmware OTA (/updatefw) ---------------------------------------------
+
+    def update_probe(self) -> dict:
+        """Is the board willing to take an OTA right now? Returns the parsed
+        JSON on any HTTP status ('ready'/'busy'; legacy firmware returns an
+        older shape = too old for OTA)."""
+        r = self._send_with_retry("GET", self.base_url + "/updatefw", timeout=6.0,
+                                  headers=self._headers())
+        return r.json()
+
+    def upload_firmware(self, image: bytes) -> dict:
+        """Flash an app image over OTA. Same multipart shape as /upload
+        (a 'firmware.binS' size field + the binary part). On 'ok' the board
+        reboots ~1s later; poll get_status() until it's back."""
+        r = requests.post(
+            self.base_url + "/updatefw",
+            data={"firmware.binS": str(len(image))},
+            files={"firmware.bin": ("firmware.bin", image, "application/octet-stream")},
+            timeout=180.0,
+            headers=self._headers(),
+        )
+        return r.json()
+
     # -- SD file management (ESP3D /upload protocol) -------------------------
     # -- SD file management (ESP3D /upload protocol) -------------------------
 
 
     def upload_file(self, sd_path: str, data: bytes, directory: str) -> dict:
     def upload_file(self, sd_path: str, data: bytes, directory: str) -> dict:
@@ -195,6 +351,7 @@ class FluidNCClient:
             data=form,
             data=form,
             files=files,
             files=files,
             timeout=30.0,
             timeout=30.0,
+            headers=self._headers(),
         )
         )
         r.raise_for_status()
         r.raise_for_status()
         return r.json() if r.text else {}
         return r.json() if r.text else {}

+ 138 - 23
modules/core/board_settings.py

@@ -23,6 +23,7 @@ settings save (host enforcement still works without the board's copy).
 """
 """
 
 
 import logging
 import logging
+import os
 import threading
 import threading
 import time
 import time
 
 
@@ -269,29 +270,34 @@ def apply_autostart(update: dict, conn=None) -> None:
 # mirrored (and the selected playlist's patterns are ensured on the board).
 # mirrored (and the selected playlist's patterns are ensured on the board).
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 
 
-def _playlist_sd_content(files: list) -> str:
-    from modules.core.pattern_manager import _to_sd_path
-    lines = [_to_sd_path(f) for f in files or []]
+def _playlist_sd_content(files: list, resolve) -> str:
+    lines = [resolve(f) for f in files or []]
     return "\n".join(lines) + "\n"
     return "\n".join(lines) + "\n"
 
 
 
 
-def mirror_playlist(name: str, files: list, conn=None, ensure_patterns: bool = False) -> None:
-    """Write a host playlist to the board as /playlists/<name>.txt (best-effort)."""
+def mirror_playlist(name: str, files: list, conn=None, ensure_patterns: bool = False,
+                    resolve=None) -> None:
+    """Write a host playlist to the board as /playlists/<name>.txt (best-effort).
+
+    `resolve` maps host entries to SD paths (make_sd_path_resolver); pass the
+    caller's resolver when patterns are ensured separately so the playlist
+    lines and the uploads land on the same paths."""
+    from modules.core.pattern_manager import _ensure_on_board, make_sd_path_resolver
     conn = conn or state.conn
     conn = conn or state.conn
     if not conn:
     if not conn:
         return
         return
+    resolve = resolve or make_sd_path_resolver(conn)
     try:
     try:
         sd_path = f"/playlists/{name}.txt"
         sd_path = f"/playlists/{name}.txt"
-        data = _playlist_sd_content(files).encode("utf-8")
+        data = _playlist_sd_content(files, resolve).encode("utf-8")
         conn.upload_file(sd_path, data, "/playlists")
         conn.upload_file(sd_path, data, "/playlists")
         logger.info(f"Mirrored playlist '{name}' to board ({len(files or [])} patterns)")
         logger.info(f"Mirrored playlist '{name}' to board ({len(files or [])} patterns)")
     except Exception as e:
     except Exception as e:
         logger.warning(f"Could not mirror playlist '{name}' to board: {e}")
         logger.warning(f"Could not mirror playlist '{name}' to board: {e}")
         return
         return
     if ensure_patterns:
     if ensure_patterns:
-        from modules.core.pattern_manager import _ensure_on_board, _to_sd_path
         for f in files or []:
         for f in files or []:
-            _ensure_on_board(f, _to_sd_path(f))
+            _ensure_on_board(f, resolve(f))
 
 
 
 
 def mirror_playlist_async(name: str, files: list) -> None:
 def mirror_playlist_async(name: str, files: list) -> None:
@@ -315,20 +321,125 @@ def unmirror_playlist(name: str, conn=None) -> None:
         logger.debug(f"Could not remove playlist '{name}' from board: {e}")
         logger.debug(f"Could not remove playlist '{name}' from board: {e}")
 
 
 
 
-def mirror_all_playlists(conn=None) -> None:
-    """Mirror every host playlist to the board (run on connect, best-effort)."""
+def _from_playlist_sd_line(line: str) -> str:
+    """Invert _to_sd_path for playlist lines: '/patterns/x.thr' -> 'x.thr'
+    (host playlist entries are relative to the patterns/ dir)."""
+    p = line.replace("\\", "/").strip()
+    if p.startswith("/sd/"):
+        p = p[3:]
+    if p.startswith("/patterns/"):
+        return p[len("/patterns/"):]
+    return p.rsplit("/", 1)[-1]
+
+
+def _make_host_path_resolver():
+    """Board SD paths don't always mirror the host patterns/ tree: files can
+    reach the board via the mobile app or a copied SD card while the host holds
+    the same pattern elsewhere (e.g. under custom_patterns/). Adopted playlist
+    entries must point at real host files or previews and play both 404.
+
+    Returns a resolve(rel) -> rel function: exact path if it exists, else a
+    unique catalog suffix match, else a unique basename match, else the raw
+    path unchanged (the pattern genuinely isn't on the host). The catalog is
+    scanned lazily once per adoption pass and results are memoized, so the
+    resolution is deterministic and repeat adoptions stay stable."""
+    from modules.core import pattern_manager
+    cache: dict = {}
+    catalog: list = []
+    scanned = False
+
+    def resolve(rel: str) -> str:
+        nonlocal catalog, scanned
+        if rel in cache:
+            return cache[rel]
+        result = rel
+        if not os.path.exists(os.path.join(pattern_manager.THETA_RHO_DIR, rel)):
+            if not scanned:
+                scanned = True
+                try:
+                    catalog = pattern_manager.list_theta_rho_files()
+                except Exception as e:
+                    logger.warning(f"Could not scan pattern catalog: {e}")
+            suffix = "/" + rel
+            matches = [f for f in catalog if f.endswith(suffix)]
+            if not matches:
+                basename = rel.rsplit("/", 1)[-1]
+                matches = [f for f in catalog if f.rsplit("/", 1)[-1] == basename]
+            if len(matches) == 1:
+                result = matches[0]
+        cache[rel] = result
+        return result
+
+    return resolve
+
+
+def adopt_board_playlists(conn=None) -> None:
+    """Read the board's /playlists/*.txt into the host catalog.
+
+    The board is the source of truth for playlists (mobile apps edit it
+    directly); the host only WRITES on deliberate user actions (playlist CRUD,
+    pressing play, selecting an autostart playlist) — never automatically.
+    Board copies win for names that exist on the board; host-only playlists
+    are kept (they reach the board the next time the user edits or plays
+    them). Reads only — no SD writes, no flash wear.
+    """
     from modules.core import playlist_manager
     from modules.core import playlist_manager
     conn = conn or state.conn
     conn = conn or state.conn
     if not conn:
     if not conn:
         return
         return
     try:
     try:
-        playlists = playlist_manager.load_playlists()
+        names = conn.list_playlists()
     except Exception as e:
     except Exception as e:
-        logger.warning(f"Could not load playlists for mirroring: {e}")
+        logger.warning(f"Could not list board playlists: {e}")
         return
         return
-    for name, entry in playlists.items():
-        files = entry.get("files", entry) if isinstance(entry, dict) else entry
-        mirror_playlist(name, files, conn=conn)
+    if not isinstance(names, list):
+        return
+    try:
+        playlists = playlist_manager.load_playlists()
+    except Exception:
+        playlists = {}
+    changed = []
+    resolve_host_path = _make_host_path_resolver()
+    for board_name in names:
+        fname = board_name if board_name.endswith(".txt") else f"{board_name}.txt"
+        name = fname[:-4]
+        if not name:
+            continue
+        try:
+            data = conn.fetch_file(f"/playlists/{fname}")
+        except Exception:
+            continue  # listed but unreadable (e.g. deleted mid-scan) — skip
+        files = [resolve_host_path(_from_playlist_sd_line(l))
+                 for l in data.decode("utf-8", "replace").splitlines() if l.strip()]
+        entry = playlists.get(name)
+        current = entry.get("files", entry) if isinstance(entry, dict) else entry
+        if current != files:
+            playlists[name] = {**entry, "files": files} if isinstance(entry, dict) else files
+            changed.append(name)
+    if changed:
+        playlist_manager.save_playlists(playlists)
+        logger.info(f"Adopted board playlists: {', '.join(changed)}")
+
+
+def adopt_auto_home(settings_map: dict) -> None:
+    """Adopt the board's $Playlist/AutoHome cadence (0 = disabled). The host
+    pushes it only when the user edits the setting in the web UI."""
+    raw = settings_map.get("Playlist/AutoHome")
+    if raw is None:
+        return
+    try:
+        every = int(float(raw))
+    except (TypeError, ValueError):
+        return
+    enabled = every > 0
+    if enabled == state.auto_home_enabled and (
+            not enabled or every == state.auto_home_after_patterns):
+        return
+    state.auto_home_enabled = enabled
+    if enabled:
+        state.auto_home_after_patterns = every
+    state.save_debounced()
+    logger.info(f"Adopted board auto-home cadence: {every or 'disabled'}")
 
 
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
@@ -371,7 +482,13 @@ def push_custom_clears_async() -> None:
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 
 
 def sync_on_connect(conn=None) -> None:
 def sync_on_connect(conn=None) -> None:
-    """Clock push + Still Sands adopt + AutoHome push + playlist mirror."""
+    """Connect-time reconciliation: clock push + adopt board-owned state.
+
+    Read-only toward the board's SD/NVS (except the clock, which is RAM/RTC):
+    Still Sands, auto-home cadence and playlists are ADOPTED from the board.
+    The host never auto-pushes content — uploads happen only on deliberate
+    user actions (play, playlist CRUD, autostart selection, settings edits).
+    """
     conn = conn or state.conn
     conn = conn or state.conn
     if not conn:
     if not conn:
         return
         return
@@ -380,11 +497,9 @@ def sync_on_connect(conn=None) -> None:
     except Exception as e:
     except Exception as e:
         logger.warning(f"Board clock sync failed: {e}")
         logger.warning(f"Board clock sync failed: {e}")
     try:
     try:
-        adopt_still_sands(conn.get_settings())
-    except Exception as e:
-        logger.warning(f"Could not adopt Still Sands settings from board: {e}")
-    try:
-        push_auto_home(conn)
+        settings_map = conn.get_settings()
+        adopt_still_sands(settings_map)
+        adopt_auto_home(settings_map)
     except Exception as e:
     except Exception as e:
-        logger.warning(f"Could not push auto-home cadence to board: {e}")
-    mirror_all_playlists(conn)
+        logger.warning(f"Could not adopt board settings: {e}")
+    adopt_board_playlists(conn)

+ 43 - 12
modules/core/cache_manager.py

@@ -32,11 +32,40 @@ def _get_metadata_cache_lock() -> asyncio.Lock:
 
 
 # Constants
 # Constants
 CACHE_DIR = os.path.join(THETA_RHO_DIR, "cached_images")
 CACHE_DIR = os.path.join(THETA_RHO_DIR, "cached_images")
-METADATA_CACHE_FILE = "metadata_cache.json"  # Now in root directory
+
+# Anchor the metadata cache to the project root (this module lives at
+# modules/core/, so parents[2] is the repo root), NOT the process CWD.
+# A bare relative path meant a service launched from a different working
+# directory would read an empty cache and needlessly regenerate every
+# ~1080-pattern metadata entry on each boot.
+_PROJECT_ROOT = Path(__file__).resolve().parents[2]
+METADATA_CACHE_FILE = str(_PROJECT_ROOT / "metadata_cache.json")
 
 
 # Cache schema version - increment when structure changes
 # Cache schema version - increment when structure changes
 CACHE_SCHEMA_VERSION = 1
 CACHE_SCHEMA_VERSION = 1
 
 
+
+def _detect_low_power() -> bool:
+    """True on resource-constrained hosts (Raspberry Pi), where cache
+    generation is throttled to avoid starving the motion loop.
+
+    Override with the LOW_POWER env var (1/0). Otherwise auto-detect a Pi via
+    /proc/device-tree/model — deliberately NOT platform.machine(), because
+    Apple-Silicon Macs also report 'arm64' and must not be throttled.
+    """
+    override = os.environ.get("LOW_POWER")
+    if override is not None:
+        return override.strip().lower() in ("1", "true", "yes", "on")
+    try:
+        with open("/proc/device-tree/model", "rb") as f:
+            return b"raspberry pi" in f.read().lower()
+    except OSError:
+        return False
+
+
+# Evaluated once at import; throttles metadata generation only on a Pi.
+LOW_POWER = _detect_low_power()
+
 # Expected cache schema structure
 # Expected cache schema structure
 EXPECTED_CACHE_SCHEMA = {
 EXPECTED_CACHE_SCHEMA = {
     'version': CACHE_SCHEMA_VERSION,
     'version': CACHE_SCHEMA_VERSION,
@@ -644,10 +673,11 @@ async def generate_metadata_cache():
             "error": None
             "error": None
         })
         })
         
         
-        logger.info(f"Generating metadata cache for {total_files} new files ({skipped_files} files already cached)...")
-        
-        # Process in smaller batches for Pi Zero 2 W
-        batch_size = 3  # Reduced from 5
+        # On a Pi, small batches + inter-batch sleeps keep the motion loop
+        # responsive; on a normal host they only make a full pass take a
+        # needless minute, so a session that stops early leaves the cache
+        # partial and re-shows the overlay next boot. Skip the throttle there.
+        batch_size = 3 if LOW_POWER else 50
         successful = 0
         successful = 0
         for i in range(0, total_files, batch_size):
         for i in range(0, total_files, batch_size):
             batch = files_to_process[i:i + batch_size]
             batch = files_to_process[i:i + batch_size]
@@ -671,8 +701,9 @@ async def generate_metadata_cache():
                         successful += 1
                         successful += 1
                         logger.debug(f"Generated metadata for {file_name}")
                         logger.debug(f"Generated metadata for {file_name}")
 
 
-                    # Small delay to reduce I/O pressure
-                    await asyncio.sleep(0.05)
+                    # Small delay to reduce I/O pressure on the Pi only.
+                    if LOW_POWER:
+                        await asyncio.sleep(0.05)
 
 
                 except Exception as e:
                 except Exception as e:
                     logger.error(f"Failed to generate metadata for {file_name}: {str(e)}")
                     logger.error(f"Failed to generate metadata for {file_name}: {str(e)}")
@@ -680,16 +711,16 @@ async def generate_metadata_cache():
             # One cache-file rewrite per batch, so progress survives restarts
             # One cache-file rewrite per batch, so progress survives restarts
             # without hammering the disk once per pattern.
             # without hammering the disk once per pattern.
             await cache_pattern_metadata_batch(batch_entries)
             await cache_pattern_metadata_batch(batch_entries)
-            
+
             # Update progress
             # Update progress
             cache_progress["processed_files"] = min(i + batch_size, total_files)
             cache_progress["processed_files"] = min(i + batch_size, total_files)
-            
+
             # Log progress
             # Log progress
             progress = min(i + batch_size, total_files)
             progress = min(i + batch_size, total_files)
             logger.info(f"Metadata cache generation progress: {progress}/{total_files} files processed")
             logger.info(f"Metadata cache generation progress: {progress}/{total_files} files processed")
-            
-            # Delay between batches for system recovery
-            if i + batch_size < total_files:
+
+            # Delay between batches for system recovery (Pi only).
+            if LOW_POWER and i + batch_size < total_files:
                 await asyncio.sleep(0.3)
                 await asyncio.sleep(0.3)
         
         
         logger.info(f"Metadata cache generation completed: {successful}/{total_files} patterns cached successfully, {skipped_files} patterns skipped (already cached)")
         logger.info(f"Metadata cache generation completed: {successful}/{total_files} patterns cached successfully, {skipped_files} patterns skipped (already cached)")

+ 241 - 7
modules/core/execution.py

@@ -32,6 +32,62 @@ from modules.core.state import state
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# ---------------------------------------------------------------------------
+# Board log harvesting (/sand_log)
+#
+# The board keeps only a small RAM ring buffer (~8 KB) that is lost on reboot.
+# The observer periodically pulls it and merges new lines (keyed by the
+# "[+<uptime>]" prefix) into a persistent host-side file, so users get table
+# history that outlives reboots — same idea as the mobile app's table log.
+# ---------------------------------------------------------------------------
+
+BOARD_LOG_FILE = "board_log.txt"
+BOARD_LOG_MAX_LINES = 4000
+BOARD_LOG_HARVEST_EVERY = 300.0  # seconds
+
+
+def _log_uptime(line: str) -> Optional[float]:
+    """'[+1104] [MSG:...]' -> 1104.0; None for lines without the prefix."""
+    if not line.startswith("[+"):
+        return None
+    end = line.find("]")
+    if end < 2:
+        return None
+    try:
+        return float(line[2:end])
+    except ValueError:
+        return None
+
+
+def merge_board_log(fetched: list[str], last_uptime: float, last_count: int) -> tuple[list[str], float, int, bool]:
+    """Return (new_lines, new_last_uptime, new_last_count, restarted).
+
+    Lines strictly newer than last_uptime are new; at exactly last_uptime we
+    skip the `last_count` occurrences already stored (the ring can hold several
+    lines within the same second). A drop in max uptime means the board
+    rebooted — everything is new again.
+    """
+    parsed = [(u, ln) for ln in fetched if ln.strip() and (u := _log_uptime(ln)) is not None]
+    if not parsed:
+        return [], last_uptime, last_count, False
+    max_uptime = max(u for u, _ in parsed)
+    restarted = max_uptime < last_uptime
+    if restarted:
+        last_uptime, last_count = -1.0, 0
+
+    new_lines: list[str] = []
+    seen_at_last = 0
+    for u, ln in parsed:
+        if u > last_uptime:
+            new_lines.append(ln)
+        elif u == last_uptime:
+            seen_at_last += 1
+            if seen_at_last > last_count:
+                new_lines.append(ln)
+    new_count = sum(1 for u, _ in parsed if u == max_uptime)
+    return new_lines, max_uptime, new_count, restarted
+
+
 # Firmware clear modes; legacy host values are mapped onto them.
 # Firmware clear modes; legacy host values are mapped onto them.
 CLEAR_MODES = ("none", "adaptive", "in", "out", "sideway", "random")
 CLEAR_MODES = ("none", "adaptive", "in", "out", "sideway", "random")
 _LEGACY_CLEAR = {
 _LEGACY_CLEAR = {
@@ -122,9 +178,11 @@ async def run_pattern(file_path: str, clear_pattern: str = "none") -> None:
     aborts any current job first)."""
     aborts any current job first)."""
     global current_run
     global current_run
     conn = _require_conn()
     conn = _require_conn()
-    from modules.core.pattern_manager import _ensure_on_board, _to_sd_path
+    from modules.core.pattern_manager import _ensure_on_board, make_sd_path_resolver
 
 
-    sd_path = _to_sd_path(file_path)
+    # Prefer a copy already on the board SD over uploading a duplicate.
+    resolve_sd = make_sd_path_resolver(conn)
+    sd_path = await asyncio.to_thread(resolve_sd, file_path)
     await asyncio.to_thread(_ensure_on_board, file_path, sd_path)
     await asyncio.to_thread(_ensure_on_board, file_path, sd_path)
     try:
     try:
         await asyncio.to_thread(conn.set_feed, int(state.speed))
         await asyncio.to_thread(conn.set_feed, int(state.speed))
@@ -152,7 +210,7 @@ async def start_playlist(playlist_name: str, run_mode: str = "single",
     global current_run
     global current_run
     conn = _require_conn()
     conn = _require_conn()
     from modules.core import playlist_manager, board_settings
     from modules.core import playlist_manager, board_settings
-    from modules.core.pattern_manager import THETA_RHO_DIR, _ensure_on_board, _to_sd_path
+    from modules.core.pattern_manager import THETA_RHO_DIR, _ensure_on_board, make_sd_path_resolver
 
 
     playlist = playlist_manager.get_playlist(playlist_name)
     playlist = playlist_manager.get_playlist(playlist_name)
     if not playlist:
     if not playlist:
@@ -184,12 +242,17 @@ async def start_playlist(playlist_name: str, run_mode: str = "single",
 
 
     # The board needs the playlist file before Run; patterns can trail behind
     # The board needs the playlist file before Run; patterns can trail behind
     # (each one plays for minutes) except the first, which is ensured now.
     # (each one plays for minutes) except the first, which is ensured now.
-    await asyncio.to_thread(board_settings.mirror_playlist, playlist_name, files, conn)
-    await asyncio.to_thread(_ensure_on_board, host_paths[0], _to_sd_path(host_paths[0]))
+    # One shared resolver so the playlist lines and the uploads agree on
+    # paths, and existing board copies are reused instead of duplicated.
+    resolve_sd = make_sd_path_resolver(conn)
+    await asyncio.to_thread(
+        board_settings.mirror_playlist, playlist_name, files, conn, False, resolve_sd)
+    first_sd = await asyncio.to_thread(resolve_sd, host_paths[0])
+    await asyncio.to_thread(_ensure_on_board, host_paths[0], first_sd)
 
 
     def _mirror_rest():
     def _mirror_rest():
         for p in host_paths[1:]:
         for p in host_paths[1:]:
-            _ensure_on_board(p, _to_sd_path(p))
+            _ensure_on_board(p, resolve_sd(p))
     import threading
     import threading
     threading.Thread(target=_mirror_rest, daemon=True).start()
     threading.Thread(target=_mirror_rest, daemon=True).start()
 
 
@@ -273,6 +336,28 @@ def _reset_run_state() -> None:
 # Status translation: /sand_status -> the frontend's /ws/status contract
 # Status translation: /sand_status -> the frontend's /ws/status contract
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 
 
+def _board_health(st: Optional[dict]) -> dict:
+    """Board health telemetry for the /ws/status payload (firmware API.md).
+
+    Prefers the live /sand_status values; falls back to the last-known values
+    mirrored into state so the UI keeps a reading across a dropped poll. Older
+    firmware omits these keys — they surface as null.
+    """
+    def pick(key: str, attr: str):
+        if st and key in st:
+            return st[key]
+        return getattr(state, attr, None)
+
+    return {
+        "heap": pick("heap", "board_heap"),
+        "heap_min": pick("heap_min", "board_heap_min"),
+        "heap_largest": pick("heap_largest", "board_heap_largest"),
+        "last_reset": pick("last_reset", "board_last_reset"),
+        "sd_ok": pick("sd_ok", "board_sd_ok"),
+        "uptime": pick("uptime", "board_uptime"),
+    }
+
+
 def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
 def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
                      now: Optional[float] = None) -> dict:
                      now: Optional[float] = None) -> dict:
     """Translate a raw board status into the /ws/status data object.
     """Translate a raw board status into the /ws/status data object.
@@ -289,6 +374,7 @@ def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
             "current_file": None,
             "current_file": None,
             "is_running": False,
             "is_running": False,
             "is_paused": False,
             "is_paused": False,
+            "is_alarm": False,
             "is_homing": state.is_homing,
             "is_homing": state.is_homing,
             "sensor_homing_failed": state.sensor_homing_failed,
             "sensor_homing_failed": state.sensor_homing_failed,
             "is_clearing": False,
             "is_clearing": False,
@@ -301,6 +387,7 @@ def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
             "current_rho": state.current_rho,
             "current_rho": state.current_rho,
             "firmware_version": state.firmware_version,
             "firmware_version": state.firmware_version,
             "table_type": None,
             "table_type": None,
+            "health": _board_health(None),
         }
         }
 
 
     pl = st.get("playlist") or {}
     pl = st.get("playlist") or {}
@@ -366,6 +453,7 @@ def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
         "current_file": current_file,
         "current_file": current_file,
         "is_running": running,
         "is_running": running,
         "is_paused": machine_state == "Hold",
         "is_paused": machine_state == "Hold",
+        "is_alarm": machine_state == "Alarm",
         "is_homing": machine_state == "Home" or state.is_homing,
         "is_homing": machine_state == "Home" or state.is_homing,
         "sensor_homing_failed": state.sensor_homing_failed,
         "sensor_homing_failed": state.sensor_homing_failed,
         "is_clearing": clearing,
         "is_clearing": clearing,
@@ -378,6 +466,7 @@ def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
         "current_rho": st.get("rho", state.current_rho),
         "current_rho": st.get("rho", state.current_rho),
         "firmware_version": st.get("fw") or state.firmware_version,
         "firmware_version": st.get("fw") or state.firmware_version,
         "table_type": None,
         "table_type": None,
+        "health": _board_health(st),
     }
     }
 
 
 
 
@@ -391,6 +480,7 @@ class BoardObserver:
     POLL_ACTIVE = 1.0
     POLL_ACTIVE = 1.0
     POLL_IDLE = 2.0
     POLL_IDLE = 2.0
     OFFLINE_GRACE = 3  # consecutive failures before a run is declared over
     OFFLINE_GRACE = 3  # consecutive failures before a run is declared over
+    RECONNECT_EVERY = 15.0  # seconds between reconnect/relocate attempts while offline
 
 
     def __init__(self):
     def __init__(self):
         self.prev: Optional[dict] = None
         self.prev: Optional[dict] = None
@@ -405,7 +495,16 @@ class BoardObserver:
         self._was_quiet_wled_off = False
         self._was_quiet_wled_off = False
         self._fail_count = 0
         self._fail_count = 0
         self._tick = 0
         self._tick = 0
+        self._last_reconnect = 0.0
         self._task: Optional[asyncio.Task] = None
         self._task: Optional[asyncio.Task] = None
+        # True while a firmware OTA is in flight: the board's web server is
+        # single-threaded, so concurrent polls would wedge the flash upload.
+        self.suspended = False
+        # Board log harvest cursor (see merge_board_log)
+        self._log_last_uptime = -1.0
+        self._log_last_count = 0
+        self._last_log_harvest = 0.0
+        self._log_cursor_loaded = False
         # Set by main.py to fan out to /ws/status clients (avoids circular import).
         # Set by main.py to fan out to /ws/status clients (avoids circular import).
         self.on_status: Optional[Callable] = None
         self.on_status: Optional[Callable] = None
 
 
@@ -450,6 +549,8 @@ class BoardObserver:
             await asyncio.sleep(interval)
             await asyncio.sleep(interval)
 
 
     async def _tick_once(self) -> float:
     async def _tick_once(self) -> float:
+        if self.suspended:
+            return self.POLL_IDLE
         st = None
         st = None
         if state.conn and state.conn.is_connected():
         if state.conn and state.conn.is_connected():
             try:
             try:
@@ -457,10 +558,73 @@ class BoardObserver:
             except Exception as e:
             except Exception as e:
                 logger.debug(f"Status poll failed: {e}")
                 logger.debug(f"Status poll failed: {e}")
         await self.process(st)
         await self.process(st)
+        if st is None:
+            await self._maybe_reconnect()
         active = bool(st and (st.get("running") or (st.get("playlist") or {}).get("active")
         active = bool(st and (st.get("running") or (st.get("playlist") or {}).get("active")
                               or _state(st) in ("Hold", "Home", "Jog")))
                               or _state(st) in ("Hold", "Home", "Jog")))
         return self.POLL_ACTIVE if active else self.POLL_IDLE
         return self.POLL_ACTIVE if active else self.POLL_IDLE
 
 
+    # -- reconnect / DHCP relocate -------------------------------------------
+
+    async def _maybe_reconnect(self) -> None:
+        """While the board is unreachable, periodically retry the saved address
+        and — when DHCP has moved the board — relocate to the mDNS entry with
+        the same MAC (fallback: same hostname). Never homes; the board kept its
+        own position, only its IP changed."""
+        if state.user_disconnected or state.is_homing or state.board_locked:
+            return
+        if state.conn and state.conn.is_connected() and self._fail_count < self.OFFLINE_GRACE:
+            return
+        now = time.time()
+        if now - self._last_reconnect < self.RECONNECT_EVERY:
+            return
+        self._last_reconnect = now
+        try:
+            await asyncio.to_thread(self._reconnect_sync)
+        except Exception as e:
+            logger.debug(f"Reconnect attempt failed: {e}")
+
+    def _reconnect_sync(self) -> None:
+        from modules.connection.fluidnc_client import FluidNCClient
+        from modules.core.mdns_discovery import discovery
+
+        url = connection_manager.board_url()
+        probe = FluidNCClient(url, api_key=state.board_api_key)
+        if probe.reachable():
+            logger.info(f"Board back online at {url} — reconnecting")
+            state.conn = probe
+            state.board_locked = False
+            state.port = url
+            connection_manager.device_init(False)
+            return
+        if probe.locked:
+            state.board_locked = True
+            logger.warning(f"Board at {url} now rejects us (401) — password required")
+            return
+
+        # Saved address is dead: look for the same hardware elsewhere on the LAN.
+        target = None
+        boards = discovery.get_boards()
+        if state.board_mac:
+            target = next((b for b in boards if b.get("mac") == state.board_mac), None)
+        if target is None and state.board_hostname:
+            hn = state.board_hostname.lower()
+            target = next(
+                (b for b in boards if (b.get("hostname") or "").lower() == hn), None)
+        if target is None or target["url"] == url:
+            return
+        probe = FluidNCClient(target["url"], api_key=state.board_api_key)
+        if not probe.reachable():
+            return
+        logger.info(f"Board moved (DHCP): relocating from {url} to {target['url']} "
+                    f"(matched by {'mac' if state.board_mac else 'hostname'})")
+        state.board_url = target["url"]
+        state.port = target["url"]
+        state.conn = probe
+        state.board_locked = False
+        state.save()
+        connection_manager.device_init(False)
+
     # -- core (sync-friendly for tests; only I/O bits are awaited) ----------
     # -- core (sync-friendly for tests; only I/O bits are awaited) ----------
 
 
     async def process(self, st: Optional[dict], now: Optional[float] = None) -> dict:
     async def process(self, st: Optional[dict], now: Optional[float] = None) -> dict:
@@ -476,6 +640,10 @@ class BoardObserver:
                 _reset_run_state()
                 _reset_run_state()
                 self.reset()
                 self.reset()
         else:
         else:
+            if self._fail_count >= self.OFFLINE_GRACE:
+                # Just recovered: pull the board log promptly (it may hold the
+                # reboot/crash story we missed while offline).
+                self._last_log_harvest = 0.0
             self._fail_count = 0
             self._fail_count = 0
             if self.prev is not None and st.get("uptime", 0) < self.prev.get("uptime", 0):
             if self.prev is not None and st.get("uptime", 0) < self.prev.get("uptime", 0):
                 logger.warning("Board rebooted mid-observation — resetting run context")
                 logger.warning("Board rebooted mid-observation — resetting run context")
@@ -487,14 +655,23 @@ class BoardObserver:
 
 
         await self._quiet_hours_wled(now)
         await self._quiet_hours_wled(now)
 
 
+        if (st is not None and state.conn and state.conn.is_connected()
+                and now - self._last_log_harvest > BOARD_LOG_HARVEST_EVERY):
+            self._last_log_harvest = now
+            asyncio.ensure_future(asyncio.to_thread(self._harvest_board_log_sync))
+
         self._tick += 1
         self._tick += 1
         if self._tick % 30 == 0 and state.conn and state.conn.is_connected():
         if self._tick % 30 == 0 and state.conn and state.conn.is_connected():
+            from modules.core import board_settings
             try:
             try:
-                from modules.core import board_settings
                 settings_map = await asyncio.to_thread(state.conn.get_settings)
                 settings_map = await asyncio.to_thread(state.conn.get_settings)
                 board_settings.adopt_still_sands(settings_map)
                 board_settings.adopt_still_sands(settings_map)
+                board_settings.adopt_auto_home(settings_map)
             except Exception as e:
             except Exception as e:
                 logger.debug(f"Board settings adopt failed: {e}")
                 logger.debug(f"Board settings adopt failed: {e}")
+            # Playlists change rarely and adopting reads every file — slower cadence.
+            if self._tick % 300 == 0:
+                asyncio.ensure_future(asyncio.to_thread(board_settings.adopt_board_playlists))
 
 
         self.last_translated = translate_status(st, self, now)
         self.last_translated = translate_status(st, self, now)
         # Mirror the progress 4-tuple for the MQTT handler, which unpacks it.
         # Mirror the progress 4-tuple for the MQTT handler, which unpacks it.
@@ -510,6 +687,63 @@ class BoardObserver:
                 logger.debug(f"Status broadcast failed: {e}")
                 logger.debug(f"Status broadcast failed: {e}")
         return self.last_translated
         return self.last_translated
 
 
+    # -- board log harvest ---------------------------------------------------
+
+    def _load_log_cursor(self) -> None:
+        """Recover the merge cursor from the stored file after a backend
+        restart, so we don't re-append the board's whole ring buffer."""
+        self._log_cursor_loaded = True
+        try:
+            with open(BOARD_LOG_FILE, "r", encoding="utf-8", errors="replace") as f:
+                tail = f.readlines()[-200:]
+        except FileNotFoundError:
+            return
+        last_uptime, count = -1.0, 0
+        for line in tail:
+            # Stored lines look like "2026-07-12 17:00:03 [+1104] [MSG:...]"
+            idx = line.find("[+")
+            u = _log_uptime(line[idx:]) if idx >= 0 else None
+            if u is None:
+                continue
+            if u == last_uptime:
+                count += 1
+            else:
+                # Any change (up OR down — the tail may span a board reboot)
+                # moves the cursor: later lines are newer.
+                last_uptime, count = u, 1
+        self._log_last_uptime, self._log_last_count = last_uptime, count
+
+    def _harvest_board_log_sync(self) -> None:
+        try:
+            if not self._log_cursor_loaded:
+                self._load_log_cursor()
+            text = state.conn._get("/sand_log", timeout=5.0).text
+            new, self._log_last_uptime, self._log_last_count, restarted = merge_board_log(
+                text.splitlines(), self._log_last_uptime, self._log_last_count)
+            if not new:
+                return
+            now = time.time()
+            max_uptime = self._log_last_uptime
+            out = []
+            if restarted:
+                out.append(f"--- table restarted ({time.strftime('%Y-%m-%d %H:%M:%S')}) ---")
+            for ln in new:
+                # Estimated wall-clock time: now minus how long ago the line was
+                # logged (per its uptime prefix).
+                u = _log_uptime(ln) or max_uptime
+                stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now - (max_uptime - u)))
+                out.append(f"{stamp} {ln}")
+            try:
+                with open(BOARD_LOG_FILE, "r", encoding="utf-8", errors="replace") as f:
+                    existing = f.read().splitlines()
+            except FileNotFoundError:
+                existing = []
+            merged = (existing + out)[-BOARD_LOG_MAX_LINES:]
+            with open(BOARD_LOG_FILE, "w", encoding="utf-8") as f:
+                f.write("\n".join(merged) + "\n")
+        except Exception as e:
+            logger.debug(f"Board log harvest failed: {e}")
+
     def _detect_edges(self, st: dict, now: float) -> None:
     def _detect_edges(self, st: dict, now: float) -> None:
         prev = self.prev or {}
         prev = self.prev or {}
         prev_pl = prev.get("playlist") or {}
         prev_pl = prev.get("playlist") or {}

+ 83 - 1
modules/core/mdns_discovery.py

@@ -18,6 +18,9 @@ from typing import Dict, List, Optional
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 SERVICE_TYPE = "_dune-weaver._tcp.local."
 SERVICE_TYPE = "_dune-weaver._tcp.local."
+# FluidNC boards advertise `_http._tcp` with TXT `api=sandtable/1`,
+# `model=dune-weaver` (same records dune-weaver-mobile browses for).
+HTTP_SERVICE_TYPE = "_http._tcp.local."
 RESOLVE_TIMEOUT_MS = 3000
 RESOLVE_TIMEOUT_MS = 3000
 
 
 try:
 try:
@@ -78,16 +81,60 @@ def service_info_to_table(info) -> Optional[dict]:
     }
     }
 
 
 
 
+def _looks_like_board(label: str, server: str, props: Dict[str, str]) -> bool:
+    """Does a resolved _http._tcp service look like a Dune Weaver board?"""
+    model = (props.get("model") or "").lower()
+    api = (props.get("api") or "").lower()
+    if model == "dune-weaver" or "sandtable" in api:
+        return True
+    # Fallback for firmware builds without TXT records: match the firmware's
+    # default hostname / service name (same heuristic as the mobile app).
+    hay = f"{label} {server}".lower()
+    return bool(re.search(r"fluidnc|dune[-\s]?weaver|sand[-\s]?table", hay))
+
+
+def service_info_to_board(info) -> Optional[dict]:
+    """Convert a resolved _http._tcp ServiceInfo into a board dict for the API.
+
+    Returns None for services that don't look like a Dune Weaver board or
+    that lack an IPv4 address.
+    """
+    props = _decode_properties(info.properties)
+    # "DWMP._http._tcp.local." -> "DWMP" (the firmware hostname = table name)
+    label = info.name.split("._")[0] if "._" in info.name else info.name
+    server = (info.server or "").rstrip(".")
+    if not _looks_like_board(label, server, props):
+        return None
+    addresses = [a for a in info.parsed_addresses() if ":" not in a]  # IPv4 only
+    if not addresses or not info.port:
+        return None
+
+    host = addresses[0]
+    port = info.port
+    hostname = server[: -len(".local")] if server.endswith(".local") else server
+    return {
+        "name": label or hostname or host,
+        "hostname": hostname or None,
+        "host": host,
+        "port": port,
+        "url": f"http://{host}" if port == 80 else f"http://{host}:{port}",
+        # Stable hardware identity (fw > v0.1.7); None on older firmware.
+        "mac": (props.get("mac") or "").lower() or None,
+    }
+
+
 class TableDiscovery:
 class TableDiscovery:
     """Advertises this table via mDNS and tracks peer tables on the LAN."""
     """Advertises this table via mDNS and tracks peer tables on the LAN."""
 
 
     def __init__(self):
     def __init__(self):
         self._aiozc = None
         self._aiozc = None
         self._browser = None
         self._browser = None
+        self._board_browser = None
         self._service_info = None
         self._service_info = None
         self._own_id: Optional[str] = None
         self._own_id: Optional[str] = None
         # Keyed by mDNS service name so Removed events can evict entries
         # Keyed by mDNS service name so Removed events can evict entries
         self._discovered: Dict[str, dict] = {}
         self._discovered: Dict[str, dict] = {}
+        self._boards: Dict[str, dict] = {}
 
 
     @property
     @property
     def is_running(self) -> bool:
     def is_running(self) -> bool:
@@ -97,6 +144,10 @@ class TableDiscovery:
         """Currently visible peer tables (excludes this table)."""
         """Currently visible peer tables (excludes this table)."""
         return list(self._discovered.values())
         return list(self._discovered.values())
 
 
+    def get_boards(self) -> List[dict]:
+        """Currently visible FluidNC controller boards (candidates for /connect)."""
+        return list(self._boards.values())
+
     async def start(self, table_id: str, table_name: str, port: int, version: Optional[str] = None):
     async def start(self, table_id: str, table_name: str, port: int, version: Optional[str] = None):
         if not ZEROCONF_AVAILABLE:
         if not ZEROCONF_AVAILABLE:
             logger.warning("zeroconf package not installed - mDNS table discovery disabled")
             logger.warning("zeroconf package not installed - mDNS table discovery disabled")
@@ -129,7 +180,10 @@ class TableDiscovery:
             self._browser = AsyncServiceBrowser(
             self._browser = AsyncServiceBrowser(
                 self._aiozc.zeroconf, SERVICE_TYPE, handlers=[self._on_service_state_change]
                 self._aiozc.zeroconf, SERVICE_TYPE, handlers=[self._on_service_state_change]
             )
             )
-            logger.info(f"mDNS: advertising '{table_name}' at {local_ip}:{port} and browsing for peer tables")
+            self._board_browser = AsyncServiceBrowser(
+                self._aiozc.zeroconf, HTTP_SERVICE_TYPE, handlers=[self._on_board_state_change]
+            )
+            logger.info(f"mDNS: advertising '{table_name}' at {local_ip}:{port} and browsing for peer tables and boards")
         except Exception as e:
         except Exception as e:
             logger.warning(f"mDNS discovery failed to start: {e}")
             logger.warning(f"mDNS discovery failed to start: {e}")
             await self.stop()
             await self.stop()
@@ -158,6 +212,8 @@ class TableDiscovery:
         try:
         try:
             if self._browser:
             if self._browser:
                 await self._browser.async_cancel()
                 await self._browser.async_cancel()
+            if self._board_browser:
+                await self._board_browser.async_cancel()
             if self._service_info:
             if self._service_info:
                 await self._aiozc.async_unregister_service(self._service_info)
                 await self._aiozc.async_unregister_service(self._service_info)
             await self._aiozc.async_close()
             await self._aiozc.async_close()
@@ -166,8 +222,10 @@ class TableDiscovery:
         finally:
         finally:
             self._aiozc = None
             self._aiozc = None
             self._browser = None
             self._browser = None
+            self._board_browser = None
             self._service_info = None
             self._service_info = None
             self._discovered.clear()
             self._discovered.clear()
+            self._boards.clear()
 
 
     @staticmethod
     @staticmethod
     def _properties(table_id: str, table_name: str, version: Optional[str]) -> Dict[str, str]:
     def _properties(table_id: str, table_name: str, version: Optional[str]) -> Dict[str, str]:
@@ -209,6 +267,30 @@ class TableDiscovery:
         except Exception as e:
         except Exception as e:
             logger.debug(f"mDNS: failed to resolve {name}: {e}")
             logger.debug(f"mDNS: failed to resolve {name}: {e}")
 
 
+    def _on_board_state_change(self, zeroconf, service_type, name, state_change):
+        """Browser callback for _http._tcp (FluidNC controller boards)."""
+        if state_change is ServiceStateChange.Removed:
+            removed = self._boards.pop(name, None)
+            if removed:
+                logger.info(f"mDNS: board '{removed['name']}' left the network")
+            return
+        asyncio.ensure_future(self._resolve_board(zeroconf, service_type, name))
+
+    async def _resolve_board(self, zeroconf, service_type, name):
+        try:
+            info = AsyncServiceInfo(service_type, name)
+            if not await info.async_request(zeroconf, RESOLVE_TIMEOUT_MS):
+                return
+            board = service_info_to_board(info)
+            if not board:
+                return
+            is_new = name not in self._boards
+            self._boards[name] = board
+            if is_new:
+                logger.info(f"mDNS: discovered board '{board['name']}' at {board['url']}")
+        except Exception as e:
+            logger.debug(f"mDNS: failed to resolve board {name}: {e}")
+
 
 
 # Module-level singleton, mirroring how other core services are exposed
 # Module-level singleton, mirroring how other core services are exposed
 discovery = TableDiscovery()
 discovery = TableDiscovery()

+ 72 - 6
modules/core/pattern_manager.py

@@ -361,13 +361,73 @@ def parse_theta_rho_file(file_path):
     return coordinates
     return coordinates
 
 
 
 
+def _host_rel_path(file_path):
+    """Normalize any pattern reference — './patterns/x.thr', 'patterns/x.thr'
+    or a catalog-relative entry like 'custom_patterns/x.thr' — to the path
+    relative to the patterns/ dir."""
+    p = str(file_path).replace("\\", "/")
+    if p.startswith("./"):
+        p = p[2:]
+    p = p.lstrip("/")
+    if p.startswith("patterns/"):
+        p = p[len("patterns/"):]
+    return p
+
+
 def _to_sd_path(file_path):
 def _to_sd_path(file_path):
-    """Map a host pattern path (e.g. './patterns/star.thr') to its board SD path
-    ('/patterns/star.thr'). The board SD mirrors the host's patterns/ tree."""
-    p = file_path.replace("\\", "/").lstrip(".").lstrip("/")
-    idx = p.find("patterns/")
-    rel = p[idx:] if idx >= 0 else ("patterns/" + os.path.basename(p))
-    return "/" + rel
+    """Canonical board SD path for a host pattern: '/patterns/<host-rel>'.
+    (The old find('patterns/') version matched inside 'custom_patterns/',
+    silently dropping that directory from playlist lines.)"""
+    return "/patterns/" + _host_rel_path(file_path)
+
+
+def make_sd_path_resolver(conn=None):
+    """Build a resolve(file_path) -> board SD path function that prefers a
+    copy already on the board's SD (per /sand_patterns) over the canonical
+    host-derived path. Patterns can reach the board through other routes
+    (mobile app, a copied SD card) under a different directory layout —
+    without this, playing an adopted playlist re-uploads every pattern to a
+    duplicate location. The board listing is fetched lazily once per resolver
+    and results are memoized; when the listing is unavailable the canonical
+    path is used (upload proceeds as before)."""
+    listing = None
+    cache = {}
+
+    def resolve(file_path):
+        nonlocal listing
+        rel = _host_rel_path(file_path)
+        if rel in cache:
+            return cache[rel]
+        if listing is None:
+            c = conn or state.conn
+            try:
+                raw = c.list_patterns() if c else []
+                listing = {str(p).replace("\\", "/").lstrip("/") for p in raw or []}
+            except Exception as e:
+                logger.debug(f"Could not list board patterns: {e}")
+                listing = set()
+        result = "/patterns/" + rel
+        if listing and rel not in listing:
+            # Longest suffix of the host path that already exists on the SD
+            # wins (e.g. host 'custom_patterns/sand-patterns/patterns/x.thr'
+            # reuses board 'sand-patterns/patterns/x.thr').
+            parts = rel.split("/")
+            for i in range(1, len(parts)):
+                cand = "/".join(parts[i:])
+                if cand in listing:
+                    result = "/patterns/" + cand
+                    break
+            else:
+                # Same file under an unrelated directory: only a UNIQUE
+                # basename match is safe — ambiguity falls back to canonical.
+                base = parts[-1]
+                matches = [p for p in listing if p.rsplit("/", 1)[-1] == base]
+                if len(matches) == 1:
+                    result = "/patterns/" + matches[0]
+        cache[rel] = result
+        return result
+
+    return resolve
 
 
 
 
 def _ensure_on_board(file_path, sd_path):
 def _ensure_on_board(file_path, sd_path):
@@ -375,6 +435,12 @@ def _ensure_on_board(file_path, sd_path):
     try:
     try:
         if not state.conn:
         if not state.conn:
             return
             return
+        if not os.path.exists(file_path):
+            # Callers pass either full './patterns/...' paths or catalog-
+            # relative entries; resolve the latter against the patterns dir.
+            alt = os.path.join(THETA_RHO_DIR, _host_rel_path(file_path))
+            if os.path.exists(alt):
+                file_path = alt
         if state.conn.file_exists(sd_path):
         if state.conn.file_exists(sd_path):
             return
             return
         with open(file_path, "rb") as f:
         with open(file_path, "rb") as f:

+ 24 - 6
modules/core/state.py

@@ -50,6 +50,9 @@ class AppState:
         # Firmware info (runtime only, detected on connect, not persisted)
         # Firmware info (runtime only, detected on connect, not persisted)
         self.firmware_type = None  # 'fluidnc', 'grbl', or 'unknown'
         self.firmware_type = None  # 'fluidnc', 'grbl', or 'unknown'
         self.firmware_version = None  # e.g., "v3.7.2"
         self.firmware_version = None  # e.g., "v3.7.2"
+        self.board_hostname = None  # board's network hostname, e.g. "DWMP" (fw > v0.1.7)
+        self.board_locked = False  # board rejected us with 401 (password-protected)
+        self.user_disconnected = False  # explicit /disconnect: suppress auto-reconnect
 
 
         # Angular homing compass reference point
         # Angular homing compass reference point
         # This is the angular offset in degrees where the sensor is placed
         # This is the angular offset in degrees where the sensor is placed
@@ -78,6 +81,19 @@ class AppState:
         # Address of the FluidNC board (e.g. "http://192.168.68.160" or a bare IP).
         # Address of the FluidNC board (e.g. "http://192.168.68.160" or a bare IP).
         # None => fall back to the DUNE_BOARD_URL env var or the built-in default.
         # None => fall back to the DUNE_BOARD_URL env var or the built-in default.
         self.board_url = None
         self.board_url = None
+        # Board API password ($Sand/Password, fw >= v0.1.11) sent as X-Sand-Key.
+        self.board_api_key = None
+        # Board STA MAC (lowercase, from /sand_status) — stable hardware identity
+        # used to re-find the board when DHCP moves it.
+        self.board_mac = None
+        # Board health telemetry mirrored from /sand_status (firmware API.md).
+        # Not persisted — live diagnostics only, surfaced in Table Control.
+        self.board_heap = None            # free heap bytes now
+        self.board_heap_min = None        # lowest free heap since boot (low-water)
+        self.board_heap_largest = None    # largest allocatable block (fragmentation)
+        self.board_last_reset = None      # power_on|software|panic|task_wdt|brownout|...
+        self.board_sd_ok = None           # boot-time SD readability probe (None=unknown)
+        self.board_uptime = None          # seconds since board boot
         self.wled_ip = None
         self.wled_ip = None
         self.led_provider = "none"  # "wled", "board", or "none"
         self.led_provider = "none"  # "wled", "board", or "none"
         self.led_controller = None
         self.led_controller = None
@@ -272,6 +288,9 @@ class AppState:
         mqtt_password_encoded = ""
         mqtt_password_encoded = ""
         if self.mqtt_password:
         if self.mqtt_password:
             mqtt_password_encoded = base64.b64encode(self.mqtt_password.encode('utf-8')).decode('ascii')
             mqtt_password_encoded = base64.b64encode(self.mqtt_password.encode('utf-8')).decode('ascii')
+        board_api_key_encoded = ""
+        if self.board_api_key:
+            board_api_key_encoded = base64.b64encode(self.board_api_key.encode('utf-8')).decode('ascii')
 
 
         return {
         return {
             "speed": self._speed,
             "speed": self._speed,
@@ -290,6 +309,8 @@ class AppState:
             "custom_clear_from_in": self.custom_clear_from_in,
             "custom_clear_from_in": self.custom_clear_from_in,
             "custom_clear_from_out": self.custom_clear_from_out,
             "custom_clear_from_out": self.custom_clear_from_out,
             "board_url": self.board_url,
             "board_url": self.board_url,
+            "board_api_key": board_api_key_encoded,
+            "board_mac": self.board_mac,
             "wled_ip": self.wled_ip,
             "wled_ip": self.wled_ip,
             "led_provider": self.led_provider,
             "led_provider": self.led_provider,
             "dw_led_idle_timeout_enabled": self.dw_led_idle_timeout_enabled,
             "dw_led_idle_timeout_enabled": self.dw_led_idle_timeout_enabled,
@@ -370,6 +391,9 @@ class AppState:
         self.custom_clear_from_in = data.get("custom_clear_from_in", None)
         self.custom_clear_from_in = data.get("custom_clear_from_in", None)
         self.custom_clear_from_out = data.get("custom_clear_from_out", None)
         self.custom_clear_from_out = data.get("custom_clear_from_out", None)
         self.board_url = data.get("board_url", None)
         self.board_url = data.get("board_url", None)
+        # Same storage scheme as the MQTT password (base64, plain-text tolerated)
+        self.board_api_key = self._decode_mqtt_password(data.get("board_api_key", "")) or None
+        self.board_mac = data.get("board_mac", None)
         self.wled_ip = data.get('wled_ip', None)
         self.wled_ip = data.get('wled_ip', None)
         self.led_provider = data.get('led_provider', "none")
         self.led_provider = data.get('led_provider', "none")
 
 
@@ -494,12 +518,6 @@ class AppState:
             except Exception as e:
             except Exception as e:
                 print(f"Error loading settings from {self.SETTINGS_FILE}: {e}")
                 print(f"Error loading settings from {self.SETTINGS_FILE}: {e}")
 
 
-    def update_steps_per_mm(self, x_steps, y_steps):
-        """Update and save steps per mm values."""
-        self.x_steps_per_mm = x_steps
-        self.y_steps_per_mm = y_steps
-        self.save()
-
     def reset_state(self):
     def reset_state(self):
         """Reset all state variables to their default values."""
         """Reset all state variables to their default values."""
         self.__init__()  # Reinitialize the state
         self.__init__()  # Reinitialize the state

+ 16 - 1
modules/led/board_led_controller.py

@@ -94,9 +94,24 @@ class BoardLEDController:
         if led is None:
         if led is None:
             return {"connected": False, "error": "Table not connected"}
             return {"connected": False, "error": "Table not connected"}
         effect = (led.get("Effect") or "off").lower()
         effect = (led.get("Effect") or "off").lower()
+        brightness_255 = int(led.get("Brightness", 128) or 0)
+        # While a pattern is running, /sand_led writes are live overrides that
+        # $LED/* (NVS) doesn't reflect until the run ends — so overlay the
+        # *effective* state from /sand_status's "led" object (fw >= v0.1.x).
+        # Without this, power/effect changes made mid-run read back stale and
+        # the power toggle can never turn the ring off during playback.
+        conn = self._conn()
+        if conn:
+            try:
+                live = (conn.get_status() or {}).get("led") or {}
+                if live.get("effect"):
+                    effect = str(live["effect"]).lower()
+                if live.get("brightness") is not None:
+                    brightness_255 = int(live["brightness"])
+            except Exception as e:
+                logger.debug(f"No live LED state from /sand_status: {e}")
         if effect != "off":
         if effect != "off":
             self._last_effect = effect
             self._last_effect = effect
-        brightness_255 = int(led.get("Brightness", 128) or 0)
         palette = (led.get("Palette") or "rainbow").lower()
         palette = (led.get("Palette") or "rainbow").lower()
         return {
         return {
             "connected": True,
             "connected": True,

+ 78 - 0
modules/update/firmware_updater.py

@@ -0,0 +1,78 @@
+"""Board firmware OTA — GitHub release lookup and version comparison.
+
+Firmware releases are published as GitHub Release assets on the firmware repo
+(`firmware.bin` = the ESP32 app-partition image). The flash itself goes through
+FluidNCClient.upload_firmware (POST /updatefw); this module only knows how to
+find the latest release and compare versions.
+"""
+
+import logging
+import re
+import time
+from typing import Optional
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+FIRMWARE_REPO = "tuanchris/dune-weaver-firmware"
+_CACHE_TTL = 600  # GitHub unauthenticated rate limit is 60/hr - cache lookups
+
+_cache: dict = {"at": 0.0, "release": None}
+
+
+def get_latest_release(force: bool = False) -> Optional[dict]:
+    """Latest published firmware release, or None when unreachable / no
+    firmware.bin asset. Cached for 10 minutes."""
+    now = time.time()
+    if not force and _cache["release"] and now - _cache["at"] < _CACHE_TTL:
+        return _cache["release"]
+    try:
+        r = requests.get(
+            f"https://api.github.com/repos/{FIRMWARE_REPO}/releases/latest",
+            headers={"Accept": "application/vnd.github+json"},
+            timeout=10.0,
+        )
+        r.raise_for_status()
+        data = r.json()
+        asset = next((a for a in data.get("assets", []) if a.get("name") == "firmware.bin"), None)
+        if not asset:
+            logger.warning("Latest firmware release has no firmware.bin asset")
+            return None
+        release = {
+            "version": data.get("tag_name") or "",
+            "download_url": asset.get("browser_download_url"),
+            "release_url": data.get("html_url") or f"https://github.com/{FIRMWARE_REPO}/releases",
+            "published_at": data.get("published_at"),
+        }
+        _cache.update(at=now, release=release)
+        return release
+    except Exception as e:
+        logger.warning(f"Firmware release lookup failed: {e}")
+        return None
+
+
+def parse_version(fw: Optional[str]) -> Optional[tuple]:
+    """'v0.1.10 (main-5ce4400d-dirty)' -> (0, 1, 10); None if unparseable."""
+    if not fw:
+        return None
+    m = re.search(r"v?(\d+)\.(\d+)\.(\d+)", fw)
+    return tuple(int(g) for g in m.groups()) if m else None
+
+
+def is_newer(latest_tag: str, current_fw: Optional[str]) -> bool:
+    latest = parse_version(latest_tag)
+    current = parse_version(current_fw)
+    if not latest or not current:
+        return False
+    return latest > current
+
+
+def download_image(url: str) -> bytes:
+    """Download and sanity-check a firmware image (ESP32 magic byte 0xE9)."""
+    r = requests.get(url, timeout=60.0)
+    r.raise_for_status()
+    image = r.content
+    if len(image) < 100_000 or image[0] != 0xE9:
+        raise ValueError("Downloaded firmware image looks invalid")
+    return image

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/dist/assets/index-BCKILkcX.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/dist/assets/index-BQCSo6mO.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/dist/assets/index-BbPVIrry.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/dist/assets/index-CEWL2r_b.css


+ 2 - 2
static/dist/index.html

@@ -58,8 +58,8 @@
           .catch(function() {});
           .catch(function() {});
       })();
       })();
     </script>
     </script>
-    <script type="module" crossorigin src="/assets/index-BbPVIrry.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CEWL2r_b.css">
+    <script type="module" crossorigin src="/assets/index-BCKILkcX.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BQCSo6mO.css">
   <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
   <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/dist/sw.js


+ 1 - 3
tests/conftest.py

@@ -110,9 +110,7 @@ def mock_state():
     mock.scheduled_pause_finish_pattern = False
     mock.scheduled_pause_finish_pattern = False
     mock.scheduled_pause_timezone = None
     mock.scheduled_pause_timezone = None
 
 
-    # Steps and gear ratio
-    mock.x_steps_per_mm = 200.0
-    mock.y_steps_per_mm = 287.0
+    # Gear ratio
     mock.gear_ratio = 10.0
     mock.gear_ratio = 10.0
 
 
     # Auto-home settings
     # Auto-home settings

+ 1 - 3
tests/unit/conftest.py

@@ -81,9 +81,7 @@ def mock_state_unit():
     mock.scheduled_pause_finish_pattern = False
     mock.scheduled_pause_finish_pattern = False
     mock.scheduled_pause_timezone = None
     mock.scheduled_pause_timezone = None
 
 
-    # Steps and gear ratio
-    mock.x_steps_per_mm = 200.0
-    mock.y_steps_per_mm = 287.0
+    # Gear ratio
     mock.gear_ratio = 10.0
     mock.gear_ratio = 10.0
 
 
     # Auto-home settings
     # Auto-home settings

+ 2 - 2
tests/unit/test_api_status.py

@@ -67,7 +67,7 @@ class TestListSerialPorts:
         """Test list_serial_ports returns a list of available ports."""
         """Test list_serial_ports returns a list of available ports."""
         mock_ports = ["/dev/ttyUSB0", "/dev/ttyACM0"]
         mock_ports = ["/dev/ttyUSB0", "/dev/ttyACM0"]
 
 
-        with patch("main.connection_manager.list_serial_ports", return_value=mock_ports):
+        with patch("main.connection_manager.list_board_urls", return_value=mock_ports):
             response = await async_client.get("/list_serial_ports")
             response = await async_client.get("/list_serial_ports")
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -79,7 +79,7 @@ class TestListSerialPorts:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_list_serial_ports_empty(self, async_client):
     async def test_list_serial_ports_empty(self, async_client):
         """Test list_serial_ports returns empty list when no ports."""
         """Test list_serial_ports returns empty list when no ports."""
-        with patch("main.connection_manager.list_serial_ports", return_value=[]):
+        with patch("main.connection_manager.list_board_urls", return_value=[]):
             response = await async_client.get("/list_serial_ports")
             response = await async_client.get("/list_serial_ports")
 
 
         assert response.status_code == 200
         assert response.status_code == 200

+ 2 - 1
tests/unit/test_board_settings.py

@@ -167,8 +167,9 @@ class TestApplyAutostart:
 
 
 class TestPlaylistMirroring:
 class TestPlaylistMirroring:
     def test_playlist_content_uses_sd_paths(self):
     def test_playlist_content_uses_sd_paths(self):
+        from modules.core.pattern_manager import _to_sd_path
         content = board_settings._playlist_sd_content(
         content = board_settings._playlist_sd_content(
-            ["./patterns/star.thr", "patterns/sub/wave.thr"]
+            ["./patterns/star.thr", "patterns/sub/wave.thr"], _to_sd_path
         )
         )
         assert content == "/patterns/star.thr\n/patterns/sub/wave.thr\n"
         assert content == "/patterns/star.thr\n/patterns/sub/wave.thr\n"
 
 

+ 42 - 2
tests/unit/test_connection_manager.py

@@ -100,6 +100,30 @@ class TestFluidNCClient:
         assert req.get.call_args[1]["params"]["action"] == "delete"
         assert req.get.call_args[1]["params"]["action"] == "delete"
         assert req.get.call_args[1]["params"]["filename"] == "old.thr"
         assert req.get.call_args[1]["params"]["filename"] == "old.thr"
 
 
+    def test_get_retries_on_503_then_succeeds(self):
+        """A transient 503 (firmware low-memory shedding) is retried, not raised."""
+        client = FluidNCClient("http://board")
+        with patch("modules.connection.fluidnc_client.requests") as req, \
+                patch("modules.connection.fluidnc_client.time.sleep"):
+            req.get.side_effect = [
+                _resp(status=503),
+                _resp(json_data={"state": "Idle"}, status=200),
+            ]
+            result = client.get_status()
+        assert result == {"state": "Idle"}
+        assert req.get.call_count == 2
+
+    def test_get_gives_up_after_max_503_retries(self):
+        """A persistent 503 exhausts retries and surfaces via raise_for_status."""
+        client = FluidNCClient("http://board")
+        with patch("modules.connection.fluidnc_client.requests") as req, \
+                patch("modules.connection.fluidnc_client.time.sleep"):
+            req.get.return_value = _resp(status=503)
+            client.get_status()
+        # 1 initial + 2 retries = 3 attempts; the final 503 is returned to _get,
+        # whose raise_for_status() (mocked here) would raise against a real board.
+        assert req.get.call_count == 3
+
 
 
 class TestConnectionManagerHelpers:
 class TestConnectionManagerHelpers:
     """Helpers in connection_manager that sit on top of the client."""
     """Helpers in connection_manager that sit on top of the client."""
@@ -110,11 +134,11 @@ class TestConnectionManagerHelpers:
         assert cm._normalize_board_url("http://x/") == "http://x"
         assert cm._normalize_board_url("http://x/") == "http://x"
         assert cm._normalize_board_url("") == ""
         assert cm._normalize_board_url("") == ""
 
 
-    def test_list_serial_ports_returns_board_url(self, mock_state):
+    def test_list_board_urls_returns_board_url(self, mock_state):
         from modules.connection import connection_manager as cm
         from modules.connection import connection_manager as cm
         mock_state.board_url = "http://192.168.1.9"
         mock_state.board_url = "http://192.168.1.9"
         with patch("modules.connection.connection_manager.state", mock_state):
         with patch("modules.connection.connection_manager.state", mock_state):
-            ports = cm.list_serial_ports()
+            ports = cm.list_board_urls()
         assert ports == ["http://192.168.1.9"]
         assert ports == ["http://192.168.1.9"]
 
 
     def test_apply_status_maps_fields(self, mock_state):
     def test_apply_status_maps_fields(self, mock_state):
@@ -125,6 +149,22 @@ class TestConnectionManagerHelpers:
         assert mock_state.current_rho == 0.5
         assert mock_state.current_rho == 0.5
         assert mock_state.speed == 900
         assert mock_state.speed == 900
 
 
+    def test_apply_status_mirrors_health_telemetry(self, mock_state):
+        from modules.connection import connection_manager as cm
+        st = {
+            "theta": 0.1, "rho": 0.2, "feed": 500,
+            "heap": 145000, "heap_min": 98000, "heap_largest": 60000,
+            "last_reset": "panic", "sd_ok": False, "uptime": 86400,
+        }
+        with patch("modules.connection.connection_manager.state", mock_state):
+            cm.apply_status(st)
+        assert mock_state.board_heap == 145000
+        assert mock_state.board_heap_min == 98000
+        assert mock_state.board_heap_largest == 60000
+        assert mock_state.board_last_reset == "panic"
+        assert mock_state.board_sd_ok is False
+        assert mock_state.board_uptime == 86400
+
     def test_is_machine_idle_true(self, mock_state):
     def test_is_machine_idle_true(self, mock_state):
         from modules.connection import connection_manager as cm
         from modules.connection import connection_manager as cm
         mock_state.conn.get_status.return_value = {"state": "Idle"}
         mock_state.conn.get_status.return_value = {"state": "Idle"}

+ 54 - 0
tests/unit/test_pattern_manager.py

@@ -243,3 +243,57 @@ class TestListThetaRhoFiles:
             files = list_theta_rho_files()
             files = list_theta_rho_files()
 
 
         assert files == []
         assert files == []
+
+
+class TestSdPathResolution:
+    """_to_sd_path / make_sd_path_resolver: host <-> board SD path mapping."""
+
+    def test_to_sd_path_keeps_custom_patterns_dir(self):
+        from modules.core.pattern_manager import _to_sd_path
+        # The old find('patterns/') logic matched inside 'custom_patterns/'
+        # and dropped that directory from the SD path.
+        assert _to_sd_path("custom_patterns/foo.thr") == "/patterns/custom_patterns/foo.thr"
+        assert _to_sd_path("./patterns/custom_patterns/foo.thr") == "/patterns/custom_patterns/foo.thr"
+        assert _to_sd_path("./patterns/star.thr") == "/patterns/star.thr"
+        assert _to_sd_path("patterns/sub/wave.thr") == "/patterns/sub/wave.thr"
+
+    def test_resolver_reuses_existing_board_suffix(self):
+        from modules.core import pattern_manager
+        conn = MagicMock()
+        conn.list_patterns.return_value = [
+            "sand-patterns/patterns/alligator.thr",
+            "star.thr",
+        ]
+        resolve = pattern_manager.make_sd_path_resolver(conn)
+        # Host copy nested under custom_patterns/ reuses the board's location.
+        assert (
+            resolve("custom_patterns/sand-patterns/patterns/alligator.thr")
+            == "/patterns/sand-patterns/patterns/alligator.thr"
+        )
+        # Exact board match stays canonical.
+        assert resolve("./patterns/star.thr") == "/patterns/star.thr"
+        # Not on the board at all: canonical path (will be uploaded there).
+        assert resolve("custom_patterns/new.thr") == "/patterns/custom_patterns/new.thr"
+        # The listing is fetched once for the whole resolver lifetime.
+        conn.list_patterns.assert_called_once()
+
+    def test_resolver_unique_basename_match(self):
+        from modules.core import pattern_manager
+        conn = MagicMock()
+        conn.list_patterns.return_value = ["library/animals/heart.thr"]
+        resolve = pattern_manager.make_sd_path_resolver(conn)
+        assert resolve("uploads/heart.thr") == "/patterns/library/animals/heart.thr"
+
+    def test_resolver_ambiguous_basename_falls_back(self):
+        from modules.core import pattern_manager
+        conn = MagicMock()
+        conn.list_patterns.return_value = ["a/heart.thr", "b/heart.thr"]
+        resolve = pattern_manager.make_sd_path_resolver(conn)
+        assert resolve("uploads/heart.thr") == "/patterns/uploads/heart.thr"
+
+    def test_resolver_survives_listing_failure(self):
+        from modules.core import pattern_manager
+        conn = MagicMock()
+        conn.list_patterns.side_effect = RuntimeError("board offline")
+        resolve = pattern_manager.make_sd_path_resolver(conn)
+        assert resolve("custom_patterns/foo.thr") == "/patterns/custom_patterns/foo.thr"

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است