Selaa lähdekoodia

Add kiosk code (#89)

* add auto play on boot, fix UI loading performance issue

* clean up

* cache check should be async

* add ignored port

* clean up error

* handle wrong serial conn

* properly close conn

* add kiosk mode

* add usb port

* generate png preview

* fix startup issue

* fix screen timeout

* standardize common selection (speed, screen timeout, and pause between patterns)

* add splash screen

* fix emojis

* misc changes

* Add eglfs support for Raspberry Pi with DSI display

- Update run.sh with automatic platform detection (macOS/Linux/eglfs)
- Configure eglfs environment variables for DSI-1 display on card0
- Update install.sh to configure boot settings and install system PySide6
- Automatically configure config.txt with vc4-kms-v3d (full KMS) for eglfs
- Install Debian Qt6/PySide6 packages with proper DRM/KMS/GBM integration
- Support touchscreen input via evdev
- Set GPU memory to 128MB for graphics performance

The PyPI PySide6 wheels don't include complete eglfs integration plugins.
This setup uses system PySide6 from Debian repos which has proper support
for embedded displays on Raspberry Pi. The install script now handles all
boot configuration automatically including switching from fkms to full KMS.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* rotate screen and invert touch

* ignoring first touch event

* add unclutter, add scrolling to palylist page

* fix service startup + preview

* fix preview for exec page

* change speed options

* add darkmode

* fix stop button

* properly exit app

---------

Co-authored-by: Claude <noreply@anthropic.com>
Tuan Nguyen 3 kuukautta sitten
vanhempi
sitoutus
d499747f90
45 muutettua tiedostoa jossa 7603 lisäystä ja 8 poistoa
  1. 2 0
      docker-compose.yml
  2. 7 0
      dune-weaver-touch/.gitattributes
  3. 387 0
      dune-weaver-touch/RASPBERRY_PI_SETUP.md
  4. 128 0
      dune-weaver-touch/README.md
  5. 1239 0
      dune-weaver-touch/backend.py
  6. 170 0
      dune-weaver-touch/configure-boot.sh
  7. 9 0
      dune-weaver-touch/dune-weaver-touch.desktop
  8. 29 0
      dune-weaver-touch/dune-weaver-touch.service
  9. 46 0
      dune-weaver-touch/install-service.sh
  10. 335 0
      dune-weaver-touch/install.sh
  11. 151 0
      dune-weaver-touch/main.py
  12. 101 0
      dune-weaver-touch/models/pattern_model.py
  13. 85 0
      dune-weaver-touch/models/playlist_model.py
  14. 204 0
      dune-weaver-touch/png_cache_manager.py
  15. 109 0
      dune-weaver-touch/qml/components/BottomNavTab.qml
  16. 67 0
      dune-weaver-touch/qml/components/BottomNavigation.qml
  17. 161 0
      dune-weaver-touch/qml/components/ConnectionSplash.qml
  18. 61 0
      dune-weaver-touch/qml/components/ConnectionStatus.qml
  19. 66 0
      dune-weaver-touch/qml/components/KeyboardHelper.qml
  20. 103 0
      dune-weaver-touch/qml/components/ModernControlButton.qml
  21. 153 0
      dune-weaver-touch/qml/components/ModernPatternCard.qml
  22. 53 0
      dune-weaver-touch/qml/components/PatternCard.qml
  23. 77 0
      dune-weaver-touch/qml/components/ThemeManager.qml
  24. 53 0
      dune-weaver-touch/qml/components/VirtualKeyboardLoader.qml
  25. 1 0
      dune-weaver-touch/qml/components/qmldir
  26. 237 0
      dune-weaver-touch/qml/main.qml
  27. 478 0
      dune-weaver-touch/qml/pages/ExecutionPage.qml
  28. 286 0
      dune-weaver-touch/qml/pages/ModernPatternListPage.qml
  29. 899 0
      dune-weaver-touch/qml/pages/ModernPlaylistPage.qml
  30. 297 0
      dune-weaver-touch/qml/pages/PatternDetailPage.qml
  31. 68 0
      dune-weaver-touch/qml/pages/PatternListPage.qml
  32. 93 0
      dune-weaver-touch/qml/pages/PlaylistPage.qml
  33. 588 0
      dune-weaver-touch/qml/pages/TableControlPage.qml
  34. 4 0
      dune-weaver-touch/requirements.txt
  35. 87 0
      dune-weaver-touch/run.sh
  36. 59 0
      dune-weaver-touch/scripts/install-scripts.sh
  37. 6 0
      dune-weaver-touch/scripts/screen-off
  38. 12 0
      dune-weaver-touch/scripts/screen-on
  39. 37 0
      dune-weaver-touch/scripts/touch-monitor
  40. 58 0
      dune-weaver-touch/setup-autologin.sh
  41. 148 0
      dune-weaver-touch/setup-autostart.sh
  42. 441 0
      dune-weaver-touch/setup_kiosk.sh
  43. 3 3
      main.py
  44. 1 1
      modules/connection/connection_manager.py
  45. 4 4
      modules/core/preview.py

+ 2 - 0
docker-compose.yml

@@ -19,6 +19,8 @@ services:
       - /sys:/sys
     devices:
       - "/dev/ttyACM0:/dev/ttyACM0"  # Serial device for stepper motors
+      - "/dev/ttyUSB0:/dev/ttyUSB0"  # Alternative serial device
+      - "/dev/ttyAMA0:/dev/ttyAMA0"  # Alternative serial device
       - "/dev/gpiomem:/dev/gpiomem"  # GPIO memory access for DW LEDs
       - "/dev/mem:/dev/mem"          # Direct memory access for PWM
     privileged: true  # Required for GPIO/PWM access

+ 7 - 0
dune-weaver-touch/.gitattributes

@@ -0,0 +1,7 @@
+# Ensure shell scripts always use LF line endings
+*.sh text eol=lf
+install.sh text eol=lf
+setup-autostart.sh text eol=lf
+
+# Scripts should also use LF
+scripts/* text eol=lf

+ 387 - 0
dune-weaver-touch/RASPBERRY_PI_SETUP.md

@@ -0,0 +1,387 @@
+# Raspberry Pi MIPI DSI Touchscreen Setup for Dune Weaver Touch
+
+## Prerequisites
+Make sure your Raspberry Pi is running a recent version of Raspberry Pi OS (Bullseye or newer).
+
+**MIPI DSI Display Notes**: This guide is specifically configured for the **Freenove 5" MIPI DSI Touchscreen (800x480)** which uses driver-free configuration on modern Raspberry Pi OS.
+
+## Install System Dependencies
+
+```bash
+# Update system packages
+sudo apt update && sudo apt upgrade -y
+
+# Install Qt6 system packages (includes eglfs platform plugin)
+sudo apt install -y qt6-base-dev qt6-qml-dev qt6-quick-dev
+
+# Install graphics and display dependencies
+sudo apt install -y \
+    libgl1-mesa-dev \
+    libgles2-mesa-dev \
+    libegl1-mesa-dev \
+    libdrm2 \
+    libxkbcommon0 \
+    libinput10 \
+    libudev1
+
+# Install Python and pip
+sudo apt install -y python3-dev python3-pip
+
+# Install PySide6 from system packages (recommended for embedded)
+sudo apt install -y python3-pyside6.qtcore python3-pyside6.qtgui python3-pyside6.qtqml python3-pyside6.qtquick python3-pyside6.qtwebsockets
+
+# Alternative: Install PySide6 via pip (if system packages don't work)
+# pip3 install PySide6
+```
+
+## Configure Freenove 5" MIPI DSI Display
+
+### Step 1: Physical Connection
+1. Connect the display ribbon cable to the MIPI DSI port (next to camera connector)
+2. Connect the USB cable for touch (to any USB port)
+3. Power on the Raspberry Pi
+
+### Step 2: Configure Boot Settings
+
+The Freenove display is driver-free on newer Pi OS, but needs proper configuration:
+
+```bash
+sudo nano /boot/config.txt
+
+# Add these lines for Freenove 5" MIPI DSI display
+dtoverlay=vc4-kms-v3d
+max_framebuffers=2
+
+# GPU memory for graphics acceleration  
+gpu_mem=128
+
+# Enable DSI display (driver-free detection)
+display_auto_detect=1
+
+# Force 800x480 resolution if auto-detect fails
+# hdmi_group=2
+# hdmi_mode=87
+# hdmi_cvt=800 480 60 6 0 0 0
+
+# Disable overscan for exact pixel mapping
+disable_overscan=1
+
+# Optional: Disable rainbow splash screen
+disable_splash=1
+
+# Optional: Rotate display if mounted upside down
+# display_rotate=2
+```
+
+### Step 3: Configure Touch Input
+
+The Freenove display uses USB for 5-point capacitive touch (driver-free):
+
+```bash
+# Verify touch is detected via USB
+lsusb | grep -i touch
+ls /dev/input/event*
+
+# Check touch events
+sudo evtest
+# Select the touch device (usually event0 or event1)
+
+# Touch should work automatically - no additional drivers needed
+```
+
+### Step 4: Reboot and Verify Display
+```bash
+sudo reboot
+
+# After reboot, verify display is working
+# Check framebuffer resolution
+fbset -fb /dev/fb0
+
+# Verify display resolution
+xrandr
+# Should show 800x480 resolution
+
+# Test display with simple graphics
+sudo apt install -y fbi
+sudo fbi -T 1 /usr/share/pixmaps/debian-logo.png
+```
+
+## Configure Display for Application
+
+### Option 1: Direct Framebuffer (Kiosk Mode - Recommended)
+For a dedicated touchscreen kiosk:
+
+```bash
+# Add to ~/.bashrc or create a startup script
+export QT_QPA_PLATFORM=eglfs
+export QT_QPA_EGLFS_WIDTH=800
+export QT_QPA_EGLFS_HEIGHT=480
+export QT_QPA_EGLFS_PHYSICAL_WIDTH=154
+export QT_QPA_EGLFS_PHYSICAL_HEIGHT=86
+export QT_QPA_GENERIC_PLUGINS=evdevtouch
+```
+
+### Option 2: X11 with Touchscreen Support
+If you need window management:
+
+```bash
+# Install X11 and touchscreen support
+sudo apt install -y xinput-calibrator xserver-xorg-input-evdev
+
+# Configure X11 for touchscreen
+export DISPLAY=:0
+export QT_QPA_PLATFORM=xcb
+```
+
+### Option 3: Wayland (Modern Alternative)
+For newer systems:
+
+```bash
+sudo apt install -y qt6-wayland
+export QT_QPA_PLATFORM=wayland
+export WAYLAND_DISPLAY=wayland-1
+```
+
+## Running the Application
+
+### Method 1: Direct Framebuffer (Fullscreen Kiosk)
+```bash
+cd /path/to/dune-weaver-touch
+QT_QPA_PLATFORM=eglfs QT_QPA_EGLFS_WIDTH=800 QT_QPA_EGLFS_HEIGHT=480 python3 main.py
+```
+
+### Method 2: X11 Window
+```bash
+cd /path/to/dune-weaver-touch
+DISPLAY=:0 QT_QPA_PLATFORM=xcb python3 main.py
+```
+
+### Method 3: Auto-detect Platform
+```bash
+cd /path/to/dune-weaver-touch
+python3 main.py
+```
+
+## Touchscreen Calibration
+
+If touch input doesn't align with display:
+
+```bash
+# Install calibration tool
+sudo apt install -y xinput-calibrator
+
+# Run calibration (follow on-screen instructions)
+sudo xinput_calibrator
+
+# Save the output to X11 configuration
+sudo tee /etc/X11/xorg.conf.d/99-calibration.conf << EOF
+Section "InputClass"
+    Identifier "calibration"
+    MatchProduct "your_touchscreen_name"
+    Option "Calibration" "min_x max_x min_y max_y"
+    Option "SwapAxes" "0"
+EndSection
+EOF
+```
+
+## Auto-start on Boot (Kiosk Mode)
+
+### Automated Setup Script
+
+We provide a comprehensive setup script that automatically configures your Raspberry Pi for kiosk mode:
+
+```bash
+# Navigate to the application directory
+cd /home/pi/dune-weaver/dune-weaver-touch
+
+# Make the setup script executable
+chmod +x setup_kiosk.sh
+
+# Run the setup script
+./setup_kiosk.sh
+
+# The script will:
+# - Detect your Pi model (3/4/5)
+# - Configure boot settings for DSI display
+# - Install required dependencies
+# - Create systemd service for auto-start
+# - Set up proper Qt platform (EGLFS/LinuxFB)
+# - Create uninstall script for easy removal
+```
+
+### Script Commands
+
+```bash
+# Install kiosk mode (default)
+./setup_kiosk.sh
+
+# Check service status
+./setup_kiosk.sh status
+
+# Test display configuration
+./setup_kiosk.sh test
+
+# Uninstall kiosk mode
+./setup_kiosk.sh uninstall
+# Or use the generated script:
+./uninstall_kiosk.sh
+```
+
+### Manual Service Control
+
+After installation, you can control the kiosk service:
+
+```bash
+# Start the service
+sudo systemctl start dune-weaver-kiosk
+
+# Stop the service
+sudo systemctl stop dune-weaver-kiosk
+
+# Check service status
+sudo systemctl status dune-weaver-kiosk
+
+# View live logs
+journalctl -u dune-weaver-kiosk -f
+
+# Disable auto-start
+sudo systemctl disable dune-weaver-kiosk
+
+# Re-enable auto-start
+sudo systemctl enable dune-weaver-kiosk
+```
+
+### Manual Setup (Alternative)
+
+If you prefer manual setup, create a systemd service:
+
+```bash
+sudo tee /etc/systemd/system/dune-weaver-touch.service << EOF
+[Unit]
+Description=Dune Weaver Touch Interface
+After=multi-user.target graphical.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=pi
+Environment=QT_QPA_PLATFORM=eglfs
+Environment=QT_QPA_EGLFS_WIDTH=800
+Environment=QT_QPA_EGLFS_HEIGHT=480
+Environment=QT_QPA_EGLFS_INTEGRATION=eglfs_kms
+Environment=QT_QPA_EGLFS_HIDECURSOR=1
+WorkingDirectory=/home/pi/dune-weaver/dune-weaver-touch
+ExecStart=/home/pi/dune-weaver/dune-weaver-touch/bin/python main.py
+Restart=always
+RestartSec=10
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+# Enable the service
+sudo systemctl enable dune-weaver-touch.service
+sudo systemctl start dune-weaver-touch.service
+```
+
+## Troubleshooting
+
+### "Could not find Qt platform plugin"
+```bash
+# Check available plugins
+python3 -c "from PySide6.QtGui import QGuiApplication; import sys; app=QGuiApplication(sys.argv); print(app.platformName())"
+
+# If eglfs missing, install system Qt packages
+sudo apt install -y qt6-qpa-plugins
+```
+
+### "Cannot open display"
+```bash
+# Make sure X11 is running or use eglfs
+export DISPLAY=:0
+xhost +local:
+```
+
+### Touch not working
+```bash
+# Check input devices
+ls /dev/input/
+cat /proc/bus/input/devices
+
+# Test touch events
+sudo apt install -y evtest
+sudo evtest
+```
+
+### Screen rotation
+Add to boot config (`/boot/config.txt`):
+```
+# Rotate display 180 degrees
+display_rotate=2
+
+# Or rotate 90 degrees
+display_rotate=1
+```
+
+## Performance Tips
+
+1. **GPU Memory Split**: Increase GPU memory in `/boot/config.txt`:
+   ```
+   gpu_mem=128
+   ```
+
+2. **Disable unnecessary services**:
+   ```bash
+   sudo systemctl disable bluetooth
+   sudo systemctl disable cups
+   ```
+
+3. **Use hardware acceleration**: Ensure Mesa drivers are properly installed
+
+## Screen Power Management (Kiosk Mode)
+
+The touch application includes built-in screen power management with touch-to-wake functionality.
+
+### Automatic Screen Timeout
+The screen will automatically turn off after 5 minutes (300 seconds) of inactivity and can be woken by touching the screen.
+
+### Manual Screen Control
+You can also control the screen manually from the command line:
+
+```bash
+# Turn screen OFF
+sudo vcgencmd display_power 0
+
+# Turn screen ON
+sudo vcgencmd display_power 1
+
+# Alternative: Control backlight
+sudo sh -c 'echo "0" > /sys/class/backlight/*/brightness'  # OFF
+sudo sh -c 'echo "255" > /sys/class/backlight/*/brightness'  # ON
+```
+
+### Configure Screen Timeout
+The default timeout is 5 minutes, but you can modify it in your application by calling:
+
+```python
+# In your backend or QML code
+backend.setScreenTimeout(600)  # 10 minutes
+```
+
+### Permissions for Screen Control
+The application needs sudo permissions for screen control. Add these to sudoers:
+
+```bash
+sudo visudo
+# Add these lines for the pi user:
+pi ALL=(ALL) NOPASSWD: /usr/bin/vcgencmd
+pi ALL=(ALL) NOPASSWD: /bin/sh -c echo * > /sys/class/backlight/*/brightness
+pi ALL=(ALL) NOPASSWD: /bin/cat /dev/input/event*
+```
+
+### Features
+- **Automatic timeout**: Screen turns off after configured inactivity period
+- **Touch-to-wake**: Any touch on the screen will wake it up immediately  
+- **Activity tracking**: All mouse/touch interactions reset the timeout timer
+- **Fallback methods**: Uses vcgencmd first, falls back to backlight control
+- **Background monitoring**: Monitors touch input devices when screen is off

+ 128 - 0
dune-weaver-touch/README.md

@@ -0,0 +1,128 @@
+# Dune Weaver Touch Interface
+
+A PySide6/QML touch interface for the Dune Weaver sand table system that works alongside the existing FastAPI web server.
+
+## 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
+
+## Architecture
+
+- **Pattern Browsing**: Direct file system access for instant loading
+- **Execution Control**: REST API calls to existing FastAPI endpoints  
+- **Status Monitoring**: WebSocket connection for real-time updates
+- **Navigation**: StackView-based page navigation
+
+## Quick Installation (Auto-Start Setup)
+
+**One command to set up everything for kiosk/production use:**
+
+```bash
+sudo ./install.sh
+```
+
+This will:
+- ✅ Create Python virtual environment with dependencies (`venv/`)
+- ✅ Install system scripts for screen control (`/usr/local/bin/screen-on`, `screen-off`, `touch-monitor`)
+- ✅ Set up systemd service for auto-start on boot  
+- ✅ Configure kiosk optimizations (clean boot, auto-login)
+- ✅ Enable automatic startup
+
+### Service Management
+```bash
+# Control the service
+sudo systemctl start dune-weaver-touch    # Start now
+sudo systemctl stop dune-weaver-touch     # Stop service
+sudo systemctl status dune-weaver-touch   # Check status
+sudo journalctl -u dune-weaver-touch -f   # View logs
+
+# Disable auto-start
+sudo systemctl disable dune-weaver-touch
+```
+
+## Manual Installation (Development)
+
+1. Create virtual environment and install dependencies:
+   ```bash
+   python3 -m venv venv
+   source venv/bin/activate  # or: . venv/bin/activate
+   pip install -r requirements.txt
+   ```
+
+2. Ensure the main Dune Weaver FastAPI server is running:
+   ```bash
+   cd ../  # Go to main dune-weaver directory
+   python main.py
+   ```
+
+3. Run the touch interface:
+   ```bash
+   ./run.sh              # Uses virtual environment automatically
+   # OR manually:
+   ./venv/bin/python main.py
+   ```
+
+## Advanced Setup Options
+
+For custom installations:
+```bash
+sudo ./setup-autostart.sh
+```
+
+Choose from multiple setup options including systemd service, desktop autostart, or kiosk optimizations.
+
+## Project Structure
+
+```
+dune-weaver-touch/
+├── main.py                     # Application entry point
+├── backend.py                  # Backend controller with API/WebSocket integration
+├── models/
+│   ├── pattern_model.py        # Pattern list model with file system access
+│   └── playlist_model.py       # Playlist model reading from JSON
+├── qml/
+│   ├── main.qml               # Main window with StackView navigation
+│   ├── 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
+│   └── components/
+│       └── PatternCard.qml        # Pattern thumbnail card
+├── requirements.txt
+└── README.md
+```
+
+## 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
+
+### Pattern Management
+1. **Browse Patterns**: Swipe to Patterns page to see grid view with thumbnail previews
+2. **Search**: Use the search field to filter patterns by name
+3. **Select Pattern**: Tap a pattern card to view details and execution options
+4. **Execute**: Choose pre-execution action and tap "Play Pattern"
+
+### Table Control
+1. **Monitor Status**: Swipe to Control page to see current pattern and progress
+2. **Control Execution**: Use Pause/Resume and Stop buttons
+3. **Quick Actions**: Use Clear In, Clear Out, or Circle pattern shortcuts
+4. **Connection Status**: View WebSocket connection status
+
+## Notes
+
+- The touch interface runs independently from the web UI
+- Both interfaces can be used simultaneously
+- Pattern browsing works even if the FastAPI server is offline
+- Execution requires the FastAPI server to be running
+- Paths are relative to the main dune-weaver directory

+ 1239 - 0
dune-weaver-touch/backend.py

@@ -0,0 +1,1239 @@
+from PySide6.QtCore import QObject, Signal, Property, Slot, QTimer
+from PySide6.QtQml import QmlElement
+from PySide6.QtWebSockets import QWebSocket
+from PySide6.QtNetwork import QAbstractSocket
+import aiohttp
+import asyncio
+import json
+import subprocess
+import threading
+import time
+from pathlib import Path
+import os
+
+QML_IMPORT_NAME = "DuneWeaver"
+QML_IMPORT_MAJOR_VERSION = 1
+
+@QmlElement
+class Backend(QObject):
+    """Backend controller for API and WebSocket communication"""
+    
+    # Constants
+    SETTINGS_FILE = "touch_settings.json"
+    DEFAULT_SCREEN_TIMEOUT = 300  # 5 minutes in seconds
+    
+    # Predefined timeout options (in seconds)
+    TIMEOUT_OPTIONS = {
+        "30 seconds": 30,
+        "1 minute": 60, 
+        "5 minutes": 300,
+        "10 minutes": 600,
+        "Never": 0  # 0 means never timeout
+    }
+    
+    # Predefined speed options 
+    SPEED_OPTIONS = {
+        "50": 50,
+        "100": 100,
+        "200": 200,
+        "300": 300,
+        "500": 500
+    }
+    
+    # Predefined pause between patterns options (in seconds)
+    PAUSE_OPTIONS = {
+        "0s": 0,        # No pause
+        "1 min": 60,    # 1 minute
+        "5 min": 300,   # 5 minutes  
+        "15 min": 900,  # 15 minutes
+        "30 min": 1800, # 30 minutes
+        "1 hour": 3600  # 1 hour
+    }
+    
+    # Signals
+    statusChanged = Signal()
+    progressChanged = Signal()
+    connectionChanged = Signal()
+    executionStarted = Signal(str, str)  # patternName, patternPreview
+    executionStopped = Signal()
+    errorOccurred = Signal(str)
+    serialPortsUpdated = Signal(list)
+    serialConnectionChanged = Signal(bool)
+    currentPortChanged = Signal(str)
+    speedChanged = Signal(int)
+    settingsLoaded = Signal()
+    screenStateChanged = Signal(bool)  # True = on, False = off
+    screenTimeoutChanged = Signal(int)  # New signal for timeout changes
+    pauseBetweenPatternsChanged = Signal(int)  # New signal for pause changes
+    
+    # Backend connection status signals
+    backendConnectionChanged = Signal(bool)  # True = backend reachable, False = unreachable
+    reconnectStatusChanged = Signal(str)  # Current reconnection status message
+    
+    def __init__(self):
+        super().__init__()
+        self.base_url = "http://localhost:8080"
+        
+        # Initialize all status properties first
+        self._current_file = ""
+        self._progress = 0
+        self._is_running = False
+        self._is_connected = False
+        self._serial_ports = []
+        self._serial_connected = False
+        self._current_port = ""
+        self._current_speed = 130
+        self._auto_play_on_boot = False
+        self._pause_between_patterns = 0  # Default: no pause (0 seconds)
+        
+        # Backend connection status
+        self._backend_connected = False
+        self._reconnect_status = "Connecting to backend..."
+        
+        # WebSocket for status with reconnection
+        self.ws = QWebSocket()
+        self.ws.connected.connect(self._on_ws_connected)
+        self.ws.disconnected.connect(self._on_ws_disconnected)
+        self.ws.errorOccurred.connect(self._on_ws_error)
+        self.ws.textMessageReceived.connect(self._on_ws_message)
+        
+        # WebSocket reconnection management
+        self._reconnect_timer = QTimer()
+        self._reconnect_timer.timeout.connect(self._attempt_ws_reconnect)
+        self._reconnect_timer.setSingleShot(True)
+        self._reconnect_attempts = 0
+        self._reconnect_delay = 1000  # Fixed 1 second delay between retries
+        
+        # Screen management
+        self._screen_on = True
+        self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT  # Will be loaded from settings
+        self._last_activity = time.time()
+        self._touch_monitor_thread = None
+        self._screen_transition_lock = threading.Lock()  # Prevent rapid state changes
+        self._last_screen_change = 0  # Track last state change time
+        self._use_touch_script = False  # Disable external touch-monitor script (too sensitive)
+        self._screen_timer = QTimer()
+        self._screen_timer.timeout.connect(self._check_screen_timeout)
+        self._screen_timer.start(1000)  # Check every second
+        # Load local settings first
+        self._load_local_settings()
+        print(f"🖥️ Screen management initialized: timeout={self._screen_timeout}s, timer started")
+        
+        # HTTP session - initialize lazily
+        self.session = None
+        self._session_initialized = False
+        
+        # Use QTimer to defer session initialization until event loop is running
+        QTimer.singleShot(100, self._delayed_init)
+        
+        # Start initial WebSocket connection (after all attributes are initialized)
+        # Use QTimer to ensure it happens after constructor completes
+        QTimer.singleShot(200, self._attempt_ws_reconnect)
+    
+    @Slot()
+    def _delayed_init(self):
+        """Initialize session after Qt event loop is running"""
+        if not self._session_initialized:
+            try:
+                loop = asyncio.get_event_loop()
+                if loop.is_running():
+                    asyncio.create_task(self._init_session())
+                else:
+                    # If no loop is running, try again later
+                    QTimer.singleShot(500, self._delayed_init)
+            except RuntimeError:
+                # No event loop yet, try again
+                QTimer.singleShot(500, self._delayed_init)
+    
+    async def _init_session(self):
+        """Initialize aiohttp session"""
+        if not self._session_initialized:
+            # Create connector with SSL disabled for localhost
+            connector = aiohttp.TCPConnector(ssl=False)
+            self.session = aiohttp.ClientSession(connector=connector)
+            self._session_initialized = True
+    
+    # Properties
+    @Property(str, notify=statusChanged)
+    def currentFile(self):
+        return self._current_file
+    
+    @Property(float, notify=progressChanged)
+    def progress(self):
+        return self._progress
+    
+    @Property(bool, notify=statusChanged)
+    def isRunning(self):
+        return self._is_running
+    
+    @Property(bool, notify=connectionChanged)
+    def isConnected(self):
+        return self._is_connected
+    
+    @Property(list, notify=serialPortsUpdated)
+    def serialPorts(self):
+        return self._serial_ports
+    
+    @Property(bool, notify=serialConnectionChanged)
+    def serialConnected(self):
+        return self._serial_connected
+    
+    @Property(str, notify=currentPortChanged)
+    def currentPort(self):
+        return self._current_port
+    
+    @Property(int, notify=speedChanged)
+    def currentSpeed(self):
+        return self._current_speed
+    
+    @Property(bool, notify=settingsLoaded)
+    def autoPlayOnBoot(self):
+        return self._auto_play_on_boot
+    
+    @Property(bool, notify=backendConnectionChanged)
+    def backendConnected(self):
+        return self._backend_connected
+    
+    @Property(str, notify=reconnectStatusChanged)
+    def reconnectStatus(self):
+        return self._reconnect_status
+    
+    # WebSocket handlers
+    @Slot()
+    def _on_ws_connected(self):
+        print("✅ WebSocket connected successfully")
+        self._is_connected = True
+        self._backend_connected = True
+        self._reconnect_attempts = 0  # Reset reconnection counter
+        self._reconnect_status = "Connected to backend"
+        self.connectionChanged.emit()
+        self.backendConnectionChanged.emit(True)
+        self.reconnectStatusChanged.emit("Connected to backend")
+        
+        # Load initial settings when we connect
+        self.loadControlSettings()
+    
+    @Slot()
+    def _on_ws_disconnected(self):
+        print("❌ WebSocket disconnected")
+        self._is_connected = False
+        self._backend_connected = False
+        self._reconnect_status = "Backend connection lost..."
+        self.connectionChanged.emit()
+        self.backendConnectionChanged.emit(False)
+        self.reconnectStatusChanged.emit("Backend connection lost...")
+        # Start reconnection attempts
+        self._schedule_reconnect()
+    
+    @Slot()
+    def _on_ws_error(self, error):
+        print(f"❌ WebSocket error: {error}")
+        self._is_connected = False
+        self._backend_connected = False
+        self._reconnect_status = f"Backend error: {error}"
+        self.connectionChanged.emit()
+        self.backendConnectionChanged.emit(False)
+        self.reconnectStatusChanged.emit(f"Backend error: {error}")
+        # Start reconnection attempts
+        self._schedule_reconnect()
+    
+    def _schedule_reconnect(self):
+        """Schedule a reconnection attempt with fixed 1-second delay."""
+        # Always retry - no maximum attempts for touch interface
+        status_msg = f"Reconnecting in 1s... (attempt {self._reconnect_attempts + 1})"
+        print(f"🔄 {status_msg}")
+        self._reconnect_status = status_msg
+        self.reconnectStatusChanged.emit(status_msg)
+        self._reconnect_timer.start(self._reconnect_delay)  # Always 1 second
+    
+    @Slot()
+    def _attempt_ws_reconnect(self):
+        """Attempt to reconnect WebSocket."""
+        if self.ws.state() == QAbstractSocket.SocketState.ConnectedState:
+            print("✅ WebSocket already connected")
+            return
+            
+        self._reconnect_attempts += 1
+        status_msg = f"Connecting to backend... (attempt {self._reconnect_attempts})"
+        print(f"🔄 {status_msg}")
+        self._reconnect_status = status_msg
+        self.reconnectStatusChanged.emit(status_msg)
+        
+        # Close existing connection if any
+        if self.ws.state() != QAbstractSocket.SocketState.UnconnectedState:
+            self.ws.close()
+        
+        # Attempt new connection
+        self.ws.open("ws://localhost:8080/ws/status")
+    
+    @Slot()
+    def retryConnection(self):
+        """Manually retry connection (reset attempts and try again)."""
+        print("🔄 Manual connection retry requested")
+        self._reconnect_attempts = 0
+        self._reconnect_timer.stop()  # Stop any scheduled reconnect
+        self._attempt_ws_reconnect()
+    
+    @Slot(str)
+    def _on_ws_message(self, message):
+        try:
+            data = json.loads(message)
+            if data.get("type") == "status_update":
+                status = data.get("data", {})
+                new_file = status.get("current_file", "")
+
+                # Detect pattern change and emit executionStarted signal
+                if new_file and new_file != self._current_file:
+                    print(f"🎯 Pattern changed from '{self._current_file}' to '{new_file}'")
+                    # Find preview for the new pattern
+                    preview_path = self._find_pattern_preview(new_file)
+                    print(f"🖼️ Preview path for new pattern: {preview_path}")
+                    # Emit signal so UI can update
+                    self.executionStarted.emit(new_file, preview_path)
+
+                self._current_file = new_file
+                self._is_running = status.get("is_running", False)
+
+                # Handle serial connection status from WebSocket
+                ws_connection_status = status.get("connection_status", False)
+                if ws_connection_status != self._serial_connected:
+                    print(f"🔌 WebSocket serial connection status changed: {ws_connection_status}")
+                    self._serial_connected = ws_connection_status
+                    self.serialConnectionChanged.emit(ws_connection_status)
+
+                    # If we're connected, we need to get the current port
+                    if ws_connection_status:
+                        # We'll need to fetch the current port via HTTP since WS doesn't include port info
+                        asyncio.create_task(self._get_current_port())
+                    else:
+                        self._current_port = ""
+                        self.currentPortChanged.emit("")
+
+                # Handle speed updates from WebSocket
+                ws_speed = status.get("speed", None)
+                if ws_speed and ws_speed != self._current_speed:
+                    print(f"⚡ WebSocket speed changed: {ws_speed}")
+                    self._current_speed = ws_speed
+                    self.speedChanged.emit(ws_speed)
+
+                if status.get("progress"):
+                    self._progress = status["progress"].get("percentage", 0)
+
+                self.statusChanged.emit()
+                self.progressChanged.emit()
+        except json.JSONDecodeError:
+            pass
+    
+    async def _get_current_port(self):
+        """Fetch the current port when we detect a connection via WebSocket"""
+        if not self.session:
+            return
+        
+        try:
+            async with self.session.get(f"{self.base_url}/serial_status") as resp:
+                if resp.status == 200:
+                    data = await resp.json()
+                    current_port = data.get("port", "")
+                    if current_port:
+                        self._current_port = current_port
+                        self.currentPortChanged.emit(current_port)
+                        print(f"🔌 Updated current port from WebSocket trigger: {current_port}")
+        except Exception as e:
+            print(f"💥 Exception getting current port: {e}")
+    
+    # API Methods
+    @Slot(str, str)
+    def executePattern(self, fileName, preExecution="adaptive"):
+        print(f"🎯 ExecutePattern called: fileName='{fileName}', preExecution='{preExecution}'")
+        asyncio.create_task(self._execute_pattern(fileName, preExecution))
+    
+    async def _execute_pattern(self, fileName, preExecution):
+        if not self.session:
+            print("❌ Backend session not ready")
+            self.errorOccurred.emit("Backend not ready, please try again")
+            return
+        
+        try:
+            request_data = {"file_name": fileName, "pre_execution": preExecution}
+            print(f"🔄 Making HTTP POST to: {self.base_url}/run_theta_rho")
+            print(f"📝 Request payload: {request_data}")
+            
+            async with self.session.post(
+                f"{self.base_url}/run_theta_rho",
+                json=request_data
+            ) as resp:
+                print(f"📡 Response status: {resp.status}")
+                print(f"📋 Response headers: {dict(resp.headers)}")
+                
+                response_text = await resp.text()
+                print(f"📄 Response body: {response_text}")
+                
+                if resp.status == 200:
+                    print("✅ Pattern execution request successful")
+                    # Find preview image for the pattern
+                    preview_path = self._find_pattern_preview(fileName)
+                    print(f"🖼️ Pattern preview path: {preview_path}")
+                    print(f"📡 About to emit executionStarted signal with: fileName='{fileName}', preview='{preview_path}'")
+                    try:
+                        self.executionStarted.emit(fileName, preview_path)
+                        print("✅ ExecutionStarted signal emitted successfully")
+                    except Exception as e:
+                        print(f"❌ Error emitting executionStarted signal: {e}")
+                else:
+                    print(f"❌ Pattern execution failed with status {resp.status}")
+                    self.errorOccurred.emit(f"Failed to execute: {resp.status} - {response_text}")
+        except Exception as e:
+            print(f"💥 Exception in _execute_pattern: {e}")
+            self.errorOccurred.emit(str(e))
+    
+    def _find_pattern_preview(self, fileName):
+        """Find the preview image for a pattern"""
+        try:
+            # Extract just the filename from the path (remove any directory prefixes)
+            clean_filename = fileName.split('/')[-1]  # Get last part of path
+            print(f"🔍 Original fileName: {fileName}, clean filename: {clean_filename}")
+
+            # Check multiple possible locations for patterns directory
+            # Use relative paths that work across different environments
+            possible_dirs = [
+                Path("../patterns"),  # One level up (for when running from touch subdirectory)
+                Path("patterns"),     # Same level (for when running from main directory)
+                Path(__file__).parent.parent / "patterns"  # Dynamic path relative to backend.py
+            ]
+
+            for patterns_dir in possible_dirs:
+                cache_dir = patterns_dir / "cached_images"
+                if cache_dir.exists():
+                    print(f"🔍 Searching for preview in cache directory: {cache_dir}")
+
+                    # Extensions to try - PNG first for better kiosk compatibility
+                    extensions = [".png", ".webp", ".jpg", ".jpeg"]
+
+                    # Filenames to try - with and without .thr suffix
+                    base_name = clean_filename.replace(".thr", "")
+                    filenames_to_try = [clean_filename, base_name]
+
+                    # Try direct path in cache_dir first (fastest)
+                    for filename in filenames_to_try:
+                        for ext in extensions:
+                            preview_file = cache_dir / (filename + ext)
+                            if preview_file.exists():
+                                print(f"✅ Found preview (direct): {preview_file}")
+                                return str(preview_file.absolute())
+
+                    # If not found directly, search recursively through subdirectories
+                    print(f"🔍 Searching recursively in {cache_dir}...")
+                    for filename in filenames_to_try:
+                        for ext in extensions:
+                            target_name = filename + ext
+                            # Use rglob to search recursively
+                            matches = list(cache_dir.rglob(target_name))
+                            if matches:
+                                # Return the first match found
+                                preview_file = matches[0]
+                                print(f"✅ Found preview (recursive): {preview_file}")
+                                return str(preview_file.absolute())
+
+            print("❌ No preview image found")
+            return ""
+        except Exception as e:
+            print(f"💥 Exception finding preview: {e}")
+            return ""
+    
+    @Slot()
+    def stopExecution(self):
+        asyncio.create_task(self._stop_execution())
+    
+    async def _stop_execution(self):
+        if not self.session:
+            self.errorOccurred.emit("Backend not ready")
+            return
+        
+        try:
+            print("🛑 Calling stop_execution endpoint...")
+            # Add timeout to prevent hanging
+            timeout = aiohttp.ClientTimeout(total=10)  # 10 second timeout
+            async with self.session.post(f"{self.base_url}/stop_execution", timeout=timeout) as resp:
+                print(f"🛑 Stop execution response status: {resp.status}")
+                if resp.status == 200:
+                    response_data = await resp.json()
+                    print(f"🛑 Stop execution response: {response_data}")
+                    self.executionStopped.emit()
+                else:
+                    print(f"❌ Stop execution failed with status: {resp.status}")
+                    response_text = await resp.text()
+                    self.errorOccurred.emit(f"Stop failed: {resp.status} - {response_text}")
+        except asyncio.TimeoutError:
+            print("⏰ Stop execution request timed out")
+            self.errorOccurred.emit("Stop execution request timed out")
+        except Exception as e:
+            print(f"💥 Exception in _stop_execution: {e}")
+            self.errorOccurred.emit(str(e))
+    
+    @Slot()
+    def pauseExecution(self):
+        print("⏸️ Pausing execution...")
+        asyncio.create_task(self._api_call("/pause_execution"))
+    
+    @Slot()
+    def resumeExecution(self):
+        print("▶️ Resuming execution...")
+        asyncio.create_task(self._api_call("/resume_execution"))
+    
+    @Slot()
+    def skipPattern(self):
+        print("⏭️ Skipping pattern...")
+        asyncio.create_task(self._api_call("/skip_pattern"))
+    
+    @Slot(str, float, str, str, bool)
+    def executePlaylist(self, playlistName, pauseTime=0.0, clearPattern="adaptive", runMode="single", shuffle=False):
+        print(f"🎵 ExecutePlaylist called: playlist='{playlistName}', pauseTime={pauseTime}, clearPattern='{clearPattern}', runMode='{runMode}', shuffle={shuffle}")
+        asyncio.create_task(self._execute_playlist(playlistName, pauseTime, clearPattern, runMode, shuffle))
+    
+    async def _execute_playlist(self, playlistName, pauseTime, clearPattern, runMode, shuffle):
+        if not self.session:
+            print("❌ Backend session not ready")
+            self.errorOccurred.emit("Backend not ready, please try again")
+            return
+        
+        try:
+            request_data = {
+                "playlist_name": playlistName,
+                "pause_time": pauseTime,
+                "clear_pattern": clearPattern,
+                "run_mode": runMode,
+                "shuffle": shuffle
+            }
+            print(f"🔄 Making HTTP POST to: {self.base_url}/run_playlist")
+            print(f"📝 Request payload: {request_data}")
+            
+            async with self.session.post(
+                f"{self.base_url}/run_playlist",
+                json=request_data
+            ) as resp:
+                print(f"📡 Response status: {resp.status}")
+                
+                response_text = await resp.text()
+                print(f"📄 Response body: {response_text}")
+                
+                if resp.status == 200:
+                    print(f"✅ Playlist execution request successful: {playlistName}")
+                    # The playlist will start executing patterns automatically
+                    # Status updates will come through WebSocket
+                else:
+                    print(f"❌ Playlist execution failed with status {resp.status}")
+                    self.errorOccurred.emit(f"Failed to execute playlist: {resp.status} - {response_text}")
+        except Exception as e:
+            print(f"💥 Exception in _execute_playlist: {e}")
+            self.errorOccurred.emit(str(e))
+    
+    async def _api_call(self, endpoint):
+        if not self.session:
+            self.errorOccurred.emit("Backend not ready")
+            return
+        
+        try:
+            print(f"📡 Calling API endpoint: {endpoint}")
+            # Add timeout to prevent hanging
+            timeout = aiohttp.ClientTimeout(total=10)  # 10 second timeout
+            async with self.session.post(f"{self.base_url}{endpoint}", timeout=timeout) as resp:
+                print(f"📡 API response status for {endpoint}: {resp.status}")
+                if resp.status == 200:
+                    response_data = await resp.json()
+                    print(f"📡 API response for {endpoint}: {response_data}")
+                else:
+                    print(f"❌ API call {endpoint} failed with status: {resp.status}")
+                    response_text = await resp.text()
+                    self.errorOccurred.emit(f"API call failed: {endpoint} - {resp.status} - {response_text}")
+        except asyncio.TimeoutError:
+            print(f"⏰ API call {endpoint} timed out")
+            self.errorOccurred.emit(f"API call {endpoint} timed out")
+        except Exception as e:
+            print(f"💥 Exception in API call {endpoint}: {e}")
+            self.errorOccurred.emit(str(e))
+    
+    # Serial Port Management
+    @Slot()
+    def refreshSerialPorts(self):
+        print("🔌 Refreshing serial ports...")
+        asyncio.create_task(self._refresh_serial_ports())
+    
+    async def _refresh_serial_ports(self):
+        if not self.session:
+            self.errorOccurred.emit("Backend not ready")
+            return
+        
+        try:
+            async with self.session.get(f"{self.base_url}/list_serial_ports") as resp:
+                if resp.status == 200:
+                    # The endpoint returns a list directly, not a dictionary
+                    ports = await resp.json()
+                    self._serial_ports = ports if isinstance(ports, list) else []
+                    print(f"📡 Found serial ports: {self._serial_ports}")
+                    self.serialPortsUpdated.emit(self._serial_ports)
+                else:
+                    print(f"❌ Failed to get serial ports: {resp.status}")
+        except Exception as e:
+            print(f"💥 Exception refreshing serial ports: {e}")
+            self.errorOccurred.emit(str(e))
+    
+    @Slot(str)
+    def connectSerial(self, port):
+        print(f"🔗 Connecting to serial port: {port}")
+        asyncio.create_task(self._connect_serial(port))
+    
+    async def _connect_serial(self, port):
+        if not self.session:
+            self.errorOccurred.emit("Backend not ready")
+            return
+        
+        try:
+            async with self.session.post(f"{self.base_url}/connect", json={"port": port}) as resp:
+                if resp.status == 200:
+                    print(f"✅ Connected to {port}")
+                    self._serial_connected = True
+                    self._current_port = port
+                    self.serialConnectionChanged.emit(True)
+                    self.currentPortChanged.emit(port)
+                else:
+                    response_text = await resp.text()
+                    print(f"❌ Failed to connect to {port}: {resp.status} - {response_text}")
+                    self.errorOccurred.emit(f"Failed to connect: {response_text}")
+        except Exception as e:
+            print(f"💥 Exception connecting to serial: {e}")
+            self.errorOccurred.emit(str(e))
+    
+    @Slot()
+    def disconnectSerial(self):
+        print("🔌 Disconnecting serial...")
+        asyncio.create_task(self._disconnect_serial())
+    
+    async def _disconnect_serial(self):
+        if not self.session:
+            self.errorOccurred.emit("Backend not ready")
+            return
+        
+        try:
+            async with self.session.post(f"{self.base_url}/disconnect") as resp:
+                if resp.status == 200:
+                    print("✅ Disconnected from serial")
+                    self._serial_connected = False
+                    self._current_port = ""
+                    self.serialConnectionChanged.emit(False)
+                    self.currentPortChanged.emit("")
+                else:
+                    response_text = await resp.text()
+                    print(f"❌ Failed to disconnect: {resp.status} - {response_text}")
+        except Exception as e:
+            print(f"💥 Exception disconnecting serial: {e}")
+            self.errorOccurred.emit(str(e))
+    
+    # Hardware Movement Controls
+    @Slot()
+    def sendHome(self):
+        print("🏠 Sending home command...")
+        asyncio.create_task(self._api_call("/send_home"))
+    
+    @Slot()
+    def moveToCenter(self):
+        print("🎯 Moving to center...")
+        asyncio.create_task(self._api_call("/move_to_center"))
+    
+    @Slot()
+    def moveToPerimeter(self):
+        print("⭕ Moving to perimeter...")
+        asyncio.create_task(self._api_call("/move_to_perimeter"))
+    
+    # Speed Control
+    @Slot(int)
+    def setSpeed(self, speed):
+        print(f"⚡ Setting speed to: {speed}")
+        asyncio.create_task(self._set_speed(speed))
+    
+    async def _set_speed(self, speed):
+        if not self.session:
+            self.errorOccurred.emit("Backend not ready")
+            return
+        
+        try:
+            async with self.session.post(f"{self.base_url}/set_speed", json={"speed": speed}) as resp:
+                if resp.status == 200:
+                    print(f"✅ Speed set to {speed}")
+                    self._current_speed = speed
+                    self.speedChanged.emit(speed)
+                else:
+                    response_text = await resp.text()
+                    print(f"❌ Failed to set speed: {resp.status} - {response_text}")
+        except Exception as e:
+            print(f"💥 Exception setting speed: {e}")
+            self.errorOccurred.emit(str(e))
+    
+    # Auto Play on Boot Setting
+    @Slot(bool)
+    def setAutoPlayOnBoot(self, enabled):
+        print(f"🚀 Setting auto play on boot: {enabled}")
+        asyncio.create_task(self._set_auto_play_on_boot(enabled))
+    
+    async def _set_auto_play_on_boot(self, enabled):
+        if not self.session:
+            self.errorOccurred.emit("Backend not ready")
+            return
+        
+        try:
+            # Use the kiosk mode API endpoint for auto-play on boot
+            async with self.session.post(f"{self.base_url}/api/kiosk-mode", json={"enabled": enabled}) as resp:
+                if resp.status == 200:
+                    print(f"✅ Auto play on boot set to {enabled}")
+                    self._auto_play_on_boot = enabled
+                else:
+                    response_text = await resp.text()
+                    print(f"❌ Failed to set auto play: {resp.status} - {response_text}")
+        except Exception as e:
+            print(f"💥 Exception setting auto play: {e}")
+            self.errorOccurred.emit(str(e))
+    
+    # Note: Screen timeout is now managed locally in touch_settings.json
+    # The main application doesn't have a kiosk-mode endpoint, so we manage this locally
+    
+    # Load Settings
+    def _load_local_settings(self):
+        """Load settings from local JSON file"""
+        try:
+            if os.path.exists(self.SETTINGS_FILE):
+                with open(self.SETTINGS_FILE, 'r') as f:
+                    settings = json.load(f)
+                    
+                screen_timeout = settings.get('screen_timeout', self.DEFAULT_SCREEN_TIMEOUT)
+                if isinstance(screen_timeout, (int, float)) and screen_timeout >= 0:
+                    self._screen_timeout = int(screen_timeout)
+                    if screen_timeout == 0:
+                        print(f"🖥️ Loaded screen timeout from local settings: Never (0s)")
+                    else:
+                        print(f"🖥️ Loaded screen timeout from local settings: {self._screen_timeout}s")
+                else:
+                    print(f"⚠️ Invalid screen timeout in settings, using default: {self.DEFAULT_SCREEN_TIMEOUT}s")
+            else:
+                print(f"📄 No local settings file found, creating with defaults")
+                self._save_local_settings()
+        except Exception as e:
+            print(f"❌ Error loading local settings: {e}, using defaults")
+            self._screen_timeout = self.DEFAULT_SCREEN_TIMEOUT
+    
+    def _save_local_settings(self):
+        """Save settings to local JSON file"""
+        try:
+            settings = {
+                'screen_timeout': self._screen_timeout,
+                'version': '1.0'
+            }
+            with open(self.SETTINGS_FILE, 'w') as f:
+                json.dump(settings, f, indent=2)
+            print(f"💾 Saved local settings: screen_timeout={self._screen_timeout}s")
+        except Exception as e:
+            print(f"❌ Error saving local settings: {e}")
+
+    @Slot()
+    def loadControlSettings(self):
+        print("📋 Loading control settings...")
+        asyncio.create_task(self._load_settings())
+    
+    async def _load_settings(self):
+        if not self.session:
+            print("⚠️ Session not ready for loading settings")
+            return
+        
+        try:
+            # Load auto play setting from the working endpoint
+            timeout = aiohttp.ClientTimeout(total=5)  # 5 second timeout
+            async with self.session.get(f"{self.base_url}/api/auto_play-mode", timeout=timeout) as resp:
+                if resp.status == 200:
+                    data = await resp.json()
+                    self._auto_play_on_boot = data.get("enabled", False)
+                    print(f"🚀 Loaded auto play setting: {self._auto_play_on_boot}")
+                # Note: Screen timeout is managed locally, not from server
+            
+            # Serial status will be handled by WebSocket updates automatically
+            # But we still load the initial port info if connected
+            async with self.session.get(f"{self.base_url}/serial_status", timeout=timeout) as resp:
+                if resp.status == 200:
+                    data = await resp.json()
+                    initial_connected = data.get("connected", False)
+                    current_port = data.get("port", "")
+                    print(f"🔌 Initial serial status: connected={initial_connected}, port={current_port}")
+                    
+                    # Only update if WebSocket hasn't already set this
+                    if initial_connected and current_port and not self._current_port:
+                        self._current_port = current_port
+                        self.currentPortChanged.emit(current_port)
+                    
+                    # Set initial connection status (WebSocket will take over from here)
+                    if self._serial_connected != initial_connected:
+                        self._serial_connected = initial_connected
+                        self.serialConnectionChanged.emit(initial_connected)
+            
+            print("✅ Settings loaded - WebSocket will handle real-time updates")
+            self.settingsLoaded.emit()
+            
+        except aiohttp.ClientConnectorError as e:
+            print(f"⚠️ Cannot connect to backend at {self.base_url}: {e}")
+            # Don't emit error - this is expected when backend is down
+            # WebSocket will handle reconnection
+        except asyncio.TimeoutError:
+            print(f"⏰ Timeout loading settings from {self.base_url}")
+            # Don't emit error - expected when backend is slow/down
+        except Exception as e:
+            print(f"💥 Unexpected error loading settings: {e}")
+            # Only emit error for unexpected issues
+            if "ssl" not in str(e).lower():
+                self.errorOccurred.emit(str(e))
+    
+    # Screen Management Properties
+    @Property(bool, notify=screenStateChanged)
+    def screenOn(self):
+        return self._screen_on
+    
+    @Property(int, notify=screenTimeoutChanged)
+    def screenTimeout(self):
+        return self._screen_timeout
+    
+    @screenTimeout.setter
+    def setScreenTimeout(self, timeout):
+        if self._screen_timeout != timeout:
+            old_timeout = self._screen_timeout
+            self._screen_timeout = timeout
+            print(f"🖥️ Screen timeout changed from {old_timeout}s to {timeout}s")
+            
+            # Save to local settings
+            self._save_local_settings()
+            
+            # Emit change signal for QML
+            self.screenTimeoutChanged.emit(timeout)
+    
+    @Slot(result='QStringList')
+    def getScreenTimeoutOptions(self):
+        """Get list of screen timeout options for QML"""
+        return list(self.TIMEOUT_OPTIONS.keys())
+    
+    @Slot(result=str)
+    def getCurrentScreenTimeoutOption(self):
+        """Get current screen timeout as option string"""
+        current_timeout = self._screen_timeout
+        for option, value in self.TIMEOUT_OPTIONS.items():
+            if value == current_timeout:
+                return option
+        # If custom value, return closest match or custom description
+        if current_timeout == 0:
+            return "Never"
+        elif current_timeout < 60:
+            return f"{current_timeout} seconds"
+        elif current_timeout < 3600:
+            minutes = current_timeout // 60
+            return f"{minutes} minute{'s' if minutes != 1 else ''}"
+        else:
+            hours = current_timeout // 3600
+            return f"{hours} hour{'s' if hours != 1 else ''}"
+    
+    @Slot(str)
+    def setScreenTimeoutByOption(self, option):
+        """Set screen timeout by option string"""
+        if option in self.TIMEOUT_OPTIONS:
+            timeout_value = self.TIMEOUT_OPTIONS[option]
+            # Don't call the setter method, just assign to trigger the property setter
+            if self._screen_timeout != timeout_value:
+                old_timeout = self._screen_timeout
+                self._screen_timeout = timeout_value
+                print(f"🖥️ Screen timeout changed from {old_timeout}s to {timeout_value}s ({option})")
+                
+                # Save to local settings
+                self._save_local_settings()
+                
+                # Emit change signal for QML
+                self.screenTimeoutChanged.emit(timeout_value)
+        else:
+            print(f"⚠️ Unknown timeout option: {option}")
+    
+    @Slot(result='QStringList')
+    def getSpeedOptions(self):
+        """Get list of speed options for QML"""
+        return list(self.SPEED_OPTIONS.keys())
+    
+    @Slot(result=str)
+    def getCurrentSpeedOption(self):
+        """Get current speed as option string"""
+        current_speed = self._current_speed
+        for option, value in self.SPEED_OPTIONS.items():
+            if value == current_speed:
+                return option
+        # If custom value, return as string
+        return str(current_speed)
+    
+    @Slot(str)
+    def setSpeedByOption(self, option):
+        """Set speed by option string"""
+        if option in self.SPEED_OPTIONS:
+            speed_value = self.SPEED_OPTIONS[option]
+            # Don't call setter method, just assign directly  
+            if self._current_speed != speed_value:
+                old_speed = self._current_speed
+                self._current_speed = speed_value
+                print(f"⚡ Speed changed from {old_speed} to {speed_value} ({option})")
+                
+                # Send to main application
+                asyncio.create_task(self._set_speed_async(speed_value))
+                
+                # Emit change signal for QML
+                self.speedChanged.emit(speed_value)
+        else:
+            print(f"⚠️ Unknown speed option: {option}")
+    
+    async def _set_speed_async(self, speed):
+        """Send speed to main application asynchronously"""
+        if not self.session:
+            return
+        try:
+            async with self.session.post(f"{self.base_url}/set_speed", json={"speed": speed}) as resp:
+                if resp.status == 200:
+                    print(f"✅ Speed set successfully: {speed}")
+                else:
+                    print(f"❌ Failed to set speed: {resp.status}")
+        except Exception as e:
+            print(f"💥 Exception setting speed: {e}")
+    
+    # Pause Between Patterns Methods
+    @Slot(result='QStringList')
+    def getPauseOptions(self):
+        """Get list of pause between patterns options for QML"""
+        return list(self.PAUSE_OPTIONS.keys())
+    
+    @Slot(result=str)
+    def getCurrentPauseOption(self):
+        """Get current pause between patterns as option string"""
+        current_pause = self._pause_between_patterns
+        for option, value in self.PAUSE_OPTIONS.items():
+            if value == current_pause:
+                return option
+        # If custom value, return descriptive string
+        if current_pause == 0:
+            return "0s"
+        elif current_pause < 60:
+            return f"{current_pause}s"
+        elif current_pause < 3600:
+            minutes = current_pause // 60
+            return f"{minutes} min"
+        else:
+            hours = current_pause // 3600
+            return f"{hours} hour"
+    
+    @Slot(str)
+    def setPauseByOption(self, option):
+        """Set pause between patterns by option string"""
+        if option in self.PAUSE_OPTIONS:
+            pause_value = self.PAUSE_OPTIONS[option]
+            if self._pause_between_patterns != pause_value:
+                old_pause = self._pause_between_patterns
+                self._pause_between_patterns = pause_value
+                print(f"⏸️ Pause between patterns changed from {old_pause}s to {pause_value}s ({option})")
+                
+                # Emit change signal for QML
+                self.pauseBetweenPatternsChanged.emit(pause_value)
+        else:
+            print(f"⚠️ Unknown pause option: {option}")
+    
+    # Property for pause between patterns
+    @Property(int, notify=pauseBetweenPatternsChanged)
+    def pauseBetweenPatterns(self):
+        """Get current pause between patterns in seconds"""
+        return self._pause_between_patterns
+    
+    # Screen Control Methods
+    @Slot()
+    def turnScreenOn(self):
+        """Turn the screen on and reset activity timer"""
+        if not self._screen_on:
+            self._turn_screen_on()
+        self._reset_activity_timer()
+    
+    @Slot()
+    def turnScreenOff(self):
+        """Turn the screen off"""
+        self._turn_screen_off()
+        # Start touch monitoring after manual screen off
+        QTimer.singleShot(1000, self._start_touch_monitoring)  # 1 second delay
+    
+    @Slot()
+    def resetActivityTimer(self):
+        """Reset the activity timer (call on user interaction)"""
+        self._reset_activity_timer()
+        if not self._screen_on:
+            self._turn_screen_on()
+    
+    def _turn_screen_on(self):
+        """Internal method to turn screen on"""
+        with self._screen_transition_lock:
+            # Debounce: Don't turn on if we just changed state
+            time_since_change = time.time() - self._last_screen_change
+            if time_since_change < 2.0:  # 2 second debounce
+                print(f"🖥️ Screen state change blocked (debounce: {time_since_change:.1f}s < 2s)")
+                return
+            
+            if self._screen_on:
+                print("🖥️ Screen already ON, skipping")
+                return
+            
+            try:
+                # Use the working screen-on script if available
+                screen_on_script = Path('/usr/local/bin/screen-on')
+                if screen_on_script.exists():
+                    result = subprocess.run(['sudo', '/usr/local/bin/screen-on'], 
+                                          capture_output=True, text=True, timeout=5)
+                    if result.returncode == 0:
+                        print("🖥️ Screen turned ON (screen-on script)")
+                    else:
+                        print(f"⚠️ screen-on script failed: {result.stderr}")
+                else:
+                    # Fallback: Manual control matching the script
+                    # Unblank framebuffer and restore backlight
+                    max_brightness = 255
+                    try:
+                        result = subprocess.run(['cat', '/sys/class/backlight/*/max_brightness'], 
+                                              shell=True, capture_output=True, text=True, timeout=2)
+                        if result.returncode == 0 and result.stdout.strip():
+                            max_brightness = int(result.stdout.strip())
+                    except:
+                        pass
+                    
+                    subprocess.run(['sudo', 'sh', '-c', 
+                                  f'echo 0 > /sys/class/graphics/fb0/blank && echo {max_brightness} > /sys/class/backlight/*/brightness'], 
+                                 check=False, timeout=5)
+                    print(f"🖥️ Screen turned ON (manual, brightness: {max_brightness})")
+                
+                self._screen_on = True
+                self._last_screen_change = time.time()
+                self.screenStateChanged.emit(True)
+            
+            except Exception as e:
+                print(f"❌ Failed to turn screen on: {e}")
+    
+    def _turn_screen_off(self):
+        """Internal method to turn screen off"""
+        print("🖥️ _turn_screen_off() called")
+        with self._screen_transition_lock:
+            # Debounce: Don't turn off if we just changed state
+            time_since_change = time.time() - self._last_screen_change
+            if time_since_change < 2.0:  # 2 second debounce
+                print(f"🖥️ Screen state change blocked (debounce: {time_since_change:.1f}s < 2s)")
+                return
+            
+            if not self._screen_on:
+                print("🖥️ Screen already OFF, skipping")
+                return
+        
+        try:
+            # Use the working screen-off script if available
+            screen_off_script = Path('/usr/local/bin/screen-off')
+            print(f"🖥️ Checking for screen-off script at: {screen_off_script}")
+            print(f"🖥️ Script exists: {screen_off_script.exists()}")
+            
+            if screen_off_script.exists():
+                print("🖥️ Executing screen-off script...")
+                result = subprocess.run(['sudo', '/usr/local/bin/screen-off'], 
+                                      capture_output=True, text=True, timeout=10)
+                print(f"🖥️ Script return code: {result.returncode}")
+                if result.stdout:
+                    print(f"🖥️ Script stdout: {result.stdout}")
+                if result.stderr:
+                    print(f"🖥️ Script stderr: {result.stderr}")
+                    
+                if result.returncode == 0:
+                    print("✅ Screen turned OFF (screen-off script)")
+                else:
+                    print(f"⚠️ screen-off script failed: return code {result.returncode}")
+            else:
+                print("🖥️ Using manual screen control...")
+                # Fallback: Manual control matching the script
+                # Blank framebuffer and turn off backlight
+                subprocess.run(['sudo', 'sh', '-c', 
+                              'echo 0 > /sys/class/backlight/*/brightness && echo 1 > /sys/class/graphics/fb0/blank'], 
+                             check=False, timeout=5)
+                print("🖥️ Screen turned OFF (manual)")
+            
+            self._screen_on = False
+            self._last_screen_change = time.time()
+            self.screenStateChanged.emit(False)
+            print("🖥️ Screen state set to OFF, signal emitted")
+            
+        except Exception as e:
+            print(f"❌ Failed to turn screen off: {e}")
+            import traceback
+            traceback.print_exc()
+    
+    def _reset_activity_timer(self):
+        """Reset the last activity timestamp"""
+        old_time = self._last_activity
+        self._last_activity = time.time()
+        time_since_last = self._last_activity - old_time
+        if time_since_last > 1:  # Only log if it's been more than 1 second
+            print(f"🖥️ Activity detected - timer reset (was idle for {time_since_last:.1f}s)")
+    
+    def _check_screen_timeout(self):
+        """Check if screen should be turned off due to inactivity"""
+        if self._screen_on and self._screen_timeout > 0:  # Only check if timeout is enabled
+            idle_time = time.time() - self._last_activity
+            # Log every 10 seconds when getting close to timeout
+            if idle_time > self._screen_timeout - 10 and idle_time % 10 < 1:
+                print(f"🖥️ Screen idle for {idle_time:.0f}s (timeout at {self._screen_timeout}s)")
+            
+            if idle_time > self._screen_timeout:
+                print(f"🖥️ Screen timeout reached! Idle for {idle_time:.0f}s (timeout: {self._screen_timeout}s)")
+                self._turn_screen_off()
+                # Add delay before starting touch monitoring to avoid catching residual events
+                QTimer.singleShot(1000, self._start_touch_monitoring)  # 1 second delay
+        # If timeout is 0 (Never), screen stays on indefinitely
+    
+    def _start_touch_monitoring(self):
+        """Start monitoring touch input for wake-up"""
+        if self._touch_monitor_thread is None or not self._touch_monitor_thread.is_alive():
+            self._touch_monitor_thread = threading.Thread(target=self._monitor_touch_input, daemon=True)
+            self._touch_monitor_thread.start()
+    
+    def _monitor_touch_input(self):
+        """Monitor touch input to wake up the screen"""
+        print("👆 Starting touch monitoring for wake-up")
+        # Add delay to let any residual touch events clear
+        time.sleep(2)
+        
+        # Flush touch device to clear any buffered events
+        try:
+            # Find and flush touch device
+            for i in range(5):
+                device = f'/dev/input/event{i}'
+                if Path(device).exists():
+                    try:
+                        # Read and discard any pending events
+                        with open(device, 'rb') as f:
+                            import fcntl
+                            import os
+                            fcntl.fcntl(f.fileno(), fcntl.F_SETFL, os.O_NONBLOCK)
+                            while True:
+                                try:
+                                    f.read(24)  # Standard input_event size
+                                except:
+                                    break
+                        print(f"👆 Flushed touch device: {device}")
+                        break
+                    except:
+                        continue
+        except Exception as e:
+            print(f"👆 Could not flush touch device: {e}")
+        
+        print("👆 Touch monitoring active")
+        try:
+            # Use external touch monitor script if available - but only if not too sensitive
+            touch_monitor_script = Path('/usr/local/bin/touch-monitor')
+            use_script = touch_monitor_script.exists() and hasattr(self, '_use_touch_script') and self._use_touch_script
+            
+            if use_script:
+                print("👆 Using touch-monitor script")
+                # Add extra delay for script-based monitoring since it's more sensitive
+                time.sleep(3)
+                print("👆 Starting touch-monitor script after flush delay")
+                process = subprocess.Popen(['sudo', '/usr/local/bin/touch-monitor'], 
+                                         stdout=subprocess.PIPE, 
+                                         stderr=subprocess.PIPE)
+                
+                # Wait for script to detect touch and wake screen
+                while not self._screen_on:
+                    if process.poll() is not None:  # Script exited (touch detected)
+                        print("👆 Touch detected by monitor script")
+                        self._turn_screen_on()
+                        self._reset_activity_timer()
+                        break
+                    time.sleep(0.1)
+                
+                if process.poll() is None:
+                    process.terminate()
+            else:
+                # Fallback: Direct monitoring
+                # Find touch input device
+                touch_device = None
+                for i in range(5):  # Check event0 through event4
+                    device = f'/dev/input/event{i}'
+                    if Path(device).exists():
+                        # Check if it's a touch device
+                        try:
+                            info = subprocess.run(['udevadm', 'info', '--query=all', f'--name={device}'], 
+                                                capture_output=True, text=True, timeout=2)
+                            if 'touch' in info.stdout.lower() or 'ft5406' in info.stdout.lower():
+                                touch_device = device
+                                break
+                        except:
+                            pass
+                
+                if not touch_device:
+                    touch_device = '/dev/input/event0'  # Default fallback
+                
+                print(f"👆 Monitoring touch device: {touch_device}")
+                
+                # Try evtest first (more responsive to single taps)
+                evtest_available = subprocess.run(['which', 'evtest'], 
+                                                 capture_output=True).returncode == 0
+                
+                if evtest_available:
+                    # Use evtest which is more sensitive to single touches
+                    print("👆 Using evtest for touch detection")
+                    process = subprocess.Popen(['sudo', 'evtest', touch_device], 
+                                             stdout=subprocess.PIPE, 
+                                             stderr=subprocess.DEVNULL,
+                                             text=True)
+                    
+                    # Wait for any event line
+                    while not self._screen_on:
+                        try:
+                            line = process.stdout.readline()
+                            if line and 'Event:' in line:
+                                print("👆 Touch detected via evtest - waking screen")
+                                process.terminate()
+                                self._turn_screen_on()
+                                self._reset_activity_timer()
+                                break
+                        except:
+                            pass
+                        
+                        if process.poll() is not None:
+                            break
+                        time.sleep(0.01)  # Small sleep to prevent CPU spinning
+                else:
+                    # Fallback: Use cat with single byte read (more responsive)
+                    print("👆 Using cat for touch detection")
+                    process = subprocess.Popen(['sudo', 'cat', touch_device], 
+                                             stdout=subprocess.PIPE, 
+                                             stderr=subprocess.DEVNULL)
+                    
+                    # Wait for any data (even 1 byte indicates touch)
+                    while not self._screen_on:
+                        try:
+                            # Non-blocking check for data
+                            import select
+                            ready, _, _ = select.select([process.stdout], [], [], 0.1)
+                            if ready:
+                                data = process.stdout.read(1)  # Read just 1 byte
+                                if data:
+                                    print("👆 Touch detected - waking screen")
+                                    process.terminate()
+                                    self._turn_screen_on()
+                                    self._reset_activity_timer()
+                                    break
+                        except:
+                            pass
+                        
+                        # Check if screen was turned on by other means
+                        if self._screen_on:
+                            process.terminate()
+                            break
+                        
+                        time.sleep(0.1)
+                
+        except Exception as e:
+            print(f"❌ Error monitoring touch input: {e}")
+        
+        print("👆 Touch monitoring stopped")

