2 Ревизии 3857993ed6 ... 3912fcb84d

Автор SHA1 Съобщение Дата
  tuanchris 3912fcb84d fix: light-mode preview contrast + resolve board SD paths to host catalog преди 5 дни
  tuanchris d0fae4a477 release: Dune Weaver 5.0.0 преди 5 дни
променени са 77 файла, в които са добавени 9909 реда и са изтрити 6175 реда
  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. 8 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. 271 11
      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-BbPVIrry.js
  66. 0 0
      static/dist/assets/index-CEWL2r_b.css
  67. 0 0
      static/dist/assets/index-Djx5fY-0.css
  68. 0 0
      static/dist/assets/index-ZKmcHv9J.js
  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. 13 0
      tests/unit/test_execution.py
  77. 54 0
      tests/unit/test_pattern_manager.py

+ 3 - 0
.gitignore

@@ -36,3 +36,6 @@ static/custom/*
 frontend/coverage/
 frontend/playwright-report/
 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.
 - **Actions** go out as `$...` commands via `/command` and the `/sand_*` routes
   (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`,
   read from `/sd/playlists/...`, and created/edited by uploading/deleting files.
 - **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
 
-- **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
 
@@ -114,20 +124,30 @@ Choose from multiple setup options including systemd service, desktop autostart,
 
 ```
 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/
-│   ├── 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/
-│   ├── main.qml               # Main window with StackView navigation
+│   ├── main.qml                    # Main window, tab navigation, error dialog
 │   ├── 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/
-│       └── 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
 └── README.md
 ```
@@ -135,10 +155,12 @@ dune-weaver-touch/
 ## Usage
 
 ### 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
 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.QtQml import QmlElement
 import asyncio
+import base64
 import json
 import logging
 import subprocess
@@ -9,7 +10,8 @@ import time
 from pathlib import Path
 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
 
 # Configure logging
@@ -39,6 +41,14 @@ def _run(coro):
         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
 class Backend(QObject):
     """Backend controller: drives a headless FluidNC sand table over HTTP."""
@@ -47,6 +57,12 @@ class Backend(QObject):
     SETTINGS_FILE = "touch_settings.json"
     DEFAULT_SCREEN_TIMEOUT = 300  # 5 minutes in seconds
     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)
     TIMEOUT_OPTIONS = {
@@ -79,9 +95,11 @@ class Backend(QObject):
     progressChanged = Signal()
     connectionChanged = Signal()
     executionStarted = Signal(str, str)  # patternName, patternPreview
+    patternPreviewReady = Signal(str, str)  # patternName, preview rendered late
     executionStopped = Signal()
     errorOccurred = Signal(str)
     serialPortsUpdated = Signal(list)          # now: list of discovered table URLs
+    discoveredTablesUpdated = Signal(list)     # [{name, url}] from mDNS discovery
     serialConnectionChanged = Signal(bool)     # now: table reachable
     currentPortChanged = Signal(str)           # now: current table URL
     speedChanged = Signal(int)
@@ -155,6 +173,17 @@ class Backend(QObject):
         self._led_color = "#ffffff"
         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
         self._screen_on = True
         self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
@@ -169,6 +198,9 @@ class Backend(QObject):
         self._load_local_settings()
         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.
         env_url = os.environ.get("DUNE_WEAVER_URL", "")
         initial_url = env_url or self._saved_table_url
@@ -180,6 +212,23 @@ class Backend(QObject):
         self._status_timer = QTimer()
         self._status_timer.timeout.connect(self._tick_status)
         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.
         QTimer.singleShot(200, self._start)
@@ -189,9 +238,15 @@ class Backend(QObject):
         self._status_timer.start(self.STATUS_POLL_MS)
         if not self.client.base_url:
             # 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)...")
         try:
             tables = await discovery.discover_tables(timeout=3.0)
@@ -199,14 +254,19 @@ class Backend(QObject):
             logger.warning(f"Discovery failed: {exc}")
             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)
-        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.")
-        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 ====================
     @Slot()
@@ -214,13 +274,29 @@ class Backend(QObject):
         _run(self._poll_status())
 
     async def _poll_status(self):
-        if not self.client.base_url:
+        if not self.client.base_url or self._poll_inflight:
             return
+        self._poll_inflight = True
         try:
             data = await self.client.status()
         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
+        finally:
+            self._poll_inflight = False
+        self._poll_failures = 0
         self._on_status(data)
 
     def _on_status(self, status):
@@ -241,7 +317,10 @@ class Backend(QObject):
             self.loadLedConfig()
             _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
         raw_file = status.get("file", "") or ""
@@ -252,11 +331,29 @@ class Backend(QObject):
                 break
         if new_file and new_file != self._current_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._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")
         if new_paused != self._is_paused:
             self._is_paused = new_paused
@@ -300,11 +397,39 @@ class Backend(QObject):
         if self._time_synced:
             return
         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
         except Exception as 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):
         """Best-effort cached preview path for a pattern (may be empty)."""
         try:
@@ -313,6 +438,21 @@ class Backend(QObject):
         except Exception:
             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 ====================
     @Property(str, notify=statusChanged)
     def currentFile(self):
@@ -334,6 +474,46 @@ class Backend(QObject):
     def isConnected(self):
         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)
     def serialPorts(self):
         return self._serial_ports
@@ -369,13 +549,14 @@ class Backend(QObject):
         if self.client.base_url:
             _run(self._poll_status())
         else:
-            _run(self._auto_discover())
+            _run(self._discover(auto_connect=True))
 
     @Slot()
     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...")
-        _run(self._auto_discover())
+        _run(self._discover())
 
     @Slot(str)
     def connectSerial(self, port):
@@ -388,6 +569,7 @@ class Backend(QObject):
         self._current_port = self.client.base_url
         self._saved_table_url = self.client.base_url
         self._time_synced = False
+        self._poll_failures = 0  # fresh table, fresh streak
         self._save_local_settings()
         self.currentPortChanged.emit(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...")
         self.client.set_base_url("")
         self._current_port = ""
+        self._table_name = ""
         self._serial_connected = False
         self._backend_connected = False
         self._is_connected = False
@@ -415,10 +598,13 @@ class Backend(QObject):
     async def _execute_pattern(self, fileName, preExecution):
         try:
             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:
             logger.error(f"executePattern failed: {exc}")
-            self.errorOccurred.emit(str(exc))
+            self.errorOccurred.emit("Couldn't start the pattern. " + friendly_error(exc))
 
     @Slot()
     def stopExecution(self):
@@ -447,7 +633,7 @@ class Backend(QObject):
                 on_ok()
         except Exception as 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)
     def executePlaylist(self, playlistName, pauseTime=0.0, clearPattern="adaptive",
@@ -463,7 +649,7 @@ class Backend(QObject):
                 run_mode=runMode, shuffle=shuffle)
         except Exception as 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 ====================
     @Slot()
@@ -494,7 +680,7 @@ class Backend(QObject):
             self.speedChanged.emit(speed)
         except Exception as 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')
     def getSpeedOptions(self):
@@ -719,6 +905,7 @@ class Backend(QObject):
                 self._playlist_run_mode = settings.get('playlist_run_mode', "loop")
                 self._playlist_clear_pattern = settings.get('playlist_clear_pattern', "adaptive")
                 self._saved_table_url = settings.get('table_url', "")
+                self._table_password_b64 = settings.get('table_password', "")
             else:
                 self._save_local_settings()
         except Exception as e:
@@ -737,6 +924,7 @@ class Backend(QObject):
                 'playlist_run_mode': self._playlist_run_mode,
                 'playlist_clear_pattern': self._playlist_clear_pattern,
                 'table_url': self._saved_table_url,
+                'table_password': self._table_password_b64,
                 'version': '2.0'
             }
             with open(self.SETTINGS_FILE, 'w') as f:
@@ -865,6 +1053,35 @@ class Backend(QObject):
     def ledColor(self):
         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()
     def loadLedConfig(self):
         logger.debug("Loading LED configuration...")
@@ -904,6 +1121,19 @@ class Backend(QObject):
             color = settings.get("LED/Color", "ffffff")
             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()
 
     def _ingest_led_status(self, led):
@@ -921,6 +1151,8 @@ class Backend(QObject):
                 changed = True
             if power:
                 self._led_last_effect = idx
+            if effect not in ("ball", "off"):
+                self._led_last_non_ball_effect = idx
         b = led.get("brightness")
         if b is not None:
             scaled = round(int(b) * 100 / 255)
@@ -961,7 +1193,7 @@ class Backend(QObject):
             await self.client.set_led(**kwargs)
         except Exception as 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)
     def setLedBrightness(self, value):
@@ -994,6 +1226,8 @@ class Backend(QObject):
             self._led_power_on = (name != "off")
             if self._led_power_on:
                 self._led_last_effect = effectId
+            if name not in ("ball", "off"):
+                self._led_last_non_ball_effect = effectId
             _run(self._apply_led(effect=name))
             self.ledStatusChanged.emit()
 
@@ -1005,6 +1239,73 @@ class Backend(QObject):
             _run(self._apply_led(palette=LED_PALETTES[paletteId]))
             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 ====================
     def _detect_backlight(self):
         if not self._lcd_brightness_path:
@@ -1088,7 +1389,7 @@ class Backend(QObject):
             subprocess.run(['sudo', 'shutdown', '-h', 'now'], check=False, timeout=5)
         except Exception as 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 ====================
     @Slot(str)

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

@@ -16,6 +16,8 @@ from __future__ import annotations
 
 import asyncio
 import logging
+import random
+import socket
 from typing import Optional
 from urllib.parse import quote
 
@@ -53,7 +55,32 @@ CLEAR_MODE_MAP = {
 }
 
 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:
@@ -69,6 +96,29 @@ def _raise_file_error(status: int, body) -> None:
     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:
     """Turn a user/mDNS supplied host into a ``http://host[:port]`` base URL."""
     value = (value or "").strip().rstrip("/")
@@ -100,6 +150,18 @@ class FirmwareClient(QObject):
         self._base_url = ""
         self._session: Optional[aiohttp.ClientSession] = None
         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
     @property
@@ -127,7 +189,12 @@ class FirmwareClient(QObject):
     # ----------------------------------------------------------------- session
     async def _ensure_session(self) -> aiohttp.ClientSession:
         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)
         return self._session
 
@@ -136,35 +203,69 @@ class FirmwareClient(QObject):
             await self._session.close()
 
     # ------------------------------------------------------------- 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:
             raise RuntimeError("No table selected")
         session = await self._ensure_session()
         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
     async def status(self) -> dict:
@@ -186,10 +287,15 @@ class FirmwareClient(QObject):
         data = await self.get_json("/sand_settings")
         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("/")
-        return await self.get_bytes(f"/sd{sd_path}")
+        return await self.get_bytes(f"/sd{sd_path}", timeout=timeout)
 
     # ------------------------------------------------------------------ actions
     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,
         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:
         """Run ``/patterns/<rel_path>`` with an optional pre-execution clear."""
@@ -209,9 +319,37 @@ class FirmwareClient(QObject):
         else:
             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,
                            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"):
             await self.command(f"$Playlist/Mode={run_mode}")
         if shuffle is not None:
@@ -267,8 +405,16 @@ class FirmwareClient(QObject):
     async def reboot(self) -> None:
         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
     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")
         url = f"{self._base_url}/upload?path={quote(path)}"
         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)
             self._set_reachable(True)
             _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())
         url = f"{self._base_url}/upload?{query}"
         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)
             self._set_reachable(True)
             _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
 from pathlib import Path
 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.QtQuickControls2 import QQuickStyle
 from qasync import QEventLoop
 from dotenv import load_dotenv
 
@@ -102,6 +103,19 @@ def main():
 
     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
     # Ignores the first touch after 2 seconds of inactivity
     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._filtered_patterns = []  # current view
         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._render_attempts = {}    # rel_path -> transient-failure count
+        self._warm_task = None        # background cache-warmer task
 
         self._client = FirmwareClient.instance()
         self._client.baseUrlChanged.connect(self._on_table_changed)
@@ -67,15 +69,15 @@ class PatternModel(QAbstractListModel):
 
     # ------------------------------------------------------------- previews
     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)
         if cached is not None:
             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.
         self._schedule_render(rel_path)
         return ""
@@ -89,6 +91,13 @@ class PatternModel(QAbstractListModel):
         except RuntimeError:
             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):
         base_url = self._client.base_url
         try:
@@ -97,6 +106,18 @@ class PatternModel(QAbstractListModel):
             self._rendering.discard(rel_path)
         if base_url != self._client.base_url:
             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._emit_preview_changed(rel_path)
 
@@ -109,8 +130,12 @@ class PatternModel(QAbstractListModel):
 
     # -------------------------------------------------------------- fetching
     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._rendering.clear()
+        self._render_attempts.clear()
         self.refresh()
 
     @Slot()
@@ -137,7 +162,58 @@ class PatternModel(QAbstractListModel):
                 rel = rel[len("patterns/"):]
             patterns.append({"name": rel, "path": rel})
         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._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):
         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.Controls 2.15
 import QtQuick.Layouts 1.15
-import QtQuick.Dialogs
 import QtQuick.VirtualKeyboard 2.15
 import DuneWeaver 1.0
 import "components"
@@ -51,27 +50,23 @@ ApplicationWindow {
             // Navigate to Execution tab (index 3) instead of pushing page
             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 {
         id: stackView
         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 {
             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 {
         id: customErrorDialog
         modal: true
         x: (window.width - width) / 2
         y: (window.height - height) / 2
-        width: 320
-        height: 180
+        width: 380
+        height: Math.max(190, errorColumn.implicitHeight + 50)
         closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
 
         property string errorText: ""
 
         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
 
             // Rotate the entire dialog content for Pi 5
@@ -277,15 +254,16 @@ ApplicationWindow {
             transformOrigin: Item.Center
 
             Column {
+                id: errorColumn
                 anchors.fill: parent
                 anchors.margins: 20
                 spacing: 15
 
                 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
                 }
 
@@ -294,12 +272,15 @@ ApplicationWindow {
                     wrapMode: Text.WordWrap
                     width: parent.width
                     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"
+                    width: 120
+                    height: Components.ThemeManager.touchTarget
                     anchors.horizontalCenter: parent.horizontalCenter
                     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 int currentEffectIndex: 0
     property int currentPaletteIndex: 0
+    property string ledColor: "#ffffff"
     property var effectsList: []
     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: [
         {"name": "White", "color": "#e8e8e8", "sendColor": "#ffffff"},
         {"name": "Warm", "color": "#d4a574", "sendColor": "#ffaa55"},
@@ -45,6 +86,14 @@ Page {
                 ledConnected = backend.ledConnected
                 currentEffectIndex = backend.ledCurrentEffect
                 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
         Rectangle {
             Layout.fillWidth: true
-            Layout.preferredHeight: 50
+            Layout.preferredHeight: Components.ThemeManager.headerHeight
             color: Components.ThemeManager.surfaceColor
 
             Rectangle {
@@ -88,18 +137,18 @@ Page {
 
             RowLayout {
                 anchors.fill: parent
-                anchors.leftMargin: 15
-                anchors.rightMargin: 10
+                anchors.leftMargin: Components.ThemeManager.spaceLg
+                anchors.rightMargin: Components.ThemeManager.spaceLg
 
                 ConnectionStatus {
                     backend: page.backend
-                    Layout.rightMargin: 8
+                    Layout.rightMargin: Components.ThemeManager.spaceSm
                 }
 
                 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
                 }
 
@@ -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.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.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
-                            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
-                        ColumnLayout {
+                        Label {
                             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
-                            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
-                            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
-                                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
-                            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
+                                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 {
+                        id: quickColumn
                         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 {
                             Layout.fillWidth: true
-                            columns: 4
-                            rowSpacing: 6
-                            columnSpacing: 6
-                            visible: effectsList.length > 0
+                            columns: 5
+                            rowSpacing: Components.ThemeManager.spaceSm
+                            columnSpacing: Components.ThemeManager.spaceSm
 
                             Repeater {
-                                model: effectsList.slice(0, 12) // Show first 12 effects
+                                model: presetColors
 
                                 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.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 {
                                         anchors.fill: parent
                                         onClicked: {
                                             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: {
                                             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.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 {
-                                            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
-                                            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
 
 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:
@@ -42,7 +62,45 @@ def _slug(base_url: str) -> str:
 
 def _cache_path(base_url: str, rel_path: str, content_hash: str) -> 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]]:
@@ -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:
     if Image is None:
         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))
     draw = ImageDraw.Draw(img)
     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(
-        [_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 = []
+    # 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:
         rho = max(0.0, min(1.0, rho))
         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:
-        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:
         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)
     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)
     if not folder.exists():
         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 ""
 
 
-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.
 
-    ``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:
         logger.warning("Pillow not available - cannot render previews")
         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:
-            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 ""
-        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)
-            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 */}
       <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>
       </div>
     </div>