+ 170 - 0
dune-weaver-touch/configure-boot.sh

@@ -0,0 +1,170 @@
+#!/bin/bash
+# Configure boot and session options for Dune Weaver Touch
+
+set -e
+
+if [ "$EUID" -ne 0 ]; then
+    echo "❌ This script must be run as root (use sudo)"
+    exit 1
+fi
+
+ACTUAL_USER="${SUDO_USER:-$USER}"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+echo "🔧 Dune Weaver Touch - Boot Configuration"
+echo "=========================================="
+echo "Current user: $ACTUAL_USER"
+echo ""
+
+show_current_config() {
+    echo "📊 Current Configuration:"
+    
+    # Check auto-login status
+    if [ -f /etc/lightdm/lightdm.conf.d/60-autologin.conf ]; then
+        echo "   ✅ Auto-login: Enabled (lightdm)"
+    elif grep -q "autologin-user=" /etc/lightdm/lightdm.conf 2>/dev/null; then
+        echo "   ✅ Auto-login: Enabled (lightdm main config)"
+    elif [ -f /etc/systemd/system/getty@tty1.service.d/override.conf ]; then
+        echo "   ✅ Auto-login: Enabled (systemd)"
+    else
+        echo "   ❌ Auto-login: Disabled"
+    fi
+    
+    # Check service status
+    if systemctl is-enabled dune-weaver-touch >/dev/null 2>&1; then
+        echo "   ✅ Service: Enabled (starts on boot)"
+    else
+        echo "   ❌ Service: Disabled"
+    fi
+    
+    # Check kiosk session
+    if [ -f /usr/share/xsessions/dune-weaver-kiosk.session ]; then
+        echo "   ✅ Kiosk Session: Available"
+    else
+        echo "   ❌ Kiosk Session: Not installed"
+    fi
+    
+    echo ""
+}
+
+enable_auto_login() {
+    echo "🔑 Enabling auto-login..."
+    
+    if [ -d /etc/lightdm ]; then
+        mkdir -p /etc/lightdm/lightdm.conf.d
+        cat > /etc/lightdm/lightdm.conf.d/60-autologin.conf << EOF
+[Seat:*]
+autologin-user=$ACTUAL_USER
+autologin-user-timeout=0
+user-session=LXDE-pi
+EOF
+        echo "   ✅ lightdm auto-login enabled"
+    else
+        # Fallback to systemd
+        mkdir -p /etc/systemd/system/getty@tty1.service.d
+        cat > /etc/systemd/system/getty@tty1.service.d/override.conf << EOF
+[Service]
+ExecStart=
+ExecStart=-/sbin/agetty --autologin $ACTUAL_USER --noclear %I \$TERM
+EOF
+        systemctl daemon-reload
+        echo "   ✅ systemd auto-login enabled"
+    fi
+}
+
+disable_auto_login() {
+    echo "🔑 Disabling auto-login..."
+    
+    # Remove lightdm auto-login configs
+    rm -f /etc/lightdm/lightdm.conf.d/60-autologin.conf
+    
+    if [ -f /etc/lightdm/lightdm.conf ]; then
+        sed -i "s/^autologin-user=.*/#autologin-user=/" /etc/lightdm/lightdm.conf
+    fi
+    
+    # Remove systemd auto-login
+    rm -rf /etc/systemd/system/getty@tty1.service.d
+    systemctl daemon-reload
+    
+    echo "   ✅ Auto-login disabled"
+}
+
+enable_service() {
+    echo "🚀 Enabling Dune Weaver service..."
+    systemctl enable dune-weaver-touch.service
+    echo "   ✅ Service will start on boot"
+}
+
+disable_service() {
+    echo "🚀 Disabling Dune Weaver service..."
+    systemctl disable dune-weaver-touch.service
+    systemctl stop dune-weaver-touch.service 2>/dev/null || true
+    echo "   ✅ Service disabled"
+}
+
+# Show current status
+show_current_config
+
+# Main menu
+echo "Choose configuration:"
+echo "1) Full Kiosk Mode (auto-login + service enabled)"
+echo "2) Service Only (manual login, auto-start service)"  
+echo "3) Manual Mode (manual login, manual start)"
+echo "4) Toggle auto-login only"
+echo "5) Toggle service only"
+echo "6) Show current status"
+echo "7) Exit"
+echo ""
+read -p "Enter your choice (1-7): " choice
+
+case $choice in
+    1)
+        enable_auto_login
+        enable_service
+        echo ""
+        echo "🎯 Full kiosk mode enabled!"
+        echo "   Reboot to see the changes"
+        ;;
+    2)
+        disable_auto_login
+        enable_service
+        echo ""
+        echo "🔧 Service-only mode enabled!"
+        echo "   Manual login required, but service starts automatically"
+        ;;
+    3)
+        disable_auto_login
+        disable_service
+        echo ""
+        echo "🛠️  Manual mode enabled!"
+        echo "   Manual login and manual service start required"
+        ;;
+    4)
+        if [ -f /etc/lightdm/lightdm.conf.d/60-autologin.conf ] || [ -f /etc/systemd/system/getty@tty1.service.d/override.conf ]; then
+            disable_auto_login
+        else
+            enable_auto_login
+        fi
+        ;;
+    5)
+        if systemctl is-enabled dune-weaver-touch >/dev/null 2>&1; then
+            disable_service
+        else
+            enable_service
+        fi
+        ;;
+    6)
+        show_current_config
+        ;;
+    7)
+        echo "👋 Exiting..."
+        exit 0
+        ;;
+    *)
+        echo "❌ Invalid choice"
+        exit 1
+        ;;
+esac
+
+echo ""
+echo "✅ Configuration updated!"

+ 9 - 0
dune-weaver-touch/dune-weaver-touch.desktop

@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Name=Dune Weaver Touch
+Comment=Dune Weaver Touch Interface
+Exec=python3 /home/pi/dune-weaver-touch/main.py
+Path=/home/pi/dune-weaver-touch
+Terminal=false
+StartupNotify=true
+X-GNOME-Autostart-enabled=true

+ 29 - 0
dune-weaver-touch/dune-weaver-touch.service

@@ -0,0 +1,29 @@
+[Unit]
+Description=Dune Weaver Touch Interface
+After=multi-user.target network-online.target
+Wants=network-online.target
+# Wait for DRM/KMS devices to be ready
+After=systemd-udev-settle.service
+Wants=systemd-udev-settle.service
+
+[Service]
+Type=simple
+User=pi
+Group=pi
+WorkingDirectory=/home/pi/dune-weaver-touch
+Environment=DISPLAY=:0
+Environment=QT_QPA_PLATFORM=eglfs
+Environment=QT_QPA_EGLFS_ALWAYS_SET_MODE=1
+Environment=QT_QPA_EGLFS_HIDECURSOR=1
+Environment=QT_QPA_FB_HIDECURSOR=1
+Environment=QT_QPA_EGLFS_INTEGRATION=eglfs_kms
+Environment=QT_QPA_EGLFS_KMS_ATOMIC=1
+ExecStart=/home/pi/dune-weaver-touch/venv/bin/python /home/pi/dune-weaver-touch/main.py
+Restart=always
+RestartSec=10
+# Restart on failure with backoff
+StartLimitInterval=200
+StartLimitBurst=5
+
+[Install]
+WantedBy=multi-user.target