@@ -133,16 +133,18 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
     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(() => {
-    if (isVisible) {
-      // Lock body scroll when bar is visible on mobile
+    if (isVisible && isExpanded) {
       document.body.style.overflow = 'hidden'
       return () => {
         document.body.style.overflow = ''
       }
     }
-  }, [isVisible])
+  }, [isVisible, isExpanded])
 
   // Use native event listener for touchmove to prevent background scroll on the bar itself
   useEffect(() => {
@@ -273,13 +275,17 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
 
   const getThemeColors = useCallback(() => {
     const isDark = document.documentElement.classList.contains('dark')
+    const styles = getComputedStyle(document.documentElement)
+    const token = (name: string, fallback: string) =>
+      styles.getPropertyValue(name).trim() || fallback
     return {
       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 currentPoint = polarToCartesian(interpTheta, interpRho, size)
+      ctx.save()
       ctx.beginPath()
       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.restore()
       ctx.strokeStyle = colors.markerBorder
       ctx.lineWidth = 2
       ctx.stroke()
     } else if (coords.length > 0 && adjustedIndex < coords.length) {
       // At the last coordinate, just draw without interpolation
       const currentPoint = polarToCartesian(coords[adjustedIndex][0], coords[adjustedIndex][1], size)
+      ctx.save()
       ctx.beginPath()
       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.restore()
       ctx.strokeStyle = colors.markerBorder
       ctx.lineWidth = 2
       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}
                               </p>
                               {status.playlist && (
@@ -801,7 +815,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                       ) : (
                         <div className="hidden md:flex items-center gap-3">
                           <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
-                          <Progress value={progressPercent} className="h-2 flex-1" />
+                          <Progress value={progressPercent} 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'}`}
                             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">
                     <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'}`}>
                       {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
                       -{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 && (
                           <p className="text-sm text-muted-foreground">
                             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 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">
                       <span className="w-16">{formatTime(elapsedTime)}</span>
                       <span>{progressPercent.toFixed(0)}%</span>
@@ -1074,7 +1088,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                     </div>
                     <div className="min-w-0 flex-1">
                       <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)}
                       </p>
                     </div>

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

@@ -5,8 +5,10 @@
  * 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 { apiClient } from '@/lib/apiClient'
+import { useStatusStore } from '@/stores/useStatusStore'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Badge } from '@/components/ui/badge'
@@ -29,12 +31,84 @@ import {
   Check,
   Pencil,
   Trash2,
+  RefreshCw,
+  Loader2,
 } from 'lucide-react'
 
 interface TableSelectorProps {
   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) {
   const {
     tables,
@@ -54,6 +128,74 @@ export function TableSelector({ children }: TableSelectorProps) {
   const [renameValue, setRenameValue] = useState('')
   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) => {
     if (table.id !== activeTable?.id) {
       setActiveTable(table)
@@ -144,9 +286,11 @@ export function TableSelector({ children }: TableSelectorProps) {
               <span className="text-sm font-medium">Sand Tables</span>
             </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">
-              {tables.map(table => (
+              {tables.filter(t => !t.isCurrent).map(table => (
                 <div
                   key={table.id}
                   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 */}
                     <span
                       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>
@@ -235,6 +379,123 @@ export function TableSelector({ children }: TableSelectorProps) {
               ))}
             </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 */}
             <Button
               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') {
       const saved = localStorage.getItem('theme')
       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
   const [appName, setAppName] = useState(DEFAULT_APP_NAME)
   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
   // 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 tableName = activeTableData?.name || activeTable?.name
-  const displayName = hasMultipleTables && tableName ? tableName : appName
+  const displayName = boardHostname || (hasMultipleTables && tableName ? tableName : appName)
 
   // Connection & status from shared store
   const isBackendConnected = useStatusStore((s) => s.isBackendConnected)
@@ -93,8 +101,7 @@ export function Layout() {
   const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
   const isHoming = useStatusStore((s) => s.status?.is_homing ?? 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 statusIsRunning = useStatusStore((s) => s.status?.is_running ?? false)
   const statusIsPaused = useStatusStore((s) => s.status?.is_paused ?? false)
@@ -123,12 +130,13 @@ export function Layout() {
   const [passwordInput, setPasswordInput] = useState('')
   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
   const fetchAppSettings = () => {
@@ -150,6 +158,7 @@ export function Layout() {
 
   useEffect(() => {
     fetchAppSettings()
+    fetchBoardName()
 
     // Listen for branding/security updates from Settings page
     const handleBrandingUpdate = () => {
@@ -158,16 +167,34 @@ export function Layout() {
     const handleSecurityUpdate = () => {
       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('security-updated', handleSecurityUpdate)
+    window.addEventListener('board-connected', handleBoardConnected)
 
     return () => {
       window.removeEventListener('branding-updated', handleBrandingUpdate)
       window.removeEventListener('security-updated', handleSecurityUpdate)
+      window.removeEventListener('board-connected', handleBoardConnected)
     }
     // Refetch when active table changes
   }, [activeTable?.id])
 
+  // Keep the header name in sync when the live connection flips.
+  useEffect(() => {
+    fetchBoardName()
+  }, [isConnected])
+
   // Check for software updates on mount
   useEffect(() => {
     apiClient.get<{ update_available?: boolean }>('/api/version')
@@ -328,6 +355,47 @@ export function Layout() {
   const logsContainerRef = useRef<HTMLDivElement>(null)
   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
   useEffect(() => {
     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}`)
       .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
@@ -587,26 +661,29 @@ export function Layout() {
 
   // Download logs as file
   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 url = URL.createObjectURL(blob)
     const a = document.createElement('a')
     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()
     URL.revokeObjectURL(url)
   }
 
   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 {
-      await apiClient.post('/api/system/restart')
-      toast.success('Dune Weaver is restarting...')
+      await apiClient.post('/api/board/restart')
+      toast.success('Table is restarting…')
     } 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">
               <span className="material-icons-outlined text-4xl text-primary">lock</span>
             </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>
             <form onSubmit={handlePasswordSubmit} className="space-y-3">
               <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">
                   <span className="material-icons-outlined text-2xl text-primary">lock</span>
                 </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>
               </div>
               <form onSubmit={handlePasswordSubmit} className="space-y-3">
@@ -1157,16 +1234,16 @@ export function Layout() {
                     error_outline
                   </span>
                 </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">
                   The sensor homing process could not complete. The limit sensors may not be positioned correctly or may be malfunctioning.
                 </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:
                   </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>Verify the sensor positions are correct</li>
                     <li>Ensure nothing is blocking the sensor path</li>
@@ -1222,7 +1299,7 @@ export function Layout() {
                   cached
                 </span>
               </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">
                 Preparing your pattern previews...
               </p>
@@ -1273,13 +1350,13 @@ export function Layout() {
                     download_for_offline
                   </span>
                 </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">
                   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>
 
-                <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.
                   </p>
                 </div>
@@ -1318,7 +1395,7 @@ export function Layout() {
                 {/* Completion message */}
                 {cacheAllProgress?.done && (
                   <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>
                       All {cacheAllProgress.total} previews cached successfully!
                     </p>
@@ -1343,22 +1420,22 @@ export function Layout() {
             <div className="text-center space-y-4">
               <div className={`inline-flex items-center justify-center w-16 h-16 rounded-full mb-2 ${
                 homingJustCompleted
-                  ? 'bg-green-500/10'
+                  ? 'bg-success/10'
                   : isHoming
                     ? 'bg-primary/10'
-                    : 'bg-amber-500/10'
+                    : 'bg-primary/10'
               }`}>
                 <span className={`material-icons-outlined text-4xl ${
                   homingJustCompleted
-                    ? 'text-green-500'
+                    ? 'text-success'
                     : isHoming
                       ? 'text-primary animate-spin'
-                      : 'text-amber-500 animate-pulse'
+                      : 'text-primary animate-pulse'
                 }`}>
                   {homingJustCompleted ? 'check_circle' : 'sync'}
                 </span>
               </div>
-              <h2 className="text-2xl font-bold">
+              <h2 className="text-2xl font-display font-bold">
                 {homingJustCompleted
                   ? 'Homing Complete'
                   : isHoming
@@ -1379,10 +1456,10 @@ export function Layout() {
               <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
                 <span className={`w-2 h-2 rounded-full ${
                   homingJustCompleted
-                    ? 'bg-green-500'
+                    ? 'bg-success'
                     : isHoming
                       ? 'bg-primary animate-pulse'
-                      : 'bg-amber-500 animate-pulse'
+                      : 'bg-primary animate-pulse'
                 }`} />
                 <span>
                   {homingJustCompleted
@@ -1435,8 +1512,8 @@ export function Layout() {
                       {formatTimestamp(log.timestamp)}
                     </span>
                     <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' :
                       'text-foreground'
                     }`}>
@@ -1535,10 +1612,10 @@ export function Layout() {
               <button className="flex items-center gap-1.5 hover:opacity-80 transition-opacity group">
                 <ShinyText
                   text={displayName}
-                  className="font-semibold text-lg"
+                  className="font-display font-semibold text-lg"
                   speed={4}
-                  color={isDark ? '#a8a8a8' : '#555555'}
-                  shineColor={isDark ? '#ffffff' : '#999999'}
+                  color={isDark ? '#A08F77' : '#8A7A63'}
+                  shineColor={isDark ? '#D9B98A' : '#A87F45'}
                   spread={75}
                 />
                 <span className="material-icons-outlined text-muted-foreground text-sm group-hover:text-foreground transition-colors">
@@ -1547,10 +1624,10 @@ export function Layout() {
                 <span
                   className={`w-2 h-2 rounded-full ${
                     !isBackendConnected
-                      ? 'bg-gray-400'
+                      ? 'bg-muted-foreground'
                       : isConnected
-                        ? 'bg-green-500 animate-pulse'
-                        : 'bg-red-500'
+                        ? 'bg-success animate-pulse'
+                        : 'bg-destructive'
                   }`}
                   title={
                     !isBackendConnected
@@ -1564,12 +1641,26 @@ export function Layout() {
             </TableSelector>
           </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="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>
           )}
 
@@ -1590,7 +1681,7 @@ export function Layout() {
               <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="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>
               </Link>
             )}
@@ -1636,14 +1727,14 @@ export function Layout() {
                   <Separator className="my-1" />
                   <button
                     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>
                     Restart
                   </button>
                   <button
                     onClick={handleShutdown}
-                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-red-500"
+                    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>
                     Shutdown
@@ -1670,7 +1761,7 @@ export function Layout() {
               <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="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>
               </Link>
             )}
@@ -1727,7 +1818,7 @@ export function Layout() {
                       handleRestart()
                       setIsMobileMenuOpen(false)
                     }}
-                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
+                    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>
                     Restart
@@ -1737,7 +1828,7 @@ export function Layout() {
                       handleShutdown()
                       setIsMobileMenuOpen(false)
                     }}
-                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-red-500"
+                    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>
                     Shutdown
@@ -1765,7 +1856,7 @@ export function Layout() {
               : 'calc(8rem + env(safe-area-inset-bottom, 0px))' // floating pill + nav + safe area
         }}
       >
-        <Outlet context={{ isPlayOnlyActive }} />
+        <Outlet key={boardEpoch} context={{ isPlayOnlyActive }} />
       </main>
 
       {/* Now Playing Bar */}
@@ -1802,18 +1893,41 @@ export function Layout() {
             {/* 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 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
                   type="text"
                   value={logSearchQuery}
@@ -1826,10 +1940,27 @@ export function Layout() {
                     <span className="material-icons-outlined text-sm">close</span>
                   </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 className="flex items-center gap-1 shrink-0">
@@ -1868,39 +1999,91 @@ export function Layout() {
               ref={logsContainerRef}
               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>
                 ))
-              ) : (
-                <p className="text-muted-foreground text-center py-4">No logs available</p>
-              )}
+              })()}
             </div>
           </>
         )}

+ 8 - 5
frontend/src/index.css

@@ -164,15 +164,18 @@ body {
 }
 
 /* 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 targets: #7A5A2E (deep bronze,
+   day — primary's hue driven to 6:1 contrast so thin lines hold up on the
+   paper card) / #D9B98A (dune sand, night). */
 .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(52%) sepia(43%) saturate(369%) hue-rotate(355deg) brightness(69%) contrast(123%);
 }
 
 .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 */

Файловите разлики са ограничени, защото са твърде много
+ 522 - 379
frontend/src/pages/LEDPage.tsx


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

@@ -1,5 +1,5 @@
 import { useState, useEffect } from 'react'
-import { useSearchParams, useNavigate } from 'react-router-dom'
+import { useSearchParams } from 'react-router-dom'
 import { toast } from 'sonner'
 import { apiClient } from '@/lib/apiClient'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
@@ -25,6 +25,8 @@ import {
 import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
 import { SearchableSelect } from '@/components/ui/searchable-select'
 import { UpdateDialog } from '@/components/UpdateDialog'
+import { AlignOrientationDialog } from '@/components/AlignOrientationDialog'
+import { TableWifiCard } from '@/components/TableWifiCard'
 
 // Types
 
@@ -79,6 +81,17 @@ interface BoardTime {
   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 {
   provider: 'none' | 'wled' | 'board'
   wled_ip?: string
@@ -98,7 +111,6 @@ interface MqttConfig {
 
 export function SettingsPage() {
   const [searchParams, setSearchParams] = useSearchParams()
-  const navigate = useNavigate()
   const sectionParam = searchParams.get('section')
 
   // Connection state — the board is reached over HTTP (FluidNC firmware),
@@ -106,6 +118,17 @@ export function SettingsPage() {
   const [boardAddress, setBoardAddress] = useState('')
   const [isConnected, setIsConnected] = useState(false)
   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
   const [settings, setSettings] = useState<Settings>({})
@@ -186,6 +209,14 @@ export function SettingsPage() {
   } | null>(null)
   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
   const scrollToSection = (sectionId: string) => {
     const element = document.getElementById(`section-${sectionId}`)
@@ -273,6 +304,39 @@ export function SettingsPage() {
     } catch (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
@@ -306,9 +370,20 @@ export function SettingsPage() {
 
   const fetchConnection = async () => {
     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)
+      setBoardLocked(!!statusData.locked)
+      setHasBoardKey(!!statusData.has_key)
+      setTableName(statusData.hostname || null)
+      setConnectedUrl(statusData.connected ? statusData.port || null : null)
       setConnectionStatus(
         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
   useEffect(() => {
     fetchConnection()
+    fetchDiscoveredBoards()
   }, [])
 
   // Refetch when backend reconnects
   useOnBackendConnected(() => {
     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 () => {
     try {
       // 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')
       return
     }
+    if (overrideAddress) setBoardAddress(overrideAddress)
     setIsLoading('connect')
     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) {
         setIsConnected(true)
-        setConnectionStatus(`Connected to ${boardAddress.trim()}`)
+        setConnectedUrl(target)
+        setBoardLocked(false)
+        setBoardPassword('')
+        setConnectionStatus(`Connected to ${target}`)
         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 {
         throw new Error(data.message || 'Connection failed')
       }
     } 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 {
       setIsLoading(null)
     }
@@ -467,8 +586,11 @@ export function SettingsPage() {
       const data = await apiClient.post<{ success?: boolean }>('/disconnect')
       if (data.success) {
         setIsConnected(false)
+        setConnectedUrl(null)
         setConnectionStatus('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) {
       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">
       {/* Page Header */}
       <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">
           Configure your sand table
         </p>
@@ -764,14 +886,15 @@ export function SettingsPage() {
             {/* Connection Status */}
             <div className="flex items-center justify-between p-4 rounded-lg border">
               <div className="flex items-center gap-3">
-                <div className={`w-10 h-10 flex items-center justify-center rounded-lg ${isConnected ? 'bg-green-100 dark:bg-green-900' : 'bg-muted'}`}>
-                  <span className={`material-icons ${isConnected ? 'text-green-600' : 'text-muted-foreground'}`}>
+                <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'}
                   </span>
                 </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}
                   </p>
                 </div>
@@ -788,6 +911,68 @@ export function SettingsPage() {
               )}
             </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 */}
             <div className="space-y-3">
               <Label htmlFor="board-address">Table address</Label>
@@ -802,7 +987,7 @@ export function SettingsPage() {
                   className="flex-1"
                 />
                 <Button
-                  onClick={handleConnect}
+                  onClick={() => handleConnect()}
                   disabled={isLoading === 'connect' || !boardAddress.trim()}
                   className="gap-2"
                 >
@@ -814,12 +999,34 @@ export function SettingsPage() {
                   Connect
                 </Button>
               </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">
                 The FluidNC controller inside the table, on the same network as this server.
                 The address is saved and reconnected automatically on startup.
               </p>
             </div>
 
+            {/* Table Wi-Fi (the board's own network; distinct from host Wi-Fi setup) */}
+            <TableWifiCard isConnected={isConnected} />
+
             {/* Home on Connect */}
             <div className="p-4 rounded-lg border space-y-3">
               <div className="flex items-center justify-between">
@@ -902,6 +1109,18 @@ export function SettingsPage() {
               </RadioGroup>
             </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) */}
             {settings.homing_mode === 1 && (
               <div className="space-y-3">
@@ -970,31 +1189,6 @@ export function SettingsPage() {
               )}
             </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
               onClick={handleSaveHomingConfig}
               disabled={isLoading === 'homing'}
@@ -2185,36 +2379,85 @@ export function SettingsPage() {
               )}
               Save Security Settings
             </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>
+              {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>
-          </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>
         </AccordionItem>
 
@@ -2234,45 +2477,94 @@ export function SettingsPage() {
             </div>
           </AccordionTrigger>
           <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="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
                 <span className="material-icons text-muted-foreground">terminal</span>
               </div>
               <div className="flex-1">
-                <p className="font-medium">Current Version</p>
-                <p className="text-sm text-muted-foreground">
-                  {versionInfo?.current ? `v${versionInfo.current}` : 'Loading...'}
+                <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>
               </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="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 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>
               </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

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

@@ -30,6 +30,36 @@ import {
 import { apiClient } from '@/lib/apiClient'
 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() {
   const [speedInput, setSpeedInput] = useState('')
   const [currentSpeed, setCurrentSpeed] = useState<number | null>(null)
@@ -41,15 +71,21 @@ export function TableControlPage() {
   const isPatternRunning = useStatusStore((s) =>
     (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)
   useEffect(() => {
     if (speed !== null) setCurrentSpeed(speed)
   }, [speed])
 
-  // Serial terminal state
+  // Command console state
   const [consoleCommand, setConsoleCommand] = useState('')
   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 consoleOutputRef = useRef<HTMLDivElement>(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">
         {/* Page Header */}
         <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">
             Manual controls for your sand table
           </p>
@@ -323,9 +359,9 @@ export function TableControlPage() {
                         This will send a soft reset to the controller.
                       </DialogDescription>
                     </DialogHeader>
-                    <Alert className="flex items-center border-amber-500/50">
-                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
-                      <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.
                       </AlertDescription>
                     </Alert>
@@ -476,11 +512,11 @@ export function TableControlPage() {
 
                     <Separator />
 
-                    <Alert className="flex items-start border-amber-500/50">
-                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">
+                    <Alert className="flex items-start border-primary/40">
+                      <span className="material-icons-outlined text-primary text-base mr-2 shrink-0">
                         warning
                       </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.
                       </AlertDescription>
                     </Alert>
@@ -590,6 +626,95 @@ export function TableControlPage() {
           </Card>
         </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 */}
         <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
           <CardHeader className="pb-3 space-y-3">
@@ -602,9 +727,9 @@ export function TableControlPage() {
                 <CardDescription className="hidden sm:block">
                   Send $-commands to the table's firmware (e.g. $Sand/HomingMode, $LED/Effect=fire)
                 </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.
                   </AlertDescription>
                 </Alert>
@@ -633,19 +758,19 @@ export function TableControlPage() {
                     key={i}
                     className={`${
                       entry.type === 'cmd'
-                        ? 'text-cyan-400'
+                        ? 'text-live'
                         : 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.text}
                   </div>
                 ))
               ) : (
-                <div className="text-gray-500 italic">
+                <div className="text-muted-foreground italic">
                   Ready. Enter a firmware command (e.g. $Sand/HomingMode, $Playlist/List)
                 </div>
               )}

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

@@ -5,6 +5,7 @@ export interface StatusData {
   current_file: string | null
   is_paused: boolean
   is_running: boolean
+  is_alarm: boolean
   is_homing: boolean
   is_clearing: boolean
   sensor_homing_failed: boolean
@@ -39,6 +40,16 @@ export interface StatusData {
   current_rho: number
   firmware_version: 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 {

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

@@ -324,6 +324,23 @@ export const handlers = [
     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
   http.post('/api/debug-serial/open', () => {
     return HttpResponse.json({ success: true })

+ 331 - 25
main.py

@@ -33,6 +33,7 @@ import base64
 import hashlib
 import time
 import subprocess
+import requests
 
 # Get log level from environment variable, default to INFO
 log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
@@ -162,11 +163,6 @@ async def lifespan(app: FastAPI):
             state.led_controller = LEDInterface("board")
             logger.info("LED controller initialized: table's built-in LEDs (firmware-controlled)")
         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
             logger.info("LED controller not configured")
 
@@ -267,6 +263,8 @@ def get_preview_semaphore() -> asyncio.Semaphore:
 # Pydantic models for request/response validation
 class ConnectRequest(BaseModel):
     port: Optional[str] = None
+    # Board API password ($Sand/Password); stored and sent as X-Sand-Key.
+    password: Optional[str] = None
 
 class auto_playModeRequest(BaseModel):
     enabled: bool
@@ -317,7 +315,7 @@ class WLEDRequest(BaseModel):
     wled_ip: Optional[str] = None
 
 class LEDConfigRequest(BaseModel):
-    provider: str  # "wled", "dw_leds", or "none"
+    provider: str  # "wled", "board", or "none"
     ip_address: Optional[str] = None  # For WLED only
     # DW LED specific fields
     num_leds: Optional[int] = None
@@ -954,6 +952,286 @@ async def update_board_settings(update: BoardSettingsUpdate):
 class BoardCommandRequest(BaseModel):
     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"])
 async def board_command(request: BoardCommandRequest):
     """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()}
 
+@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"])
 async def get_known_tables():
     """
@@ -1257,8 +1545,10 @@ async def set_homing_config(request: HomingConfigRequest):
 
 @app.get("/list_serial_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")
 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()
         state.board_url = 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.conn = FluidNCClient(url)
+        state.conn = FluidNCClient(url, api_key=state.board_api_key)
         if not state.conn.reachable():
+            state.board_locked = state.conn.locked
             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}")
+        state.board_locked = False
         if not await asyncio.to_thread(connection_manager.device_init, False):
             raise HTTPException(status_code=500, detail="Failed to initialize board")
         logger.info(f"Successfully connected to board at {url}")
@@ -1287,25 +1586,26 @@ async def connect(request: ConnectRequest):
 @app.post("/disconnect")
 async def disconnect():
     try:
+        state.user_disconnected = True  # suppress the observer's auto-reconnect
         state.conn.close()
-        logger.info('Successfully disconnected from serial port')
+        logger.info('Successfully disconnected from board')
         return {"success": True}
     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))
 
 @app.post("/restart_connection")
 async def restart(request: ConnectRequest):
     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")
 
     try:
-        logger.info(f"Restarting connection on port {request.port}")
+        logger.info(f"Restarting connection to board {request.port}")
         connection_manager.restart_connection()
         return {"success": True}
     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))
 
 
@@ -2022,6 +2322,12 @@ async def serial_status():
     return {
         "connected": connected,
         "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")
@@ -2960,7 +3266,7 @@ async def led_control_page(request: Request):
 @app.get("/api/dw_leds/status")
 async def dw_leds_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"}
 
     try:
@@ -2972,7 +3278,7 @@ async def dw_leds_status():
 @app.post("/api/dw_leds/power")
 async def dw_leds_power(request: dict):
     """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")
 
     state_value = request.get("state", 1)
@@ -2996,7 +3302,7 @@ async def dw_leds_power(request: dict):
 @app.post("/api/dw_leds/brightness")
 async def dw_leds_brightness(request: dict):
     """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")
 
     value = request.get("value", 50)
@@ -3014,7 +3320,7 @@ async def dw_leds_brightness(request: dict):
 @app.post("/api/dw_leds/color")
 async def dw_leds_color(request: dict):
     """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")
 
     # 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")
 async def dw_leds_colors(request: dict):
     """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")
 
     # Parse colors from request
@@ -3092,7 +3398,7 @@ async def dw_leds_colors(request: dict):
 @app.get("/api/dw_leds/effects")
 async def dw_leds_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")
 
     try:
@@ -3114,7 +3420,7 @@ async def dw_leds_effects():
 @app.get("/api/dw_leds/palettes")
 async def dw_leds_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")
 
     try:
@@ -3133,7 +3439,7 @@ async def dw_leds_palettes():
 @app.post("/api/dw_leds/effect")
 async def dw_leds_effect(request: dict):
     """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")
 
     effect_id = request.get("effect_id", 0)
@@ -3155,7 +3461,7 @@ async def dw_leds_effect(request: dict):
 @app.post("/api/dw_leds/palette")
 async def dw_leds_palette(request: dict):
     """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")
 
     palette_id = request.get("palette_id", 0)
@@ -3175,7 +3481,7 @@ async def dw_leds_palette(request: dict):
 @app.post("/api/dw_leds/speed")
 async def dw_leds_speed(request: dict):
     """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")
 
     value = request.get("speed", 128)
@@ -3210,7 +3516,7 @@ async def dw_leds_ball(request: dict):
 @app.post("/api/dw_leds/intensity")
 async def dw_leds_intensity(request: dict):
     """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")
 
     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
 the rest of the app already calls (connect_device, device_init, home,
 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
 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"]
     if st.get("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:
@@ -97,7 +108,7 @@ def poll_status_once() -> dict | None:
 # Connection lifecycle
 ###############################################################################
 
-def list_serial_ports():
+def list_board_urls():
     """
     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
@@ -152,6 +163,11 @@ def device_init(homing=True):
 
     state.firmware_type = "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)
     _sync_homing_settings()
 
@@ -185,11 +201,17 @@ def connect_device(homing=True):
 
     url = board_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():
-        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
     else:
+        state.board_locked = False
         state.port = url
         logger.info(f"Connected to board at {url}")
         device_init(homing)

+ 161 - 4
modules/connection/fluidnc_client.py

@@ -17,25 +17,96 @@ Design notes:
 """
 
 import logging
+import random
+import socket
+import time
+from urllib.parse import urlparse
+
 import requests
 
 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:
     """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"
-        self.base_url = base_url.rstrip("/")
+        self.base_url = _pin_ipv4(base_url.rstrip("/"))
         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).
         self._connected = False
+        # True when the board rejected us with 401 (locked, wrong/missing key).
+        self.locked = False
 
     # -- 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):
-        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()
         return r
 
@@ -52,17 +123,52 @@ class FluidNCClient:
         try:
             self._get("/sand_status", timeout=3.0)
             self._connected = True
+            self.locked = False
         except Exception as e:
             logger.debug(f"Board unreachable at {self.base_url}: {e}")
             self._connected = False
         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 ---------------------------------------------------------------
 
     def get_status(self) -> dict:
         """The board's /sand_status object (schema in API.md)."""
         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:
         """Flat string map of board settings (keys like 'Sand/HomingMode')."""
         return self._get("/sand_settings").json()
@@ -79,7 +185,8 @@ class FluidNCClient:
 
     def file_exists(self, sd_path: str) -> bool:
         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
             r.close()
             return ok
@@ -179,6 +286,55 @@ class FluidNCClient:
         """Reboot the controller (loses position) — host re-homes afterward."""
         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) -------------------------
 
     def upload_file(self, sd_path: str, data: bytes, directory: str) -> dict:
@@ -195,6 +351,7 @@ class FluidNCClient:
             data=form,
             files=files,
             timeout=30.0,
+            headers=self._headers(),
         )
         r.raise_for_status()
         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 os
 import threading
 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).
 # ---------------------------------------------------------------------------
 
-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"
 
 
-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
     if not conn:
         return
+    resolve = resolve or make_sd_path_resolver(conn)
     try:
         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")
         logger.info(f"Mirrored playlist '{name}' to board ({len(files or [])} patterns)")
     except Exception as e:
         logger.warning(f"Could not mirror playlist '{name}' to board: {e}")
         return
     if ensure_patterns:
-        from modules.core.pattern_manager import _ensure_on_board, _to_sd_path
         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:
@@ -315,20 +321,125 @@ def unmirror_playlist(name: str, conn=None) -> None:
         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
     conn = conn or state.conn
     if not conn:
         return
     try:
-        playlists = playlist_manager.load_playlists()
+        names = conn.list_playlists()
     except Exception as e:
-        logger.warning(f"Could not load playlists for mirroring: {e}")
+        logger.warning(f"Could not list board playlists: {e}")
         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:
-    """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
     if not conn:
         return
@@ -380,11 +497,9 @@ def sync_on_connect(conn=None) -> None:
     except Exception as e:
         logger.warning(f"Board clock sync failed: {e}")
     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:
-        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
 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 = 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 = {
     'version': CACHE_SCHEMA_VERSION,
@@ -644,10 +673,11 @@ async def generate_metadata_cache():
             "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
         for i in range(0, total_files, batch_size):
             batch = files_to_process[i:i + batch_size]
@@ -671,8 +701,9 @@ async def generate_metadata_cache():
                         successful += 1
                         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:
                     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
             # without hammering the disk once per pattern.
             await cache_pattern_metadata_batch(batch_entries)
-            
+
             # Update progress
             cache_progress["processed_files"] = min(i + batch_size, total_files)
-            
+
             # Log progress
             progress = min(i + batch_size, total_files)
             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)
         
         logger.info(f"Metadata cache generation completed: {successful}/{total_files} patterns cached successfully, {skipped_files} patterns skipped (already cached)")

+ 271 - 11
modules/core/execution.py

@@ -32,6 +32,62 @@ from modules.core.state import state
 
 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.
 CLEAR_MODES = ("none", "adaptive", "in", "out", "sideway", "random")
 _LEGACY_CLEAR = {
@@ -80,17 +136,43 @@ def _state(st: Optional[dict]) -> str:
     return ((st or {}).get("state") or "").split(":", 1)[0]
 
 
+_sd_path_cache: dict = {}
+
+
 def _from_sd_path(sd_path: str) -> Optional[str]:
     """Map a board SD path ('/patterns/x.thr', '/sd/patterns/x.thr') to the
-    host-relative path ('./patterns/x.thr') the frontend/history expect."""
+    host-relative path ('./patterns/x.thr') the frontend/history expect.
+
+    The board's SD layout can differ from the host catalog (a host
+    'custom_patterns/x.thr' uploads to SD 'patterns/x.thr'), so when the
+    literal mapping doesn't exist locally, re-find the pattern in the host
+    catalog by path suffix, then unique basename — the reverse of
+    make_sd_path_resolver's matching. Otherwise previews/history for a
+    playing custom pattern point at a path the host doesn't have."""
     if not sd_path:
         return None
+    cached = _sd_path_cache.get(sd_path)
+    if cached:
+        return cached
     p = sd_path.replace("\\", "/")
     if p.startswith("/sd/"):
         p = p[3:]
-    if not p.startswith("/"):
-        p = "/" + p
-    return "." + p
+    p = p.lstrip("/")
+    result = "./" + p
+    if not os.path.exists(result):
+        from modules.core.pattern_manager import list_theta_rho_files
+        rel = p[len("patterns/"):] if p.startswith("patterns/") else p
+        catalog = list_theta_rho_files()
+        hits = [r for r in catalog if r == rel or r.endswith("/" + rel)]
+        if not hits:
+            base = rel.rsplit("/", 1)[-1]
+            hits = [r for r in catalog if r.rsplit("/", 1)[-1] == base]
+        if len(hits) == 1:
+            result = "./patterns/" + hits[0]
+    if len(_sd_path_cache) > 512:
+        _sd_path_cache.clear()
+    _sd_path_cache[sd_path] = result
+    return result
 
 
 # ---------------------------------------------------------------------------
@@ -122,9 +204,11 @@ async def run_pattern(file_path: str, clear_pattern: str = "none") -> None:
     aborts any current job first)."""
     global current_run
     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)
     try:
         await asyncio.to_thread(conn.set_feed, int(state.speed))
@@ -152,7 +236,7 @@ async def start_playlist(playlist_name: str, run_mode: str = "single",
     global current_run
     conn = _require_conn()
     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)
     if not playlist:
@@ -184,12 +268,17 @@ async def start_playlist(playlist_name: str, run_mode: str = "single",
 
     # The board needs the playlist file before Run; patterns can trail behind
     # (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():
         for p in host_paths[1:]:
-            _ensure_on_board(p, _to_sd_path(p))
+            _ensure_on_board(p, resolve_sd(p))
     import threading
     threading.Thread(target=_mirror_rest, daemon=True).start()
 
@@ -273,6 +362,28 @@ def _reset_run_state() -> None:
 # 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,
                      now: Optional[float] = None) -> dict:
     """Translate a raw board status into the /ws/status data object.
@@ -289,6 +400,7 @@ def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
             "current_file": None,
             "is_running": False,
             "is_paused": False,
+            "is_alarm": False,
             "is_homing": state.is_homing,
             "sensor_homing_failed": state.sensor_homing_failed,
             "is_clearing": False,
@@ -301,6 +413,7 @@ def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
             "current_rho": state.current_rho,
             "firmware_version": state.firmware_version,
             "table_type": None,
+            "health": _board_health(None),
         }
 
     pl = st.get("playlist") or {}
@@ -366,6 +479,7 @@ def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
         "current_file": current_file,
         "is_running": running,
         "is_paused": machine_state == "Hold",
+        "is_alarm": machine_state == "Alarm",
         "is_homing": machine_state == "Home" or state.is_homing,
         "sensor_homing_failed": state.sensor_homing_failed,
         "is_clearing": clearing,
@@ -378,6 +492,7 @@ def translate_status(st: Optional[dict], obs: Optional["BoardObserver"] = None,
         "current_rho": st.get("rho", state.current_rho),
         "firmware_version": st.get("fw") or state.firmware_version,
         "table_type": None,
+        "health": _board_health(st),
     }
 
 
@@ -391,6 +506,7 @@ class BoardObserver:
     POLL_ACTIVE = 1.0
     POLL_IDLE = 2.0
     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):
         self.prev: Optional[dict] = None
@@ -405,7 +521,16 @@ class BoardObserver:
         self._was_quiet_wled_off = False
         self._fail_count = 0
         self._tick = 0
+        self._last_reconnect = 0.0
         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).
         self.on_status: Optional[Callable] = None
 
@@ -450,6 +575,8 @@ class BoardObserver:
             await asyncio.sleep(interval)
 
     async def _tick_once(self) -> float:
+        if self.suspended:
+            return self.POLL_IDLE
         st = None
         if state.conn and state.conn.is_connected():
             try:
@@ -457,10 +584,73 @@ class BoardObserver:
             except Exception as e:
                 logger.debug(f"Status poll failed: {e}")
         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")
                               or _state(st) in ("Hold", "Home", "Jog")))
         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) ----------
 
     async def process(self, st: Optional[dict], now: Optional[float] = None) -> dict:
@@ -476,6 +666,10 @@ class BoardObserver:
                 _reset_run_state()
                 self.reset()
         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
             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")
@@ -487,14 +681,23 @@ class BoardObserver:
 
         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
         if self._tick % 30 == 0 and state.conn and state.conn.is_connected():
+            from modules.core import board_settings
             try:
-                from modules.core import board_settings
                 settings_map = await asyncio.to_thread(state.conn.get_settings)
                 board_settings.adopt_still_sands(settings_map)
+                board_settings.adopt_auto_home(settings_map)
             except Exception as 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)
         # Mirror the progress 4-tuple for the MQTT handler, which unpacks it.
@@ -510,6 +713,63 @@ class BoardObserver:
                 logger.debug(f"Status broadcast failed: {e}")
         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:
         prev = self.prev 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__)
 
 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
 
 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:
     """Advertises this table via mDNS and tracks peer tables on the LAN."""
 
     def __init__(self):
         self._aiozc = None
         self._browser = None
+        self._board_browser = None
         self._service_info = None
         self._own_id: Optional[str] = None
         # Keyed by mDNS service name so Removed events can evict entries
         self._discovered: Dict[str, dict] = {}
+        self._boards: Dict[str, dict] = {}
 
     @property
     def is_running(self) -> bool:
@@ -97,6 +144,10 @@ class TableDiscovery:
         """Currently visible peer tables (excludes this table)."""
         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):
         if not ZEROCONF_AVAILABLE:
             logger.warning("zeroconf package not installed - mDNS table discovery disabled")
@@ -129,7 +180,10 @@ class TableDiscovery:
             self._browser = AsyncServiceBrowser(
                 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:
             logger.warning(f"mDNS discovery failed to start: {e}")
             await self.stop()
@@ -158,6 +212,8 @@ class TableDiscovery:
         try:
             if self._browser:
                 await self._browser.async_cancel()
+            if self._board_browser:
+                await self._board_browser.async_cancel()
             if self._service_info:
                 await self._aiozc.async_unregister_service(self._service_info)
             await self._aiozc.async_close()
@@ -166,8 +222,10 @@ class TableDiscovery:
         finally:
             self._aiozc = None
             self._browser = None
+            self._board_browser = None
             self._service_info = None
             self._discovered.clear()
+            self._boards.clear()
 
     @staticmethod
     def _properties(table_id: str, table_name: str, version: Optional[str]) -> Dict[str, str]:
@@ -209,6 +267,30 @@ class TableDiscovery:
         except Exception as 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
 discovery = TableDiscovery()

+ 72 - 6
modules/core/pattern_manager.py

@@ -361,13 +361,73 @@ def parse_theta_rho_file(file_path):
     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):
-    """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):
@@ -375,6 +435,12 @@ def _ensure_on_board(file_path, sd_path):
     try:
         if not state.conn:
             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):
             return
         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)
         self.firmware_type = None  # 'fluidnc', 'grbl', or 'unknown'
         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
         # 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).
         # None => fall back to the DUNE_BOARD_URL env var or the built-in default.
         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.led_provider = "none"  # "wled", "board", or "none"
         self.led_controller = None
@@ -272,6 +288,9 @@ class AppState:
         mqtt_password_encoded = ""
         if self.mqtt_password:
             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 {
             "speed": self._speed,
@@ -290,6 +309,8 @@ class AppState:
             "custom_clear_from_in": self.custom_clear_from_in,
             "custom_clear_from_out": self.custom_clear_from_out,
             "board_url": self.board_url,
+            "board_api_key": board_api_key_encoded,
+            "board_mac": self.board_mac,
             "wled_ip": self.wled_ip,
             "led_provider": self.led_provider,
             "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_out = data.get("custom_clear_from_out", 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.led_provider = data.get('led_provider', "none")
 
@@ -494,12 +518,6 @@ class AppState:
             except Exception as 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):
         """Reset all state variables to their default values."""
         self.__init__()  # Reinitialize the state

+ 16 - 1
modules/led/board_led_controller.py

@@ -94,9 +94,24 @@ class BoardLEDController:
         if led is None:
             return {"connected": False, "error": "Table not connected"}
         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":
             self._last_effect = effect
-        brightness_255 = int(led.get("Brightness", 128) or 0)
         palette = (led.get("Palette") or "rainbow").lower()
         return {
             "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-BbPVIrry.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/dist/assets/index-CEWL2r_b.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/dist/assets/index-Djx5fY-0.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/dist/assets/index-ZKmcHv9J.js


+ 2 - 2
static/dist/index.html

@@ -58,8 +58,8 @@
           .catch(function() {});
       })();
     </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-ZKmcHv9J.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Djx5fY-0.css">
   <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
   <body>
     <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_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
 
     # 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_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
 
     # 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."""
         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")
 
         assert response.status_code == 200
@@ -79,7 +79,7 @@ class TestListSerialPorts:
     @pytest.mark.asyncio
     async def test_list_serial_ports_empty(self, async_client):
         """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")
 
         assert response.status_code == 200

+ 2 - 1
tests/unit/test_board_settings.py

@@ -167,8 +167,9 @@ class TestApplyAutostart:
 
 class TestPlaylistMirroring:
     def test_playlist_content_uses_sd_paths(self):
+        from modules.core.pattern_manager import _to_sd_path
         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"
 

+ 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"]["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:
     """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("") == ""
 
-    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
         mock_state.board_url = "http://192.168.1.9"
         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"]
 
     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.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):
         from modules.connection import connection_manager as cm
         mock_state.conn.get_status.return_value = {"state": "Idle"}

+ 13 - 0
tests/unit/test_execution.py

@@ -113,6 +113,19 @@ class TestMapping:
         assert _from_sd_path("/sd/patterns/x.thr") == "./patterns/x.thr"
         assert _from_sd_path("") is None
 
+    def test_sd_path_refinds_relocated_pattern(self, tmp_path, monkeypatch):
+        # A host custom pattern uploads to SD as 'patterns/<basename>'; the
+        # board reports that SD path, and the mapping must re-find the host
+        # copy (custom_patterns/...) rather than a path that doesn't exist.
+        from modules.core import execution, pattern_manager
+        (tmp_path / "custom_patterns").mkdir()
+        (tmp_path / "custom_patterns" / "capybara.thr").write_text("0 0\n")
+        monkeypatch.setattr(pattern_manager, "THETA_RHO_DIR", str(tmp_path))
+        monkeypatch.setattr(execution, "_sd_path_cache", {})
+        assert _from_sd_path("/sd/patterns/capybara.thr") == "./patterns/custom_patterns/capybara.thr"
+        # Unknown-everywhere paths keep the literal mapping as fallback
+        assert _from_sd_path("/sd/patterns/nope.thr") == "./patterns/nope.thr"
+
 
 class TestTranslateStatus:
     def test_offline(self):

+ 54 - 0
tests/unit/test_pattern_manager.py

@@ -243,3 +243,57 @@ class TestListThetaRhoFiles:
             files = list_theta_rho_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"

Някои файлове не бяха показани, защото твърде много файлове са промени