+ 46 - 0
dune-weaver-touch/install-service.sh

@@ -0,0 +1,46 @@
+#!/bin/bash
+
+# Install Dune Weaver Touch as a systemd service
+
+# Check if running as root
+if [ "$EUID" -ne 0 ]; then
+    echo "Please run as root (use sudo)"
+    exit 1
+fi
+
+# Get the current directory
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+APP_DIR="$SCRIPT_DIR"
+
+# Get the current user (the one who called sudo)
+ACTUAL_USER="${SUDO_USER:-$USER}"
+USER_HOME=$(eval echo ~$ACTUAL_USER)
+
+echo "Installing Dune Weaver Touch service..."
+echo "App directory: $APP_DIR"
+echo "User: $ACTUAL_USER"
+echo "User home: $USER_HOME"
+
+# Update paths in the service file
+sed "s|/home/pi/dune-weaver-touch|$APP_DIR|g" "$SCRIPT_DIR/dune-weaver-touch.service" > /tmp/dune-weaver-touch.service
+sed -i "s|User=pi|User=$ACTUAL_USER|g" /tmp/dune-weaver-touch.service
+sed -i "s|Group=pi|Group=$ACTUAL_USER|g" /tmp/dune-weaver-touch.service
+
+# Copy service file to systemd directory
+cp /tmp/dune-weaver-touch.service /etc/systemd/system/
+
+# Reload systemd
+systemctl daemon-reload
+
+# Enable the service
+systemctl enable dune-weaver-touch.service
+
+echo "✅ Service installed and enabled!"
+echo ""
+echo "Commands to manage the service:"
+echo "  Start:   sudo systemctl start dune-weaver-touch"
+echo "  Stop:    sudo systemctl stop dune-weaver-touch"  
+echo "  Status:  sudo systemctl status dune-weaver-touch"
+echo "  Logs:    sudo journalctl -u dune-weaver-touch -f"
+echo ""
+echo "The app will now start automatically on boot."

+ 335 - 0
dune-weaver-touch/install.sh

@@ -0,0 +1,335 @@
+#!/bin/bash
+# Dune Weaver Touch - One-Command Installer
+# This script sets up everything needed to run Dune Weaver Touch on boot
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ACTUAL_USER="${SUDO_USER:-$USER}"
+USER_HOME=$(eval echo ~$ACTUAL_USER)
+
+echo "🎯 Dune Weaver Touch - Complete Installation"
+echo "============================================="
+echo "App directory: $SCRIPT_DIR"
+echo "User: $ACTUAL_USER"
+echo ""
+
+# Check if running as root
+if [ "$EUID" -ne 0 ]; then
+    echo "❌ This installer must be run with sudo privileges"
+    echo ""
+    echo "Usage: sudo ./install.sh"
+    echo ""
+    exit 1
+fi
+
+echo "🔧 Installing system components..."
+echo ""
+
+# Function to install system scripts
+install_scripts() {
+    echo "📄 Installing system scripts..."
+    
+    local scripts=("screen-on" "screen-off" "touch-monitor")
+    
+    for script in "${scripts[@]}"; do
+        local source_path="$SCRIPT_DIR/scripts/$script"
+        local target_path="/usr/local/bin/$script"
+        
+        if [ -f "$source_path" ]; then
+            cp "$source_path" "$target_path"
+            chmod 755 "$target_path"
+            chown root:root "$target_path"
+            echo "   ✅ $script → $target_path"
+        else
+            echo "   ⚠️  $script not found at $source_path"
+        fi
+    done
+    
+    echo "   📄 System scripts installed"
+}
+
+# Function to setup systemd service
+setup_systemd() {
+    echo "🚀 Setting up systemd service..."
+
+    # Update paths in the service file
+    sed "s|/home/pi/dune-weaver-touch|$SCRIPT_DIR|g" "$SCRIPT_DIR/dune-weaver-touch.service" > /tmp/dune-weaver-touch.service
+    sed -i "s|User=pi|User=$ACTUAL_USER|g" /tmp/dune-weaver-touch.service
+    sed -i "s|Group=pi|Group=$ACTUAL_USER|g" /tmp/dune-weaver-touch.service
+
+    # Copy service file
+    cp /tmp/dune-weaver-touch.service /etc/systemd/system/
+
+    # Enable service
+    systemctl daemon-reload
+    systemctl enable dune-weaver-touch.service
+
+    echo "   🚀 Systemd service installed and enabled"
+}
+
+# Function to configure boot settings for DSI display
+configure_boot_settings() {
+    echo "🖥️  Configuring boot settings for DSI display..."
+
+    local CONFIG_FILE="/boot/firmware/config.txt"
+    # Fallback to old path if new path doesn't exist
+    [ ! -f "$CONFIG_FILE" ] && CONFIG_FILE="/boot/config.txt"
+
+    if [ ! -f "$CONFIG_FILE" ]; then
+        echo "   ⚠️  config.txt not found, skipping boot configuration"
+        return
+    fi
+
+    # Backup config.txt
+    cp "$CONFIG_FILE" "${CONFIG_FILE}.backup.$(date +%Y%m%d_%H%M%S)"
+    echo "   ✅ Backed up config.txt"
+
+    # Remove old/conflicting KMS settings
+    sed -i '/dtoverlay=vc4-fkms-v3d/d' "$CONFIG_FILE"
+    sed -i '/dtoverlay=vc4-xfkms-v3d/d' "$CONFIG_FILE"
+
+    # Add full KMS if not present
+    if ! grep -q "dtoverlay=vc4-kms-v3d" "$CONFIG_FILE"; then
+        # Find [all] section or add at end
+        if grep -q "^\[all\]" "$CONFIG_FILE"; then
+            sed -i '/^\[all\]/a dtoverlay=vc4-kms-v3d' "$CONFIG_FILE"
+        else
+            echo -e "\n[all]\ndtoverlay=vc4-kms-v3d" >> "$CONFIG_FILE"
+        fi
+        echo "   ✅ Enabled full KMS (vc4-kms-v3d) for eglfs support"
+    else
+        echo "   ℹ️  Full KMS already enabled"
+    fi
+
+    # Add GPU memory if not present
+    if ! grep -q "gpu_mem=" "$CONFIG_FILE"; then
+        echo "gpu_mem=128" >> "$CONFIG_FILE"
+        echo "   ✅ Set GPU memory to 128MB"
+    else
+        echo "   ℹ️  GPU memory already configured"
+    fi
+
+    # Disable splash screens for cleaner boot
+    if ! grep -q "disable_splash=1" "$CONFIG_FILE"; then
+        echo "disable_splash=1" >> "$CONFIG_FILE"
+        echo "   ✅ Disabled rainbow splash"
+    else
+        echo "   ℹ️  Rainbow splash already disabled"
+    fi
+
+    echo "   🖥️  Boot configuration updated for eglfs"
+}
+
+# Function to setup touch rotation via udev rule
+setup_touch_rotation() {
+    echo "👆 Setting up touchscreen rotation..."
+
+    local UDEV_RULE_FILE="/etc/udev/rules.d/99-ft5x06-rotate.rules"
+
+    # Create udev rule for FT5x06 touch controller (180° rotation)
+    cat > "$UDEV_RULE_FILE" << 'EOF'
+# Rotate FT5x06 touchscreen 180 degrees using libinput calibration matrix
+# Matrix format: a b c d e f 0 0 1
+# For 180° rotation: -1 0 1  0 -1 1  0 0 1
+# This inverts both X and Y axes (equivalent to 180° rotation)
+SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="*generic ft5x06*", \
+  ENV{LIBINPUT_CALIBRATION_MATRIX}="-1 0 1  0 -1 1  0 0 1"
+EOF
+
+    chmod 644 "$UDEV_RULE_FILE"
+    echo "   ✅ Touch rotation udev rule created: $UDEV_RULE_FILE"
+
+    # Reload udev rules
+    udevadm control --reload-rules
+    udevadm trigger
+    echo "   ✅ Udev rules reloaded"
+
+    echo "   👆 Touch rotation configured (180°)"
+}
+
+# Function to hide mouse cursor
+hide_mouse_cursor() {
+    echo "🖱️  Configuring mouse cursor hiding..."
+
+    # Install unclutter for hiding mouse cursor when idle
+    echo "   📦 Installing unclutter..."
+    apt install -y unclutter > /dev/null 2>&1
+
+    # Create autostart directory if it doesn't exist
+    local AUTOSTART_DIR="$USER_HOME/.config/autostart"
+    mkdir -p "$AUTOSTART_DIR"
+    chown -R "$ACTUAL_USER:$ACTUAL_USER" "$USER_HOME/.config"
+
+    # Create unclutter autostart entry
+    cat > "$AUTOSTART_DIR/unclutter.desktop" << 'EOF'
+[Desktop Entry]
+Type=Application
+Name=Unclutter
+Comment=Hide mouse cursor when idle
+Exec=unclutter -idle 0.1 -root
+Hidden=false
+NoDisplay=false
+X-GNOME-Autostart-enabled=true
+EOF
+
+    chown "$ACTUAL_USER:$ACTUAL_USER" "$AUTOSTART_DIR/unclutter.desktop"
+
+    echo "   🖱️  Mouse cursor hiding configured"
+}
+
+# Function to setup kiosk optimizations
+setup_kiosk_optimizations() {
+    echo "🖥️  Setting up kiosk optimizations..."
+
+    local CMDLINE_FILE="/boot/firmware/cmdline.txt"
+    [ ! -f "$CMDLINE_FILE" ] && CMDLINE_FILE="/boot/cmdline.txt"
+
+    # Configure cmdline.txt for display and boot
+    if [ -f "$CMDLINE_FILE" ]; then
+        cp "$CMDLINE_FILE" "${CMDLINE_FILE}.backup.$(date +%Y%m%d_%H%M%S)"
+
+        # Add video parameter for DSI display with rotation
+        if ! grep -q "video=DSI-1:800x480@60,rotate=180" "$CMDLINE_FILE"; then
+            sed -i 's/$/ video=DSI-1:800x480@60,rotate=180/' "$CMDLINE_FILE"
+            echo "   ✅ DSI display configuration added (800x480@60, rotated 180°)"
+        else
+            echo "   ℹ️  DSI display configuration already present"
+        fi
+
+        # Add quiet splash for cleaner boot
+        if ! grep -q "quiet splash" "$CMDLINE_FILE"; then
+            sed -i 's/$/ quiet splash/' "$CMDLINE_FILE"
+            echo "   ✅ Boot splash enabled"
+        else
+            echo "   ℹ️  Boot splash already enabled"
+        fi
+    fi
+
+    # Note about auto-login - let user configure manually
+    echo "   ℹ️  Auto-login configuration skipped (manual setup recommended)"
+
+    echo "   🖥️  Kiosk optimizations applied"
+}
+
+# Function to setup Python virtual environment and install dependencies
+setup_python_environment() {
+    echo "🐍 Setting up Python virtual environment..."
+
+    # Install system Qt6 and PySide6 packages for full eglfs support
+    echo "   📦 Installing system Qt6 and PySide6 packages..."
+    apt update
+    apt install -y \
+        python3-pyside6.qtcore \
+        python3-pyside6.qtgui \
+        python3-pyside6.qtqml \
+        python3-pyside6.qtquick \
+        python3-pyside6.qtquickcontrols2 \
+        python3-pyside6.qtquickwidgets \
+        python3-pyside6.qtwebsockets \
+        python3-pyside6.qtnetwork \
+        qml6-module-qtquick \
+        qml6-module-qtquick-controls \
+        qml6-module-qtquick-layouts \
+        qml6-module-qtquick-window \
+        qml6-module-qtquick-dialogs \
+        qml6-module-qt-labs-qmlmodels \
+        qt6-virtualkeyboard-plugin \
+        qml6-module-qtquick-virtualkeyboard \
+        qt6-base-dev \
+        qt6-declarative-dev \
+        libqt6opengl6 \
+        libqt6core5compat6 \
+        libqt6network6 \
+        libqt6websockets6 > /dev/null 2>&1
+
+    echo "   ✅ System Qt6/PySide6 packages installed"
+
+    # Create virtual environment with system site packages
+    if [ ! -d "$SCRIPT_DIR/venv" ]; then
+        echo "   📦 Creating virtual environment with system site packages..."
+        python3 -m venv --system-site-packages "$SCRIPT_DIR/venv" || {
+            echo "   ⚠️  Could not create virtual environment. Installing python3-venv..."
+            apt install -y python3-venv python3-full
+            python3 -m venv --system-site-packages "$SCRIPT_DIR/venv"
+        }
+    else
+        echo "   ℹ️  Virtual environment already exists"
+    fi
+
+    # Install non-Qt dependencies
+    echo "   📦 Installing Python dependencies (qasync, aiohttp, Pillow)..."
+    "$SCRIPT_DIR/venv/bin/python" -m pip install --upgrade pip > /dev/null 2>&1
+    "$SCRIPT_DIR/venv/bin/pip" install \
+        qasync>=0.27.0 \
+        aiohttp>=3.9.0 \
+        Pillow>=10.0.0 > /dev/null 2>&1
+
+    # Change ownership to the actual user (not root)
+    chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SCRIPT_DIR/venv"
+
+    echo "   🐍 Python virtual environment ready"
+}
+
+# Main installation process
+echo "Starting complete installation..."
+echo ""
+
+# Install everything
+setup_python_environment
+install_scripts
+setup_systemd
+configure_boot_settings
+setup_touch_rotation
+hide_mouse_cursor
+setup_kiosk_optimizations
+
+echo ""
+echo "🎉 Installation Complete!"
+echo "========================"
+echo ""
+echo "✅ Python virtual environment created at: $SCRIPT_DIR/venv"
+echo "✅ System scripts installed in /usr/local/bin/"
+echo "✅ Systemd service configured for auto-start"
+echo "✅ Mouse cursor hiding configured (Qt + unclutter)"
+echo "✅ Kiosk optimizations applied"
+echo ""
+echo "🔧 Service Management:"
+echo "   Start now:  sudo systemctl start dune-weaver-touch"
+echo "   Stop:       sudo systemctl stop dune-weaver-touch"
+echo "   Status:     sudo systemctl status dune-weaver-touch"
+echo "   Logs:       sudo journalctl -u dune-weaver-touch -f"
+echo ""
+echo "🚀 Next Steps:"
+echo "   1. ${YELLOW}⚠️  REBOOT REQUIRED${NC} for config.txt changes (vc4-kms-v3d) to take effect"
+echo "   2. Configure auto-login (recommended for kiosk mode):"
+echo "      sudo ./setup-autologin.sh    (automated setup)"
+echo "      OR manually: sudo raspi-config → System Options → Boot/Auto Login → Desktop Autologin"
+echo "   3. After reboot, the app will start automatically on boot via systemd service"
+echo "   4. Check the logs if you encounter any issues: sudo journalctl -u dune-weaver-touch -f"
+echo ""
+echo "💡 To start the service now without rebooting:"
+echo "   sudo systemctl start dune-weaver-touch"
+echo ""
+echo "🛠️  For development/testing (run manually):"
+echo "   cd $SCRIPT_DIR"
+echo "   ./run.sh"
+echo ""
+echo "⚙️  To change boot/login settings later:"
+echo "   sudo ./configure-boot.sh"
+echo ""
+
+# Ask if user wants to start now
+read -p "🤔 Would you like to start the service now? (y/N): " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]; then
+    echo "🚀 Starting Dune Weaver Touch service..."
+    systemctl start dune-weaver-touch
+    sleep 2
+    systemctl status dune-weaver-touch --no-pager -l
+    echo ""
+    echo "✅ Service started! Check the status above."
+fi
+
+echo ""
+echo "🎯 Installation completed successfully!"

+ 151 - 0
dune-weaver-touch/main.py

@@ -0,0 +1,151 @@
+import sys
+import os
+import asyncio
+import logging
+import time
+import signal
+from pathlib import Path
+from PySide6.QtCore import QUrl, QTimer, QObject, QEvent
+from PySide6.QtGui import QGuiApplication, QTouchEvent, QMouseEvent
+from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType
+from qasync import QEventLoop
+
+from backend import Backend
+from models.pattern_model import PatternModel
+from models.playlist_model import PlaylistModel
+from png_cache_manager import ensure_png_cache_startup
+
+# Configure logging
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+class FirstTouchFilter(QObject):
+    """
+    Event filter that ignores the first touch event after inactivity.
+    Many capacitive touchscreens need the first touch to wake up or calibrate,
+    and this touch often has incorrect coordinates.
+    """
+    def __init__(self, idle_threshold_seconds=2.0):
+        super().__init__()
+        self.idle_threshold = idle_threshold_seconds
+        self.last_touch_time = 0
+        self.ignore_next_touch = False
+        logger.info(f"👆 First-touch filter initialized (idle threshold: {idle_threshold_seconds}s)")
+
+    def eventFilter(self, obj, event):
+        """Filter out the first touch after idle period"""
+        try:
+            event_type = event.type()
+
+            # Handle touch events
+            if event_type == QEvent.Type.TouchBegin:
+                current_time = time.time()
+                time_since_last_touch = current_time - self.last_touch_time
+
+                # If it's been more than threshold since last touch, ignore this one
+                if time_since_last_touch > self.idle_threshold:
+                    logger.debug(f"👆 Ignoring wake-up touch (idle for {time_since_last_touch:.1f}s)")
+                    self.last_touch_time = current_time
+                    return True  # Filter out (ignore) this event
+
+                self.last_touch_time = current_time
+
+            elif event_type in (QEvent.Type.TouchUpdate, QEvent.Type.TouchEnd):
+                # Update last touch time for any touch activity
+                self.last_touch_time = time.time()
+
+            # Pass through the event
+            return False
+        except KeyboardInterrupt:
+            # Re-raise KeyboardInterrupt to allow clean shutdown
+            raise
+        except Exception as e:
+            logger.error(f"Error in eventFilter: {e}")
+            return False
+
+async def startup_tasks():
+    """Run async startup tasks"""
+    logger.info("🚀 Starting dune-weaver-touch async initialization...")
+    
+    # Ensure PNG cache is available for all WebP previews
+    try:
+        logger.info("🎨 Checking PNG preview cache...")
+        png_cache_success = await ensure_png_cache_startup()
+        if png_cache_success:
+            logger.info("✅ PNG cache check completed successfully")
+        else:
+            logger.warning("⚠️ PNG cache check completed with warnings")
+    except Exception as e:
+        logger.error(f"❌ PNG cache check failed: {e}")
+    
+    logger.info("✨ dune-weaver-touch startup tasks completed")
+
+def main():
+    # Enable virtual keyboard
+    os.environ['QT_IM_MODULE'] = 'qtvirtualkeyboard'
+
+    app = QGuiApplication(sys.argv)
+
+    # 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)
+    app.installEventFilter(first_touch_filter)
+    logger.info("✅ First-touch filter installed on application")
+
+    # Setup async event loop
+    loop = QEventLoop(app)
+    asyncio.set_event_loop(loop)
+    
+    # Register types
+    qmlRegisterType(Backend, "DuneWeaver", 1, 0, "Backend")
+    qmlRegisterType(PatternModel, "DuneWeaver", 1, 0, "PatternModel")
+    qmlRegisterType(PlaylistModel, "DuneWeaver", 1, 0, "PlaylistModel")
+    
+    # Load QML
+    engine = QQmlApplicationEngine()
+    qml_file = Path(__file__).parent / "qml" / "main.qml"
+    engine.load(QUrl.fromLocalFile(str(qml_file)))
+    
+    if not engine.rootObjects():
+        return -1
+    
+    # Schedule startup tasks after a brief delay to ensure event loop is running
+    def schedule_startup():
+        try:
+            # Check if we're in an event loop context
+            current_loop = asyncio.get_running_loop()
+            current_loop.create_task(startup_tasks())
+        except RuntimeError:
+            # No running loop, create task directly
+            asyncio.create_task(startup_tasks())
+    
+    # Use QTimer to delay startup tasks
+    startup_timer = QTimer()
+    startup_timer.timeout.connect(schedule_startup)
+    startup_timer.setSingleShot(True)
+    startup_timer.start(100)  # 100ms delay
+
+    # Setup signal handlers for clean shutdown
+    def signal_handler(signum, frame):
+        logger.info("🛑 Received shutdown signal, exiting...")
+        loop.stop()
+        app.quit()
+
+    signal.signal(signal.SIGINT, signal_handler)
+    signal.signal(signal.SIGTERM, signal_handler)
+
+    try:
+        with loop:
+            loop.run_forever()
+    except KeyboardInterrupt:
+        logger.info("🛑 KeyboardInterrupt received, shutting down...")
+    finally:
+        loop.close()
+
+    return 0
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 101 - 0
dune-weaver-touch/models/pattern_model.py

@@ -0,0 +1,101 @@
+from PySide6.QtCore import QAbstractListModel, Qt, Slot, Signal
+from PySide6.QtQml import QmlElement
+from pathlib import Path
+
+QML_IMPORT_NAME = "DuneWeaver"
+QML_IMPORT_MAJOR_VERSION = 1
+
+@QmlElement
+class PatternModel(QAbstractListModel):
+    """Model for pattern list with direct file system access"""
+    
+    NameRole = Qt.UserRole + 1
+    PathRole = Qt.UserRole + 2
+    PreviewRole = Qt.UserRole + 3
+    
+    def __init__(self):
+        super().__init__()
+        self._patterns = []
+        self._filtered_patterns = []
+        # Look for patterns in the parent directory (main dune-weaver folder)
+        self.patterns_dir = Path("../patterns")
+        self.cache_dir = Path("../patterns/cached_images")
+        self.refresh()
+    
+    def roleNames(self):
+        return {
+            self.NameRole: b"name",
+            self.PathRole: b"path", 
+            self.PreviewRole: b"preview"
+        }
+    
+    def rowCount(self, parent=None):
+        return len(self._filtered_patterns)
+    
+    def data(self, index, role):
+        if not index.isValid() or index.row() >= len(self._filtered_patterns):
+            return None
+        
+        pattern = self._filtered_patterns[index.row()]
+        
+        if role == self.NameRole:
+            return pattern["name"]
+        elif role == self.PathRole:
+            return pattern["path"]
+        elif role == self.PreviewRole:
+            # For patterns in subdirectories, check both flattened and hierarchical cache structures
+            pattern_name = pattern["name"]
+            
+            # Try PNG format for kiosk compatibility
+            # First try hierarchical structure (preserving subdirectories)
+            preview_path_hierarchical = self.cache_dir / f"{pattern_name}.png"
+            if preview_path_hierarchical.exists():
+                return str(preview_path_hierarchical.absolute())
+            
+            # Then try flattened structure (replace / with _)
+            preview_name_flat = pattern_name.replace("/", "_").replace("\\", "_")
+            preview_path_flat = self.cache_dir / f"{preview_name_flat}.png"
+            if preview_path_flat.exists():
+                return str(preview_path_flat.absolute())
+            
+            # Fallback to WebP if PNG not found (for existing caches)
+            preview_path_hierarchical_webp = self.cache_dir / f"{pattern_name}.webp"
+            if preview_path_hierarchical_webp.exists():
+                return str(preview_path_hierarchical_webp.absolute())
+            
+            preview_path_flat_webp = self.cache_dir / f"{preview_name_flat}.webp"
+            if preview_path_flat_webp.exists():
+                return str(preview_path_flat_webp.absolute())
+            
+            return ""
+        
+        return None
+    
+    @Slot()
+    def refresh(self):
+        print(f"Loading patterns from: {self.patterns_dir.absolute()}")
+        self.beginResetModel()
+        patterns = []
+        for file_path in self.patterns_dir.rglob("*.thr"):
+            relative = file_path.relative_to(self.patterns_dir)
+            patterns.append({
+                "name": str(relative),
+                "path": str(file_path)
+            })
+        self._patterns = sorted(patterns, key=lambda x: x["name"])
+        self._filtered_patterns = self._patterns.copy()
+        print(f"Loaded {len(self._patterns)} patterns")
+        self.endResetModel()
+    
+    @Slot(str)
+    def filter(self, search_text):
+        self.beginResetModel()
+        if not search_text:
+            self._filtered_patterns = self._patterns.copy()
+        else:
+            search_lower = search_text.lower()
+            self._filtered_patterns = [
+                p for p in self._patterns 
+                if search_lower in p["name"].lower()
+            ]
+        self.endResetModel()

+ 85 - 0
dune-weaver-touch/models/playlist_model.py

@@ -0,0 +1,85 @@
+from PySide6.QtCore import QAbstractListModel, Qt, Slot, Signal
+from PySide6.QtQml import QmlElement
+from pathlib import Path
+import json
+
+QML_IMPORT_NAME = "DuneWeaver"
+QML_IMPORT_MAJOR_VERSION = 1
+
+@QmlElement
+class PlaylistModel(QAbstractListModel):
+    """Model for playlist list with direct JSON file access"""
+    
+    NameRole = Qt.UserRole + 1
+    ItemCountRole = Qt.UserRole + 2
+    
+    def __init__(self):
+        super().__init__()
+        self._playlists = []
+        # Look for playlists in the parent directory (main dune-weaver folder)
+        self.playlists_file = Path("../playlists.json")
+        self.refresh()
+    
+    def roleNames(self):
+        return {
+            self.NameRole: b"name",
+            self.ItemCountRole: b"itemCount"
+        }
+    
+    def rowCount(self, parent=None):
+        return len(self._playlists)
+    
+    def data(self, index, role):
+        if not index.isValid() or index.row() >= len(self._playlists):
+            return None
+        
+        playlist = self._playlists[index.row()]
+        
+        if role == self.NameRole:
+            return playlist["name"]
+        elif role == self.ItemCountRole:
+            return playlist["itemCount"]
+        
+        return None
+    
+    @Slot()
+    def refresh(self):
+        self.beginResetModel()
+        playlists = []
+        
+        if self.playlists_file.exists():
+            try:
+                with open(self.playlists_file, 'r') as f:
+                    self._playlist_data = json.load(f)
+                    for name, playlist_patterns in self._playlist_data.items():
+                        # playlist_patterns is a list of pattern filenames
+                        playlists.append({
+                            "name": name,
+                            "itemCount": len(playlist_patterns) if isinstance(playlist_patterns, list) else 0
+                        })
+            except (json.JSONDecodeError, KeyError, AttributeError):
+                self._playlist_data = {}
+        else:
+            self._playlist_data = {}
+        
+        self._playlists = sorted(playlists, key=lambda x: x["name"])
+        self.endResetModel()
+    
+    @Slot(str, result=list)
+    def getPatternsForPlaylist(self, playlistName):
+        """Get the list of patterns for a given playlist"""
+        if hasattr(self, '_playlist_data') and playlistName in self._playlist_data:
+            patterns = self._playlist_data[playlistName]
+            if isinstance(patterns, list):
+                # Clean up pattern names for display
+                cleaned_patterns = []
+                for pattern in patterns:
+                    # Remove path and .thr extension for display
+                    clean_name = pattern
+                    if '/' in clean_name:
+                        clean_name = clean_name.split('/')[-1]
+                    if clean_name.endswith('.thr'):
+                        clean_name = clean_name[:-4]
+                    cleaned_patterns.append(clean_name)
+                return cleaned_patterns
+        return []

+ 204 - 0
dune-weaver-touch/png_cache_manager.py

@@ -0,0 +1,204 @@
+"""PNG Cache Manager for dune-weaver-touch
+
+Converts WebP previews to PNG format for optimal Qt/QML compatibility.
+"""
+
+import asyncio
+import os
+import logging
+from pathlib import Path
+from typing import List, Tuple
+try:
+    from PIL import Image
+except ImportError:
+    Image = None
+
+logger = logging.getLogger(__name__)
+
+class PngCacheManager:
+    """Manages PNG cache generation from WebP sources for touch interface"""
+    
+    def __init__(self, cache_dir: Path = None):
+        # Default to the main cache directory relative to touch app
+        self.cache_dir = cache_dir or Path("../patterns/cached_images")
+        self.conversion_stats = {
+            "total_webp_found": 0,
+            "png_already_exist": 0,
+            "converted_successfully": 0,
+            "conversion_errors": 0
+        }
+    
+    async def ensure_png_cache_available(self) -> bool:
+        """
+        Ensure PNG previews are available for all WebP files.
+        Returns True if all conversions completed successfully.
+        """
+        if not Image:
+            logger.error("PIL (Pillow) not available - cannot convert WebP to PNG")
+            return False
+        
+        if not self.cache_dir.exists():
+            logger.info(f"Cache directory {self.cache_dir} does not exist - no conversion needed")
+            return True
+        
+        logger.info(f"Starting PNG cache check for directory: {self.cache_dir}")
+        
+        # Find all WebP files that need PNG conversion
+        webp_files = await self._find_webp_files_needing_conversion()
+        
+        if not webp_files:
+            logger.info("All WebP files already have PNG equivalents")
+            return True
+        
+        logger.info(f"Found {len(webp_files)} WebP files needing PNG conversion")
+        
+        # Convert WebP files to PNG in batches
+        success = await self._convert_webp_to_png_batch(webp_files)
+        
+        # Log conversion statistics
+        self._log_conversion_stats()
+        
+        return success
+    
+    async def _find_webp_files_needing_conversion(self) -> List[Path]:
+        """Find WebP files that don't have corresponding PNG files"""
+        def _scan_webp():
+            webp_files = []
+            for webp_file in self.cache_dir.rglob("*.webp"):
+                # Check if corresponding PNG exists
+                png_file = webp_file.with_suffix(".png")
+                if not png_file.exists():
+                    webp_files.append(webp_file)
+                else:
+                    self.conversion_stats["png_already_exist"] += 1
+                self.conversion_stats["total_webp_found"] += 1
+            return webp_files
+        
+        return await asyncio.to_thread(_scan_webp)
+    
+    async def _convert_webp_to_png_batch(self, webp_files: List[Path]) -> bool:
+        """Convert WebP files to PNG in parallel batches"""
+        batch_size = 5  # Process 5 files at a time to avoid overwhelming the system
+        all_success = True
+        
+        for i in range(0, len(webp_files), batch_size):
+            batch = webp_files[i:i + batch_size]
+            batch_tasks = [self._convert_single_webp_to_png(webp_file) for webp_file in batch]
+            batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
+            
+            # Check results
+            for webp_file, result in zip(batch, batch_results):
+                if isinstance(result, Exception):
+                    logger.error(f"Failed to convert {webp_file}: {result}")
+                    self.conversion_stats["conversion_errors"] += 1
+                    all_success = False
+                elif result:
+                    self.conversion_stats["converted_successfully"] += 1
+                    logger.debug(f"Converted {webp_file} to PNG")
+                else:
+                    self.conversion_stats["conversion_errors"] += 1
+                    all_success = False
+            
+            # Log progress
+            processed = min(i + batch_size, len(webp_files))
+            logger.info(f"PNG conversion progress: {processed}/{len(webp_files)} files processed")
+        
+        return all_success
+    
+    async def _convert_single_webp_to_png(self, webp_file: Path) -> bool:
+        """Convert a single WebP file to PNG format"""
+        try:
+            png_file = webp_file.with_suffix(".png")
+            
+            def _convert():
+                # Open WebP image and convert to PNG
+                with Image.open(webp_file) as img:
+                    # Convert to RGB if necessary (PNG doesn't support some WebP modes)
+                    if img.mode in ('RGBA', 'LA', 'P'):
+                        # Keep transparency for these modes
+                        img.save(png_file, "PNG", optimize=True)
+                    else:
+                        # Convert to RGB for other modes
+                        rgb_img = img.convert('RGB')
+                        rgb_img.save(png_file, "PNG", optimize=True)
+                
+                # Set file permissions to match the WebP file
+                try:
+                    webp_stat = webp_file.stat()
+                    os.chmod(png_file, webp_stat.st_mode)
+                except (OSError, PermissionError):
+                    # Not critical if we can't set permissions
+                    pass
+            
+            await asyncio.to_thread(_convert)
+            return True
+            
+        except Exception as e:
+            logger.error(f"Failed to convert {webp_file} to PNG: {e}")
+            return False
+    
+    def _log_conversion_stats(self):
+        """Log conversion statistics"""
+        stats = self.conversion_stats
+        logger.info("PNG Cache Conversion Statistics:")
+        logger.info(f"  Total WebP files found: {stats['total_webp_found']}")
+        logger.info(f"  PNG files already existed: {stats['png_already_exist']}")
+        logger.info(f"  Files converted successfully: {stats['converted_successfully']}")
+        logger.info(f"  Conversion errors: {stats['conversion_errors']}")
+        
+        if stats['conversion_errors'] > 0:
+            logger.warning(f"⚠️ {stats['conversion_errors']} files failed to convert")
+        else:
+            logger.info("✅ All WebP to PNG conversions completed successfully")
+    
+    async def convert_specific_pattern(self, pattern_name: str) -> bool:
+        """Convert a specific pattern's WebP to PNG if needed"""
+        if not Image:
+            return False
+        
+        # Handle both hierarchical and flat naming conventions
+        webp_files = []
+        
+        # Try hierarchical structure first
+        webp_hierarchical = self.cache_dir / f"{pattern_name}.webp"
+        if webp_hierarchical.exists():
+            png_hierarchical = webp_hierarchical.with_suffix(".png")
+            if not png_hierarchical.exists():
+                webp_files.append(webp_hierarchical)
+        
+        # Try flattened structure
+        pattern_name_flat = pattern_name.replace("/", "_").replace("\\", "_")
+        webp_flat = self.cache_dir / f"{pattern_name_flat}.webp"
+        if webp_flat.exists():
+            png_flat = webp_flat.with_suffix(".png")
+            if not png_flat.exists():
+                webp_files.append(webp_flat)
+        
+        if not webp_files:
+            return True  # No conversion needed
+        
+        # Convert found WebP files
+        tasks = [self._convert_single_webp_to_png(webp_file) for webp_file in webp_files]
+        results = await asyncio.gather(*tasks)
+        
+        return all(results)
+
+
+async def ensure_png_cache_startup():
+    """
+    Startup function to ensure PNG cache is available.
+    Call this during application startup.
+    """
+    try:
+        cache_manager = PngCacheManager()
+        success = await cache_manager.ensure_png_cache_available()
+        
+        if success:
+            logger.info("PNG cache startup check completed successfully")
+        else:
+            logger.warning("PNG cache startup check completed with some errors")
+        
+        return success
+    except Exception as e:
+        logger.error(f"PNG cache startup check failed: {e}")
+        return False

+ 109 - 0
dune-weaver-touch/qml/components/BottomNavTab.qml

@@ -0,0 +1,109 @@
+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
+                console.log("BottomNavTab icon value:", iconValue)
+
+                // 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
+                    default: {
+                        console.log("Unknown icon:", iconValue, "- using 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()
+        }
+    }
+}

+ 67 - 0
dune-weaver-touch/qml/components/BottomNavigation.qml

@@ -0,0 +1,67 @@
+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)
+        }
+        
+        // Execution Tab
+        BottomNavTab {
+            Layout.fillWidth: true
+            Layout.fillHeight: true
+            icon: "play_arrow"
+            text: "Execution"
+            active: bottomNav.currentIndex === 3
+            onClicked: bottomNav.tabClicked(3)
+        }
+    }
+}

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

@@ -0,0 +1,161 @@
+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 }
+        }
+    }
+}

+ 61 - 0
dune-weaver-touch/qml/components/ConnectionStatus.qml

@@ -0,0 +1,61 @@
+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) {
+            console.log("ConnectionStatus: No backend available")
+            return "#FF5722"  // Red if no backend
+        }
+        
+        var connected = backend.serialConnected
+        console.log("ConnectionStatus: backend.serialConnected =", connected)
+        
+        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) {
+            console.log("ConnectionStatus: serialConnectionChanged signal received:", connected)
+            // The color binding will automatically update
+        }
+    }
+    
+    // Debug logging
+    Component.onCompleted: {
+        console.log("ConnectionStatus: Component completed, backend =", backend)
+        if (backend) {
+            console.log("ConnectionStatus: initial serialConnected =", backend.serialConnected)
+        }
+    }
+    
+    onBackendChanged: {
+        console.log("ConnectionStatus: backend changed to", backend)
+        if (backend) {
+            console.log("ConnectionStatus: new backend serialConnected =", backend.serialConnected)
+        }
+    }
+    
+    // Animate color changes
+    Behavior on color {
+        ColorAnimation {
+            duration: 300
+            easing.type: Easing.OutQuart
+        }
+    }
+}

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

@@ -0,0 +1,66 @@
+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
+            }
+        }
+    }
+}

+ 103 - 0
dune-weaver-touch/qml/components/ModernControlButton.qml

@@ -0,0 +1,103 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import QtQuick.Effects
+
+Rectangle {
+    property alias text: buttonLabel.text
+    property string icon: ""
+    property color buttonColor: "#2196F3"
+    property bool enabled: true
+    property int fontSize: 16
+    
+    signal clicked()
+    
+    radius: 12
+    color: enabled ? buttonColor : "#E0E0E0"
+    opacity: enabled ? 1.0 : 0.6
+    
+    // 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
+    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.fontSize + 2
+            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()
+                }
+            }
+        }
+    }
+}

+ 153 - 0
dune-weaver-touch/qml/components/ModernPatternCard.qml

@@ -0,0 +1,153 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Effects
+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
+    
+    // Drop shadow effect
+    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()
+                }
+            }
+        }
+    }
+}

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

@@ -0,0 +1,53 @@
+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()
+    }
+}

+ 77 - 0
dune-weaver-touch/qml/components/ThemeManager.qml

@@ -0,0 +1,77 @@
+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 ? "#606060" : "#cccccc"
+
+    // 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
+        console.log("🎨 Dark mode:", darkMode ? "enabled" : "disabled")
+    }
+
+    // Helper function to get contrast color
+    function getContrastColor(baseColor) {
+        return darkMode ? Qt.lighter(baseColor, 1.2) : Qt.darker(baseColor, 1.1)
+    }
+}

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

@@ -0,0 +1,53 @@
+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
+                }
+            }
+        }
+    }
+}

+ 1 - 0
dune-weaver-touch/qml/components/qmldir

@@ -0,0 +1 @@
+singleton ThemeManager 1.0 ThemeManager.qml

+ 237 - 0
dune-weaver-touch/qml/main.qml

@@ -0,0 +1,237 @@
+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"
+
+ApplicationWindow {
+    id: window
+    visible: true
+    width: 800
+    height: 480
+    title: "Dune Weaver Touch"
+
+    property int currentPageIndex: 0
+    property alias stackView: stackView
+    property alias backend: backend
+    property bool shouldNavigateToExecution: false
+    property string currentPatternName: ""
+    property string currentPatternPreview: ""
+    
+    onCurrentPageIndexChanged: {
+        console.log("📱 currentPageIndex changed to:", currentPageIndex)
+    }
+    
+    onShouldNavigateToExecutionChanged: {
+        if (shouldNavigateToExecution) {
+            console.log("🎯 Navigating to execution page")
+            console.log("🎯 Current stack depth:", stackView.depth)
+            
+            // If we're in a sub-page (like PatternDetailPage), pop back to main view first
+            if (stackView.depth > 1) {
+                console.log("🎯 Popping back to main view first")
+                stackView.pop()
+            }
+            
+            // Then navigate to ExecutionPage tab
+            console.log("🎯 Setting currentPageIndex to 3")
+            currentPageIndex = 3
+            shouldNavigateToExecution = false
+        }
+    }
+    
+    Backend {
+        id: backend
+        
+        onExecutionStarted: function(patternName, patternPreview) {
+            console.log("🎯 QML: ExecutionStarted signal received! patternName='" + patternName + "', preview='" + patternPreview + "'")
+            console.log("🎯 Setting shouldNavigateToExecution = true")
+            // Store pattern info for ExecutionPage
+            window.currentPatternName = patternName
+            window.currentPatternPreview = patternPreview
+            // Navigate to Execution tab (index 3) instead of pushing page
+            shouldNavigateToExecution = true
+            console.log("🎯 shouldNavigateToExecution set to:", shouldNavigateToExecution)
+        }
+        
+        onErrorOccurred: function(error) {
+            errorDialog.text = error
+            errorDialog.open()
+        }
+        
+        onScreenStateChanged: function(isOn) {
+            console.log("🖥️ Screen state changed:", isOn ? "ON" : "OFF")
+        }
+        
+        onBackendConnectionChanged: function(connected) {
+            console.log("🔗 Backend connection changed:", connected)
+            if (connected && stackView.currentItem.toString().indexOf("ConnectionSplash") !== -1) {
+                console.log("✅ Backend connected, switching to main view")
+                stackView.replace(mainSwipeView)
+            } else if (!connected && stackView.currentItem.toString().indexOf("ConnectionSplash") === -1) {
+                console.log("❌ Backend disconnected, switching to splash screen")
+                stackView.replace(connectionSplash)
+            }
+        }
+    }
+    
+    // Global touch/mouse handler for activity tracking
+    MouseArea {
+        anchors.fill: parent
+        acceptedButtons: Qt.NoButton  // Don't interfere with other mouse areas
+        hoverEnabled: true
+        propagateComposedEvents: true
+        
+        onPressed: {
+            console.log("🖥️ QML: Touch/press detected - resetting activity timer")
+            backend.resetActivityTimer()
+        }
+        
+        onPositionChanged: {
+            console.log("🖥️ QML: Mouse movement detected - resetting activity timer")
+            backend.resetActivityTimer()
+        }
+        
+        onClicked: {
+            console.log("🖥️ QML: Click detected - resetting activity timer")
+            backend.resetActivityTimer()
+        }
+    }
+    
+    PatternModel {
+        id: patternModel
+    }
+    
+    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: {
+                    console.log("🔄 Manual retry requested")
+                    backend.retryConnection()
+                }
+            }
+        }
+        
+        Component {
+            id: mainSwipeView
+            
+            Item {
+                // Main content area
+                StackLayout {
+                    id: stackLayout
+                    anchors.top: parent.top
+                    anchors.left: parent.left
+                    anchors.right: parent.right
+                    anchors.bottom: bottomNav.top
+                    currentIndex: window.currentPageIndex
+                    
+                    Component.onCompleted: {
+                        console.log("📱 StackLayout created with currentIndex:", currentIndex, "bound to window.currentPageIndex:", window.currentPageIndex)
+                    }
+                    
+                    // Patterns Page
+                    Loader {
+                        source: "pages/ModernPatternListPage.qml"
+                        onLoaded: {
+                            item.patternModel = patternModel
+                            item.backend = backend
+                            item.stackView = stackView
+                        }
+                    }
+                    
+                    // Playlists Page  
+                    Loader {
+                        source: "pages/ModernPlaylistPage.qml"
+                        onLoaded: {
+                            item.backend = backend
+                            item.stackView = stackView
+                            item.mainWindow = window
+                        }
+                    }
+                    
+                    // Control Page
+                    Loader {
+                        source: "pages/TableControlPage.qml"
+                        onLoaded: {
+                            item.backend = backend
+                        }
+                    }
+                    
+                    // Execution Page
+                    Loader {
+                        source: "pages/ExecutionPage.qml"
+                        onLoaded: {
+                            item.backend = backend
+                            item.stackView = stackView
+                            item.patternName = Qt.binding(function() { return window.currentPatternName })
+                            item.patternPreview = Qt.binding(function() { return window.currentPatternPreview })
+                        }
+                    }
+                }
+                
+                // Bottom Navigation
+                BottomNavigation {
+                    id: bottomNav
+                    anchors.bottom: parent.bottom
+                    anchors.left: parent.left
+                    anchors.right: parent.right
+                    currentIndex: window.currentPageIndex
+                    
+                    onTabClicked: function(index) {
+                        console.log("📱 Tab clicked:", index)
+                        window.currentPageIndex = index
+                    }
+                }
+            }
+        }
+    }
+    
+    MessageDialog {
+        id: errorDialog
+        title: "Error"
+        buttons: MessageDialog.Ok
+    }
+    
+    // Virtual Keyboard Support
+    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
+                }
+            }
+        }
+    }
+}

+ 478 - 0
dune-weaver-touch/qml/pages/ExecutionPage.qml

@@ -0,0 +1,478 @@
+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: {
+        console.log("ExecutionPage: backend changed to", backend)
+        if (backend) {
+            console.log("ExecutionPage: backend.serialConnected =", backend.serialConnected)
+            console.log("ExecutionPage: backend.isConnected =", backend.isConnected)
+        }
+    }
+    
+    Component.onCompleted: {
+        console.log("ExecutionPage: Component completed, backend =", backend)
+        if (backend) {
+            console.log("ExecutionPage: initial serialConnected =", backend.serialConnected)
+        }
+    }
+    
+    // Direct connection to backend signals
+    Connections {
+        target: backend
+
+        function onSerialConnectionChanged(connected) {
+            console.log("ExecutionPage: received serialConnectionChanged signal:", connected)
+        }
+
+        function onConnectionChanged() {
+            console.log("ExecutionPage: received connectionChanged signal")
+            if (backend) {
+                console.log("ExecutionPage: after connectionChanged, serialConnected =", backend.serialConnected)
+            }
+        }
+
+        function onExecutionStarted(fileName, preview) {
+            console.log("🎯 ExecutionPage: executionStarted signal received!")
+            console.log("🎯 Pattern:", fileName)
+            console.log("🎯 Preview path:", 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
+                                console.log("🖼️ Using backend patternPreview:", finalSource)
+                            } else {
+                                console.log("🖼️ No preview from backend")
+                            }
+
+                            return finalSource
+                        }
+                        fillMode: Image.PreserveAspectFit
+
+                        onStatusChanged: {
+                            console.log("📷 Image status:", status, "for source:", source)
+                            if (status === Image.Error) {
+                                console.log("❌ Image failed to load:", source)
+                            } else if (status === Image.Ready) {
+                                console.log("✅ Image loaded successfully:", source)
+                            } else if (status === Image.Loading) {
+                                console.log("🔄 Image loading:", source)
+                            }
+                        }
+
+                        onSourceChanged: {
+                            console.log("🔄 Image source changed to:", source)
+                        }
+                        
+                        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
+                                            text: (backend && backend.isRunning) ? "||" : "▶"
+                                            color: "white"
+                                            font.pixelSize: 14
+                                            font.bold: true
+                                        }
+                                        
+                                        MouseArea {
+                                            id: pauseMouseArea
+                                            anchors.fill: parent
+                                            enabled: backend && backend.currentFile !== ""
+                                            onClicked: {
+                                                if (backend) {
+                                                    if (backend.isRunning) {
+                                                        backend.pauseExecution()
+                                                    } else {
+                                                        backend.resumeExecution()
+                                                    }
+                                                }
+                                            }
+                                        }
+                                    }
+                                    
+                                    // 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: ["100", "150", "200", "300", "500"]
+                                        
+                                        Rectangle {
+                                            width: (speedControlRow.width - 32) / 5  // 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()
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+            }
+        }
+    }
+}

+ 286 - 0
dune-weaver-touch/qml/pages/ModernPatternListPage.qml

@@ -0,0 +1,286 @@
+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
+
+    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: patternModel.rowCount() + " patterns"
+                    font.pixelSize: 12
+                    color: Components.ThemeManager.textTertiary
+                    visible: !searchExpanded
+                }
+                
+                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"
+                            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 {
+                    text: "✕"
+                    font.pixelSize: 18
+                    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("")
+                    }
+                }
+            }
+        }
+        
+        // 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: patternModel.rowCount() === 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
+                }
+            }
+        }
+    }
+}

+ 899 - 0
dune-weaver-touch/qml/pages/ModernPlaylistPage.qml

@@ -0,0 +1,899 @@
+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: []
+    
+    // Playlist execution settings
+    property real pauseTime: backend ? backend.pauseBetweenPatterns : 0
+    property string clearPattern: "adaptive"
+    property string runMode: "single"
+    property bool shuffle: false
+    
+    PlaylistModel {
+        id: playlistModel
+    }
+    
+    // Update patterns when playlist selection changes
+    onSelectedPlaylistChanged: {
+        if (selectedPlaylist) {
+            currentPlaylistPatterns = playlistModel.getPatternsForPlaylist(selectedPlaylist)
+            console.log("Loaded patterns for", selectedPlaylist + ":", currentPlaylistPatterns)
+        } else {
+            currentPlaylistPatterns = []
+        }
+    }
+    
+    // Debug playlist loading
+    Component.onCompleted: {
+        console.log("ModernPlaylistPage completed, playlist count:", playlistModel.rowCount())
+        console.log("showingPlaylistDetail:", showingPlaylistDetail)
+    }
+    
+    // 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 
+                    }
+                }
+            }
+            
+            // 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
+                    }
+                }
+            }
+            
+            // 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
+                            
+                            Label {
+                                text: "Patterns"
+                                font.pixelSize: 14
+                                font.bold: true
+                                color: Components.ThemeManager.textPrimary
+                            }
+                            
+                            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: 35
+                                        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.margins: 10
+                                            spacing: 8
+                                            
+                                            Label {
+                                                text: (index + 1) + "."
+                                                font.pixelSize: 11
+                                                color: Components.ThemeManager.textSecondary
+                                                Layout.preferredWidth: 25
+                                            }
+
+                                            Label {
+                                                text: modelData
+                                                font.pixelSize: 11
+                                                color: Components.ThemeManager.textPrimary
+                                                Layout.fillWidth: true
+                                                elide: Text.ElideRight
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                            
+                            // 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) {
+                                                console.log("Playing playlist:", selectedPlaylist, "with settings:", {
+                                                    pauseTime: pauseTime,
+                                                    clearPattern: clearPattern,
+                                                    runMode: runMode,
+                                                    shuffle: shuffle
+                                                })
+                                                backend.executePlaylist(selectedPlaylist, pauseTime, clearPattern, runMode, shuffle)
+                                                
+                                                // Navigate to execution page
+                                                console.log("🎵 Navigating to execution page after playlist start")
+                                                if (mainWindow) {
+                                                    console.log("🎵 Setting shouldNavigateToExecution = true")
+                                                    mainWindow.shouldNavigateToExecution = true
+                                                } else {
+                                                    console.log("🎵 ERROR: mainWindow is null, cannot navigate")
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                                
+                                // 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: {
+                                            shuffle = !shuffle
+                                            console.log("Shuffle toggled:", shuffle)
+                                        }
+                                    }
+                                }
+                            }
+                            
+                            // 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"
+                                                onCheckedChanged: {
+                                                    if (checked) runMode = "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"
+                                                onCheckedChanged: {
+                                                    if (checked) runMode = "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
+                                                        }
+                                                    }
+                                                }
+                                            }
+                                            
+                                            // 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 {
+                                                text: "Adaptive"
+                                                font.pixelSize: 11
+                                                checked: clearPattern === "adaptive"
+                                                onCheckedChanged: {
+                                                    if (checked) clearPattern = "adaptive"
+                                                }
+
+                                                contentItem: Text {
+                                                    text: parent.text
+                                                    font: parent.font
+                                                    color: Components.ThemeManager.textPrimary
+                                                    verticalAlignment: Text.AlignVCenter
+                                                    leftPadding: parent.indicator.width + parent.spacing
+                                                }
+                                            }
+
+                                            RadioButton {
+                                                text: "Clear Center"
+                                                font.pixelSize: 11
+                                                checked: clearPattern === "clear_center"
+                                                onCheckedChanged: {
+                                                    if (checked) clearPattern = "clear_center"
+                                                }
+
+                                                contentItem: Text {
+                                                    text: parent.text
+                                                    font: parent.font
+                                                    color: Components.ThemeManager.textPrimary
+                                                    verticalAlignment: Text.AlignVCenter
+                                                    leftPadding: parent.indicator.width + parent.spacing
+                                                }
+                                            }
+
+                                            RadioButton {
+                                                text: "Clear Edge"
+                                                font.pixelSize: 11
+                                                checked: clearPattern === "clear_perimeter"
+                                                onCheckedChanged: {
+                                                    if (checked) clearPattern = "clear_perimeter"
+                                                }
+
+                                                contentItem: Text {
+                                                    text: parent.text
+                                                    font: parent.font
+                                                    color: Components.ThemeManager.textPrimary
+                                                    verticalAlignment: Text.AlignVCenter
+                                                    leftPadding: parent.indicator.width + parent.spacing
+                                                }
+                                            }
+
+                                            RadioButton {
+                                                text: "None"
+                                                font.pixelSize: 11
+                                                checked: clearPattern === "none"
+                                                onCheckedChanged: {
+                                                    if (checked) clearPattern = "none"
+                                                }
+
+                                                contentItem: Text {
+                                                    text: parent.text
+                                                    font: parent.font
+                                                    color: Components.ThemeManager.textPrimary
+                                                    verticalAlignment: Text.AlignVCenter
+                                                    leftPadding: parent.indicator.width + parent.spacing
+                                                }
+                                            }
+                                        }
+                                    }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 297 - 0
dune-weaver-touch/qml/pages/PatternDetailPage.qml

@@ -0,0 +1,297 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import "../components"
+import "../components" as Components
+
+Page {
+    id: page
+    property string patternName: ""
+    property string patternPath: ""
+    property string patternPreview: ""
+    property var backend: null
+
+    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)
+                                }
+                            }
+                        }
+                    }
+                    
+                    // 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
+                            }
+                        }
+                    }
+                }
+            }
+            }
+        }
+    }
+}

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

@@ -0,0 +1,68 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import "../components"
+
+Page {
+    id: page
+    
+    header: ToolBar {
+        RowLayout {
+            anchors.fill: parent
+            anchors.margins: 10
+            
+            TextField {
+                id: searchField
+                Layout.fillWidth: true
+                placeholderText: "Search patterns..."
+                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: "#999"
+        font.pixelSize: 18
+    }
+}

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

@@ -0,0 +1,93 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import DuneWeaver 1.0
+
+Page {
+    header: ToolBar {
+        RowLayout {
+            anchors.fill: parent
+            anchors.margins: 10
+            
+            Button {
+                text: "← Back"
+                font.pixelSize: 14
+                flat: true
+                onClicked: stackView.pop()
+            }
+            
+            Label {
+                text: "Playlists"
+                Layout.fillWidth: true
+                font.pixelSize: 20
+                font.bold: true
+            }
+        }
+    }
+    
+    PlaylistModel {
+        id: playlistModel
+    }
+    
+    ListView {
+        anchors.fill: parent
+        anchors.margins: 20
+        model: playlistModel
+        spacing: 10
+        
+        delegate: Rectangle {
+            width: parent.width
+            height: 80
+            color: mouseArea.pressed ? "#e0e0e0" : "#f5f5f5"
+            radius: 8
+            border.color: "#d0d0d0"
+            
+            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
+                    }
+                    
+                    Label {
+                        text: model.itemCount + " patterns"
+                        color: "#666"
+                        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: "#999"
+        font.pixelSize: 18
+    }
+}

+ 588 - 0
dune-weaver-touch/qml/pages/TableControlPage.qml

@@ -0,0 +1,588 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import QtQuick.Effects
+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) {
+            console.log("Serial ports updated:", ports)
+            serialPorts = ports
+        }
+        
+        function onSerialConnectionChanged(connected) {
+            console.log("Serial connection changed:", connected)
+            isSerialConnected = connected
+        }
+        
+        function onCurrentPortChanged(port) {
+            console.log("Current port changed:", port)
+            if (port) {
+                selectedPort = port
+            }
+        }
+        
+        
+        function onSettingsLoaded() {
+            console.log("Settings loaded")
+            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
+                                    
+                                    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 ? "◉" : "○"
+                                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: "↻"
+                                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: "⌂"
+                                buttonColor: "#2563eb"
+                                fontSize: 12
+                                enabled: isSerialConnected
+                                
+                                onClicked: {
+                                    if (backend) backend.sendHome()
+                                }
+                            }
+                            
+                            ModernControlButton {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: 45
+                                text: "Center"
+                                icon: "◎"
+                                buttonColor: "#2563eb"
+                                fontSize: 12
+                                enabled: isSerialConnected
+                                
+                                onClicked: {
+                                    if (backend) backend.moveToCenter()
+                                }
+                            }
+                            
+                            ModernControlButton {
+                                Layout.fillWidth: true
+                                Layout.preferredHeight: 45
+                                text: "Perimeter"
+                                icon: "○"
+                                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
+                                }
+                            }
+                        }
+                    }
+                }
+
+                // Add some bottom spacing for better scrolling
+                Item {
+                    Layout.preferredHeight: 20
+                }
+            }
+        }
+    }
+}

+ 4 - 0
dune-weaver-touch/requirements.txt

@@ -0,0 +1,4 @@
+PySide6>=6.5.0
+qasync>=0.27.0
+aiohttp>=3.9.0
+Pillow>=10.0.0

+ 87 - 0
dune-weaver-touch/run.sh

@@ -0,0 +1,87 @@
+#!/bin/bash
+# Development runner - uses the virtual environment
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Check if virtual environment exists
+if [ ! -d "$SCRIPT_DIR/venv" ]; then
+    echo "❌ Virtual environment not found!"
+    echo "   Run: sudo ./install.sh"
+    echo "   Or manually create: python3 -m venv venv && venv/bin/pip install -r requirements.txt"
+    exit 1
+fi
+
+# Check if backend is running at localhost:8080
+echo "🔍 Checking backend availability at localhost:8080..."
+BACKEND_URL="http://localhost:8080"
+MAX_ATTEMPTS=30
+ATTEMPT=0
+
+while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
+    if curl -s --connect-timeout 2 "$BACKEND_URL/serial_status" > /dev/null 2>&1; then
+        echo "✅ Backend is available at localhost:8080"
+        break
+    else
+        ATTEMPT=$((ATTEMPT + 1))
+        if [ $ATTEMPT -eq 1 ]; then
+            echo "⏳ Waiting for backend to become available..."
+            echo "   Make sure the main Dune Weaver application is running"
+            echo "   Attempting connection ($ATTEMPT/$MAX_ATTEMPTS)..."
+        elif [ $((ATTEMPT % 5)) -eq 0 ]; then
+            echo "   Still waiting... ($ATTEMPT/$MAX_ATTEMPTS)"
+        fi
+        sleep 1
+    fi
+done
+
+if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
+    echo "❌ Backend not available after $MAX_ATTEMPTS attempts"
+    echo "   Please ensure the main Dune Weaver application is running at localhost:8080"
+    echo "   You can start it with: cd .. && python main.py"
+    exit 1
+fi
+
+# Run the application using the virtual environment
+echo ""
+echo "🚀 Starting Dune Weaver Touch (development mode)"
+echo "   Using virtual environment: $SCRIPT_DIR/venv"
+echo "   Connected to backend: $BACKEND_URL"
+echo "   Press Ctrl+C to stop"
+echo ""
+
+cd "$SCRIPT_DIR"
+
+# Detect platform and set appropriate Qt backend
+if [[ "$OSTYPE" == "darwin"* ]]; then
+    # macOS - use native cocoa backend (default, no need to set)
+    echo "   Platform: macOS (using native cocoa backend)"
+    export QT_QPA_PLATFORM=""  # Let Qt use default
+elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
+    # Linux - check for DRM devices to determine if eglfs is available
+    if [ -e /dev/dri/card0 ] || [ -e /dev/dri/card1 ]; then
+        echo "   Platform: Linux with DRM (using eglfs backend)"
+        export QT_QPA_PLATFORM=eglfs
+        export QT_QPA_EGLFS_INTEGRATION=eglfs_kms
+        export QT_QPA_EGLFS_KMS_ATOMIC=1
+        export QT_QPA_EGLFS_HIDECURSOR=1
+        export QT_QPA_EGLFS_ALWAYS_SET_MODE=1
+
+        # Touch rotation is handled by udev rule: /etc/udev/rules.d/99-ft5x06-rotate.rules
+        # The rule applies a libinput calibration matrix for 180° rotation
+
+        # Use eglfs_config.json with corrected connector name (DSI-1)
+        if [ -f "$SCRIPT_DIR/eglfs_config.json" ]; then
+            echo "   Using eglfs config: $SCRIPT_DIR/eglfs_config.json"
+            export QT_QPA_EGLFS_KMS_CONFIG="$SCRIPT_DIR/eglfs_config.json"
+        else
+            echo "   EGLFS mode: Auto-detection (no config file)"
+        fi
+    else
+        echo "   Platform: Linux without DRM (using xcb/X11 backend)"
+        export QT_QPA_PLATFORM=xcb
+    fi
+else
+    echo "   Platform: Unknown (using default Qt backend)"
+fi
+
+exec ./venv/bin/python main.py

+ 59 - 0
dune-weaver-touch/scripts/install-scripts.sh

@@ -0,0 +1,59 @@
+#!/bin/bash
+# Install Dune Weaver Touch scripts to system locations
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Check if running as root
+if [ "$EUID" -ne 0 ]; then
+    echo "❌ This script must be run as root (use sudo)"
+    echo "   These scripts need to be installed in /usr/local/bin/ with proper permissions"
+    exit 1
+fi
+
+echo "🔧 Installing Dune Weaver Touch system scripts..."
+echo ""
+
+# Function to install a script
+install_script() {
+    local script_name="$1"
+    local source_path="$SCRIPT_DIR/$script_name"
+    local target_path="/usr/local/bin/$script_name"
+    
+    if [ ! -f "$source_path" ]; then
+        echo "❌ Source script not found: $source_path"
+        return 1
+    fi
+    
+    echo "📄 Installing $script_name..."
+    
+    # Copy script
+    cp "$source_path" "$target_path"
+    
+    # Set proper permissions (executable by root, readable by all)
+    chmod 755 "$target_path"
+    chown root:root "$target_path"
+    
+    echo "   ✅ Installed: $target_path"
+}
+
+# Install all scripts
+install_script "screen-on"
+install_script "screen-off" 
+install_script "touch-monitor"
+
+echo ""
+echo "🎯 All scripts installed successfully!"
+echo ""
+echo "Installed scripts:"
+echo "   /usr/local/bin/screen-on     - Turn display on"
+echo "   /usr/local/bin/screen-off    - Turn display off"  
+echo "   /usr/local/bin/touch-monitor - Monitor touch input"
+echo ""
+echo "Test the scripts:"
+echo "   sudo /usr/local/bin/screen-off"
+echo "   sudo /usr/local/bin/screen-on"
+echo ""
+echo "⚠️  Note: The touch-monitor script is disabled by default in the app"
+echo "   due to sensitivity issues. Direct touch monitoring is used instead."

+ 6 - 0
dune-weaver-touch/scripts/screen-off

@@ -0,0 +1,6 @@
+# /usr/local/bin/screen-off
+#!/usr/bin/env bash
+set -e
+BL="/sys/class/backlight/$(ls /sys/class/backlight | head -n1)"
+if [ -e "$BL/bl_power" ]; then echo 4 | sudo tee "$BL/bl_power"; else echo 0 | sudo tee "$BL/brightness"; fi
+echo 1 | sudo tee /sys/class/graphics/fb0/blank >/dev/null

+ 12 - 0
dune-weaver-touch/scripts/screen-on

@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -e
+BL="/sys/class/backlight/$(ls /sys/class/backlight | head -n1)"
+# Unblank framebuffer first
+echo 0 | sudo tee /sys/class/graphics/fb0/blank >/dev/null
+# Then restore backlight
+if [ -e "$BL/bl_power" ]; then
+    echo 0 | sudo tee "$BL/bl_power"
+else
+    MAX=$(cat "$BL/max_brightness")
+    echo "$MAX" | sudo tee "$BL/brightness"
+fi

+ 37 - 0
dune-weaver-touch/scripts/touch-monitor

@@ -0,0 +1,37 @@
+# /usr/local/bin/screen-off
+#!/usr/bin/env bash
+set -e
+BL="/sys/class/backlight/$(ls /sys/class/backlight | head -n1)"
+if [ -e "$BL/bl_power" ]; then echo 4 | sudo tee "$BL/bl_power"; else echo 0 | sudo tee "$BL/brightness"; fi
+echo 1 | sudo tee /sys/class/graphics/fb0/blank >/dev/null
+tuannguyen@dune-weaver-test:~ $ cat /usr/local/bin/touch-monitor
+#!/usr/bin/env bash
+# Monitor touch events and wake screen when detected
+
+# Find touch input device
+TOUCH_DEV=""
+for dev in /dev/input/event*; do
+    if udevadm info --query=all --name=$dev 2>/dev/null | grep -q -i "touch\|ft5406"; then
+        TOUCH_DEV=$dev
+        break
+    fi
+done
+
+if [ -z "$TOUCH_DEV" ]; then
+    # Fallback to first event device
+    TOUCH_DEV="/dev/input/event0"
+fi
+
+echo "Monitoring touch device: $TOUCH_DEV"
+
+# Monitor for any input
+while true; do
+    # Use timeout to read one event (16 bytes)
+    if timeout 0.1 cat "$TOUCH_DEV" 2>/dev/null | head -c 16 > /dev/null; then
+        echo "Touch detected - waking screen"
+        /usr/local/bin/screen-on
+        # Exit so the main app can take over
+        exit 0
+    fi
+    sleep 0.1
+done

+ 58 - 0
dune-weaver-touch/setup-autologin.sh

@@ -0,0 +1,58 @@
+#!/bin/bash
+# Quick auto-login setup for Dune Weaver Touch
+
+set -e
+
+if [ "$EUID" -ne 0 ]; then
+    echo "❌ This script must be run as root (use sudo)"
+    exit 1
+fi
+
+ACTUAL_USER="${SUDO_USER:-$USER}"
+
+echo "🔑 Dune Weaver Touch - Auto-Login Setup"
+echo "======================================="
+echo "User: $ACTUAL_USER"
+echo ""
+
+# Check if raspi-config is available
+if command -v raspi-config >/dev/null 2>&1; then
+    echo "🔧 Setting up auto-login using raspi-config..."
+    
+    # Use raspi-config to enable auto-login to desktop
+    raspi-config nonint do_boot_behaviour B4
+    
+    if [ $? -eq 0 ]; then
+        echo "✅ Auto-login configured successfully!"
+        echo ""
+        echo "📝 What was configured:"
+        echo "   - Boot to desktop with auto-login enabled"
+        echo "   - User: $ACTUAL_USER"
+        echo "   - The Dune Weaver service will start automatically"
+        echo ""
+        echo "🚀 Reboot to see the changes:"
+        echo "   sudo reboot"
+    else
+        echo "❌ raspi-config failed. Try manual configuration:"
+        echo "   sudo raspi-config"
+        echo "   → System Options → Boot/Auto Login → Desktop Autologin"
+    fi
+else
+    echo "⚠️  raspi-config not found."
+    echo ""
+    echo "Manual alternatives:"
+    echo "1. If you have a desktop environment with lightdm:"
+    echo "   sudo nano /etc/lightdm/lightdm.conf"
+    echo "   Uncomment and set: autologin-user=$ACTUAL_USER"
+    echo ""
+    echo "2. For console auto-login (minimal systems):"
+    echo "   sudo systemctl edit getty@tty1"
+    echo "   Add: [Service]"
+    echo "        ExecStart="
+    echo "        ExecStart=-/sbin/agetty --autologin $ACTUAL_USER --noclear %I \\$TERM"
+    echo ""
+fi
+
+echo ""
+echo "ℹ️  The Dune Weaver Touch service is already configured to start automatically."
+echo "   After enabling auto-login and rebooting, you'll have a complete kiosk setup!"

+ 148 - 0
dune-weaver-touch/setup-autostart.sh

@@ -0,0 +1,148 @@
+#!/bin/bash
+
+# Setup Dune Weaver Touch to start automatically on boot
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ACTUAL_USER="${SUDO_USER:-$USER}"
+USER_HOME=$(eval echo ~$ACTUAL_USER)
+
+echo "=== Dune Weaver Touch Auto-Start Setup ==="
+echo "App directory: $SCRIPT_DIR"
+echo "User: $ACTUAL_USER"
+echo ""
+
+# Function to install system scripts
+install_scripts() {
+    echo "Installing system scripts..."
+    
+    if [ "$EUID" -ne 0 ]; then
+        echo "❌ Script installation requires root privileges. Run with sudo."
+        return 1
+    fi
+    
+    "$SCRIPT_DIR/scripts/install-scripts.sh"
+    echo "✅ System scripts installed"
+}
+
+# Function to setup systemd service
+setup_systemd() {
+    echo "Setting up systemd service..."
+    
+    # Check if running as root
+    if [ "$EUID" -ne 0 ]; then
+        echo "❌ Systemd setup requires root privileges. Run with sudo."
+        return 1
+    fi
+    
+    # Install scripts first
+    install_scripts
+    
+    # Update paths in the service file
+    sed "s|/home/pi/dune-weaver-touch|$SCRIPT_DIR|g" "$SCRIPT_DIR/dune-weaver-touch.service" > /tmp/dune-weaver-touch.service
+    sed -i "s|User=pi|User=$ACTUAL_USER|g" /tmp/dune-weaver-touch.service
+    sed -i "s|Group=pi|Group=$ACTUAL_USER|g" /tmp/dune-weaver-touch.service
+    
+    # Copy service file
+    cp /tmp/dune-weaver-touch.service /etc/systemd/system/
+    
+    # Enable service
+    systemctl daemon-reload
+    systemctl enable dune-weaver-touch.service
+    
+    echo "✅ Systemd service installed and enabled"
+    echo "   The app will start automatically on boot"
+    echo ""
+    echo "Service commands:"
+    echo "   sudo systemctl start dune-weaver-touch"
+    echo "   sudo systemctl stop dune-weaver-touch" 
+    echo "   sudo systemctl status dune-weaver-touch"
+    echo "   sudo journalctl -u dune-weaver-touch -f"
+}
+
+# Function to setup desktop autostart
+setup_desktop() {
+    echo "Setting up desktop autostart..."
+    
+    # Create autostart directory if it doesn't exist
+    mkdir -p "$USER_HOME/.config/autostart"
+    
+    # Update paths in desktop file
+    sed "s|/home/pi/dune-weaver-touch|$SCRIPT_DIR|g" "$SCRIPT_DIR/dune-weaver-touch.desktop" > "$USER_HOME/.config/autostart/dune-weaver-touch.desktop"
+    
+    # Make sure the user owns the file
+    chown $ACTUAL_USER:$ACTUAL_USER "$USER_HOME/.config/autostart/dune-weaver-touch.desktop"
+    
+    echo "✅ Desktop autostart configured"
+    echo "   The app will start when the user logs in"
+}
+
+# Function to setup boot splash (optional)
+setup_boot_splash() {
+    echo "Setting up boot splash screen..."
+    
+    if [ "$EUID" -ne 0 ]; then
+        echo "❌ Boot splash setup requires root privileges. Run with sudo."
+        return 1
+    fi
+    
+    # Disable boot messages for cleaner boot
+    if ! grep -q "quiet splash" /boot/cmdline.txt; then
+        sed -i 's/$/ quiet splash/' /boot/cmdline.txt
+        echo "✅ Boot splash enabled"
+    else
+        echo "ℹ️  Boot splash already enabled"
+    fi
+    
+    # Disable rainbow splash
+    if ! grep -q "disable_splash=1" /boot/config.txt; then
+        echo "disable_splash=1" >> /boot/config.txt
+        echo "✅ Rainbow splash disabled"
+    else
+        echo "ℹ️  Rainbow splash already disabled"
+    fi
+}
+
+# Main menu
+echo "Choose setup method:"
+echo "1) Systemd service (recommended for headless/kiosk mode)"
+echo "2) Desktop autostart (for desktop environments)" 
+echo "3) Both systemd + desktop autostart"
+echo "4) Install system scripts only"
+echo "5) Add boot splash optimizations"
+echo "6) Complete kiosk setup (scripts + systemd + boot splash)"
+echo ""
+read -p "Enter your choice (1-6): " choice
+
+case $choice in
+    1)
+        setup_systemd
+        ;;
+    2)
+        setup_desktop
+        ;;
+    3)
+        setup_systemd
+        setup_desktop
+        ;;
+    4)
+        install_scripts
+        ;;
+    5)
+        setup_boot_splash
+        ;;
+    6)
+        install_scripts
+        setup_systemd
+        setup_boot_splash
+        echo ""
+        echo "🎯 Complete kiosk setup done!"
+        echo "   Reboot to see the full kiosk experience"
+        ;;
+    *)
+        echo "❌ Invalid choice"
+        exit 1
+        ;;
+esac
+
+echo ""
+echo "✅ Setup complete!"

+ 441 - 0
dune-weaver-touch/setup_kiosk.sh

@@ -0,0 +1,441 @@
+#!/bin/bash
+
+#############################################
+# Dune Weaver Touch - Kiosk Mode Setup Script
+# For Raspberry Pi with Freenove 5" DSI Display
+# Author: Dune Weaver Team
+# Version: 1.0
+#############################################
+
+set -e  # Exit on error
+
+# Color codes for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Configuration variables
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+USER_NAME=$(whoami)
+HOME_DIR="/home/$USER_NAME"
+APP_DIR="$SCRIPT_DIR"
+SERVICE_NAME="dune-weaver-kiosk"
+VENV_PATH="$APP_DIR/bin/activate"
+
+# Display banner
+echo -e "${GREEN}"
+echo "================================================"
+echo "   Dune Weaver Touch - Kiosk Setup"
+echo "   Freenove 5\" DSI Display (800x480)"
+echo "================================================"
+echo -e "${NC}"
+
+# Function to detect Raspberry Pi model
+detect_pi_model() {
+    local model=$(cat /proc/cpuinfo | grep "Model" | cut -d ':' -f 2 | sed 's/^ *//')
+    if [[ $model == *"Pi 3"* ]]; then
+        echo "3"
+    elif [[ $model == *"Pi 4"* ]]; then
+        echo "4"
+    elif [[ $model == *"Pi 5"* ]]; then
+        echo "5"
+    else
+        echo "unknown"
+    fi
+}
+
+# Function to backup files
+backup_file() {
+    local file=$1
+    if [ -f "$file" ]; then
+        cp "$file" "${file}.backup.$(date +%Y%m%d_%H%M%S)"
+        echo -e "${YELLOW}Backed up: $file${NC}"
+    fi
+}
+
+# Function to setup boot configuration
+setup_boot_config() {
+    echo -e "${GREEN}Configuring boot settings for DSI display...${NC}"
+    
+    backup_file "/boot/config.txt"
+    
+    local pi_model=$(detect_pi_model)
+    echo -e "${GREEN}Detected Raspberry Pi Model: $pi_model${NC}"
+    
+    # Remove conflicting HDMI settings if they exist
+    sudo sed -i '/hdmi_force_hotplug/d' /boot/config.txt
+    sudo sed -i '/hdmi_group/d' /boot/config.txt
+    sudo sed -i '/hdmi_mode/d' /boot/config.txt
+    sudo sed -i '/hdmi_cvt/d' /boot/config.txt
+    sudo sed -i '/hdmi_drive/d' /boot/config.txt
+    sudo sed -i '/config_hdmi_boost/d' /boot/config.txt
+    sudo sed -i '/dtoverlay=ads7846/d' /boot/config.txt
+    
+    # Remove old overlays
+    sudo sed -i '/dtoverlay=vc4-fkms-v3d/d' /boot/config.txt
+    sudo sed -i '/dtoverlay=vc4-kms-v3d/d' /boot/config.txt
+    
+    # Add proper configuration based on Pi model
+    if [ "$pi_model" == "3" ]; then
+        # Pi 3 configuration
+        echo -e "${GREEN}Configuring for Pi 3...${NC}"
+        cat << EOF | sudo tee -a /boot/config.txt > /dev/null
+
+# Dune Weaver Kiosk - DSI Display Configuration (Pi 3)
+dtoverlay=vc4-fkms-v3d
+gpu_mem=128
+max_framebuffers=2
+display_auto_detect=1
+disable_overscan=1
+EOF
+    else
+        # Pi 4/5 configuration
+        echo -e "${GREEN}Configuring for Pi 4/5...${NC}"
+        cat << EOF | sudo tee -a /boot/config.txt > /dev/null
+
+# Dune Weaver Kiosk - DSI Display Configuration (Pi 4/5)
+dtoverlay=vc4-kms-v3d
+gpu_mem=128
+max_framebuffers=2
+display_auto_detect=1
+disable_overscan=1
+EOF
+    fi
+    
+    # Add common settings
+    cat << EOF | sudo tee -a /boot/config.txt > /dev/null
+
+# Common settings
+dtparam=i2c_arm=on
+dtparam=spi=on
+enable_uart=1
+max_usb_current=1
+
+# Disable splash screens for faster boot
+disable_splash=1
+EOF
+    
+    echo -e "${GREEN}Boot configuration updated${NC}"
+}
+
+# Function to install dependencies
+install_dependencies() {
+    echo -e "${GREEN}Installing required dependencies...${NC}"
+    
+    sudo apt-get update
+    sudo apt-get install -y \
+        libgles2-mesa \
+        libgles2-mesa-dev \
+        libgbm-dev \
+        libdrm-dev \
+        libinput-dev \
+        libudev-dev \
+        libxkbcommon-dev \
+        fbset \
+        evtest \
+        qtvirtualkeyboard-plugin \
+        qml-module-qtquick-virtualkeyboard \
+        qt6-virtualkeyboard-plugin \
+        qml6-module-qt-labs-qmlmodels || {
+        echo -e "${YELLOW}Some packages may not be available, continuing...${NC}"
+    }
+    
+    echo -e "${GREEN}Dependencies installed${NC}"
+}
+
+# Function to create startup script
+create_startup_script() {
+    echo -e "${GREEN}Creating startup script...${NC}"
+    
+    local pi_model=$(detect_pi_model)
+    local START_SCRIPT="$APP_DIR/start_kiosk.sh"
+    
+    cat << 'EOF' > "$START_SCRIPT"
+#!/bin/bash
+
+# Dune Weaver Touch Kiosk Startup Script
+# Auto-generated by setup_kiosk.sh
+
+# Wait for system to fully boot
+sleep 10
+
+# Log file
+LOG_FILE="/tmp/dune-weaver-kiosk.log"
+exec &> >(tee -a "$LOG_FILE")
+
+echo "========================================="
+echo "Starting Dune Weaver Touch Kiosk"
+echo "Date: $(date)"
+echo "========================================="
+
+# Detect Pi model for platform selection
+PI_MODEL=$(cat /proc/cpuinfo | grep "Model" | cut -d ':' -f 2)
+
+# Set working directory
+cd "$(dirname "$0")"
+
+# Check if virtual environment exists
+if [ ! -f "bin/activate" ]; then
+    echo "ERROR: Virtual environment not found!"
+    exit 1
+fi
+
+# Activate virtual environment
+source bin/activate
+
+# Function to run with LinuxFB (Pi 3 with DSI)
+run_linuxfb() {
+    export QT_QPA_PLATFORM=linuxfb:fb=/dev/fb0
+    export QT_QPA_FONTDIR=/usr/share/fonts/truetype
+    export QT_QPA_FB_HIDECURSOR=1
+    export QT_QPA_EVDEV_TOUCHSCREEN_PARAMETERS=/dev/input/event0:rotate=0
+    
+    # Enable Virtual Keyboard
+    export QT_IM_MODULE=qtvirtualkeyboard
+    export QT_VIRTUALKEYBOARD_STYLE=default
+    export QT_VIRTUALKEYBOARD_LAYOUT_PATH=/usr/share/qt5/qtvirtualkeyboard/layouts
+    
+    echo "Starting with LinuxFB platform and virtual keyboard..."
+    python main.py
+}
+
+# Function to run with EGLFS (Pi 4/5 with DSI)
+run_eglfs() {
+    export QT_QPA_PLATFORM=eglfs
+    export QT_QPA_EGLFS_WIDTH=800
+    export QT_QPA_EGLFS_HEIGHT=480
+    export QT_QPA_EGLFS_INTEGRATION=eglfs_kms
+    export QT_QPA_EGLFS_KMS_ATOMIC=1
+    export QT_QPA_EGLFS_HIDECURSOR=1
+    export QT_QPA_EVDEV_TOUCHSCREEN_PARAMETERS=/dev/input/event0:rotate=0
+    
+    # Enable Virtual Keyboard
+    export QT_IM_MODULE=qtvirtualkeyboard
+    export QT_VIRTUALKEYBOARD_STYLE=default
+    export QT_VIRTUALKEYBOARD_LAYOUT_PATH=/usr/share/qt5/qtvirtualkeyboard/layouts
+    
+    echo "Starting with EGLFS platform and virtual keyboard..."
+    python main.py
+}
+
+# Check for DRM device (needed for EGLFS)
+if [ -e /dev/dri/card0 ] || [ -e /dev/dri/card1 ]; then
+    echo "DRM device found, using EGLFS"
+    run_eglfs
+else
+    echo "No DRM device, using LinuxFB"
+    run_linuxfb
+fi
+
+echo "Application exited at $(date)"
+EOF
+    
+    chmod +x "$START_SCRIPT"
+    echo -e "${GREEN}Startup script created: $START_SCRIPT${NC}"
+}
+
+# Function to create systemd service
+create_systemd_service() {
+    echo -e "${GREEN}Creating systemd service...${NC}"
+    
+    local SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
+    
+    cat << EOF | sudo tee "$SERVICE_FILE" > /dev/null
+[Unit]
+Description=Dune Weaver Touch Kiosk Mode
+After=multi-user.target graphical.target
+Wants=network-online.target
+After=network-online.target
+
+[Service]
+Type=simple
+User=$USER_NAME
+Group=$USER_NAME
+WorkingDirectory=$APP_DIR
+
+# Auto-restart on failure
+Restart=always
+RestartSec=10
+
+# Start script
+ExecStart=$APP_DIR/start_kiosk.sh
+
+# Stop timeout
+TimeoutStopSec=10
+
+# Logging
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target
+EOF
+    
+    # Reload systemd and enable service
+    sudo systemctl daemon-reload
+    sudo systemctl enable "${SERVICE_NAME}.service"
+    
+    echo -e "${GREEN}Systemd service created and enabled${NC}"
+}
+
+# Function to create uninstall script
+create_uninstall_script() {
+    echo -e "${GREEN}Creating uninstall script...${NC}"
+    
+    cat << 'EOF' > "$APP_DIR/uninstall_kiosk.sh"
+#!/bin/bash
+
+# Dune Weaver Touch - Kiosk Mode Uninstall Script
+
+set -e
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+SERVICE_NAME="dune-weaver-kiosk"
+
+echo -e "${YELLOW}Uninstalling Dune Weaver Kiosk Mode...${NC}"
+
+# Stop and disable service
+if systemctl list-units --full -all | grep -Fq "${SERVICE_NAME}.service"; then
+    echo "Stopping and disabling service..."
+    sudo systemctl stop "${SERVICE_NAME}.service" 2>/dev/null || true
+    sudo systemctl disable "${SERVICE_NAME}.service" 2>/dev/null || true
+    sudo rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
+    sudo systemctl daemon-reload
+    echo -e "${GREEN}Service removed${NC}"
+fi
+
+# Remove startup script
+if [ -f "start_kiosk.sh" ]; then
+    rm -f "start_kiosk.sh"
+    echo -e "${GREEN}Startup script removed${NC}"
+fi
+
+# Restore boot config if backup exists
+LATEST_BACKUP=$(ls -t /boot/config.txt.backup.* 2>/dev/null | head -n1)
+if [ -n "$LATEST_BACKUP" ]; then
+    echo -e "${YELLOW}Found backup: $LATEST_BACKUP${NC}"
+    read -p "Restore boot configuration from backup? (y/n): " -n 1 -r
+    echo
+    if [[ $REPLY =~ ^[Yy]$ ]]; then
+        sudo cp "$LATEST_BACKUP" /boot/config.txt
+        echo -e "${GREEN}Boot configuration restored${NC}"
+        echo -e "${YELLOW}Reboot required for changes to take effect${NC}"
+    fi
+fi
+
+echo -e "${GREEN}Kiosk mode uninstalled successfully${NC}"
+echo -e "${YELLOW}You may want to reboot your Raspberry Pi${NC}"
+EOF
+    
+    chmod +x "$APP_DIR/uninstall_kiosk.sh"
+    echo -e "${GREEN}Uninstall script created: $APP_DIR/uninstall_kiosk.sh${NC}"
+}
+
+# Function to test the display
+test_display() {
+    echo -e "${GREEN}Testing display configuration...${NC}"
+    
+    # Check framebuffer
+    if [ -e /dev/fb0 ]; then
+        echo -e "${GREEN}✓ Framebuffer device found${NC}"
+        fbset -i | head -5
+    else
+        echo -e "${RED}✗ No framebuffer device found${NC}"
+    fi
+    
+    # Check for DRM device
+    if [ -e /dev/dri/card0 ] || [ -e /dev/dri/card1 ]; then
+        echo -e "${GREEN}✓ DRM device found${NC}"
+        ls -la /dev/dri/
+    else
+        echo -e "${YELLOW}! No DRM device (normal for Pi 3)${NC}"
+    fi
+    
+    # Check touch input
+    echo -e "${GREEN}Available input devices:${NC}"
+    ls -la /dev/input/event* 2>/dev/null || echo "No input devices found"
+}
+
+# Main installation function
+install_kiosk() {
+    echo -e "${GREEN}Starting Kiosk Mode Installation...${NC}"
+    
+    # Check if running on Raspberry Pi
+    if ! grep -q "Raspberry Pi" /proc/cpuinfo; then
+        echo -e "${YELLOW}Warning: This doesn't appear to be a Raspberry Pi${NC}"
+        read -p "Continue anyway? (y/n): " -n 1 -r
+        echo
+        if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+            exit 1
+        fi
+    fi
+    
+    # Check if virtual environment exists
+    if [ ! -f "$VENV_PATH" ]; then
+        echo -e "${RED}Error: Virtual environment not found at $VENV_PATH${NC}"
+        echo "Please run this script from the dune-weaver-touch directory"
+        exit 1
+    fi
+    
+    # Install steps
+    install_dependencies
+    setup_boot_config
+    create_startup_script
+    create_systemd_service
+    create_uninstall_script
+    test_display
+    
+    echo -e "${GREEN}"
+    echo "================================================"
+    echo "   Installation Complete!"
+    echo "================================================"
+    echo -e "${NC}"
+    echo
+    echo "Next steps:"
+    echo "1. Reboot your Raspberry Pi:"
+    echo "   ${GREEN}sudo reboot${NC}"
+    echo
+    echo "2. The kiosk will start automatically after reboot"
+    echo
+    echo "3. To manually control the service:"
+    echo "   Start:   ${GREEN}sudo systemctl start ${SERVICE_NAME}${NC}"
+    echo "   Stop:    ${GREEN}sudo systemctl stop ${SERVICE_NAME}${NC}"
+    echo "   Status:  ${GREEN}sudo systemctl status ${SERVICE_NAME}${NC}"
+    echo "   Logs:    ${GREEN}journalctl -u ${SERVICE_NAME} -f${NC}"
+    echo
+    echo "4. To uninstall:"
+    echo "   ${GREEN}./uninstall_kiosk.sh${NC}"
+    echo
+    
+    read -p "Reboot now? (y/n): " -n 1 -r
+    echo
+    if [[ $REPLY =~ ^[Yy]$ ]]; then
+        sudo reboot
+    fi
+}
+
+# Parse command line arguments
+case "${1:-}" in
+    uninstall)
+        if [ -f "$APP_DIR/uninstall_kiosk.sh" ]; then
+            "$APP_DIR/uninstall_kiosk.sh"
+        else
+            echo -e "${RED}Uninstall script not found${NC}"
+            exit 1
+        fi
+        ;;
+    test)
+        test_display
+        ;;
+    status)
+        sudo systemctl status "${SERVICE_NAME}.service"
+        ;;
+    *)
+        install_kiosk
+        ;;
+esac

+ 3 - 3
main.py

@@ -172,9 +172,9 @@ async def lifespan(app: FastAPI):
         """Check and generate cache in background."""
         try:
             logger.info("Starting cache check...")
-            
+
             from modules.core.cache_manager import is_cache_generation_needed_async, generate_cache_background
-            
+
             if await is_cache_generation_needed_async():
                 logger.info("Cache generation needed, starting background task...")
                 asyncio.create_task(generate_cache_background())  # Don't await - run in background
@@ -182,7 +182,7 @@ async def lifespan(app: FastAPI):
                 logger.info("Cache is up to date, skipping generation")
         except Exception as e:
             logger.warning(f"Failed during cache generation: {str(e)}")
-    
+
     # Start cache check in background immediately
     asyncio.create_task(delayed_cache_check())
 

+ 1 - 1
modules/connection/connection_manager.py

@@ -491,7 +491,7 @@ def get_machine_steps(timeout=10):
     
     # Process results and determine table type
     if settings_complete:
-        if y_steps_per_mm == 180:
+        if y_steps_per_mm == 180 and x_steps_per_mm == 256:
             state.table_type = 'dune_weaver_mini'
         elif y_steps_per_mm == 287:
             state.table_type = 'dune_weaver'

+ 4 - 4
modules/core/preview.py

@@ -6,8 +6,8 @@ from io import BytesIO
 from PIL import Image, ImageDraw
 from modules.core.pattern_manager import parse_theta_rho_file, THETA_RHO_DIR
 
-async def generate_preview_image(pattern_file):
-    """Generate a Webp preview for a pattern file, optimized for a 300x300 view."""
+async def generate_preview_image(pattern_file, format='WEBP'):
+    """Generate a preview for a pattern file, optimized for a 300x300 view."""
     file_path = os.path.join(THETA_RHO_DIR, pattern_file)
     # Use asyncio.to_thread to prevent blocking the event loop
     coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
@@ -34,7 +34,7 @@ async def generate_preview_image(pattern_file):
         draw.text((text_x, text_y), text, fill="black")
         
         img_byte_arr = BytesIO()
-        img.save(img_byte_arr, format='WEBP')
+        img.save(img_byte_arr, format=format)
         img_byte_arr.seek(0)
         return img_byte_arr.getvalue()
 
@@ -67,6 +67,6 @@ async def generate_preview_image(pattern_file):
     img = img.rotate(180)
 
     img_byte_arr = BytesIO()
-    img.save(img_byte_arr, format='WEBP', lossless=False, alpha_quality=20, method=0)
+    img.save(img_byte_arr, format=format, lossless=False, alpha_quality=20, method=0)
     img_byte_arr.seek(0)
     return img_byte_arr.getvalue()